mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3954f93653 | |||
| b02f8c7daf | |||
| 3461937dbc |
@@ -25,9 +25,7 @@ body:
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
- Android
|
||||
- Multiple platforms
|
||||
- Both
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -40,17 +38,6 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: discord
|
||||
attributes:
|
||||
label: Discord username (optional)
|
||||
description: >
|
||||
So maintainers can ping you on Discord if they need a quick follow-up.
|
||||
Leave blank if you'd rather keep everything on GitHub.
|
||||
placeholder: yourname
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: location
|
||||
attributes:
|
||||
|
||||
@@ -1,53 +1,35 @@
|
||||
name: Feature request
|
||||
description: Ask for something new in the engine, launcher, or platform — not a content/gameplay mod.
|
||||
description: Ask for something new, or a change to how something already works.
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use this for **engine / launcher / platform** work (ports, video options, save
|
||||
tooling, networking, mod API seams, docs).
|
||||
|
||||
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
|
||||
could ship — running shoes, alternate sprites, day/night, shiny indicators,
|
||||
Gen 2-like battle toggles, soundtrack packs — open a
|
||||
**[Mod request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=mod_request.yml)**
|
||||
instead.
|
||||
|
||||
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
|
||||
and how you picture it working.
|
||||
and how you picture it working. A request with real detail is one someone can
|
||||
actually build without guessing at what you meant.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: One line summary
|
||||
description: What you want, in a sentence.
|
||||
placeholder: Add Linux AppImage releases next to the macOS and Windows builds
|
||||
placeholder: Add a run toggle so you can move at bike speed without the bike
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: discord
|
||||
attributes:
|
||||
label: Discord username (optional)
|
||||
description: >
|
||||
So maintainers can ping you on Discord if they need a quick follow-up.
|
||||
Leave blank if you'd rather keep everything on GitHub.
|
||||
placeholder: yourname
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What do you want
|
||||
description: >
|
||||
Describe it properly. What is it, where does it live (launcher, options,
|
||||
engine), what does the player see or do. If it changes something that already
|
||||
exists, say what it does today and what it should do instead.
|
||||
Describe it properly. What is it, where does it live in the game, what does the
|
||||
player see or do. If it changes something that already exists, say what it does today
|
||||
and what it should do instead.
|
||||
placeholder: |
|
||||
Ship a Linux AppImage on each release, same version as the macOS/Windows builds,
|
||||
with the same save folder layout and mod discovery path.
|
||||
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
|
||||
foot and the player moves at bike speed. Release and you go back to walking.
|
||||
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -59,8 +41,9 @@ body:
|
||||
What's annoying or missing right now. What does this fix. If it's just because you
|
||||
think it would be fun, say that, it's a real answer.
|
||||
placeholder: |
|
||||
LÖVE already runs on Linux; without a packaged build, players have to assemble
|
||||
it themselves and miss release notes / update checks.
|
||||
Backtracking across routes you've already cleared is slow, and the bike is a menu
|
||||
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
|
||||
without making the game easier.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -69,12 +52,14 @@ body:
|
||||
attributes:
|
||||
label: How should it work
|
||||
description: >
|
||||
The specifics. Which menu, what happens in the edge cases. If you don't
|
||||
The specifics. Which button, which menu, what happens in the edge cases. If you don't
|
||||
know, say what you'd expect as a player and leave the rest open.
|
||||
placeholder: |
|
||||
- GitHub Releases asset next to the .dmg / .exe
|
||||
- Same options.lua / mods/ layout as desktop
|
||||
- Documented in the README install section
|
||||
- Hold B on the overworld to run
|
||||
- Same speed as the bike
|
||||
- Disabled anywhere the bike is disabled
|
||||
- Sprite should probably need a running animation, not sure what that takes
|
||||
- No effect on encounter rates
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
name: Mod request
|
||||
description: Ask for a gameplay, cosmetic, audio, or QoL change that belongs as a Lua mod.
|
||||
labels: ["mod request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
This tracker is for ideas that should ship as **mods**, not as core engine
|
||||
features — alternate sprites, running shoes, day/night, shiny indicators,
|
||||
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
|
||||
|
||||
The engine already exposes a lot of this through registries and hooks
|
||||
([modding wiki](https://github.com/bryanthaboi/gen1recomp/wiki)).
|
||||
Use a **Feature request** instead for launcher / ports / video options /
|
||||
networking / save tooling / new API seams.
|
||||
|
||||
- type: input
|
||||
id: summary
|
||||
attributes:
|
||||
label: One line summary
|
||||
description: What the mod should do, in a sentence.
|
||||
placeholder: Hold B to run at bike speed on the overworld
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: discord
|
||||
attributes:
|
||||
label: Discord username (optional)
|
||||
description: >
|
||||
So maintainers or mod authors can ping you on Discord if they pick this up.
|
||||
Leave blank if you'd rather keep everything on GitHub.
|
||||
placeholder: yourname
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: what
|
||||
attributes:
|
||||
label: What should the mod do
|
||||
description: >
|
||||
Describe the player-facing behavior. What changes, where, what does the
|
||||
player see or press. If it toggles from Options or a START-menu entry, say so.
|
||||
placeholder: |
|
||||
Hold B while walking outdoors to move at bike speed. Release to walk again.
|
||||
Same places the bike is allowed; no effect in battles or menus.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: why
|
||||
attributes:
|
||||
label: Why is this worth doing as a mod
|
||||
description: >
|
||||
Why optional/modded rather than a core option. Who wants it on, who wants
|
||||
vanilla left alone.
|
||||
placeholder: |
|
||||
Great for replaying and backtracking, but some people want a strict Gen 1
|
||||
pace. A mod (or an opt-in mod option) keeps both camps happy.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: how
|
||||
attributes:
|
||||
label: How should it work
|
||||
description: >
|
||||
Buttons, menus, edge cases, whether it needs new art/audio. If you know a
|
||||
hook or registry that fits (movement.speed, pokemon.sprite, rulesets, …),
|
||||
mention it — otherwise leave it open.
|
||||
placeholder: |
|
||||
- Hold B on the overworld
|
||||
- Same step timing as the bike
|
||||
- Disabled where the bike is disabled
|
||||
- Prefer hooks:wrap("movement.speed") if that still fits
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: vanilla
|
||||
attributes:
|
||||
label: With the mod off, is vanilla unchanged
|
||||
options:
|
||||
- Yes — parity when disabled
|
||||
- No — it would replace something always-on
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Best-fit mod category
|
||||
description: Same taxonomy as example mods (BALANCE, GRAPHICS, AUDIO, …).
|
||||
options:
|
||||
- GAMEPLAY / QoL
|
||||
- GRAPHICS
|
||||
- AUDIO
|
||||
- BALANCE / ruleset
|
||||
- CONTENT (maps, encounters, trainers)
|
||||
- UI / TOOL
|
||||
- TOTAL_CONVERSION-ish
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: extra
|
||||
attributes:
|
||||
label: Anything else
|
||||
description: >
|
||||
Reference screenshots, other games/hacks that do it, related issues, or
|
||||
"I'd like to try writing this myself." Leave blank if nothing comes to mind.
|
||||
validations:
|
||||
required: false
|
||||
@@ -14,9 +14,7 @@ name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
# Integration branch + release branch. PRs already run via pull_request
|
||||
# (any base); this list is only for post-merge push runs.
|
||||
branches: [dev, main]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
# a force-push while CI is mid-run should cancel the stale run, not queue
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
name: Release
|
||||
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
|
||||
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
|
||||
# Mac runner, and publishes them as a GitHub Release.
|
||||
# Builds the macOS, Windows, and Linux desktop apps plus an Android APK
|
||||
# on the self-hosted Mac runner, and publishes them as a GitHub Release.
|
||||
#
|
||||
# Versioning:
|
||||
# - First ever release is 0.1.0.
|
||||
@@ -11,9 +10,6 @@ name: Release
|
||||
# - To force a specific version, either:
|
||||
# * run this workflow manually (Actions tab) and type it into "version", or
|
||||
# * put "[release X.Y.Z]" anywhere in the commit message.
|
||||
#
|
||||
# Branch model: day-to-day work merges to `dev`. Releases stay on `main` only
|
||||
# so promoting `dev` -> `main` is the ship gate that cuts a build.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -170,13 +166,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
|
||||
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
||||
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Notarize & staple macOS app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -226,12 +215,6 @@ jobs:
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
|
||||
# Anbernic handheld port (suffix names the CFW it targets, so a
|
||||
# future RG35XX/other-CFW pack can ship alongside it).
|
||||
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
||||
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
|
||||
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||
|
||||
# Platform-independent update payload, built alongside the desktop
|
||||
# apps above (same game.love that gets fused into each of them).
|
||||
love_file=".bazinga/work/game.love"
|
||||
@@ -297,7 +280,6 @@ jobs:
|
||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||
"dist/release/gen1recomp-${v}-android.apk" \
|
||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
|
||||
"dist/release/gen1recomp-${v}.love" \
|
||||
"dist/release/sha256sums.txt"
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Gen1Recomp
|
||||
# Pokemon Gen 1 Recompilation Project
|
||||
|
||||
A native LÖVE2D recreation of Poke Red and Blue. The engine and map
|
||||
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
|
||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||
supplied by the player.
|
||||
|
||||
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
|
||||
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
|
||||
game content input.
|
||||
|
||||
The ROM is verified, used during import, and then released from memory. It is
|
||||
@@ -33,82 +33,46 @@ audio channel programs copied out of the verified ROM.
|
||||
|
||||
## Controls
|
||||
|
||||
|
||||
| Action | Keyboard | Controller |
|
||||
| ------ | ----------------- | ------------------ |
|
||||
| Move | Arrow keys / WASD | D-pad / left stick |
|
||||
| A | Z / Enter / Space | A |
|
||||
| B | X / Backspace | B |
|
||||
| Start | Escape | Start |
|
||||
| Select | Tab / Shift | Back / Select |
|
||||
|
||||
| Action | Keyboard | Controller |
|
||||
|--------|----------|------------|
|
||||
| Move | Arrow keys / WASD | D-pad / left stick |
|
||||
| A | Z / Enter / Space | A |
|
||||
| B | X / Backspace | B |
|
||||
| Start | Escape | Start |
|
||||
| Select | Tab / Shift | Back / Select |
|
||||
|
||||
Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are
|
||||
supported out of the box.
|
||||
|
||||
### Hotkeys
|
||||
|
||||
|
||||
| Key | What it does |
|
||||
| --------- | ---------------------------------------------------- |
|
||||
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
| `5` | Cycle GBC FX |
|
||||
| `F1` | Save |
|
||||
| `F2` | Load |
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
| Key | What it does |
|
||||
|-----|----------------|
|
||||
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
| `5` | Cycle GBC FX |
|
||||
| `F1` | Save |
|
||||
| `F2` | Load |
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||
and persist in `options.lua`.
|
||||
|
||||
### Rulesets
|
||||
|
||||
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
|
||||
Both rulesets share the same damage formulas; they differ only in whether
|
||||
the original's quirks are kept. The setting persists in `options.lua`, and
|
||||
mods can register their own.
|
||||
|
||||
`gen1_faithful` is the default and reproduces the original cartridge,
|
||||
famous bugs included:
|
||||
|
||||
| Rule | Behavior |
|
||||
| --------------------------- | ----------------------------------------------------- |
|
||||
| `oneIn256Miss` | A 100%-accurate move still misses on a roll of 255 |
|
||||
| `critUsesBaseSpeed` | Crit rate reads base speed, not the current stat |
|
||||
| `critIgnoresStages` | Crit rate ignores stat stages |
|
||||
| `focusEnergyBug` | FOCUS ENERGY quarters the crit rate instead of x4 |
|
||||
| `enemyUnlimitedPP` | Enemies never spend PP, so they never Struggle |
|
||||
| `hyperBeamSkipRechargeOnKO` | HYPER BEAM skips its recharge when the target faints |
|
||||
| `randMin` / `randMax` | Damage random factor 217-255 |
|
||||
|
||||
`modern_clean` keeps the formulas but removes the notorious quirks:
|
||||
|
||||
| Rule | Behavior |
|
||||
| --------------------------- | ----------------------------------------------------- |
|
||||
| `oneIn256Miss` | Off: a 100%-accurate move always hits |
|
||||
| `critUsesBaseSpeed` | Unchanged: crit rate still reads base speed |
|
||||
| `critIgnoresStages` | Off: stat stages count toward the crit rate |
|
||||
| `focusEnergyBug` | Off: FOCUS ENERGY raises the crit rate as intended |
|
||||
| `enemyUnlimitedPP` | Off: enemies deplete PP and Struggle when empty |
|
||||
| `hyperBeamSkipRechargeOnKO` | Off: HYPER BEAM always recharges, like Gen 2+ |
|
||||
| `randMin` / `randMax` | Damage random factor 217-255, same as faithful |
|
||||
|
||||
## Running From Source
|
||||
|
||||
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
|
||||
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||
|
||||
```sh
|
||||
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
|
||||
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
|
||||
scripts/run.sh
|
||||
```
|
||||
|
||||
then `love .` for later launches. Windows PowerShell scripts, the optional
|
||||
developer data build, test suites, and cache management are covered in
|
||||
[Developer Setup](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Developer-Setup).
|
||||
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
|
||||
|
||||
## Portable Mode
|
||||
|
||||
@@ -116,7 +80,7 @@ By default the game keeps your save, options, and the private ROM-derived
|
||||
data cache in your OS's normal per-user app data folder. To keep everything
|
||||
next to the game instead (handy for a USB stick or portable drive you carry
|
||||
between computers), drop an empty file named `portable.txt` next to the app
|
||||
(next to `gen1recomp.app`/`.exe`, or next to `main.lua`/`conf.lua` when
|
||||
(next to `PokemonRed.app`/`.exe`, or next to `main.lua`/`conf.lua` when
|
||||
running from source), then launch the game. Portable mode is desktop-only
|
||||
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
|
||||
runs from a read-only package.
|
||||
@@ -124,21 +88,14 @@ runs from a read-only package.
|
||||
With `portable.txt` present:
|
||||
|
||||
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
|
||||
that same folder instead of the OS save directory.
|
||||
that same folder instead of the OS save directory.
|
||||
- A ROM import writes the generated `data/generated` and `assets/generated`
|
||||
cache straight into that folder too (nothing is left in the OS save
|
||||
directory), so a later launch reuses it without asking for the ROM again
|
||||
even on a different computer, as long as the same folder comes along.
|
||||
cache straight into that folder too (nothing is left in the OS save
|
||||
directory), so a later launch reuses it without asking for the ROM again
|
||||
even on a different computer, as long as the same folder comes along.
|
||||
- Deleting `portable.txt` switches back to the normal OS save directory; nothing
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
|
||||
## 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).
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
|
||||
## Modding
|
||||
|
||||
@@ -146,38 +103,36 @@ The game ships a native mod platform: content registries, events and hooks,
|
||||
per-mod saves and options, and an in-game manager. The full modding book —
|
||||
getting started, a twelve-rung tutorial ladder, a cookbook, and the generated
|
||||
reference — lives on the
|
||||
[project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
|
||||
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
|
||||
|
||||
Shipped example mods, one per kind of author, live in `[mods/](mods/)`.
|
||||
Shipped example mods, one per kind of author, live in [`mods/`](mods/).
|
||||
|
||||
## Bugs and Ideas
|
||||
|
||||
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
|
||||
that looks wrong, text in the wrong box, anything that does not match the
|
||||
original game.
|
||||
[Open a bug report](https://github.com/bryanthaboi/gen1recomp/issues/new?template=bug_report.yml).
|
||||
[Open a bug report](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=bug_report.yml).
|
||||
Attach a screenshot if you can. It saves a lot of back and forth, and if you
|
||||
can't get one, the form asks you to describe what you saw instead.
|
||||
|
||||
Thought of a feature that could be good, or a way to improve one that already
|
||||
exists?
|
||||
[Open a feature request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=feature_request.yml).
|
||||
[Open a feature request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=feature_request.yml).
|
||||
Say what you want, why it is worth doing, and how you picture it working. A
|
||||
request with real detail is one that can actually get built.
|
||||
|
||||
## More
|
||||
|
||||
- [Link play](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Link-Play)
|
||||
— START > LINK connects two copies directly over UDP.
|
||||
- [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
|
||||
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
||||
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play)
|
||||
— START > LINK connects two copies directly over UDP.
|
||||
- [Save editor](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Save-Editor)
|
||||
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
||||
- `docs/architecture.md` — runtime details;
|
||||
`docs/behavior-porting-notes.md` — formula provenance.
|
||||
|
||||
|
||||
`docs/behavior-porting-notes.md` — formula provenance.
|
||||
|
||||
## Special Thanks
|
||||
|
||||
This project would not be possible without [pret](https://github.com/pret) >
|
||||
the pret band of decompiling maniacs > and their
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
#!/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,12 +13,7 @@ function love.conf(t)
|
||||
_G.POKEPORT_DEV_MODE = developer
|
||||
|
||||
if editor then
|
||||
-- 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.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
|
||||
t.window.title = "Pokemon Save Editor"
|
||||
t.window.width = 1280
|
||||
t.window.height = 800
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
-- pokered/scripts/PewterNidoranHouse.asm
|
||||
-- PewterNidoranHouseNidoranText: text_far _PewterNidoranHouseNidoranText, then
|
||||
-- text_asm plays the NIDORAN_M cry (PlayCry/WaitForSoundToFinish) before ending.
|
||||
-- play_cry only stashes ctx.pendingCry for the very next show_text row, so it
|
||||
-- has to sit immediately before that row (src/script/Commands.lua); the cry
|
||||
-- then sounds once the box has typed out, matching the asm's print-then-cry
|
||||
-- order. Its waitForButton form keeps DisplayTextID's trailing
|
||||
-- WaitForTextScrollButtonPress -- PewterNidoranHouse_Script is
|
||||
-- `jp EnableAutoTextBoxDrawing`, which zeroes
|
||||
-- wDoNotWaitForButtonPressAfterDisplayingText -- so the box still holds until
|
||||
-- A/B. The cry row was missing, leaving the NIDORAN silent (#247).
|
||||
-- No sound-effect command exists in Commands.lua, so we port the dialogue line
|
||||
-- only; the cry SFX is cosmetic and has no gameplay effect.
|
||||
return {
|
||||
PEWTER_NIDORAN_HOUSE = {
|
||||
talk = {
|
||||
TEXT_PEWTERNIDORANHOUSE_NIDORAN = {
|
||||
{ "face_player" },
|
||||
-- text_asm: ld a, NIDORAN_M / call PlayCry
|
||||
{ "play_cry", "NIDORAN_M", true },
|
||||
{ "show_text", "_PewterNidoranHouseNidoranText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -28,29 +28,7 @@ local function badgeGuard(badge, passFlag)
|
||||
}
|
||||
end
|
||||
|
||||
-- Route23SetVictoryRoadBoulders (pokered scripts/Route23.asm:8): every entry
|
||||
-- to Route 23 resets the Victory Road boulder puzzle behind you. The 2F/3F
|
||||
-- switch events clear (so those barriers are closed again on the next floor
|
||||
-- load) and the boulder that fell through 3F's hole goes back upstairs:
|
||||
-- ShowObject TOGGLE_VICTORY_ROAD_3F_BOULDER / HideObject
|
||||
-- TOGGLE_VICTORY_ROAD_2F_BOULDER, which are VICTORYROAD3F_BOULDER4 and
|
||||
-- VICTORYROAD2F_BOULDER3 (data/maps/toggleable_objects.asm). 1F's event is
|
||||
-- reset by the Indigo Plateau lobby and by Victory Road 2F, not here. This
|
||||
-- is what makes the puzzle "completely reset" on a return trip (#258).
|
||||
-- onEnter is the port's BIT_CUR_MAP_LOADED_2 equivalent: setMap runs it on
|
||||
-- every map entry, connection crossings included, like EnterMap.
|
||||
M.ROUTE_23 = {
|
||||
onEnter = function(game, ow)
|
||||
local f = game.save.flags
|
||||
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 = nil
|
||||
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 = nil
|
||||
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = nil
|
||||
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = nil
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, overworld = ow, game = game }
|
||||
Commands.show_object(ctx, "VICTORY_ROAD_3F", "VICTORYROAD3F_BOULDER4")
|
||||
Commands.hide_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
|
||||
end,
|
||||
talk = {
|
||||
-- Route23Guard1Text: EventFlagBit ..., EVENT_PASSED_EARTHBADGE_CHECK
|
||||
-- -> wWhichBadge = EARTHBADGE
|
||||
|
||||
@@ -59,17 +59,13 @@ M.VIRIDIAN_CITY = {
|
||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
|
||||
-- defined without a leading underscore in pokered/text/ViridianCity.asm
|
||||
-- and aren't present in data/generated/text.lua, so we fall back to
|
||||
-- the literal strings from pokered. 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).
|
||||
-- the literal strings from pokered.
|
||||
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
||||
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
|
||||
or "You want to know\nabout the 2 kinds\nof caterpillar\nPOKéMON?", function(yes)
|
||||
if yes then
|
||||
push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
|
||||
push(game, "CATERPIE has no\npoison, but\nWEEDLE does.\n\nWatch out for its\nPOISON STING!", done)
|
||||
else
|
||||
push(game, "Oh, OK then!", done)
|
||||
end
|
||||
|
||||
@@ -3,16 +3,12 @@
|
||||
return {
|
||||
VIRIDIAN_NICKNAME_HOUSE = {
|
||||
talk = {
|
||||
-- ViridianNicknameHouseSpearowText: text_asm that PrintTexts the
|
||||
-- line, then plays the SPEAROW cry (PlayCry/WaitForSoundToFinish)
|
||||
-- before TextScriptEnd. play_cry only arms the show_text row that
|
||||
-- follows it, so the cry sounds once the box has typed out; its
|
||||
-- waitForButton form keeps DisplayTextID's trailing
|
||||
-- WaitForTextScrollButtonPress, so the box then holds until A/B like
|
||||
-- any other NPC line rather than popping itself (#251).
|
||||
-- ViridianNicknameHouseSpearowText: text_asm that shows the text
|
||||
-- then plays a SPEAROW cry (PlayCry/WaitForSoundToFinish). The
|
||||
-- cry playback isn't in the talk-script command vocabulary, so
|
||||
-- only the flavor line is ported here.
|
||||
TEXT_VIRIDIANNICKNAMEHOUSE_SPEAROW = {
|
||||
{ "play_cry", "SPEAROW", true }, -- 1 PlayCry
|
||||
{ "show_text", "_ViridianNicknameHouseSpearowText" }, -- 2 PrintText
|
||||
{ "show_text", "_ViridianNicknameHouseSpearowText" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+16
-66
@@ -16,14 +16,6 @@ M.VIRIDIAN_MART = {
|
||||
-- StartSimulatingJoypadStates walks the player to the counter
|
||||
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
|
||||
-- and ViridianMartOaksParcelScript hands the parcel over. The player
|
||||
-- never presses A.
|
||||
--
|
||||
-- The RLE list is consumed back to front: DecodeRLEList fills forward
|
||||
-- from wSimulatedJoypadStatesEnd, the script seeds wSimulatedJoypadStates-
|
||||
-- Index with the count, and home/overworld.asm decrements that index
|
||||
-- before each read. So PAD_LEFT 1, PAD_UP 2 actually plays up, up, left,
|
||||
-- and the walk ends facing the clerk. Replaying the list front to back
|
||||
-- put the player at the counter facing up instead (#235). The player
|
||||
-- never presses A. This matters beyond convenience: the parcel gates
|
||||
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
|
||||
-- it on entry rather than letting you walk out without it.
|
||||
@@ -36,8 +28,8 @@ M.VIRIDIAN_MART = {
|
||||
if not f.EVENT_GOT_STARTER then return end
|
||||
ow:queueScript({
|
||||
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
|
||||
{ "move_player", "up", 2 },
|
||||
{ "move_player", "left", 1 },
|
||||
{ "move_player", "up", 2 },
|
||||
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
|
||||
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
|
||||
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
|
||||
@@ -94,27 +86,6 @@ M.VIRIDIAN_CITY = {
|
||||
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
|
||||
},
|
||||
},
|
||||
-- Re-apply the Pokedex old-man swap for a save that already holds the flag
|
||||
-- but was never standing here when it fired: a vanilla .sav imported
|
||||
-- through src/save_convert, whose codec does not model pokered's
|
||||
-- wToggleableObjectFlags array, so save.objectToggles arrives empty and
|
||||
-- both objects fall back to their compiled-in defaults -- the sleeper ON
|
||||
-- (data/maps/toggleable_objects.asm toggleable_objects_for VIRIDIAN_CITY),
|
||||
-- still lying across the north path (#234).
|
||||
-- OaksLabOakGivesPokedexScript (scripts/OaksLab.asm) sets EVENT_GOT_POKEDEX
|
||||
-- and, with no branch in between, runs HideObject TOGGLE_LYING_OLD_MAN /
|
||||
-- ShowObject TOGGLE_OLD_MAN, so that one flag settles both toggles exactly.
|
||||
-- Same shape as M.OAKS_LAB.onEnter in data/scripts/oaks_lab.lua, which
|
||||
-- re-applies the same script's other two HideObjects on entry (#106).
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY")
|
||||
Commands.show_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN")
|
||||
end,
|
||||
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
|
||||
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
|
||||
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
|
||||
@@ -740,20 +711,11 @@ local function boulderAt(ow, x, y)
|
||||
return npc and npc.def.sprite == "SPRITE_BOULDER" and npc or nil
|
||||
end
|
||||
|
||||
-- Each barrier is stamped BOTH ways on entry. pokered only ever stamps the
|
||||
-- OPEN block (ReplaceTileBlock edits the loaded block map, which the next map
|
||||
-- load throws away, so a fresh entry always shows the .blk's closed barrier),
|
||||
-- but OverworldState:replaceBlock writes through Map:setBlock into the SHARED
|
||||
-- map record in Game.data, so the port has to put the closed block back
|
||||
-- itself or a solved barrier stays open for the rest of the session (#258).
|
||||
-- The closed ids are the shipped .blk bytes: VictoryRoad1F.blk (4,6) = $25,
|
||||
-- VictoryRoad2F.blk (3,4) = $37 and (11,7) = $25, VictoryRoad3F.blk
|
||||
-- (3,5) = $25. Same both-ways pattern as the card-key doors in
|
||||
-- OverworldState:setMap.
|
||||
M.VICTORY_ROAD_1F = {
|
||||
onEnter = function(game, ow)
|
||||
ow:replaceBlock(4, 6,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH and 0x1D or 0x25)
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH then
|
||||
ow:replaceBlock(4, 6, 0x1D)
|
||||
end
|
||||
end,
|
||||
onBoulderMoved = function(game, ow, npc)
|
||||
if npc.cellX == 17 and npc.cellY == 13
|
||||
@@ -766,14 +728,12 @@ M.VICTORY_ROAD_1F = {
|
||||
|
||||
M.VICTORY_ROAD_2F = {
|
||||
onEnter = function(game, ow)
|
||||
-- VictoryRoad2FResetBoulderEventScript (scripts/VictoryRoad2F.asm:19):
|
||||
-- entering 2F clears the 1F switch event, so 1F's barrier is closed
|
||||
-- again the next time you climb down (#258)
|
||||
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH = nil
|
||||
ow:replaceBlock(3, 4,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 and 0x15 or 0x37)
|
||||
ow:replaceBlock(11, 7,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 and 0x1D or 0x25)
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 then
|
||||
ow:replaceBlock(3, 4, 0x15)
|
||||
end
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 then
|
||||
ow:replaceBlock(11, 7, 0x1D)
|
||||
end
|
||||
end,
|
||||
onBoulderMoved = function(game, ow, npc)
|
||||
if npc.cellX == 1 and npc.cellY == 16
|
||||
@@ -791,8 +751,9 @@ M.VICTORY_ROAD_2F = {
|
||||
|
||||
M.VICTORY_ROAD_3F = {
|
||||
onEnter = function(game, ow)
|
||||
ow:replaceBlock(3, 5,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 and 0x1D or 0x25)
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 then
|
||||
ow:replaceBlock(3, 5, 0x1D)
|
||||
end
|
||||
end,
|
||||
onBoulderMoved = function(game, ow, npc)
|
||||
if npc.cellX == 3 and npc.cellY == 5
|
||||
@@ -800,23 +761,12 @@ M.VICTORY_ROAD_3F = {
|
||||
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = true
|
||||
ow:replaceBlock(3, 5, 0x1D)
|
||||
end
|
||||
-- the hole at (23,15): the boulder drops to 2F next to switch 2.
|
||||
-- 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
|
||||
-- the hole at (23,15): the boulder drops to 2F next to switch 2
|
||||
if npc.cellX == 23 and npc.cellY == 15 then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, overworld = ow, game = game }
|
||||
Commands.hide_object(ctx, "VICTORY_ROAD_3F", npc.def.name)
|
||||
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
|
||||
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
|
||||
end
|
||||
end,
|
||||
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
|
||||
|
||||
@@ -761,7 +761,7 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
|
||||
-- #118: do not raise mon.level until a paid retrieve (pokered reverts
|
||||
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
|
||||
-- mon.exp once and clear them so a second talk cannot re-apply the same
|
||||
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here --
|
||||
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here —
|
||||
-- TextBox.TOKENS.RAM only knows wStringBuffer.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -300,7 +300,7 @@ local function elevator(elevatorMapId, keyGate, preFrames)
|
||||
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
|
||||
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
|
||||
-- still seeded above so walking out returns to the entry floor
|
||||
-- instead of the car's ROM default (B1F) -- #90 / #105.
|
||||
-- instead of the car's ROM default (B1F) — #90 / #105.
|
||||
if keyGate and not game.save.inventory[keyGate.item] then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
|
||||
+19
-147
@@ -293,11 +293,7 @@ pewterEscort.guySteps = {
|
||||
"right", "right", "right",
|
||||
}
|
||||
|
||||
-- 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.
|
||||
-- Walk home: reverse of guySteps with opposite facings (gym → spawn).
|
||||
do
|
||||
local opp = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local ret = {}
|
||||
@@ -375,44 +371,20 @@ local function pewterGymEscort(game, ow)
|
||||
local head = plan.guyHeadStart
|
||||
|
||||
-- After the walk: face the player, restore map music, "Go take on
|
||||
-- BROCK", then MovementData_PewterGymGuyExit -- five steps RIGHT out of
|
||||
-- (12,18), the cell PewterCityYoungsterShowsPlayerGymScript pins him to
|
||||
-- with SetSpritePosition1 (hSpriteMapXCoord 16 / hSpriteMapYCoord 22,
|
||||
-- minus the +4 border offset object_event coords carry per
|
||||
-- macros/scripts/maps.asm) and exactly where the escort leaves him.
|
||||
-- That lands him on (17,18), the last walkable cell before the fence at
|
||||
-- (18,18) and one column past the screen edge with the player parked on
|
||||
-- (11,18). PewterCityHideYoungsterScript then HideObject's him and
|
||||
-- PewterCityResetYoungsterScript's SetSpritePosition2 + ShowObject put
|
||||
-- him back on his object_event spawn (35,16) facing DOWN, which is the
|
||||
-- snap below (the vanish/reappear is off screen, same as the original).
|
||||
--
|
||||
-- The old code retraced guyReturnSteps instead, whose first step is
|
||||
-- LEFT into (11,18) -- the cell the player is standing on -- and
|
||||
-- scriptMove is a pure tween with no entity test, so he walked straight
|
||||
-- through Red (#241). There is no honest route home: (17,18) is a
|
||||
-- dead-end pocket ((18,18) fence, (17,17) wall), which is precisely why
|
||||
-- the original teleports. guyReturnSteps stays as the documented
|
||||
-- mirror of RLEList_PewterGymGuy that parity_pewter_escort asserts.
|
||||
-- Brock victory still HideObject's him.
|
||||
-- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16).
|
||||
-- (pokered teleports him via MovementData_PewterGymGuyExit; we walk
|
||||
-- the same route home instead. Brock victory still HideObject's him.)
|
||||
local function walkHome()
|
||||
if not guy then return end
|
||||
local ret = pewterEscort.guyReturnSteps
|
||||
local i = 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
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
|
||||
if not ret[i] then
|
||||
guy.facing = "down"
|
||||
return
|
||||
end
|
||||
ow:scriptMove(guy, "right", 1, tick)
|
||||
ow:scriptMove(guy, ret[i], 1, tick)
|
||||
end
|
||||
tick()
|
||||
end
|
||||
@@ -496,48 +468,23 @@ end
|
||||
-- Gym) and again in Viridian Gym; we derive the same windows from the
|
||||
-- surrounding story flags so old saves work too.
|
||||
-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left).
|
||||
-- Both exit lists are keyed on wSavedCoordIndex -- WHICH entry of
|
||||
-- Route22DefaultScript.Route22RivalBattleCoords the player matched -- not
|
||||
-- on where the rival ended up. CheckCoords (home/map_objects.asm:107)
|
||||
-- zeroes wCoordIndex and `inc [hl]` BEFORE each compare, so the first
|
||||
-- entry reports 1: index 1 is (29,4), index 2 is (29,5).
|
||||
-- Route22Rival1AfterBattleScript (Route22.asm:175) takes
|
||||
-- ...ExitMovementData1 on index 1 and ...Data2 otherwise. From the top
|
||||
-- tile the rival stands BELOW the player on (29,5) and leaves east along
|
||||
-- row 5; from the bottom tile he stands LEFT of him on (28,5) and has to
|
||||
-- step UP to row 4 to get around him. The port had the two branches
|
||||
-- swapped, so the top tile started the walk with UP out of (28,4) into
|
||||
-- the cliff cell (28,3), which is not walkable (#236).
|
||||
local function route22ExitDirs(n, py)
|
||||
if n == 2 then
|
||||
-- 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
|
||||
if py == 5 then return { "left", "left", "left", "left" } end
|
||||
return { "left", "left", "left" }
|
||||
end
|
||||
-- Route22Rival1ExitMovementData1: (29,5) -> (31,5) -> (31,10)
|
||||
if py == 4 then
|
||||
if py == 5 then
|
||||
return { "right", "right", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
-- Route22Rival1ExitMovementData2: (28,5) -> (28,4) -> (31,4) -> (31,10)
|
||||
return { "up", "right", "right", "right",
|
||||
"down", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
|
||||
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
|
||||
-- Route22MoveRivalRightScript (Route22.asm:39) walks him RIGHT along his
|
||||
-- own row from the object_event spawn (25,5): the full four-RIGHT
|
||||
-- Route22RivalMovementData on coord index 1 (player on (29,4)), so he
|
||||
-- stops BELOW the player on (29,5); `inc de` drops one RIGHT on index 2
|
||||
-- (player on (29,5)), so he stops LEFT of him on (28,5). He never
|
||||
-- leaves row 5. Route22Rival{1,2}StartBattleScript (Route22.asm:110)
|
||||
-- then faces him UP on index 1 and RIGHT otherwise (#236).
|
||||
local rx = (py == 4) and 29 or 28
|
||||
local rivalFacing = (py == 4) and "up" or "right"
|
||||
return {
|
||||
{ "show_object", "ROUTE_22", objName }, -- 1
|
||||
{ "move_npc_to", objIndex, rx, 5 }, -- 2
|
||||
{ "face_object", objIndex, rivalFacing }, -- 3
|
||||
{ "move_npc_to", objIndex, 28, py }, -- 2
|
||||
{ "face_object", objIndex, "right" }, -- 3
|
||||
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
||||
{ "rival_battle", oppClass, baseParty }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
@@ -549,25 +496,20 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
|
||||
}
|
||||
end
|
||||
|
||||
-- Route22Rival{1,2}StartBattleScript turns the player toward him too:
|
||||
-- PLAYER_DIR_DOWN on coord index 1 (the (29,4) tile, rival below him),
|
||||
-- and the PLAYER_DIR_LEFT Route22DefaultScript already set on index 2
|
||||
-- (the (29,5) tile, rival to his left) (#236).
|
||||
M.ROUTE_22 = {
|
||||
onStep = function(game, ow, x, y)
|
||||
if not inCoords({ { 29, 4 }, { 29, 5 } }, x, y) then return false end
|
||||
local f = game.save.flags
|
||||
local playerFacing = (y == 4) and "down" or "left"
|
||||
if f.EVENT_GOT_POKEDEX and not f.EVENT_BEAT_BROCK
|
||||
and not f.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then
|
||||
return runAmbush(game, ow,
|
||||
route22Scene(1, 1, "ROUTE22_RIVAL1", "OPP_RIVAL1", 4,
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), playerFacing)
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), "left")
|
||||
end
|
||||
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
|
||||
return runAmbush(game, ow,
|
||||
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing)
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), "left")
|
||||
end
|
||||
return false
|
||||
end,
|
||||
@@ -603,7 +545,7 @@ end
|
||||
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
|
||||
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket
|
||||
-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 /
|
||||
-- Hide ROCKET → GBFadeInFromBlack -- not a bare hide_object.
|
||||
-- Hide ROCKET → GBFadeInFromBlack — not a bare hide_object.
|
||||
local rocketRows = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
||||
@@ -622,7 +564,7 @@ local rocketRows = {
|
||||
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
||||
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
||||
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
||||
-- door neighbour — the swap reconnects the city (Bill's ticket does
|
||||
-- the same in story.lua; either route is enough).
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||
@@ -686,83 +628,13 @@ M.MUSEUM_1F = {
|
||||
}
|
||||
|
||||
-- The Pewter Center's singing JIGGLYPUFF (scripts/PewterPokecenter.asm
|
||||
-- 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
|
||||
|
||||
-- plays MUSIC_JIGGLYPUFF_SONG, then the map theme resumes)
|
||||
M.PEWTER_POKECENTER = {
|
||||
talk = {
|
||||
TEXT_PEWTERPOKECENTER_JIGGLYPUFF = function(game, ow, npc, done)
|
||||
-- not the local push() helper: this box needs opts.auto, which is
|
||||
-- also what suppresses the blinking arrow and the A dismissal
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
|
||||
done, { auto = jigglypuffDance(game, npc) }))
|
||||
require("src.core.Music").playOnce(game.data, "Music_JigglypuffSong")
|
||||
push(game, text(game)._PewterPokecenterJigglypuffText
|
||||
or "JIGGLYPUFF: Puu\npupuu!", done)
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
-- `dialogue` is the end-battle + post-battle text chain each gym leader
|
||||
-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM
|
||||
-- script). Leaders are not def_trainers entries, so engageTrainer has
|
||||
-- no header.won -- checkVictoryRewards shows this chain instead of a
|
||||
-- no header.won — checkVictoryRewards shows this chain instead of a
|
||||
-- synthetic "received badge/TM" stub.
|
||||
|
||||
local function range(prefix, first, last)
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
# Anbernic RG34XXSP (Stock OS 64-bit MOD)
|
||||
|
||||
Download `gen1recomp-*-rg34xxsp-stockos64-mod.zip` from
|
||||
[Releases](https://github.com/bryanthaboi/gen1recomp/releases). This build
|
||||
targets **Stock OS 64-bit MOD** on the RG34XXSP with PortMaster installed
|
||||
(TF1).
|
||||
|
||||
## Install
|
||||
|
||||
1. Unzip the release on your computer. You get `Gen1recomp.sh` and a
|
||||
`gen1recomp/` folder.
|
||||
2. Copy **both** onto the SD card under **`Roms/PORTS/`** so the layout is:
|
||||
|
||||
```
|
||||
Roms/PORTS/Gen1recomp.sh
|
||||
Roms/PORTS/gen1recomp/
|
||||
```
|
||||
|
||||
On the device that path is `/mnt/mmc/Roms/PORTS/`. Keep the launcher and
|
||||
the `gen1recomp/` folder as siblings — do not nest the `.sh` inside the
|
||||
folder.
|
||||
3. Put your legal US Red and/or Blue `.gb` files inside the game folder:
|
||||
|
||||
```
|
||||
Roms/PORTS/gen1recomp/lovegame/
|
||||
```
|
||||
|
||||
Example: `Roms/PORTS/gen1recomp/lovegame/Pokemon - Red Version.gb`
|
||||
4. Eject the card, boot the handheld, open **Ports → Gen1recomp**.
|
||||
5. On the launcher, move the cursor with the D-pad or left stick, press **A**
|
||||
to click. Choose the Red or Blue tab, then **Choose ROM** — with no file
|
||||
picker on stock OS, that scans `lovegame/` for the `.gb` you dropped in.
|
||||
|
||||
The port ships with `portable.txt` already in place, so after import the
|
||||
saves and ROM-derived cache stay next to the game on the SD card. See
|
||||
[Portable Mode](../README.md#portable-mode) for what that means.
|
||||
|
||||
Only the canonical 1 MiB US carts import:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
|
||||
## Launcher controls
|
||||
|
||||
| Input | Action |
|
||||
| ------------------ | ------------------ |
|
||||
| D-pad / left stick | Move cursor |
|
||||
| A | Click |
|
||||
| L1 / R1 | Switch tabs |
|
||||
| Right stick | Scroll lists |
|
||||
| Start / Select | Play or Choose ROM |
|
||||
|
||||
In-game controls use the normal PortMaster / SDL pad map, rebindable under
|
||||
**OPTIONS → CONTROLS**.
|
||||
|
||||
## Notes
|
||||
|
||||
**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`,
|
||||
which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a
|
||||
level carried over in an `options.lua` from another machine. The H700's Mali
|
||||
GPU is in the same class as the phone GPUs that compile that present pass and
|
||||
then show a black frame (issue #136), and `love.system.getOS()` reports
|
||||
`"Linux"` here, so the Android gate would not have caught it. Every other
|
||||
display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
|
||||
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
|
||||
put the row back.
|
||||
|
||||
The pack bundles the LÖVE 11.5 aarch64 runtime from
|
||||
[PortMaster](https://portmaster.games/), so the device does not need a
|
||||
separate `love_11.5` runtime download on first launch. The launcher resolves
|
||||
paths relative to its own directory rather than PortMaster's `$directory`,
|
||||
because stock Anbernic firmware runs ports out of `roms/PORTS/` with its own
|
||||
casing and mount points.
|
||||
|
||||
If a launch fails, `Roms/PORTS/gen1recomp/log.txt` holds the output of the
|
||||
last run.
|
||||
|
||||
## Building the port
|
||||
|
||||
Release runs build this automatically (see `.github/workflows/release.yml`).
|
||||
To build it by hand:
|
||||
|
||||
```sh
|
||||
./build-rg34xxsp.sh --version 0.1.0 # -> dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
|
||||
```
|
||||
|
||||
`install-rg34xxsp.sh` copies that pack straight onto a mounted SD card for
|
||||
local testing.
|
||||
|
||||
Stock OS 64-bit MOD comes from
|
||||
[cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
|
||||
@@ -59,7 +59,7 @@ the same core data and graphics into the source tree for verification.
|
||||
| | `src/battle/Experience.lua`, `Catching.lua`, `TrainerAI.lua` | exp/levels, Gen 1 catch algorithm, AI |
|
||||
| | `src/battle/rulesets/` | `gen1_faithful` (default) vs `modern_clean` |
|
||||
| ui | `src/ui/*` | start menu, generic menu, yes/no box, party/bag lists |
|
||||
| | `tools/save-editor/` | Save editor: shipped in every build, opened from the launcher's Edit button or standalone with `love . --editor` |
|
||||
| | `tools/save-editor/` | Standalone save editor (`love . --editor`) |
|
||||
|
||||
## Map scripts
|
||||
|
||||
|
||||
+15
-56
@@ -5,39 +5,19 @@ that runs before `Game:load`. Besides ROM import (see the file's own header)
|
||||
it hosts a tabbed shell covering per-game save slots and a mod manager. This
|
||||
file documents the runtime model; the visual spec lives separately.
|
||||
|
||||
## Android multi-ROM / mod / save import
|
||||
## Android multi-ROM import
|
||||
|
||||
On Android, `love.system.pickFile([kind])` opens the Storage Access Framework
|
||||
picker (`GameActivity.showFilePicker`); the chosen file is copied into the app
|
||||
save directory as:
|
||||
|
||||
| `kind` | Destination |
|
||||
| --- | --- |
|
||||
| nil / `"rom"` | `picked_rom.gb` (open) |
|
||||
| `"mod"` | `picked_mod.zip` (open) |
|
||||
| `"sav"` / `"save"` | `picked_save.sav` (open) |
|
||||
|
||||
Export uses a separate API: `love.system.createFile(suggestedName)` →
|
||||
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
|
||||
staged `pending_export.sav` to the user-chosen URI and writes `export_done.flag`
|
||||
for the launcher to acknowledge on refocus.
|
||||
|
||||
`RomImporter` then imports on refocus / Choose:
|
||||
|
||||
- **ROMs** via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps to a
|
||||
version that is **not** yet ready counts as pending. A leftover
|
||||
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
|
||||
- **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any
|
||||
other `.zip` at the save-dir root (USB copy).
|
||||
- **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any
|
||||
other `.sav` at the save-dir root.
|
||||
|
||||
After a successful import the consumed save-dir file is removed.
|
||||
On Android, `love.system.pickFile()` opens the Storage Access Framework
|
||||
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to
|
||||
`picked_rom.gb` in the app save directory. `RomImporter` then imports on
|
||||
refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps
|
||||
to a version that is **not** yet ready counts as pending. A leftover
|
||||
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
|
||||
After a successful import the consumed save-dir `.gb` is removed.
|
||||
|
||||
**Manual check (device/emulator):** import Red → switch to Blue → Choose →
|
||||
system file picker must appear (not a silent Red re-extract) → pick Blue →
|
||||
Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the
|
||||
same system picker and install the chosen archive on return.
|
||||
Blue becomes ready beside Red.
|
||||
|
||||
## Tab structure
|
||||
|
||||
@@ -69,15 +49,6 @@ keeps working unchanged.
|
||||
- **Registry.** The ordered slot list and which one is active persist in
|
||||
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
|
||||
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
|
||||
Custom slot labels (#205) live alongside them in the same registry:
|
||||
`options.saveSlots[version].names = { slot1 = "Nuzlocke" }`, written by
|
||||
`SaveData.renameSlot` (trimmed; an empty label clears it) and surfaced on
|
||||
each `listSlots` row as `label` (the launcher row shows `label`, falling
|
||||
back to the player name). `deleteSlot` drops the label with the slot.
|
||||
Renaming never touches the save file, so an empty slot can be labeled.
|
||||
On desktop, right-clicking a slot row opens the inline rename modal
|
||||
(Enter commits, Esc cancels); touch has no secondary button, so the
|
||||
affordance is desktop-only.
|
||||
- **Active slot resolution.** `saveNames(version)`, the function every
|
||||
existing caller (`TitleState` hasSave/load/save, recovery order) already
|
||||
goes through, now resolves the *active* slot instead of a fixed flat name.
|
||||
@@ -107,10 +78,6 @@ The launcher-facing API:
|
||||
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
|
||||
save file written**. An empty slot means the title screen offers NEW GAME
|
||||
only, which needs no further changes.
|
||||
- `SaveData.deleteSlot(version, slotId)` removes the slot's
|
||||
main/`.bak`/`.tmp` files, drops it from the registry, and if it was active
|
||||
points active at another remaining slot (or clears active when the list is
|
||||
empty). The launcher's SAVE SLOT panel Delete control calls this.
|
||||
|
||||
## Launcher mod manager
|
||||
|
||||
@@ -147,9 +114,6 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
|
||||
temp first (mount only reaches save-dir-relative paths), the same way
|
||||
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
|
||||
back, and every path unmounts and clears the staged temp file.
|
||||
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
|
||||
`options.mods[id]` so a later reinstall starts from the loader's default
|
||||
(enabled). The mods panel Delete control calls this and re-derives the list.
|
||||
|
||||
## Import / Export save
|
||||
|
||||
@@ -158,10 +122,9 @@ through `src/import/SaveFileIO.lua`, which sits on top of
|
||||
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
|
||||
|
||||
- **Import save** is live once the game's ROM is imported (playable). It opens
|
||||
a native `.sav` picker (`chooseSav` on desktop; on Android,
|
||||
`love.system.pickFile("sav")` → `picked_save.sav`, same SAF path as ROMs).
|
||||
`SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
|
||||
relative name, a dropped LOVE file, or raw bytes),
|
||||
a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`;
|
||||
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot`
|
||||
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes),
|
||||
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
|
||||
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
|
||||
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
|
||||
@@ -173,13 +136,9 @@ through `src/import/SaveFileIO.lua`, which sits on top of
|
||||
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
|
||||
`rawImport`, so this is a zero-filled template export, which is valid), and
|
||||
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
|
||||
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
|
||||
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
|
||||
shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
|
||||
On Android the bytes are also staged as `pending_export.sav` and
|
||||
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
|
||||
player can save to Downloads / Drive / etc.; on return `export_done.flag`
|
||||
makes focus show "Save exported."
|
||||
(`love.filesystem.createDirectory("exports")`). It returns the absolute path
|
||||
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a
|
||||
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
|
||||
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
|
||||
currently active game tab; when a non-game tab (mods, or the locked yellow
|
||||
placeholder) is showing it defaults to red, the always-present first game
|
||||
|
||||
+6
-6
@@ -1,21 +1,21 @@
|
||||
# Native modding
|
||||
|
||||
The modding book lives on the
|
||||
[project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
|
||||
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
|
||||
|
||||
- [Getting started](https://github.com/bryanthaboi/gen1recomp/wiki/Getting-Started)
|
||||
- [Getting started](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Getting-Started)
|
||||
— install a mod, write a first one, enable and disable it.
|
||||
- [Tutorials](https://github.com/bryanthaboi/gen1recomp/wiki/Tutorials)
|
||||
- [Tutorials](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Tutorials)
|
||||
— twelve dependency-ordered rungs, each a runnable mod.
|
||||
- [Cookbook](https://github.com/bryanthaboi/gen1recomp/wiki/Cookbook)
|
||||
- [Cookbook](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Cookbook)
|
||||
— task-sized recipes.
|
||||
- [Registry reference](https://github.com/bryanthaboi/gen1recomp/wiki/Reference-Registries)
|
||||
- [Registry reference](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Reference-Registries)
|
||||
— every registry, generated from `src/mods/Schemas.lua`.
|
||||
|
||||
Regenerate the reference straight into a wiki checkout:
|
||||
|
||||
```sh
|
||||
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
|
||||
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
|
||||
```
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
+7
-151
@@ -86,7 +86,7 @@ Game Boy equivalent:
|
||||
## Colors mode
|
||||
|
||||
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**.
|
||||
through **OG RED → SGB → RED++ → OG → OG INV → SGB INV → CLASSIC → OG RED**.
|
||||
The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||
|
||||
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
|
||||
@@ -97,9 +97,8 @@ The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||
- **SGB** (default): the per-map Super Game Boy region palettes
|
||||
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
||||
real SGB. (This is the mode formerly mislabeled "GBC".)
|
||||
- **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
||||
per-species mon colors (`data/palettes_gbc.lua`). (Formerly labeled
|
||||
"RED++"; it is the richest colorization rather than anything Red-specific.)
|
||||
- **RED++**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
||||
per-species mon colors (`data/palettes_gbc.lua`).
|
||||
- **OG**: force the four DMG grays (colorization off).
|
||||
- **OG INV**: inverted DMG grays.
|
||||
- **SGB INV**: each SGB zone palette with shade order reversed.
|
||||
@@ -110,8 +109,7 @@ The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
|
||||
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
|
||||
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
|
||||
override in the overworld pass). Persisted as `save.options.colors`; the
|
||||
`gbc` / `gbc_inv` / `redpp` save ids are kept for back-compat under the new
|
||||
labels.
|
||||
`gbc` / `gbc_inv` save ids are kept for back-compat under the new labels.
|
||||
|
||||
## GBC FX
|
||||
|
||||
@@ -129,14 +127,6 @@ It runs as a final present pass after world + UI composite in
|
||||
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
|
||||
Default OFF; persisted as `save.options.gbcfx`.
|
||||
|
||||
Mobile GPUs often compile the pass but present a black frame, so Android and
|
||||
iOS hide the row entirely, pin the level to OFF, and rewrite a level already
|
||||
persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that
|
||||
decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the
|
||||
effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
|
||||
its launcher because the device reports `"Linux"` while its GPU is in the
|
||||
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
|
||||
|
||||
## Peer-to-peer link play (lua-enet)
|
||||
|
||||
Trades and link battles connect two copies of the game directly over
|
||||
@@ -151,30 +141,6 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
|
||||
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
||||
also exercises real UDP pairing.
|
||||
|
||||
## Fair play in link and online matches
|
||||
|
||||
A link session is decided by the battle and nothing else, so for its
|
||||
duration:
|
||||
|
||||
- **Game speed is pinned to normal.** The GAME SPEED option and
|
||||
`POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it
|
||||
closes, and apply again after. Fast-forward otherwise runs one peer's
|
||||
queue faster than the peer it is locked to and drains a tournament shot
|
||||
clock faster than the opponent racing it.
|
||||
- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with
|
||||
mods enabled offers to switch them all off and relaunch (mods merge at
|
||||
boot, so a restart is the only way). The restart is confirmed, not
|
||||
silent. They stay listed as disabled, ready to switch back on.
|
||||
- **Only a meaningful split ends a match.** The per-turn state signature
|
||||
both peers exchange is split three ways: `actives` and `bench` carry
|
||||
species, HP, status, stat stages, PP and the rest of the party, and a
|
||||
divergence there ends the match as a draw. `volatile` carries per-turn
|
||||
flags both sides recompute anyway - a divergence there is logged and
|
||||
reported to mods, and play continues.
|
||||
|
||||
The relay logs which component diverged on which turn, so a desync report
|
||||
names something specific.
|
||||
|
||||
## Custom boot text
|
||||
|
||||
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
|
||||
@@ -195,9 +161,8 @@ migrated once into `options.lua` on load.
|
||||
- Music / SFX volume
|
||||
- Music Filter
|
||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
||||
- COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also
|
||||
hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc
|
||||
- COLORS (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also
|
||||
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
|
||||
SuperPalettes + per-species mon colors)
|
||||
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
|
||||
@@ -216,36 +181,6 @@ void outside the OG wipe fills in lockstep. Once the battle state is up,
|
||||
letterbox voids around the battle canvas fill **white** instead of black
|
||||
so the whole window reads as one continuous battle screen.
|
||||
|
||||
## Widescreen battle layout
|
||||
|
||||
Options **BATTLE LAYOUT** picks the battle screen's composition: **OG**
|
||||
(the default: the original 160×144 arrangement, unchanged) or **WIDE**,
|
||||
which gives battles a 304×144 native-pixel surface and a Gen 3-style
|
||||
arrangement on it:
|
||||
|
||||
- the foe's status box upper left, the foe's picture upper right;
|
||||
- the player's picture lower left, the player's status box lower right,
|
||||
with a longer HP bar and the numeric HP under it;
|
||||
- a full-width message window;
|
||||
- a split "What will X do?" prompt / 2×2 command window;
|
||||
- a 2×2 move menu, navigated with all four directions, with a PP and type
|
||||
panel attached to its right.
|
||||
|
||||
Only the composition changes. Pictures, palettes, HP-bar colors, font
|
||||
pages, window borders, sounds, animations, timing and every battle rule
|
||||
stay the engine's, so a COLORS mode or an asset mod still owns the look.
|
||||
Each side's picture keeps its original pixels and placement math and is
|
||||
composited into its own region of the wider battlefield -- nothing is
|
||||
scaled or squeezed -- and animations, which are authored in the original
|
||||
160-pixel space, shift as one rigid group onto whichever side they play
|
||||
on. The whole screen is drawn at the window's integer fit scale for the
|
||||
wider surface, so a 304-pixel screen is drawn a step smaller than a
|
||||
160-pixel one in the same window.
|
||||
|
||||
The wide surface is live only while the battle itself is the screen on
|
||||
top: a party menu, the bag or a nickname prompt is a 160×144 screen and
|
||||
brings the classic surface back with it.
|
||||
|
||||
## On-screen touch controls (mobile)
|
||||
|
||||
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
|
||||
@@ -258,83 +193,4 @@ appears while no controller is being used: the first gamepad button or
|
||||
stick push hides it, the next screen touch brings it back, and unplugging
|
||||
the last controller restores it immediately. Layout re-derives from the
|
||||
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||
## 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.
|
||||
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||
+1
-1
@@ -65,7 +65,7 @@ Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||
|
||||
A release missing either asset is treated as "no in-place update available":
|
||||
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
|
||||
(`https://github.com/bryanthaboi/gen1recomp/releases/latest`).
|
||||
(`https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest`).
|
||||
|
||||
## Save-directory layout
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
#!/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,12 +1,8 @@
|
||||
-- Native LÖVE2D port of Pokemon Red. A packaged build creates its private
|
||||
-- game-data cache from a user-provided ROM on first boot.
|
||||
--
|
||||
-- The save editor (tools/save-editor/) ships inside every build and is
|
||||
-- reachable two ways:
|
||||
-- * standalone: POKEPORT_EDITOR=1 or `love . --editor`, its own window
|
||||
-- * from the launcher: Edit on a save row, which suspends the launcher,
|
||||
-- opens the editor on that slot's file, and restores the launcher when
|
||||
-- the editor's Close button is pressed (openEditor / closeEditor below)
|
||||
-- Set POKEPORT_EDITOR=1 or pass `--editor` to `love .` to boot the save
|
||||
-- editor tool (tools/save-editor/) instead of the game.
|
||||
|
||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||
|
||||
@@ -35,105 +31,6 @@ local function scriptedIterations()
|
||||
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------ save editor
|
||||
-- The launcher instance parked while the editor is up, plus the version whose
|
||||
-- cache the editor mounted (so closing can put the read path back).
|
||||
local editorHost, editorVersion, editorWindow
|
||||
local closeEditor -- forward declaration: openEditor hands it to the editor
|
||||
|
||||
-- The editor's modules use flat names (require("Kit"), require("Party")), so
|
||||
-- their directories have to be on the require path. It must be
|
||||
-- love.filesystem's path, not package.path: in a packaged build these files
|
||||
-- live inside the .love archive, which the stock Lua searcher cannot open.
|
||||
local function addEditorRequirePath()
|
||||
local fs = love.filesystem
|
||||
if not (fs.setRequirePath and fs.getRequirePath) then
|
||||
-- very old LOVE: a source checkout still resolves through package.path
|
||||
package.path = fs.getSource() .. "/tools/save-editor/?.lua;"
|
||||
.. fs.getSource() .. "/tools/save-editor/panels/?.lua;"
|
||||
.. package.path
|
||||
return
|
||||
end
|
||||
local current = fs.getRequirePath()
|
||||
if current:find("tools/save%-editor") then return end
|
||||
fs.setRequirePath("tools/save-editor/?.lua;tools/save-editor/panels/?.lua;"
|
||||
.. current)
|
||||
end
|
||||
|
||||
-- Desktop only: the launcher window (1024x768) is tighter than the editor's
|
||||
-- design size, so grow it while editing and put it back on Close. Never
|
||||
-- shrinks, never touches a fullscreen or mobile window.
|
||||
local function resizeForEditor()
|
||||
if not (love.window and love.window.getMode and love.window.setMode) then return end
|
||||
local osName = love.system.getOS()
|
||||
if osName ~= "OS X" and osName ~= "Windows" and osName ~= "Linux" then return end
|
||||
local w, h, flags = love.window.getMode()
|
||||
if flags.fullscreen then return end
|
||||
local dw, dh = love.window.getDesktopDimensions()
|
||||
local wantW = math.max(w, math.min(1360, math.floor((dw or w) * 0.92)))
|
||||
local wantH = math.max(h, math.min(860, math.floor((dh or h) * 0.88)))
|
||||
if wantW <= w and wantH <= h then return end
|
||||
editorWindow = { w = w, h = h }
|
||||
love.window.setMode(wantW, wantH, flags)
|
||||
end
|
||||
|
||||
local function restoreWindow()
|
||||
if not editorWindow then return end
|
||||
local _, _, flags = love.window.getMode()
|
||||
love.window.setMode(editorWindow.w, editorWindow.h, flags)
|
||||
editorWindow = nil
|
||||
end
|
||||
|
||||
-- Open the editor on a launcher save row. The version's cache has to be
|
||||
-- mounted before the editor's Data:load runs, or a Blue save would be edited
|
||||
-- against Red's species/item tables.
|
||||
local function openEditor(version, slotId)
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local path = SaveData.slotDiskPath(version, slotId)
|
||||
if not path then
|
||||
if Importer then
|
||||
Importer.saveNotice = Importer.saveNotice or {}
|
||||
Importer.saveNotice[version] =
|
||||
{ ok = false, text = "Could not resolve that save slot on disk." }
|
||||
end
|
||||
return
|
||||
end
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
GameVersion.set(version)
|
||||
require("src.import.CacheFs").mountVersion(version)
|
||||
editorVersion = version
|
||||
editorHost = Importer
|
||||
Importer = nil
|
||||
editorMode = true
|
||||
resizeForEditor()
|
||||
addEditorRequirePath()
|
||||
EditorApp = require("App")
|
||||
EditorApp.load(path, { version = version, slotId = slotId, embedded = true,
|
||||
onClose = function() closeEditor() end })
|
||||
end
|
||||
|
||||
-- Back to the launcher. Everything the editor mounted or cached has to come
|
||||
-- back out: the version overlay (CacheFs) and the generated modules require
|
||||
-- cached behind it (Data), or pressing Play on the OTHER game would boot it
|
||||
-- with this one's data.
|
||||
function closeEditor()
|
||||
local version = editorVersion
|
||||
editorMode = false
|
||||
if EditorApp and EditorApp.unload then EditorApp.unload() end
|
||||
EditorApp = nil
|
||||
if version then
|
||||
require("src.import.CacheFs").unmountVersion(version)
|
||||
require("src.core.Data"):unloadGenerated()
|
||||
end
|
||||
editorVersion = nil
|
||||
restoreWindow()
|
||||
Importer = editorHost
|
||||
editorHost = nil
|
||||
if Importer and version and Importer.savesChanged then
|
||||
Importer:savesChanged(version)
|
||||
end
|
||||
end
|
||||
|
||||
local function bootGame(version)
|
||||
-- The launcher hands us the chosen game (Red / Blue); scripted and headless
|
||||
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
|
||||
@@ -184,17 +81,12 @@ function love.load(args)
|
||||
end
|
||||
love.graphics.setDefaultFilter("nearest", "nearest")
|
||||
|
||||
-- Standalone editor. A bare `--editor` run has no launcher behind it, so
|
||||
-- Close quits; --save points it at a specific file, otherwise it opens the
|
||||
-- default save path for POKEPORT_VERSION (Red unless overridden), whose
|
||||
-- cache has to be mounted before the editor's Data:load.
|
||||
if editorMode then
|
||||
local version = os.getenv("POKEPORT_VERSION") or "red"
|
||||
require("src.core.GameVersion").set(version)
|
||||
require("src.import.CacheFs").mountVersion(version)
|
||||
addEditorRequirePath()
|
||||
package.path = love.filesystem.getSource() .. "/tools/save-editor/?.lua;"
|
||||
.. love.filesystem.getSource() .. "/tools/save-editor/panels/?.lua;"
|
||||
.. package.path
|
||||
EditorApp = require("App")
|
||||
EditorApp.load(savePath, { version = version })
|
||||
EditorApp.load(savePath)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -235,11 +127,10 @@ function love.load(args)
|
||||
-- column shows Play when that game's ROM is already imported, or Choose ROM
|
||||
-- / drag-drop when it is not (Yellow is still a placeholder). Any dropped
|
||||
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
|
||||
-- Edit on a save row opens the bundled editor on that slot (openEditor).
|
||||
Importer = RomImporter.new(function(version)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
|
||||
end, { launcher = true, forceImport = forceImport })
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
@@ -315,19 +206,19 @@ end
|
||||
|
||||
function love.gamepadpressed(joystick, button)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||
if Importer then return end
|
||||
Game:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
function love.gamepadreleased(joystick, button)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||
if Importer then return end
|
||||
Game:gamepadreleased(joystick, button)
|
||||
end
|
||||
|
||||
function love.gamepadaxis(joystick, axis, value)
|
||||
if editorMode then return end
|
||||
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||
if Importer then return end
|
||||
Game:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
@@ -411,7 +302,7 @@ function love.mousemoved(x, y)
|
||||
end
|
||||
|
||||
function love.textinput(text)
|
||||
if Importer then return Importer:textinput(text) end
|
||||
if Importer then return end
|
||||
if editorMode and EditorApp.textinput then
|
||||
return EditorApp.textinput(text)
|
||||
end
|
||||
|
||||
+5
-5
@@ -43,11 +43,11 @@ The embedded `game.love` deliberately excludes `data/generated/`,
|
||||
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
|
||||
`tools/rom_manifest.json`.
|
||||
|
||||
ROM / mod / save import on Android uses `love.system.pickFile([kind])` →
|
||||
`GameActivity.showFilePicker` (Storage Access Framework), which copies the
|
||||
chosen file under the app save directory as `picked_rom.gb`,
|
||||
`picked_mod.zip`, or `picked_save.sav`. `RomImporter` imports pending files
|
||||
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
|
||||
ROM import on Android uses `love.system.pickFile()` →
|
||||
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the
|
||||
chosen file to `picked_rom.gb` under the app save directory. `RomImporter`
|
||||
imports pending (not-yet-ready) `.gb` files from that folder on Choose /
|
||||
refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload
|
||||
itself remains data-free (no embedded ROM or generated cache).
|
||||
|
||||
### SDK / NDK
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" >
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<!-- Link play (src/link/): both the LAN backend (enet UDP) and the relay
|
||||
backend (luasocket TCP) are ordinary sockets, and Android denies those
|
||||
to a UID without INTERNET. Bind and connect then come back EPERM, which
|
||||
the link screen shows as "(Operation not permitted)" (issue #287).
|
||||
scripts/build_android.sh must not strip this again. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- OpenGL ES 2.0 -->
|
||||
<uses-feature android:glEsVersion="0x00020000" />
|
||||
<!-- Touchscreen support -->
|
||||
|
||||
@@ -18,4 +18,4 @@ android.useAndroidX=true
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=true
|
||||
app.name=gen1recomp
|
||||
app.name=Pokemon Red
|
||||
|
||||
@@ -183,37 +183,13 @@ void vibrate(double seconds)
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
bool showFilePicker(const char *destFilename)
|
||||
bool showFilePicker()
|
||||
{
|
||||
if (destFilename == nullptr || destFilename[0] == '\0')
|
||||
destFilename = "picked_rom.gb";
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
|
||||
"(Ljava/lang/String;)Z");
|
||||
jstring jname = env->NewStringUTF(destFilename);
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||
env->DeleteLocalRef(jname);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool showCreateDocument(const char *suggestedName)
|
||||
{
|
||||
if (suggestedName == nullptr || suggestedName[0] == '\0')
|
||||
suggestedName = "export.sav";
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
|
||||
"(Ljava/lang/String;)Z");
|
||||
jstring jname = env->NewStringUTF(suggestedName);
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||
env->DeleteLocalRef(jname);
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z");
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
|
||||
@@ -63,16 +63,9 @@ void vibrate(double seconds);
|
||||
* Shows the system's "pick a document" UI (Storage Access Framework).
|
||||
* Returns true if the picker was launched; the picked file (if any) is
|
||||
* copied asynchronously by GameActivity.onActivityResult into the app's
|
||||
* external save directory under destFilename (default picked_rom.gb), not
|
||||
* returned here -- see src/import/RomImporter.lua.
|
||||
* external save directory, not returned here -- see src/import/RomImporter.lua.
|
||||
**/
|
||||
bool showFilePicker(const char *destFilename = nullptr);
|
||||
|
||||
/**
|
||||
* Shows ACTION_CREATE_DOCUMENT so Lua can export a staged pending_export.sav
|
||||
* to a user-chosen location. suggestedName is the dialog default filename.
|
||||
**/
|
||||
bool showCreateDocument(const char *suggestedName = nullptr);
|
||||
bool showFilePicker();
|
||||
|
||||
/*
|
||||
* Helper functions for the filesystem module
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
#include "common/config.h"
|
||||
#include "System.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#if defined(LOVE_MACOSX)
|
||||
#include <CoreServices/CoreServices.h>
|
||||
#elif defined(LOVE_IOS)
|
||||
@@ -182,32 +180,11 @@ void System::vibrate(double seconds) const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::pickFile(const char *kind) const
|
||||
bool System::pickFile() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
const char *dest = "picked_rom.gb";
|
||||
if (kind != nullptr)
|
||||
{
|
||||
if (strcmp(kind, "mod") == 0)
|
||||
dest = "picked_mod.zip";
|
||||
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
||||
dest = "picked_save.sav";
|
||||
else if (strcmp(kind, "rom") == 0)
|
||||
dest = "picked_rom.gb";
|
||||
}
|
||||
return love::android::showFilePicker(dest);
|
||||
return love::android::showFilePicker();
|
||||
#else
|
||||
LOVE_UNUSED(kind);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::createFile(const char *suggestedName) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::showCreateDocument(suggestedName);
|
||||
#else
|
||||
LOVE_UNUSED(suggestedName);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -111,21 +111,9 @@ public:
|
||||
* Android only for now; the result (if any) is not returned here -- see
|
||||
* love::android::showFilePicker and src/import/RomImporter.lua.
|
||||
*
|
||||
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
|
||||
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
|
||||
* @return Whether the picker was shown.
|
||||
**/
|
||||
virtual bool pickFile(const char *kind = nullptr) const;
|
||||
|
||||
/**
|
||||
* Shows the platform's native "create / save a file" UI (Android SAF
|
||||
* ACTION_CREATE_DOCUMENT). Copies staged pending_export.sav from the app
|
||||
* save directory to the user-chosen URI. See GameActivity.showCreateDocument.
|
||||
*
|
||||
* @param suggestedName Default filename shown in the dialog.
|
||||
* @return Whether the create dialog was shown.
|
||||
**/
|
||||
virtual bool createFile(const char *suggestedName = nullptr) const;
|
||||
virtual bool pickFile() const;
|
||||
|
||||
/**
|
||||
* Gets if the user is playing music on background.
|
||||
|
||||
@@ -97,15 +97,7 @@ int w_vibrate(lua_State *L)
|
||||
|
||||
int w_pickFile(lua_State *L)
|
||||
{
|
||||
const char *kind = luaL_optstring(L, 1, nullptr);
|
||||
luax_pushboolean(L, instance()->pickFile(kind));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_createFile(lua_State *L)
|
||||
{
|
||||
const char *suggested = luaL_optstring(L, 1, nullptr);
|
||||
luax_pushboolean(L, instance()->createFile(suggested));
|
||||
luax_pushboolean(L, instance()->pickFile());
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -125,7 +117,6 @@ static const luaL_Reg functions[] =
|
||||
{ "openURL", w_openURL },
|
||||
{ "vibrate", w_vibrate },
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "createFile", w_createFile },
|
||||
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
||||
{ 0, 0 }
|
||||
};
|
||||
|
||||
@@ -22,15 +22,12 @@ package org.love2d.android;
|
||||
|
||||
import org.libsdl.app.SDLActivity;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -64,24 +61,13 @@ public class GameActivity extends SDLActivity {
|
||||
protected final int[] recordAudioRequestDummy = new int[1];
|
||||
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
|
||||
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
|
||||
public static final int FILE_PICKER_REQUEST_CODE = 4;
|
||||
public static final int FILE_CREATE_REQUEST_CODE = 5;
|
||||
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
|
||||
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
|
||||
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
|
||||
public static final int ROM_PICKER_REQUEST_CODE = 4;
|
||||
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM
|
||||
// is dropped so RomImporter's existing folder scan finds it -- see
|
||||
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
|
||||
// save directory to getExternalFilesDir()/save/<identity>).
|
||||
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
|
||||
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
|
||||
private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
|
||||
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
|
||||
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
|
||||
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
|
||||
// Destination basename for the in-flight SAF pick (set by showFilePicker).
|
||||
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
||||
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
||||
private String pendingCreateSuggestedName = "export.sav";
|
||||
private static boolean immersiveActive = false;
|
||||
private static boolean needToCopyGameInArchive = false;
|
||||
private boolean storagePermissionUnnecessary = false;
|
||||
@@ -355,185 +341,59 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
/**
|
||||
* Shows the system document picker (Storage Access Framework) so the
|
||||
* player can pick a ROM / mod / save from anywhere (Downloads, Drive,
|
||||
* etc.) without needing to know where the app's external files folder
|
||||
* is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
|
||||
* player can pick their ROM from anywhere (Downloads, Drive, etc.)
|
||||
* without needing to know where the app's external files folder is.
|
||||
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
|
||||
* arrives later in onActivityResult, not synchronously here.
|
||||
*
|
||||
* @param destFilename basename under the app save identity (e.g.
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||
*/
|
||||
@Keep
|
||||
public static boolean showFilePicker(String destFilename) {
|
||||
public static boolean showRomFilePicker() {
|
||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (destFilename == null || destFilename.length() == 0) {
|
||||
destFilename = PICKED_ROM_FILENAME;
|
||||
}
|
||||
// Reject path separators so a hostile JNI caller cannot escape the
|
||||
// save identity directory.
|
||||
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
|
||||
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
|
||||
return false;
|
||||
}
|
||||
|
||||
self.pendingPickFilename = destFilename;
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
try {
|
||||
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
||||
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not open file picker: " + e.getMessage());
|
||||
Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** ROM convenience wrapper; prefer showFilePicker with an explicit name. */
|
||||
@Keep
|
||||
public static boolean showRomFilePicker() {
|
||||
return showFilePicker(PICKED_ROM_FILENAME);
|
||||
}
|
||||
|
||||
/** Mod .zip convenience wrapper used by love.system.pickFile("mod"). */
|
||||
@Keep
|
||||
public static boolean showModFilePicker() {
|
||||
return showFilePicker(PICKED_MOD_FILENAME);
|
||||
}
|
||||
|
||||
/** Battery .sav convenience wrapper used by love.system.pickFile("sav"). */
|
||||
@Keep
|
||||
public static boolean showSaveFilePicker() {
|
||||
return showFilePicker(PICKED_SAVE_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
||||
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
||||
* etc. Suggested name is the dialog's default filename.
|
||||
*/
|
||||
@Keep
|
||||
public static boolean showCreateDocument(String suggestedName) {
|
||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (suggestedName == null || suggestedName.length() == 0) {
|
||||
suggestedName = "export.sav";
|
||||
}
|
||||
if (suggestedName.indexOf('/') >= 0 || suggestedName.indexOf('\\') >= 0) {
|
||||
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
|
||||
return false;
|
||||
}
|
||||
File source = new File(
|
||||
new File(self.getExternalFilesDir(null), "save"),
|
||||
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
|
||||
if (!source.isFile()) {
|
||||
Log.d("GameActivity", "no pending export at " + source);
|
||||
return false;
|
||||
}
|
||||
|
||||
self.pendingCreateSuggestedName = suggestedName;
|
||||
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("application/octet-stream");
|
||||
intent.putExtra(Intent.EXTRA_TITLE, suggestedName);
|
||||
try {
|
||||
self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not open create-document picker: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private File saveIdentityDir() {
|
||||
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||
}
|
||||
|
||||
private boolean copyFileToUri(File source, Uri destUri) {
|
||||
InputStream in = null;
|
||||
OutputStream out = null;
|
||||
try {
|
||||
in = new BufferedInputStream(new FileInputStream(source));
|
||||
out = getContentResolver().openOutputStream(destUri);
|
||||
if (out == null) return false;
|
||||
byte[] buf = new byte[8192];
|
||||
int n;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
out.flush();
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "copy to URI failed: " + e.getMessage());
|
||||
return false;
|
||||
} finally {
|
||||
try { if (in != null) in.close(); } catch (IOException ignored) {}
|
||||
try { if (out != null) out.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (requestCode == FILE_CREATE_REQUEST_CODE) {
|
||||
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
|
||||
Log.d("GameActivity", "create-document cancelled");
|
||||
return;
|
||||
}
|
||||
File source = new File(saveIdentityDir(), PENDING_EXPORT_FILENAME);
|
||||
if (!source.isFile()) {
|
||||
Log.d("GameActivity", "pending export missing at result time");
|
||||
return;
|
||||
}
|
||||
Uri uri = data.getData();
|
||||
if (copyFileToUri(source, uri)) {
|
||||
// Signal Lua on next focus that the SAF export finished.
|
||||
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
|
||||
try {
|
||||
FileOutputStream fos = new FileOutputStream(flag, false);
|
||||
fos.write("ok".getBytes());
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not write export_done flag: " + e.getMessage());
|
||||
}
|
||||
// Keep pending_export.sav so a retry still works; Lua may remove it.
|
||||
} else {
|
||||
Log.d("GameActivity", "could not write export to " + uri);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (requestCode != FILE_PICKER_REQUEST_CODE) return;
|
||||
if (requestCode != ROM_PICKER_REQUEST_CODE) return;
|
||||
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
|
||||
Log.d("GameActivity", "file picker returned no file (cancelled?)");
|
||||
Log.d("GameActivity", "ROM picker returned no file (cancelled?)");
|
||||
return;
|
||||
}
|
||||
|
||||
Uri uri = data.getData();
|
||||
File destDir = saveIdentityDir();
|
||||
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||
if (!destDir.exists() && !destDir.mkdirs()) {
|
||||
Log.d("GameActivity", "could not create " + destDir);
|
||||
return;
|
||||
}
|
||||
String destName = pendingPickFilename != null
|
||||
? pendingPickFilename : PICKED_ROM_FILENAME;
|
||||
File destFile = new File(destDir, destName);
|
||||
File destFile = new File(destDir, PICKED_ROM_FILENAME);
|
||||
|
||||
InputStream source;
|
||||
try {
|
||||
source = getContentResolver().openInputStream(uri);
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
|
||||
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
if (source == null) {
|
||||
Log.d("GameActivity", "ContentResolver returned no stream for picked file");
|
||||
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM");
|
||||
return;
|
||||
}
|
||||
if (!copyAssetFile(source, destFile.getPath())) {
|
||||
Log.d("GameActivity", "could not copy picked file to " + destFile);
|
||||
Log.d("GameActivity", "could not copy picked ROM to " + destFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
return {
|
||||
summary = "Oak's Charmander gift becomes a level 20 Mew with inverted sprites.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "beginner", "cosmetic", "data-only", "legacy" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Example mod gallery
|
||||
|
||||
Eight reference mods, one per modder persona. Each is small enough to read
|
||||
Seven reference mods, one per modder persona. Each is small enough to read
|
||||
in one sitting, exercises a different slice of the mod API, and is a real,
|
||||
runnable, tested mod — not a snippet.
|
||||
|
||||
@@ -16,7 +16,6 @@ Copy the one closest to what you want to build.
|
||||
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
|
||||
| 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API |
|
||||
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
|
||||
| 8 | [`example_silly_oak`](example_silly_oak) | Intro author | `UI` | Extra Oak questions, sprite swaps, answers in `mod.save` |
|
||||
|
||||
## None of these load by default
|
||||
|
||||
@@ -47,8 +46,7 @@ and `field`, and exercises:
|
||||
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
|
||||
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
|
||||
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
|
||||
`game.ready`, `assets.transformed`, `intro.oak_speech.build`,
|
||||
`intro.oak_speech.answered` and `intro.oak_speech.finished`
|
||||
`game.ready` and `assets.transformed`
|
||||
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
|
||||
`mod:read`, `mod.log` and `mod.path`
|
||||
- asset transforms, the `trueColor` opt-out, script labels and `choice`,
|
||||
@@ -68,7 +66,7 @@ mods/examples/<id>/
|
||||
.modkitignore keeps the suite out of the distributed package
|
||||
```
|
||||
|
||||
`tests/mod_examples_tests.lua` in the engine's own suite loads all eight
|
||||
`tests/mod_examples_tests.lua` in the engine's own suite loads all seven
|
||||
together and asserts the above, so the gallery cannot rot.
|
||||
|
||||
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "Faster final starters, half-price TMs, a re-slotted Route 1.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "balance", "data-only", "beginner" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "ui", "tool", "quality-of-life" },
|
||||
differences = {
|
||||
changed = { "the START menu gains a DEXNAV row above SAVE" },
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "audio", "chiptune", "ui" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "quest", "story", "scripting" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "Sable Cove: one town, three species, one badge. The smallest whole conversion.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "total-conversion", "capstone" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "A teal player recolor derived from your own cache, plus two palette records.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "cosmetic", "graphics", "beginner" },
|
||||
screenshots = {
|
||||
{ transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" },
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Version headings match `manifest.json`'s `version`.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
|
||||
- Answers written to `mod.save` via `intro.oak_speech.answered`.
|
||||
- Custom `toast_kid.png` sprite shown mid-speech.
|
||||
@@ -1,43 +0,0 @@
|
||||
# Silly Oak Intro Example
|
||||
|
||||
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
|
||||
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
|
||||
|
||||
## Try it (play through yourself)
|
||||
|
||||
```sh
|
||||
rm -rf mods/example_silly_oak
|
||||
cp -r mods/examples/example_silly_oak mods/
|
||||
love .
|
||||
```
|
||||
|
||||
Then **NEW GAME** and mash A / pick the menus. Disable or delete
|
||||
`mods/example_silly_oak` when you're done so vanilla boots clean.
|
||||
|
||||
## Headless check
|
||||
|
||||
```sh
|
||||
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
|
||||
```
|
||||
|
||||
## Auto driver (screenshots + save asserts)
|
||||
|
||||
```sh
|
||||
rm -rf mods/example_silly_oak
|
||||
cp -r mods/examples/example_silly_oak mods/
|
||||
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
|
||||
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
|
||||
```
|
||||
|
||||
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
| Seam | Where |
|
||||
|---|---|
|
||||
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list |
|
||||
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids |
|
||||
| step kinds `say` / `yesno` / `choice` | `main.lua` |
|
||||
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
|
||||
| `events:on("intro.oak_speech.answered")` | `main.lua` → `mod.save` |
|
||||
| `events:on("intro.oak_speech.finished")` | `main.lua` |
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 245 B |
@@ -1,107 +0,0 @@
|
||||
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
|
||||
-- swaps (oak / rival / player / pokemon / a custom image), and answers
|
||||
-- that land in mod.save.
|
||||
--
|
||||
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
|
||||
|
||||
return function(mod)
|
||||
local toastPic = mod.path .. "/assets/toast_kid.png"
|
||||
|
||||
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
|
||||
steps = next(steps, speech)
|
||||
|
||||
-- after oak says hello, immediately derail
|
||||
mod.ui.insertStepAfter(steps, "oak_welcome", {
|
||||
id = "silly_quiz_intro",
|
||||
kind = "say",
|
||||
pic = "oak",
|
||||
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
|
||||
})
|
||||
|
||||
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
|
||||
id = "silly_toast",
|
||||
kind = "yesno",
|
||||
pic = "oak",
|
||||
saveKey = "likes_toast",
|
||||
text = "Do you like\ntoast?",
|
||||
})
|
||||
|
||||
-- brand new sprite mid-speech
|
||||
mod.ui.insertStepAfter(steps, "silly_toast", {
|
||||
id = "silly_toast_kid",
|
||||
kind = "say",
|
||||
pic = { type = "image", path = toastPic },
|
||||
reveal = "fade",
|
||||
saveKey = nil,
|
||||
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
|
||||
})
|
||||
|
||||
-- existing mon with a wipe + cry, parked after the real demo mon
|
||||
mod.ui.insertStepAfter(steps, "demo_mon", {
|
||||
id = "silly_mew",
|
||||
kind = "say",
|
||||
pic = { type = "pokemon", id = "MEW" },
|
||||
reveal = "wipe",
|
||||
cry = "MEW",
|
||||
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
|
||||
})
|
||||
|
||||
mod.ui.insertStepAfter(steps, "silly_mew", {
|
||||
id = "silly_snack",
|
||||
kind = "choice",
|
||||
pic = "oak",
|
||||
saveKey = "snack",
|
||||
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
|
||||
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
|
||||
})
|
||||
|
||||
-- swap to rival pic for a loaded question before naming him
|
||||
mod.ui.insertStepBefore(steps, "ask_rival_name", {
|
||||
id = "silly_trust",
|
||||
kind = "choice",
|
||||
pic = "rival",
|
||||
reveal = "fade",
|
||||
saveKey = "trusts_rival",
|
||||
text = "Look at this kid.\nTrustworthy?",
|
||||
choices = { "SURE", "NO" },
|
||||
values = { true, false },
|
||||
})
|
||||
|
||||
-- player pic for one last bit after both names are set
|
||||
mod.ui.insertStepAfter(steps, "name_rival", {
|
||||
id = "silly_pineapple",
|
||||
kind = "yesno",
|
||||
pic = "player",
|
||||
saveKey = "pineapple_on_pizza",
|
||||
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
|
||||
})
|
||||
|
||||
mod.ui.insertStepAfter(steps, "silly_pineapple", {
|
||||
id = "silly_closing",
|
||||
kind = "say",
|
||||
pic = "oak",
|
||||
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
|
||||
})
|
||||
|
||||
return steps
|
||||
end)
|
||||
|
||||
-- every answered step with a saveKey lands in mod.save (and therefore
|
||||
-- save.modData[mod.id] once the slot is written)
|
||||
mod.events:on("intro.oak_speech.answered", function(ev)
|
||||
if not ev.saveKey then return end
|
||||
mod.save:set(ev.saveKey, ev.value)
|
||||
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
|
||||
end)
|
||||
|
||||
mod.events:on("intro.oak_speech.finished", function(ev)
|
||||
local answers = ev.answers or {}
|
||||
for key, value in pairs(answers) do
|
||||
if mod.save:get(key) == nil then
|
||||
mod.save:set(key, value)
|
||||
end
|
||||
end
|
||||
mod.save:set("quiz_done", true)
|
||||
mod.log:info("silly oak quiz done")
|
||||
end)
|
||||
end
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"id": "example_silly_oak",
|
||||
"name": "Silly Oak Intro Example",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "UI",
|
||||
"game_version": ">=0.0.0-0 <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
-- Sharing metadata for the manager detail pane.
|
||||
return {
|
||||
summary = "Oak asks dumb questions during the intro and remembers your answers.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "intro", "ui", "oak", "hooks" },
|
||||
differences = {
|
||||
changed = {
|
||||
"Oak's NEW GAME speech gains extra questions and sprite beats",
|
||||
},
|
||||
added = {
|
||||
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
|
||||
"Custom Toast Kid pic mid-intro",
|
||||
},
|
||||
known = { "vanilla naming and the shrink-away still run" },
|
||||
},
|
||||
credits = {
|
||||
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
|
||||
},
|
||||
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
|
||||
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
|
||||
-- and answers landing in mod.save.
|
||||
--
|
||||
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
|
||||
-- fresh checkout without a ROM skip cleanly -- the gallery is also
|
||||
-- covered by tests/mod_examples_tests.lua when generated data is present.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local function hasGenerated()
|
||||
local handle = io.open("data/generated/constants.lua", "r")
|
||||
if handle then handle:close() return true end
|
||||
return false
|
||||
end
|
||||
if not hasGenerated() then
|
||||
print("example_silly_oak_test skipped (needs data/generated/)")
|
||||
os.exit(0)
|
||||
end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local OakSpeech = require("src.ui.OakSpeech")
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
|
||||
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local mod = run.mod
|
||||
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
|
||||
local ModUI = require("src.ui.ModUI")
|
||||
local bucket = function()
|
||||
return run.loader.modSave.example_silly_oak or {}
|
||||
end
|
||||
local toastPath = (mod.path or "mods/examples/example_silly_oak")
|
||||
.. "/assets/toast_kid.png"
|
||||
|
||||
-- ------- build hook injects every silly beat around vanilla anchors
|
||||
|
||||
local speech = OakSpeech.new({
|
||||
data = Data,
|
||||
save = { player = { name = "RED", rival = "BLUE" } },
|
||||
stack = { push = function() end, pop = function() end },
|
||||
}, nil)
|
||||
local steps = speech:buildSteps()
|
||||
|
||||
local ids = {}
|
||||
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
|
||||
local function has(id)
|
||||
for _, x in ipairs(ids) do if x == id then return true end end
|
||||
return false
|
||||
end
|
||||
|
||||
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
|
||||
"vanilla anchors still present")
|
||||
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
|
||||
"toast quiz beats injected")
|
||||
T.check(has("silly_mew") and has("silly_snack"),
|
||||
"MEW reveal and snack choice injected")
|
||||
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
|
||||
"rival trust + pineapple beats injected")
|
||||
|
||||
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
|
||||
local function indexOf(id)
|
||||
for i, x in ipairs(ids) do if x == id then return i end end
|
||||
return 0
|
||||
end
|
||||
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
|
||||
"Toast Kid shows before the demo mon")
|
||||
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
|
||||
"MEW shows after the demo mon")
|
||||
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
|
||||
"trust question is before rival naming")
|
||||
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
|
||||
and indexOf("silly_pineapple") < indexOf("legend"),
|
||||
"pineapple lands between rival name and the legend beat")
|
||||
|
||||
-- ------- step shapes cover choice / yesno / custom image / pokemon
|
||||
|
||||
local byId = {}
|
||||
for _, step in ipairs(steps) do byId[step.id] = step end
|
||||
|
||||
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
|
||||
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
|
||||
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
|
||||
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
|
||||
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
|
||||
"Toast Kid uses a custom image pic")
|
||||
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
|
||||
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
|
||||
"MEW beat uses pokemon pic + cry")
|
||||
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
|
||||
|
||||
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
|
||||
|
||||
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
|
||||
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
|
||||
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
|
||||
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
|
||||
{ type = "pokemon", id = "MEW", flip = true }, speech)
|
||||
local customImg = OakSpeech.resolvePic({ data = Data },
|
||||
{ type = "image", path = toastPath }, speech)
|
||||
-- headless love stub may return nil images; the call itself must not throw
|
||||
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
|
||||
or type(oakImg) == "table",
|
||||
"oak shorthand resolves without error")
|
||||
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
|
||||
or type(rivalImg) == "table",
|
||||
"rival shorthand resolves without error")
|
||||
T.check(playerImg == speech.playerPic or playerImg == nil
|
||||
or type(playerImg) == "userdata" or type(playerImg) == "table",
|
||||
"player shorthand resolves without error")
|
||||
T.check(mewFlip == true, "pokemon flip flag is honored")
|
||||
T.check(customImg ~= nil or true, "custom image path is accepted")
|
||||
|
||||
-- ------- answered event writes mod.save (loader.modSave bucket)
|
||||
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "likes_toast", value = true, label = "YES", index = 1,
|
||||
step = byId.silly_toast, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
|
||||
step = byId.silly_snack, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
|
||||
step = byId.silly_trust, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
|
||||
step = byId.silly_pineapple, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.finished", {
|
||||
speech = speech, answers = speech.answers,
|
||||
})
|
||||
|
||||
local saved = bucket()
|
||||
T.eq(saved.likes_toast, true, "likes_toast saved")
|
||||
T.eq(saved.snack, "OLD ROD", "snack saved")
|
||||
T.eq(saved.trusts_rival, false, "trusts_rival saved")
|
||||
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
|
||||
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
|
||||
|
||||
-- ------- ModUI step helpers (public surface)
|
||||
|
||||
local tiny = {
|
||||
{ id = "a", kind = "say" },
|
||||
{ id = "b", kind = "say" },
|
||||
}
|
||||
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
|
||||
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
|
||||
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
|
||||
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
|
||||
ModUI.removeStep(tiny, "mid")
|
||||
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
|
||||
|
||||
run.release()
|
||||
T.finish("example_silly_oak")
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
tags = { "battle", "mechanic", "ruleset", "hardcore" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
+1
-13
@@ -56,28 +56,16 @@ done
|
||||
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
|
||||
|
||||
# --------------------------------------------------------------- game.love
|
||||
# tools/save-editor is part of the shipped app, not a dev-only script: the
|
||||
# launcher's Edit button on a save row opens it in-process (main.lua), and
|
||||
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
|
||||
# love.filesystem's require path, so it has to live inside the archive.
|
||||
say "packing game.love"
|
||||
LOVE_FILE="$WORK/game.love"
|
||||
rm -f "$LOVE_FILE"
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
# The editor is only reachable if its entry point and both module directories
|
||||
# made it in; a silent miss would ship a launcher whose Edit button crashes.
|
||||
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
tools/save-editor/panels/Party.lua; do
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|
||||
|| fail "game.love is missing $required (save editor would not load)"
|
||||
done
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
||||
|
||||
# ------------------------------------------------------- stamp release version
|
||||
|
||||
@@ -113,12 +113,11 @@ import pathlib, re, sys
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
text = path.read_text()
|
||||
|
||||
# Drop mic / legacy storage, not needed by this game.
|
||||
# Keep VIBRATE (love.system.vibrate), BLUETOOTH (optional gamepads) and
|
||||
# INTERNET: link play is not offline-only any more, and stripping INTERNET
|
||||
# made every LAN host and every relay connect fail with EPERM (issue #287).
|
||||
# Drop network / mic / legacy storage, not needed for offline play.
|
||||
# Keep VIBRATE (love.system.vibrate) and BLUETOOTH (optional gamepads).
|
||||
# Orientation / label come from gradle.properties placeholders.
|
||||
for perm in (
|
||||
"android.permission.INTERNET",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
):
|
||||
@@ -137,19 +136,14 @@ pack_game_love() {
|
||||
say "packing game.love for love-android embed flavor"
|
||||
mkdir -p "$EMBED_ASSETS"
|
||||
rm -f "$LOVE_FILE"
|
||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||
# row opens it in-process, so it must be inside the archive (see build.sh).
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||
|
||||
# This script packs its own game.love (it does not reuse build.sh's), so it
|
||||
|
||||
@@ -164,19 +164,15 @@ pack_game_love() {
|
||||
say "packing game.love for love-ios resources"
|
||||
mkdir -p "$RESOURCES_DIR"
|
||||
rm -f "$LOVE_FILE"
|
||||
# Same payload as scripts/build.sh / build_android.sh: game sources plus
|
||||
# tools/save-editor, which the launcher's Edit button opens in-process.
|
||||
# Same payload as scripts/build.sh / build_android.sh: game sources only.
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||
}
|
||||
|
||||
|
||||
@@ -116,13 +116,7 @@ if [ -f data/generated/maps.lua ]; then
|
||||
echo "-- T3 content: skipped (--quick)"
|
||||
else
|
||||
run_tier "T3 content behavior (Red)" run_content_behavior
|
||||
# The save editor ships inside every build (the launcher's Edit button on
|
||||
# a save row opens it), so its panel suites run in CI rather than by hand.
|
||||
run_tier "T3 save editor" "$LUA" tests/run_save_editor_tests.lua
|
||||
run_tier "T3 save editor: boxes + items" "$LUA" tests/save_editor_task6_tests.lua
|
||||
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
|
||||
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
|
||||
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
|
||||
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
|
||||
fi
|
||||
else
|
||||
|
||||
+207
-655
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@
|
||||
local MoveEffects = require("src.battle.MoveEffects")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local EffectRegistry = {}
|
||||
|
||||
@@ -92,7 +91,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if target.invulnerable and not neverMiss then
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -114,7 +113,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if not battle:accuracyRoll(move, user, target) then
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
-- Jump Kick crash, Explode self-destruct
|
||||
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
||||
user.trappingTurns = nil
|
||||
@@ -141,7 +140,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
end
|
||||
if not counterable or (battle.lastDamage or 0) == 0 then
|
||||
battle:cancelMoveAnim()
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
return
|
||||
end
|
||||
dmg = math.min(65535, battle.lastDamage * 2)
|
||||
@@ -164,14 +163,14 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if info.typeMult == 0 then
|
||||
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target)))
|
||||
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target)))
|
||||
if record and record.onMiss then record.onMiss(ctx, "immune") end
|
||||
return
|
||||
end
|
||||
if info.missed then
|
||||
-- 0.25x floored the damage to zero: the original registers a miss
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||
if record and record.onMiss then record.onMiss(ctx, "floored") end
|
||||
return
|
||||
end
|
||||
@@ -215,12 +214,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
|
||||
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
|
||||
-- every strike -- damage was only rolled once
|
||||
if info.crit then battle:sayNext(Strings("Critical hit!")) end
|
||||
if info.ohko then battle:sayNext(Strings("One-hit KO!")) end
|
||||
if info.crit then battle:sayNext("Critical hit!") end
|
||||
if info.ohko then battle:sayNext("One-hit KO!") end
|
||||
if info.typeMult > 10 then
|
||||
battle:sayNext(Strings("It's super\neffective!"))
|
||||
battle:sayNext("It's super\neffective!")
|
||||
elseif info.typeMult < 10 then
|
||||
battle:sayNext(Strings("It's not very\neffective..."))
|
||||
battle:sayNext("It's not very\neffective...")
|
||||
end
|
||||
if Runtime.wants("battle.damage_dealt") then
|
||||
Runtime.emit("battle.damage_dealt", {
|
||||
@@ -238,9 +237,9 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if hits > 1 then
|
||||
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
|
||||
if user.isPlayer then
|
||||
battle:sayNext(Strings("Hit the enemy\n%d times!", hits))
|
||||
battle:sayNext(("Hit the enemy\n%d times!"):format(hits))
|
||||
else
|
||||
battle:sayNext(Strings("Hit %d times!", hits))
|
||||
battle:sayNext(("Hit %d times!"):format(hits))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -252,7 +251,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
elseif moveInst.struggle then
|
||||
-- struggle recoils even when its effect id resolves to no record
|
||||
local recoil = math.max(1, math.floor(dmg / 2))
|
||||
battle:sayNext(Strings("%s's\nhit with recoil!", displayName(user)))
|
||||
battle:sayNext(("%s's\nhit with recoil!"):format(displayName(user)))
|
||||
battle:applyDamage(user, recoil)
|
||||
end
|
||||
|
||||
|
||||
+58
-65
@@ -15,7 +15,6 @@ local Logger = require("src.core.Logger")
|
||||
local StatusRegistry = require("src.battle.StatusRegistry")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local MoveEffects = {}
|
||||
|
||||
@@ -37,14 +36,14 @@ local STAT_LABEL = {
|
||||
local function changeStage(battle, who, stat, delta, fromEnemy)
|
||||
if fromEnemy and (who.substituteHP or who.mist) then
|
||||
if who.mist then
|
||||
return { Strings("%s is\nprotected by MIST!", displayName(who)) }
|
||||
return { displayName(who) .. " is\nprotected by MIST!" }
|
||||
end
|
||||
return { Strings("But, it failed!") }
|
||||
return { "But, it failed!" }
|
||||
end
|
||||
local cur = who.stages[stat] or 0
|
||||
local new = math.max(-6, math.min(6, cur + delta))
|
||||
if new == cur then
|
||||
return { Strings("Nothing happened!") }
|
||||
return { ("Nothing happened!") }
|
||||
end
|
||||
who.stages[stat] = new
|
||||
-- effects.asm:505-506: after any stat-stage change, modified stats are
|
||||
@@ -54,13 +53,13 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
|
||||
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
|
||||
-- two-stage variants scroll "greatly" onto a third line
|
||||
if delta >= 2 then
|
||||
return { Strings("%s's\n%s\ngreatly rose!", displayName(who), STAT_LABEL[stat]) }
|
||||
return { ("%s's\n%s\ngreatly rose!"):format(displayName(who), STAT_LABEL[stat]) }
|
||||
elseif delta == 1 then
|
||||
return { Strings("%s's\n%s rose!", displayName(who), STAT_LABEL[stat]) }
|
||||
return { ("%s's\n%s rose!"):format(displayName(who), STAT_LABEL[stat]) }
|
||||
elseif delta == -1 then
|
||||
return { Strings("%s's\n%s fell!", displayName(who), STAT_LABEL[stat]) }
|
||||
return { ("%s's\n%s fell!"):format(displayName(who), STAT_LABEL[stat]) }
|
||||
end
|
||||
return { Strings("%s's\n%s\ngreatly fell!", displayName(who), STAT_LABEL[stat]) }
|
||||
return { ("%s's\n%s\ngreatly fell!"):format(displayName(who), STAT_LABEL[stat]) }
|
||||
end
|
||||
MoveEffects.changeStage = changeStage
|
||||
|
||||
@@ -89,10 +88,10 @@ end
|
||||
local function statusMove(status)
|
||||
return function(battle, user, target, move)
|
||||
if target.mon.status then
|
||||
return { Strings("But, it failed!") }
|
||||
return { "But, it failed!" }
|
||||
end
|
||||
if status == "PSN" and target.substituteHP then
|
||||
return { Strings("But, it failed!") }
|
||||
return { "But, it failed!" }
|
||||
end
|
||||
local msgs = inflictStatus(battle, target, status, {
|
||||
toxic = move and move.id == "TOXIC",
|
||||
@@ -100,7 +99,7 @@ local function statusMove(status)
|
||||
source = move and move.id,
|
||||
})
|
||||
if #msgs == 0 then
|
||||
return { Strings("But, it failed!") }
|
||||
return { "But, it failed!" }
|
||||
end
|
||||
return msgs
|
||||
end
|
||||
@@ -112,7 +111,7 @@ local function statusSide(status, chance)
|
||||
-- target (regardless of the burn roll)
|
||||
if move and move.type == "FIRE" and target.mon.status == "FRZ" then
|
||||
target.mon.status = nil
|
||||
return { Strings("Fire defrosted\n%s!", displayName(target)) }
|
||||
return { ("Fire defrosted\n%s!"):format(displayName(target)) }
|
||||
end
|
||||
if battle.rng(0, 255) >= chance then return {} end
|
||||
return inflictStatus(battle, target, status, {
|
||||
@@ -145,10 +144,10 @@ end
|
||||
|
||||
local function confuse(battle, target, pierceSub)
|
||||
if target.confusedTurns or (target.substituteHP and not pierceSub) then
|
||||
return { Strings("But, it failed!") }
|
||||
return { "But, it failed!" }
|
||||
end
|
||||
target.confusedTurns = battle.rng(2, 5)
|
||||
return { Strings("%s\nbecame confused!", displayName(target)) }
|
||||
return { ("%s\nbecame confused!"):format(displayName(target)) }
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
@@ -182,53 +181,53 @@ MoveEffects.primary = {
|
||||
LEECH_SEED_EFFECT = function(battle, user, target)
|
||||
-- leech_seed.asm has no substitute check: seeding lands through one
|
||||
if target.leechSeeded then
|
||||
return { Strings("But, it failed!") }
|
||||
return { "But, it failed!" }
|
||||
end
|
||||
for _, t in ipairs(target.curTypes) do
|
||||
if t == "GRASS" then return { Strings("But, it failed!") } end
|
||||
if t == "GRASS" then return { "But, it failed!" } end
|
||||
end
|
||||
target.leechSeeded = true
|
||||
return { Strings("%s\nwas seeded!", displayName(target)) }
|
||||
return { ("%s\nwas seeded!"):format(displayName(target)) }
|
||||
end,
|
||||
|
||||
HEAL_EFFECT = function(battle, user, target, move)
|
||||
local mon = user.mon
|
||||
if move.id == "REST" then
|
||||
if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
|
||||
if mon.hp == mon.stats.hp then return { "But, it failed!" } end
|
||||
mon.hp = mon.stats.hp
|
||||
mon.status = "SLP"
|
||||
user.sleepTurns = 2
|
||||
user.toxicCounter = nil
|
||||
return { Strings("%s\nstarted sleeping!", displayName(user)) }
|
||||
return { ("%s\nstarted sleeping!"):format(displayName(user)) }
|
||||
end
|
||||
if mon.hp == mon.stats.hp then return { Strings("But, it failed!") } end
|
||||
if mon.hp == mon.stats.hp then return { "But, it failed!" } end
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + math.floor(mon.stats.hp / 2))
|
||||
return { Strings("%s\nregained health!", displayName(user)) }
|
||||
return { ("%s\nregained health!"):format(displayName(user)) }
|
||||
end,
|
||||
|
||||
LIGHT_SCREEN_EFFECT = function(battle, user)
|
||||
if user.lightScreen then return { Strings("But, it failed!") } end
|
||||
if user.lightScreen then return { "But, it failed!" } end
|
||||
user.lightScreen = true
|
||||
return { Strings("%s's\nprotected against\nspecial attacks!", displayName(user)) }
|
||||
return { ("%s's\nprotected against\nspecial attacks!"):format(displayName(user)) }
|
||||
end,
|
||||
|
||||
REFLECT_EFFECT = function(battle, user)
|
||||
if user.reflect then return { Strings("But, it failed!") } end
|
||||
if user.reflect then return { "But, it failed!" } end
|
||||
user.reflect = true
|
||||
return { Strings("%s\ngained armor!", displayName(user)) }
|
||||
return { ("%s\ngained armor!"):format(displayName(user)) }
|
||||
end,
|
||||
|
||||
MIST_EFFECT = function(battle, user)
|
||||
if user.mist then return { Strings("But, it failed!") } end
|
||||
if user.mist then return { "But, it failed!" } end
|
||||
user.mist = true
|
||||
-- _ShroudedInMistText (lowercase "mist")
|
||||
return { Strings("%s's\nshrouded in mist!", displayName(user)) }
|
||||
return { ("%s's\nshrouded in mist!"):format(displayName(user)) }
|
||||
end,
|
||||
|
||||
FOCUS_ENERGY_EFFECT = function(battle, user)
|
||||
if user.focusEnergy then return { Strings("But, it failed!") } end
|
||||
if user.focusEnergy then return { "But, it failed!" } end
|
||||
user.focusEnergy = true
|
||||
return { Strings("%s's\ngetting pumped!", displayName(user)) }
|
||||
return { ("%s's\ngetting pumped!"):format(displayName(user)) }
|
||||
end,
|
||||
|
||||
HAZE_EFFECT = function(battle, user, target)
|
||||
@@ -255,33 +254,33 @@ MoveEffects.primary = {
|
||||
target.skipMove = true
|
||||
end
|
||||
target.mon.status = nil
|
||||
return { Strings("All STATUS changes\nare eliminated!") }
|
||||
return { "All STATUS changes\nare eliminated!" }
|
||||
end,
|
||||
|
||||
SUBSTITUTE_EFFECT = function(battle, user)
|
||||
if user.substituteHP then return { Strings("%s\nhas a SUBSTITUTE!", displayName(user)) } end
|
||||
if user.substituteHP then return { ("%s\nhas a SUBSTITUTE!"):format(displayName(user)) } end
|
||||
local cost = math.floor(user.mon.stats.hp / 4)
|
||||
-- substitute.asm only fails on subtraction underflow (current HP
|
||||
-- strictly below maxHP/4); at equality the substitute is built and
|
||||
-- the user is left standing on exactly 0 HP (it faints only when
|
||||
-- the engine next checks HP, not here)
|
||||
if user.mon.hp < cost then
|
||||
return { Strings("Too weak to make\na SUBSTITUTE!") }
|
||||
return { "Too weak to make\na SUBSTITUTE!" }
|
||||
end
|
||||
user.mon.hp = user.mon.hp - cost
|
||||
user.substituteHP = cost + 1
|
||||
-- _SubstituteText
|
||||
return { Strings("It created a\nSUBSTITUTE!") }
|
||||
return { "It created a\nSUBSTITUTE!" }
|
||||
end,
|
||||
|
||||
CONVERSION_EFFECT = function(battle, user, target)
|
||||
-- conversion.asm fails against a mid-Fly/Dig target (INVULNERABLE)
|
||||
if target.invulnerable then
|
||||
return { Strings("But, it failed!") }
|
||||
return { "But, it failed!" }
|
||||
end
|
||||
user.curTypes = { target.curTypes[1], target.curTypes[2] }
|
||||
-- _ConvertedTypeText
|
||||
return { Strings("Converted type to\n%s's!", displayName(target)) }
|
||||
return { ("Converted type to\n%s's!"):format(displayName(target)) }
|
||||
end,
|
||||
|
||||
-- MIMIC_EFFECT lives in BattleState:resolveMimic: MimicEffect
|
||||
@@ -312,27 +311,27 @@ MoveEffects.primary = {
|
||||
table.insert(user.curMoves, { id = mv.id, pp = 5, mimic = true })
|
||||
end
|
||||
-- _TransformedText: the copied name prints bare (wNameBuffer)
|
||||
return { Strings("%s\ntransformed into\n%s!", displayName(user), target.name) }
|
||||
return { ("%s\ntransformed into\n%s!"):format(displayName(user), target.name) }
|
||||
end,
|
||||
|
||||
DISABLE_EFFECT = function(battle, user, target)
|
||||
if target.disabledSlot then return { Strings("But, it failed!") } end
|
||||
if target.disabledSlot then return { "But, it failed!" } end
|
||||
local usable = {}
|
||||
for i, mv in ipairs(target.curMoves) do
|
||||
if mv.pp > 0 then table.insert(usable, i) end
|
||||
end
|
||||
if #usable == 0 then return { Strings("But, it failed!") } end
|
||||
if #usable == 0 then return { "But, it failed!" } end
|
||||
local slot = usable[battle.rng(1, #usable)]
|
||||
target.disabledSlot = slot
|
||||
target.disabledTurns = battle.rng(1, 8)
|
||||
local id = target.curMoves[slot].id
|
||||
-- _MoveWasDisabledText: "X's / MOVE was / disabled!"
|
||||
return { Strings("%s's\n%s was\ndisabled!", displayName(target),
|
||||
return { ("%s's\n%s was\ndisabled!"):format(displayName(target),
|
||||
battle.data.moves[id].name) }
|
||||
end,
|
||||
|
||||
SPLASH_EFFECT = function()
|
||||
return { Strings("No effect!") }
|
||||
return { "No effect!" }
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -429,9 +428,7 @@ local function drainHalf(text)
|
||||
local mon = ctx.user.mon
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||
ctx.drain()
|
||||
-- `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)))
|
||||
ctx.say(text:format(displayName(ctx.target)))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -439,7 +436,7 @@ end
|
||||
-- flags the miss before the special-damage override)
|
||||
local function immuneMsg(ctx)
|
||||
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
|
||||
return Strings("It doesn't affect\n%s!", displayName(ctx.target))
|
||||
return ("It doesn't affect\n%s!"):format(displayName(ctx.target))
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -501,12 +498,12 @@ MoveEffects.full = {
|
||||
-- removed): overkill and substitute hits recoil at full strength
|
||||
local recoil = math.max(1, math.floor(ctx.rawDamage
|
||||
/ (ctx.moveInst.struggle and 2 or 4)))
|
||||
ctx.say(Strings("%s's\nhit with recoil!", displayName(ctx.user)))
|
||||
ctx.say(("%s's\nhit with recoil!"):format(displayName(ctx.user)))
|
||||
ctx.battle:applyDamage(ctx.user, recoil)
|
||||
end,
|
||||
},
|
||||
DRAIN_HP_EFFECT = {
|
||||
afterDamage = drainHalf(Strings.source("Sucked health from\n%s!")),
|
||||
afterDamage = drainHalf("Sucked health from\n%s!"),
|
||||
},
|
||||
DREAM_EATER_EFFECT = {
|
||||
-- only works on sleeping targets (checked before damage)
|
||||
@@ -514,7 +511,7 @@ MoveEffects.full = {
|
||||
if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
|
||||
return true
|
||||
end,
|
||||
afterDamage = drainHalf(Strings.source("%s's\ndream was eaten!")),
|
||||
afterDamage = drainHalf("%s's\ndream was eaten!"),
|
||||
},
|
||||
|
||||
-- charge moves: first turn just charges; Fly AND Dig go
|
||||
@@ -560,7 +557,7 @@ MoveEffects.full = {
|
||||
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
||||
if not user.confusedTurns then
|
||||
user.confusedTurns = ctx.rng(2, 5)
|
||||
ctx.say(Strings("%s\nbecame confused!", displayName(user)))
|
||||
ctx.say(("%s\nbecame confused!"):format(displayName(user)))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -569,7 +566,7 @@ MoveEffects.full = {
|
||||
JUMP_KICK_EFFECT = {
|
||||
onMiss = function(ctx, reason)
|
||||
if reason ~= "accuracy" then return end
|
||||
ctx.say(Strings("%s\nkept going and\ncrashed!", displayName(ctx.user)))
|
||||
ctx.say(("%s\nkept going and\ncrashed!"):format(displayName(ctx.user)))
|
||||
ctx.damage(ctx.user, 1)
|
||||
end,
|
||||
},
|
||||
@@ -584,12 +581,8 @@ MoveEffects.full = {
|
||||
},
|
||||
HYPER_BEAM_EFFECT = {
|
||||
afterDamage = function(ctx)
|
||||
-- Gen 1: no recharge when the target faints OR its substitute breaks.
|
||||
-- Ruleset hyperBeamSkipRechargeOnKO=false forces Gen 2+ always-recharge.
|
||||
local ruleset = ctx.battle and ctx.battle.ruleset
|
||||
local skipOnKO = not ruleset or ruleset.hyperBeamSkipRechargeOnKO ~= false
|
||||
local targetDown = ctx.target.mon.hp <= 0 or ctx.brokeSub
|
||||
if not skipOnKO or not targetDown then
|
||||
-- no recharge when the target faints OR its substitute breaks
|
||||
if ctx.target.mon.hp > 0 and not ctx.brokeSub then
|
||||
ctx.user.mustRecharge = true
|
||||
end
|
||||
end,
|
||||
@@ -598,7 +591,7 @@ MoveEffects.full = {
|
||||
afterDamage = function(ctx)
|
||||
local battle = ctx.battle
|
||||
battle.payDay = (battle.payDay or 0) + 2 * ctx.user.mon.level
|
||||
ctx.say(Strings("Coins scattered\neverywhere!"))
|
||||
ctx.say("Coins scattered\neverywhere!")
|
||||
end,
|
||||
},
|
||||
SWIFT_EFFECT = { neverMiss = true },
|
||||
@@ -613,7 +606,7 @@ MoveEffects.full = {
|
||||
local user = ctx.user
|
||||
user.bideTurns = ctx.rng(2, 3)
|
||||
user.bideDamage = 0
|
||||
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||
ctx.say(("%s\nis storing energy!"):format(displayName(user)))
|
||||
end,
|
||||
},
|
||||
SWITCH_AND_TELEPORT_EFFECT = {
|
||||
@@ -634,27 +627,27 @@ MoveEffects.full = {
|
||||
end
|
||||
if ok then
|
||||
if move.id == "ROAR" then
|
||||
ctx.say(Strings("%s\nran away scared!", displayName(target)))
|
||||
ctx.say(("%s\nran away scared!"):format(displayName(target)))
|
||||
elseif move.id == "WHIRLWIND" then
|
||||
ctx.say(Strings("%s\nwas blown away!", displayName(target)))
|
||||
ctx.say(("%s\nwas blown away!"):format(displayName(target)))
|
||||
else
|
||||
ctx.say(Strings("%s\nran from battle!", displayName(user)))
|
||||
ctx.say(("%s\nran from battle!"):format(displayName(user)))
|
||||
end
|
||||
battle.result = "run"
|
||||
battle.afterQueue = "finish"
|
||||
elseif move.id == "TELEPORT" then
|
||||
battle:cancelMoveAnim()
|
||||
ctx.say(Strings("But, it failed!"))
|
||||
ctx.say("But, it failed!")
|
||||
else
|
||||
battle:cancelMoveAnim()
|
||||
ctx.say(Strings("It didn't affect\n%s!", displayName(target)))
|
||||
ctx.say(("It didn't affect\n%s!"):format(displayName(target)))
|
||||
end
|
||||
elseif move.id == "TELEPORT" then
|
||||
battle:cancelMoveAnim()
|
||||
ctx.say(Strings("But, it failed!"))
|
||||
ctx.say("But, it failed!")
|
||||
else
|
||||
battle:cancelMoveAnim()
|
||||
ctx.say(Strings("%s\nis unaffected!", displayName(target)))
|
||||
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
|
||||
end
|
||||
end,
|
||||
},
|
||||
@@ -672,7 +665,7 @@ MoveEffects.full = {
|
||||
callsMove = function(ctx)
|
||||
local last = ctx.target.lastMove
|
||||
if not last then
|
||||
ctx.say(Strings("The MIRROR MOVE\nfailed!"))
|
||||
ctx.say("The MIRROR MOVE\nfailed!")
|
||||
return nil
|
||||
end
|
||||
return last
|
||||
|
||||
+21
-31
@@ -5,8 +5,6 @@
|
||||
-- and residual sweep. Callers without a battle (pure-module tests) fall
|
||||
-- back to the vanilla records, which is bit-identical behavior.
|
||||
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Status = {}
|
||||
|
||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
||||
@@ -30,11 +28,8 @@ local function hasType(battler, wanted)
|
||||
end
|
||||
|
||||
-- shared PSN/BRN residual: 1/16 max HP, multiplied (and advanced) by the
|
||||
-- Toxic counter (HandlePoisonBurnLeechSeed). The caller passes the whole
|
||||
-- sentence rather than the noun: "hurt by poison" and "hurt by the burn"
|
||||
-- decline differently once translated, so a shared fragment cannot be the
|
||||
-- translatable unit.
|
||||
local function damageOverTime(template)
|
||||
-- Toxic counter (HandlePoisonBurnLeechSeed)
|
||||
local function damageOverTime(what)
|
||||
return function(battler)
|
||||
local mon = battler.mon
|
||||
local base = math.max(1, math.floor(mon.stats.hp / 16))
|
||||
@@ -44,15 +39,10 @@ local function damageOverTime(template)
|
||||
battler.toxicCounter = battler.toxicCounter + 1
|
||||
end
|
||||
mon.hp = math.max(0, mon.hp - dmg)
|
||||
return { Strings(template, name(battler)) }
|
||||
return { ("%s's\nhurt by %s!"):format(name(battler), what) }
|
||||
end
|
||||
end
|
||||
|
||||
-- Labels stay plain literals on purpose: this table is built at require
|
||||
-- time, before Strings.load has a catalog, so a Strings() here would
|
||||
-- freeze the English. They are already translatable through the
|
||||
-- statuses registry (mod.content.statuses:patch(id, { label = ... })).
|
||||
--
|
||||
-- The five persistent conditions as records: the beforeMove gauntlet, the
|
||||
-- residual sweep, the inflict text/immunities (StatusRegistry.inflict),
|
||||
-- the catch/wobble bonuses (Catching.attempt), the HUD label, and the
|
||||
@@ -67,13 +57,13 @@ Status.RECORDS = {
|
||||
battler.sleepTurns = (battler.sleepTurns or 1) - 1
|
||||
if battler.sleepTurns <= 0 then
|
||||
battler.mon.status = nil
|
||||
return false, { Strings("%s\nwoke up!", name(battler)) } -- wakes, loses the turn
|
||||
return false, { name(battler) .. "\nwoke up!" } -- wakes but loses the turn
|
||||
end
|
||||
return false, { Strings("%s\nis fast asleep!", name(battler)) }
|
||||
return false, { name(battler) .. "\nis fast asleep!" }
|
||||
end,
|
||||
onInflict = function(battle, target, opts, display)
|
||||
target.sleepTurns = battle.rng(1, 7)
|
||||
return { Strings("%s\nfell asleep!", display) }
|
||||
return { ("%s\nfell asleep!"):format(display) }
|
||||
end,
|
||||
},
|
||||
FRZ = {
|
||||
@@ -81,35 +71,35 @@ Status.RECORDS = {
|
||||
catchBonus = 25, shakeBonus = 10,
|
||||
beforeMovePriority = 30,
|
||||
beforeMove = function(battler)
|
||||
return false, { Strings("%s\nis frozen solid!", name(battler)) }
|
||||
return false, { name(battler) .. "\nis frozen solid!" }
|
||||
end,
|
||||
canInflict = function(target) return not hasType(target, "ICE") end,
|
||||
onInflict = function(_, _, _, display)
|
||||
return { Strings("%s\nwas frozen solid!", display) }
|
||||
return { ("%s\nwas frozen solid!"):format(display) }
|
||||
end,
|
||||
},
|
||||
PSN = {
|
||||
id = "PSN", label = "PSN", hudLabel = "PSN",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
residual = damageOverTime(Strings.source("%s's\nhurt by poison!")),
|
||||
residual = damageOverTime("poison"),
|
||||
canInflict = function(target) return not hasType(target, "POISON") end,
|
||||
onInflict = function(_, target, opts, display)
|
||||
if opts.toxic then
|
||||
target.toxicCounter = 1
|
||||
-- _BadlyPoisonedText
|
||||
return { Strings("%s's\nbadly poisoned!", display) }
|
||||
return { ("%s's\nbadly poisoned!"):format(display) }
|
||||
end
|
||||
return { Strings("%s\nwas poisoned!", display) }
|
||||
return { ("%s\nwas poisoned!"):format(display) }
|
||||
end,
|
||||
},
|
||||
BRN = {
|
||||
id = "BRN", label = "BRN", hudLabel = "BRN",
|
||||
catchBonus = 12, shakeBonus = 5,
|
||||
statPenalty = { stat = "attack", div = 2 },
|
||||
residual = damageOverTime(Strings.source("%s's\nhurt by the burn!")),
|
||||
residual = damageOverTime("the burn"),
|
||||
canInflict = function(target) return not hasType(target, "FIRE") end,
|
||||
onInflict = function(_, _, _, display)
|
||||
return { Strings("%s\nwas burned!", display) }
|
||||
return { ("%s\nwas burned!"):format(display) }
|
||||
end,
|
||||
},
|
||||
PAR = {
|
||||
@@ -120,7 +110,7 @@ Status.RECORDS = {
|
||||
beforeMove = function(battler, rng)
|
||||
-- cp 25 percent / jr nc: fully paralyzed on rand < 63 (63/256)
|
||||
if rng(0, 255) < 63 then
|
||||
return false, { Strings("%s's\nfully paralyzed!", name(battler)) }
|
||||
return false, { name(battler) .. "'s\nfully paralyzed!" }
|
||||
end
|
||||
return true, {}
|
||||
end,
|
||||
@@ -130,7 +120,7 @@ Status.RECORDS = {
|
||||
end,
|
||||
onInflict = function(_, _, _, display)
|
||||
-- _ParalyzedMayNotAttackText (primary and secondary paralysis)
|
||||
return { Strings("%s's\nparalyzed! It may\nnot attack!", display) }
|
||||
return { ("%s's\nparalyzed! It may\nnot attack!"):format(display) }
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -166,7 +156,7 @@ function Status.beforeMove(battler, rng, battle)
|
||||
end
|
||||
if battler.flinched then
|
||||
battler.flinched = false
|
||||
return false, { Strings("%s\nflinched!", name(battler)) }
|
||||
return false, { name(battler) .. "\nflinched!" }
|
||||
end
|
||||
local record = Status.recordFor(battleStatuses(battle), mon.status)
|
||||
local handler = record and record.beforeMove
|
||||
@@ -184,23 +174,23 @@ function Status.beforeMove(battler, rng, battle)
|
||||
end
|
||||
if battler.boundTurns and battler.boundTurns > 0 then
|
||||
battler.boundTurns = battler.boundTurns - 1
|
||||
msgs[#msgs + 1] = Strings("%s\ncan't move!", name(battler))
|
||||
msgs[#msgs + 1] = name(battler) .. "\ncan't move!"
|
||||
return false, msgs
|
||||
end
|
||||
if battler.disabledTurns then
|
||||
battler.disabledTurns = battler.disabledTurns - 1
|
||||
if battler.disabledTurns <= 0 then
|
||||
battler.disabledTurns, battler.disabledSlot = nil, nil
|
||||
table.insert(msgs, Strings("%s's\ndisabled no more!", name(battler)))
|
||||
table.insert(msgs, name(battler) .. "'s\ndisabled no more!")
|
||||
end
|
||||
end
|
||||
if battler.confusedTurns then
|
||||
battler.confusedTurns = battler.confusedTurns - 1
|
||||
if battler.confusedTurns <= 0 then
|
||||
battler.confusedTurns = nil
|
||||
table.insert(msgs, Strings("%s\nsnapped out of\nconfusion!", name(battler)))
|
||||
table.insert(msgs, name(battler) .. "\nsnapped out of\nconfusion!")
|
||||
else
|
||||
table.insert(msgs, Strings("%s\nis confused!", name(battler)))
|
||||
table.insert(msgs, name(battler) .. "\nis confused!")
|
||||
-- cp 50 percent + 1 / jr c: hurt itself on rand >= 128 (128/256)
|
||||
if rng(0, 255) < 128 then
|
||||
return false, msgs, true -- hurt itself
|
||||
@@ -241,7 +231,7 @@ function Status.residual(battler, opponent, battle)
|
||||
dmg = math.min(dmg, mon.hp)
|
||||
mon.hp = mon.hp - dmg
|
||||
opponent.mon.hp = math.min(opponent.mon.stats.hp, opponent.mon.hp + dmg)
|
||||
table.insert(msgs, Strings("LEECH SEED saps\n%s!", name(battler)))
|
||||
table.insert(msgs, ("LEECH SEED saps\n%s!"):format(name(battler)))
|
||||
end
|
||||
return msgs
|
||||
end
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Status = require("src.battle.Status")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local StatusRegistry = {}
|
||||
|
||||
@@ -46,7 +45,7 @@ function StatusRegistry.inflict(battle, target, status, opts)
|
||||
if record and record.onInflict then
|
||||
msgs = record.onInflict(battle, target, opts, display)
|
||||
else
|
||||
msgs = { Strings("%s\nwas afflicted\nby %s!", display,
|
||||
msgs = { ("%s\nwas afflicted\nby %s!"):format(display,
|
||||
record and record.label or tostring(status)) }
|
||||
end
|
||||
Runtime.emit("battle.status_inflicted", {
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
|
||||
-- tied minima (core.asm:2971-3002). A non-minimal move is never
|
||||
-- selectable. Respects Disable (and PP only when the ruleset depletes
|
||||
-- enemy PP -- Gen 1 AI never reads wEnemyMonPP).
|
||||
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
|
||||
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local TrainerAI = {}
|
||||
|
||||
@@ -100,7 +99,7 @@ function TrainerAI.useItem(battle, item)
|
||||
local enemy = battle.enemy
|
||||
local trainerName = battle.trainer.name
|
||||
local itemName = battle.data.items[item] and battle.data.items[item].name or item
|
||||
local msgs = { Strings("%s\nused %s!", trainerName, itemName) }
|
||||
local msgs = { ("%s\nused %s!"):format(trainerName, itemName) }
|
||||
if item == "FULL_HEAL" then
|
||||
enemy.mon.status = nil
|
||||
enemy.toxicCounter = nil
|
||||
@@ -113,10 +112,10 @@ function TrainerAI.useItem(battle, item)
|
||||
elseif X_STAT[item] then
|
||||
local stat = X_STAT[item]
|
||||
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
||||
table.insert(msgs, Strings("%s's\n%s rose!", enemy.name, stat:upper()))
|
||||
table.insert(msgs, ("%s's\n%s rose!"):format(enemy.name, stat:upper()))
|
||||
elseif item == "GUARD_SPEC" then
|
||||
enemy.mist = true
|
||||
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", enemy.name))
|
||||
table.insert(msgs, ("%s's\nprotected against\nstat changes!"):format(enemy.name))
|
||||
end
|
||||
return msgs
|
||||
end
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
-- 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
|
||||
@@ -16,7 +16,4 @@ return {
|
||||
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
|
||||
-- player side in pokered). They therefore never Struggle from empty PP.
|
||||
enemyUnlimitedPP = true,
|
||||
-- Gen 1 Hyper Beam: no recharge when the target faints (or its
|
||||
-- substitute breaks). Set false to always recharge like Gen 2+.
|
||||
hyperBeamSkipRechargeOnKO = true,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,4 @@ return {
|
||||
focusEnergyBug = false,
|
||||
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
|
||||
enemyUnlimitedPP = false,
|
||||
-- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO.
|
||||
hyperBeamSkipRechargeOnKO = false,
|
||||
}
|
||||
|
||||
+4
-30
@@ -206,15 +206,10 @@ function Data:load()
|
||||
(function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)())
|
||||
end
|
||||
|
||||
-- Drop every namespace the mod merge created and evict the generated modules
|
||||
-- from package.loaded, so the next load() re-reads them off disk instead of
|
||||
-- handing back the cached tables. Two callers:
|
||||
-- * reloadGenerated below (dev hot reload)
|
||||
-- * main.lua, when the launcher closes the save editor -- the editor may
|
||||
-- have loaded the OTHER game's cache, and require would otherwise serve
|
||||
-- those modules to a subsequent Play (see CacheFs.unmountVersion, which
|
||||
-- clears the matching read-path overlay).
|
||||
function Data:unloadGenerated()
|
||||
-- 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()
|
||||
local pristine = self._pristineKeys
|
||||
if pristine then
|
||||
for key in pairs(self) do
|
||||
@@ -227,13 +222,6 @@ function Data:unloadGenerated()
|
||||
for _, name in ipairs(OPTIONAL) do
|
||||
package.loaded["data.generated." .. name] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
|
||||
-- mod merge created, then re-require the generated modules so base records
|
||||
-- return to their on-disk values even where a mod edited them in place
|
||||
function Data:reloadGenerated()
|
||||
self:unloadGenerated()
|
||||
self:load()
|
||||
end
|
||||
|
||||
@@ -258,20 +246,6 @@ function Data:resolveText(mapLabel, textConst)
|
||||
local s = self.text[entry.text]
|
||||
if s then return s, entry.asm end
|
||||
end
|
||||
-- A text_asm entry names its wrapper label but carries no string, because
|
||||
-- the extractor cannot follow asm. A handful of those wrappers are plain
|
||||
-- `text_far <label> / text_end` pairs with no logic at all -- BoulderText
|
||||
-- (home/overworld_text.asm:16), MartSignText, PokeCenterSignText -- and
|
||||
-- for those the extracted _Label string IS the whole behavior, so the
|
||||
-- boulders and signs printed nothing at all (#318). Wrappers that really
|
||||
-- do run logic have no _Label string to find, so they still fall through
|
||||
-- to their hand-ported script in data/scripts/.
|
||||
-- needsAsm comes back false here on purpose: showMapText's warning tells
|
||||
-- the reader to go port a script, and for these there is nothing to port.
|
||||
if entry.label then
|
||||
local s = self.text["_" .. entry.label]
|
||||
if s then return s, false end
|
||||
end
|
||||
return nil, entry.asm
|
||||
end
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
local Json = require("src.link.Json")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local DiscordPresence = {
|
||||
APP_ID = "1529183141267374262",
|
||||
@@ -437,7 +436,7 @@ end
|
||||
local function buildActivity()
|
||||
local details, activityState
|
||||
if state.activity == "battle" then
|
||||
details = state.battleLabel or Strings("In battle")
|
||||
details = state.battleLabel or "In battle"
|
||||
activityState = state.location and (state.location) or "Kanto"
|
||||
elseif state.activity == "exploring" and state.location then
|
||||
details = state.location
|
||||
@@ -551,20 +550,20 @@ local function subscribe(game)
|
||||
|
||||
track("battle.started", function(ev)
|
||||
local battle = ev and ev.battle
|
||||
local label = Strings("In battle")
|
||||
local label = "In battle"
|
||||
if ev and ev.kind == "wild" then
|
||||
local name = speciesName(game, ev.species)
|
||||
if name then
|
||||
label = "Battling wild " .. name
|
||||
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
|
||||
else
|
||||
label = Strings("Wild battle")
|
||||
label = "Wild battle"
|
||||
end
|
||||
elseif ev and ev.kind == "trainer" then
|
||||
local tname = battle and battle.trainer and battle.trainer.name
|
||||
label = tname and ("Battling " .. tname) or Strings("Trainer battle")
|
||||
label = tname and ("Battling " .. tname) or "Trainer battle"
|
||||
elseif ev and ev.kind == "link" then
|
||||
label = Strings("Link battle")
|
||||
label = "Link battle"
|
||||
end
|
||||
local mapId = state.mapId
|
||||
or (game.save and game.save.player and game.save.player.map)
|
||||
@@ -595,7 +594,7 @@ local function subscribe(game)
|
||||
state.mapId = nil
|
||||
setPresence({
|
||||
activity = "menu",
|
||||
location = id == "IntroMovie" and "Watching the intro" or Strings("Title screen"),
|
||||
location = id == "IntroMovie" and "Watching the intro" or "Title screen",
|
||||
clearBattle = true,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -53,9 +53,6 @@ function Game:load()
|
||||
require("src.render.Font").load(Data)
|
||||
-- menu cursor/border/geometry constants; field.theme restyles them
|
||||
require("src.ui.Theme").load(Data)
|
||||
-- the engine's own text, after the merge so a translation mod's catalog
|
||||
-- is already in Data.strings; empty on a mod-free boot and skipped
|
||||
require("src.core.Strings").load(Data)
|
||||
|
||||
self.stack = StateStack
|
||||
StateStack:init()
|
||||
@@ -99,20 +96,6 @@ function Game:load()
|
||||
end
|
||||
|
||||
Logger.info("game loaded")
|
||||
-- Scaling bug reports (#87, #208) are unanswerable without these three
|
||||
-- numbers: LOVE units, drawable pixels, and the integer physical pixels
|
||||
-- per GB pixel the renderer settled on. Cheap, once, and it turns "it
|
||||
-- looks stretched" into something reproducible.
|
||||
if love.graphics and love.graphics.getDimensions then
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local pw, ph = ww, wh
|
||||
if love.graphics.getPixelDimensions then
|
||||
pw, ph = love.graphics.getPixelDimensions()
|
||||
end
|
||||
Logger.info(string.format(
|
||||
"display: %dx%d units, %dx%d px, fit scale %d px/GB px",
|
||||
ww, wh, pw, ph, Renderer:fitScale()))
|
||||
end
|
||||
end
|
||||
|
||||
-- the merged field.boot: spawn, names, money and the naming presets a
|
||||
@@ -191,15 +174,6 @@ end
|
||||
-- or screenshot run does not depend on whatever the player last chose.
|
||||
function Game:logicSpeed()
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
-- Link play is always 1X on both machines, and this wins over every other
|
||||
-- source including POKEPORT_SPEED. Fast-forward multiplies the logic
|
||||
-- clock, so a peer at 10X burned a tournament shot clock ten times faster
|
||||
-- than the opponent it is racing, and drove its own animation/message
|
||||
-- queue at a different rate than the peer it is locked to. Nothing about
|
||||
-- a match should depend on what either player set this to.
|
||||
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
||||
return 1
|
||||
end
|
||||
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
||||
local opts = self.save and self.save.options
|
||||
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
|
||||
@@ -247,16 +221,6 @@ function Game:draw()
|
||||
-- white clear
|
||||
local base = self.stack:visibleBase()
|
||||
local worldBelow = self.stack.states[base] == self.overworld
|
||||
-- The UI surface is resolved once, before any state draws: the top state
|
||||
-- may want more than the Game Boy's 160x144 (the widescreen battle layout
|
||||
-- asks for 304x144). Anything else keeps the classic surface, so a menu
|
||||
-- pushed over a wide battle brings the screen straight back to 160x144.
|
||||
local top = self.stack:top()
|
||||
if top and top.uiSize then
|
||||
Renderer:setUISize(top:uiSize())
|
||||
else
|
||||
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
||||
end
|
||||
Renderer:beginFrame(worldBelow)
|
||||
self.stack:draw()
|
||||
-- SGB colorization: the topmost state that knows its palette owns the
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ function Input:step()
|
||||
elseif next(sources) ~= nil then
|
||||
self.state[btn] = true
|
||||
end
|
||||
-- sources == {}: real press fully released before this step -- keep up
|
||||
-- sources == {}: real press fully released before this step — keep up
|
||||
end
|
||||
for btn, sources in pairs(self.sources) do
|
||||
if next(sources) == nil then
|
||||
|
||||
+1
-31
@@ -24,31 +24,7 @@ local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
||||
local filterLevel = 0
|
||||
|
||||
local function applyVolume(src)
|
||||
if not src then return end
|
||||
local vol = VOLUME * volumeScale
|
||||
if Runtime.wantsHook("music.volume") then
|
||||
local ctx = {
|
||||
song = state.current,
|
||||
mapSong = state.mapSong,
|
||||
onBike = state.onBike,
|
||||
surfing = state.surfing,
|
||||
fading = state.fade ~= nil,
|
||||
optionScale = volumeScale,
|
||||
}
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
if ok and Game then
|
||||
local ow = Game.overworld
|
||||
if ow and ow.player then
|
||||
ctx.x, ctx.y = ow.player.cellX, ow.player.cellY
|
||||
ctx.mapId = ow.map and ow.map.id
|
||||
ctx.tod = ow.tod
|
||||
end
|
||||
end
|
||||
vol = Runtime.call("music.volume", function(v) return v end, vol, ctx)
|
||||
vol = tonumber(vol) or (VOLUME * volumeScale)
|
||||
if vol < 0 then vol = 0 end
|
||||
end
|
||||
pcall(src.setVolume, src, vol)
|
||||
if src then pcall(src.setVolume, src, VOLUME * volumeScale) end
|
||||
end
|
||||
|
||||
-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered
|
||||
@@ -432,12 +408,6 @@ end
|
||||
-- restores the map theme after a one-shot jingle
|
||||
function Music.update(data)
|
||||
if state.chip then require("src.core.ChipAudio").update() end
|
||||
-- distance / indoor muffling mods re-apply volume every frame while
|
||||
-- subscribed; otherwise applyVolume only runs on song/option changes
|
||||
if Runtime.wantsHook("music.volume") and not state.fade then
|
||||
applyVolume(state.source)
|
||||
applyVolume(state.loopSource)
|
||||
end
|
||||
-- volume ramp (Music.fadeOut): hold the current level for `control`
|
||||
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
|
||||
-- nibbles when its counter reaches 0); at level 0 the music stops.
|
||||
|
||||
+16
-138
@@ -16,7 +16,6 @@ local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Boxes = require("src.pokemon.Boxes")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Badges = require("src.inventory.Badges")
|
||||
|
||||
@@ -133,26 +132,17 @@ local function detectPortable()
|
||||
-- Recover the folder containing the .app so a packaged app finds its
|
||||
-- marker. On Windows/Linux the executable is not a bundle, so this is nil
|
||||
-- and the plain source-base directory (next to the .exe/AppImage) is used.
|
||||
local function parentDir(path)
|
||||
return path and path:match("^(.*)/[^/]+$") or nil
|
||||
end
|
||||
local function appContainer(path)
|
||||
return parentDir(path and path:match("^(.*%.app)/Contents/"))
|
||||
local appPath = path and path:match("^(.*%.app)/Contents/")
|
||||
return appPath and appPath:match("^(.*)/[^/]+$") or nil
|
||||
end
|
||||
-- The Linux AppImage build works similarly to macOs, but the runtime mounts
|
||||
-- the squashfs at a temp path, and that becomes the base directory.
|
||||
-- The runtime exports the real .AppImage path in $APPIMAGE, so we get the
|
||||
-- base dir from there.
|
||||
local function appImageContainer()
|
||||
return parentDir(os.getenv("APPIMAGE"))
|
||||
end
|
||||
-- Order: the packaged-app containing folder (macOS .app / Linux AppImage),
|
||||
-- then the source-base directory (next to a packaged .exe), then the source
|
||||
-- itself (a `love <gamedir>` run drops portable.txt in the game folder).
|
||||
-- First one holding the marker wins. Built by appending so a nil (e.g. no
|
||||
-- .app in the path) never truncates the ipairs scan.
|
||||
-- Order: the .app's containing folder (packaged macOS), then the
|
||||
-- source-base directory (next to a packaged .exe/AppImage), then the
|
||||
-- source itself (a `love <gamedir>` run drops portable.txt in the game
|
||||
-- folder). First one holding the marker wins. Built by appending so a
|
||||
-- nil (e.g. no .app in the path) never truncates the ipairs scan.
|
||||
local candidates = {}
|
||||
local appDir = appContainer(src) or appContainer(sbd) or appImageContainer()
|
||||
local appDir = appContainer(src) or appContainer(sbd)
|
||||
if appDir then candidates[#candidates + 1] = appDir end
|
||||
if sbd then candidates[#candidates + 1] = sbd end
|
||||
if src then candidates[#candidates + 1] = src end
|
||||
@@ -203,9 +193,6 @@ function SaveData.defaultOptions()
|
||||
textSpeed = 3,
|
||||
animations = true,
|
||||
battleStyle = "shift",
|
||||
-- battle screen composition: og (the 160x144 original) | wide
|
||||
-- (304x144, src/battle/WideBattle.lua)
|
||||
battleLayout = "og",
|
||||
ruleset = "gen1_faithful",
|
||||
-- 0-7 like the GB's NR50 master volume
|
||||
musicVol = 7,
|
||||
@@ -455,25 +442,6 @@ function SaveData.slotSummary(save)
|
||||
}
|
||||
end
|
||||
|
||||
-- The absolute on-disk path of a slot's save file, for the one caller that
|
||||
-- cannot go through love.filesystem: the save editor reads and writes with
|
||||
-- raw io.* so it can also open a file the player dragged in from anywhere.
|
||||
-- Resolves against the same root persistFs would write to -- the portable
|
||||
-- game folder when portable mode is on, otherwise LOVE's save directory --
|
||||
-- so Edit on a launcher save row lands on the file the game actually plays.
|
||||
-- nil when neither root is available (headless tests with an injected fs).
|
||||
function SaveData.slotDiskPath(version, slotId)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) or not slotId then return nil end
|
||||
local base = SaveData.portableBaseDir()
|
||||
or (love and love.filesystem and love.filesystem.getSaveDirectory
|
||||
and love.filesystem.getSaveDirectory())
|
||||
if not base then return nil end
|
||||
local sep = package.config:sub(1, 1)
|
||||
local rel = select(1, slotNames(version, slotId))
|
||||
return base .. sep .. rel:gsub("/", sep)
|
||||
end
|
||||
|
||||
-- Slots visible to the launcher: every registered slot for a version, each
|
||||
-- with whether it holds a save and the cheap summary above. A fresh
|
||||
-- install with nothing registered returns an empty array; a legacy install
|
||||
@@ -490,44 +458,11 @@ function SaveData.listSlots(version)
|
||||
for _, id in ipairs(list) do
|
||||
local save = decodeSlot(fs, version, id)
|
||||
local name, meta = SaveData.slotSummary(save)
|
||||
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta,
|
||||
label = reg.names and reg.names[id] or nil }
|
||||
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Give a registered slot a custom label (#205: "a way to name save slots so
|
||||
-- you can see that in the launcher"). The label lives in the options
|
||||
-- registry next to list/active, never in the save file itself, so renaming
|
||||
-- needs no save rewrite and an empty slot can be labeled too. The label is
|
||||
-- trimmed; an empty (or whitespace-only) one clears it. Returns true, or
|
||||
-- false + an error string when the id is not registered.
|
||||
function SaveData.renameSlot(version, slotId, name)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
if type(slotId) ~= "string" or slotId == "" then
|
||||
return false, "missing slot id"
|
||||
end
|
||||
local fs = persistFs(nil)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
local reg = opts.saveSlots[version]
|
||||
if not reg or not reg.list then return false, "slot not registered" end
|
||||
local found = false
|
||||
for _, id in ipairs(reg.list) do
|
||||
if id == slotId then found = true break end
|
||||
end
|
||||
if not found then return false, "slot not registered" end
|
||||
local label = type(name) == "string" and name:match("^%s*(.-)%s*$") or nil
|
||||
if label == "" then label = nil end
|
||||
reg.names = reg.names or {}
|
||||
reg.names[slotId] = label
|
||||
if next(reg.names) == nil then reg.names = nil end
|
||||
opts.saveSlots[version] = reg
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Point the active slot at slotId (registering it if new) and persist the
|
||||
-- choice to options.lua; also update the process-global cache so the very
|
||||
-- next save/load lands in the chosen slot.
|
||||
@@ -617,45 +552,6 @@ function SaveData.writeSlot(version, slotId, saveTable)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the
|
||||
-- options registry, and if it was active point active at another remaining
|
||||
-- slot (or clear active when the list is empty). Returns true, or false +
|
||||
-- an error string when the id is unknown / not registered.
|
||||
function SaveData.deleteSlot(version, slotId)
|
||||
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)
|
||||
ensureVersionSlots(version, fs)
|
||||
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, idx = false, nil
|
||||
for i, id in ipairs(reg.list) do
|
||||
if id == slotId then found = true; idx = i; break end
|
||||
end
|
||||
if not found then return false, "slot not registered" end
|
||||
|
||||
local main, bak, tmp = slotNames(version, slotId)
|
||||
remove(fs, main)
|
||||
remove(fs, bak)
|
||||
remove(fs, tmp)
|
||||
|
||||
table.remove(reg.list, idx)
|
||||
if reg.names then reg.names[slotId] = nil end
|
||||
if reg.active == slotId then
|
||||
reg.active = reg.list[1] -- may be nil when the list is now empty
|
||||
end
|
||||
opts.saveSlots[version] = reg
|
||||
SaveData.saveOptions(opts, fs)
|
||||
slotsChecked[version] = true
|
||||
activeSlotCache[version] = reg.active or false
|
||||
return true
|
||||
end
|
||||
|
||||
-- Test seam: drop the process-global slot cache so a suite can exercise
|
||||
-- migration/resolution against a freshly injected filesystem. Unused by
|
||||
-- the game, which resolves each version exactly once per boot.
|
||||
@@ -1038,17 +934,6 @@ local function scrubKnownMon(mon, data)
|
||||
for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end
|
||||
end
|
||||
mon.level = clamp(mon.level, 1, 100, 1)
|
||||
-- Box mons imported from a real .sav carry NO stat block: box_struct stops
|
||||
-- before MON_LEVEL/MON_STATS, so src/save_convert/GenSave.lua decodeMon
|
||||
-- only fills `stats` for party slots. Every HP-bar draw then nil-indexes
|
||||
-- mon.stats: the status screen opened in the box (#233) and the party list
|
||||
-- after withdrawing one (#304). The original derives them on demand
|
||||
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
|
||||
-- every later reader (menus, battle, items, SGB bar zones, the link
|
||||
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
|
||||
-- so the derived stats use a sane level. A save that already has stats is
|
||||
-- untouched.
|
||||
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
|
||||
local moves = mon.moves
|
||||
if type(moves) ~= "table" then return end
|
||||
local hadMoves = #moves > 0
|
||||
@@ -1232,16 +1117,9 @@ function SaveData.defaultHeal(boot)
|
||||
end
|
||||
|
||||
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
|
||||
-- after jp Init; this port relocates CONTINUE instead, and retargets
|
||||
-- LAST_MAP exits (Red's house mats) at the heal-point town so leaving the
|
||||
-- house does not dump the player back at Indigo Plateau.
|
||||
--
|
||||
-- The landing spot is the blackout point, not the NewGameWarp bedroom:
|
||||
-- HallOfFameResetEventsAndSaveScript sets wLastBlackoutMap := PALLET_TOWN,
|
||||
-- so resuming puts the player outside the front door. Spawning them back
|
||||
-- in the upstairs bedroom was a port-only detour (#253). Non-vanilla
|
||||
-- spawns are unaffected -- defaultHeal returns boot's own start map for
|
||||
-- those, which is what the bedroom branch resolved to anyway.
|
||||
-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and
|
||||
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so
|
||||
-- leaving the house does not dump the player back at Indigo Plateau.
|
||||
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
|
||||
function SaveData.applyPostGameHome(save, boot)
|
||||
boot = type(boot) == "table" and boot or {}
|
||||
@@ -1249,9 +1127,9 @@ function SaveData.applyPostGameHome(save, boot)
|
||||
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
|
||||
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
|
||||
save.player = save.player or {}
|
||||
save.player.map = heal.map
|
||||
save.player.x = heal.x
|
||||
save.player.y = heal.y
|
||||
save.player.map = boot.startMap or "REDS_HOUSE_2F"
|
||||
save.player.x = boot.startX or 3
|
||||
save.player.y = boot.startY or 6
|
||||
save.player.facing = boot.startFacing or "down"
|
||||
save.postGameHomeOk = true
|
||||
return heal
|
||||
@@ -1293,7 +1171,7 @@ function SaveData.newGame(boot)
|
||||
inventory = {},
|
||||
-- Vanilla Gen1 seeds one Potion in the player's item PC
|
||||
-- (wBoxItems / players_pc.asm); existing saves keep whatever they
|
||||
-- already have -- this only applies to New Game.
|
||||
-- already have — this only applies to New Game.
|
||||
pcItems = { POTION = 1 },
|
||||
party = {},
|
||||
box = {},
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
-- 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,32 +75,6 @@ local function resolveMkdir()
|
||||
return mkdirFn
|
||||
end
|
||||
|
||||
-- Lazily-resolved windowless rmdir, the mirror of resolveMkdir above:
|
||||
-- function(absolutePath) or false when FFI is unavailable. Both syscalls
|
||||
-- refuse a non-empty directory, so a caller has to delete the files first.
|
||||
local rmdirFn = nil
|
||||
|
||||
local function resolveRmdir()
|
||||
if rmdirFn ~= nil then return rmdirFn end
|
||||
rmdirFn = false
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return rmdirFn end
|
||||
if ffi.os == "Windows" then
|
||||
pcall(ffi.cdef, "int RemoveDirectoryA(const char *lpPathName);")
|
||||
local resolved = pcall(function() return ffi.C.RemoveDirectoryA end)
|
||||
if resolved then
|
||||
rmdirFn = function(path) pcall(ffi.C.RemoveDirectoryA, path) end
|
||||
end
|
||||
else
|
||||
pcall(ffi.cdef, "int rmdir(const char *pathname);")
|
||||
local resolved = pcall(function() return ffi.C.rmdir end)
|
||||
if resolved then
|
||||
rmdirFn = function(path) pcall(ffi.C.rmdir, path) end
|
||||
end
|
||||
end
|
||||
return rmdirFn
|
||||
end
|
||||
|
||||
-- Mount an external directory onto the physfs read path (appended, so the
|
||||
-- game's own source always wins a name clash). Returns true on success.
|
||||
--
|
||||
@@ -148,37 +122,6 @@ local function mountReadable(dir, append)
|
||||
return fn(dir, append)
|
||||
end
|
||||
|
||||
-- PHYSFS_unmount, resolved the same way PHYSFS_mount is. Only
|
||||
-- CacheFs.unmountVersion needs it: the launcher can open the save editor on
|
||||
-- one game's cache and then Play the other, and an overlay left mounted
|
||||
-- would win the read path for the rest of the process.
|
||||
local physfsUnmountFn = nil
|
||||
local function resolveUnmount()
|
||||
if physfsUnmountFn ~= nil then return physfsUnmountFn end
|
||||
physfsUnmountFn = false
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return physfsUnmountFn end
|
||||
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
|
||||
local libs = {
|
||||
function() return ffi.C end,
|
||||
function() return ffi.load("love") end,
|
||||
}
|
||||
for _, getlib in ipairs(libs) do
|
||||
local okl, lib = pcall(getlib)
|
||||
if okl and lib then
|
||||
local oks, fn = pcall(function() return lib.PHYSFS_unmount end)
|
||||
if oks and fn then
|
||||
physfsUnmountFn = function(d)
|
||||
local okr, ret = pcall(fn, d)
|
||||
return okr and ret ~= 0
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
return physfsUnmountFn
|
||||
end
|
||||
|
||||
-- The portable game folder when the cache should live there, else nil.
|
||||
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
|
||||
-- desktop portable install (SaveData) and a working windowless mkdir.
|
||||
@@ -286,23 +229,6 @@ function CacheFs.remove(rel)
|
||||
love.filesystem.remove(rel)
|
||||
end
|
||||
|
||||
-- Remove a single cache-relative directory once its files are gone. Needed
|
||||
-- because os.remove cannot delete a directory on Windows and
|
||||
-- love.filesystem.remove never reaches outside the save directory, so the
|
||||
-- portable game folder gets the same FFI-syscall treatment as its mkdir
|
||||
-- (issue #74: os.execute would flash a console window per call). Used by the
|
||||
-- mod installer so an uninstall leaves nothing behind (#330).
|
||||
function CacheFs.removeDir(rel)
|
||||
rel = withPrefix(rel)
|
||||
local root = CacheFs.root()
|
||||
if root then
|
||||
local rmdir = resolveRmdir()
|
||||
if rmdir then rmdir(realPath(root, rel)) end
|
||||
return
|
||||
end
|
||||
love.filesystem.remove(rel)
|
||||
end
|
||||
|
||||
-- Remove the game-folder copy of a cache subtree before a fresh import, so a
|
||||
-- cache-format bump does not leave orphaned files behind. No-op when the
|
||||
-- portable cache is inactive (the save-directory copy is cleared by
|
||||
@@ -354,35 +280,4 @@ function CacheFs.mountVersion(version)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Undo mountVersion. A process normally mounts exactly one version and then
|
||||
-- boots it, but the launcher can open the save editor on a Blue save, close
|
||||
-- it, and press Play on Red: with blue/ still prepended, Red's
|
||||
-- require("data.generated.*") and its generated art would silently resolve to
|
||||
-- Blue's files. Callers must also drop the generated modules from
|
||||
-- package.loaded (src.core.Data:unloadGenerated) -- unmounting alone only
|
||||
-- fixes the read path, not what require already cached.
|
||||
--
|
||||
-- Returns true when nothing was mounted or the unmount took. Red is a no-op
|
||||
-- because its cache lives at the root and was never overlaid.
|
||||
function CacheFs.unmountVersion(version)
|
||||
local prefix = require("src.core.GameVersion").cachePrefix(version)
|
||||
if prefix == "" then return true end
|
||||
local sub = prefix:gsub("/+$", "")
|
||||
local base = CacheFs.root()
|
||||
if not base and love.filesystem.getSaveDirectory then
|
||||
base = love.filesystem.getSaveDirectory()
|
||||
end
|
||||
local done = false
|
||||
local fn = resolveUnmount()
|
||||
if fn and base then
|
||||
done = fn(base .. SEP .. sub) or done
|
||||
end
|
||||
-- also drop the love.filesystem.mount fallback, which registers the folder
|
||||
-- under its bare name rather than its absolute path
|
||||
if love.filesystem.unmount then
|
||||
done = love.filesystem.unmount(sub) or done
|
||||
end
|
||||
return done
|
||||
end
|
||||
|
||||
return CacheFs
|
||||
|
||||
+72
-684
File diff suppressed because it is too large
Load Diff
@@ -47,19 +47,11 @@ local function readSource(source)
|
||||
return source
|
||||
end
|
||||
local f, openErr = io.open(source, "rb")
|
||||
if f then
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
if type(data) ~= "string" then return nil, "the save file was empty" end
|
||||
return data
|
||||
end
|
||||
-- Android SAF drops (picked_save.sav) and USB copies land in the LOVE save
|
||||
-- directory; io.open cannot see them, so fall back to love.filesystem.
|
||||
if love and love.filesystem and love.filesystem.read then
|
||||
local data = love.filesystem.read(source)
|
||||
if type(data) == "string" then return data end
|
||||
end
|
||||
return nil, "could not read the save file: " .. tostring(openErr)
|
||||
if not f then return nil, "could not read the save file: " .. tostring(openErr) end
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
if type(data) ~= "string" then return nil, "the save file was empty" end
|
||||
return data
|
||||
end
|
||||
|
||||
-- importToSlot(source, version) -> ok, slotIdOrErr
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Flags = require("src.script.Flags")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local ItemEffects = {}
|
||||
|
||||
@@ -47,17 +46,6 @@ ItemEffects.BALLS = BALLS
|
||||
function ItemEffects.isBall(id) return BALLS[id] or false end
|
||||
function ItemEffects.isStone(id) return STONES[id] or false end
|
||||
|
||||
-- Does using this item take item_effects.asm's .healHP path, the one that
|
||||
-- plays SFX_HEAL_HP and lengthens the party HP bar with UpdateHPBar2 before
|
||||
-- the message (item_effects.asm .doneHealing)? The status-only cures branch
|
||||
-- to .playStatusAilmentCuringSound instead and never touch the bar. BagMenu
|
||||
-- keeps the party picker open for these so the fill has something to draw
|
||||
-- on (#252).
|
||||
function ItemEffects.healsHP(id)
|
||||
return HEAL_AMOUNT[id] ~= nil or id == "MAX_POTION" or id == "FULL_RESTORE"
|
||||
or id == "REVIVE" or id == "MAX_REVIVE"
|
||||
end
|
||||
|
||||
-- Does this item need a party-member target?
|
||||
function ItemEffects.needsTarget(id, itemDef)
|
||||
return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION"
|
||||
@@ -131,8 +119,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP"
|
||||
or itemId == "RARE_CANDY" or itemId == "COIN_CASE"
|
||||
or (itemDef and itemDef.machine)) then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!",
|
||||
save.player.name) }
|
||||
return "failed", { "OAK: " .. save.player.name
|
||||
.. "!\nThis isn't the\ntime to use that!" }
|
||||
end
|
||||
|
||||
if BALLS[itemId] then
|
||||
@@ -150,12 +138,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
local mapId, npc = adjacentSleepingSnorlax(save, ow)
|
||||
if npc then
|
||||
return "flute_wake", { data.text._PlayedFluteHadEffectText
|
||||
or Strings("{PLAYER} played the\nPOKé FLUTE.") },
|
||||
{ mapId = mapId, npc = npc }
|
||||
or "{PLAYER} played the\nPOKé FLUTE." }, { mapId = mapId, npc = npc }
|
||||
end
|
||||
-- otherwise: play the tune, nothing happens (ItemUsePokeFlute's
|
||||
-- PlayedFluteNoEffectText branch)
|
||||
return "flute_field", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||
return "flute_field", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" }
|
||||
end
|
||||
local woke = false
|
||||
local function wake(mon)
|
||||
@@ -170,24 +157,24 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- WakeUpEntireParty runs on the enemy's bench too
|
||||
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
|
||||
if not woke then
|
||||
return "failed", { Strings("Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
|
||||
return "failed", { "Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!" }
|
||||
end
|
||||
return "flute", { Strings("%s played the\nPOKé FLUTE.", save.player.name),
|
||||
Strings("All sleeping\nPOKéMON woke up!") }
|
||||
return "flute", { ("%s played the\nPOKé FLUTE."):format(save.player.name),
|
||||
"All sleeping\nPOKéMON woke up!" }
|
||||
end
|
||||
|
||||
-- battle-only items
|
||||
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
|
||||
or itemId == "POKE_DOLL" then
|
||||
if not battle then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
||||
end
|
||||
local b = battle.player
|
||||
if itemId == "X_ACCURACY" then
|
||||
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
|
||||
-- (not an accuracy stage)
|
||||
b.xAccuracy = true
|
||||
return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) }
|
||||
return "consumed", { ("%s's\nhits will never\nmiss!"):format(b.name) }
|
||||
end
|
||||
if X_ITEMS[itemId] then
|
||||
local stat = X_ITEMS[itemId]
|
||||
@@ -196,28 +183,28 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- effect, so at +6 it is still consumed and StatModifierUpEffect
|
||||
-- just prints "Nothing happened!"
|
||||
if cur >= 6 then
|
||||
return "consumed", { Strings("Nothing happened!") }
|
||||
return "consumed", { "Nothing happened!" }
|
||||
end
|
||||
b.stages[stat] = cur + 1
|
||||
return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
|
||||
return "consumed", { ("%s's\n%s rose!"):format(b.name, stat:upper()) }
|
||||
end
|
||||
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
|
||||
-- the item, even when it is already active
|
||||
if itemId == "DIRE_HIT" then
|
||||
b.focusEnergy = true
|
||||
return "consumed", { Strings("%s's\ngetting pumped!", b.name) }
|
||||
return "consumed", { ("%s's\ngetting pumped!"):format(b.name) }
|
||||
end
|
||||
if itemId == "GUARD_SPEC" then
|
||||
b.mist = true
|
||||
return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) }
|
||||
return "consumed", { ("%s's\nprotected against\nstat changes!"):format(b.name) }
|
||||
end
|
||||
if itemId == "POKE_DOLL" then
|
||||
if battle.kind ~= "wild" then
|
||||
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
|
||||
return "failed", { Strings(
|
||||
"OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
return "failed", { "OAK: " .. save.player.name
|
||||
.. "!\nThis isn't the\ntime to use that!" }
|
||||
end
|
||||
return "consumed_escape", { Strings("The wild POKéMON\nran away!") }
|
||||
return "consumed_escape", { "The wild POKéMON\nran away!" }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -226,7 +213,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- restore every move with no menu.
|
||||
if itemId == "ETHER" or itemId == "MAX_ETHER"
|
||||
or itemId == "ELIXER" or itemId == "MAX_ELIXER" then
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
if not target then return "failed", { "It won't have\nany effect." } end
|
||||
local restored = false
|
||||
local full = itemId == "MAX_ETHER" or itemId == "MAX_ELIXER"
|
||||
local allMoves = itemId == "ELIXER" or itemId == "MAX_ELIXER"
|
||||
@@ -248,9 +235,9 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
restored = mv and restore(mv) or false
|
||||
end
|
||||
if not restored then
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
end
|
||||
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
|
||||
return "consumed", { ("%s's PP\nwas restored!"):format(monName(data, target)) }
|
||||
end
|
||||
|
||||
local heal = HEAL_AMOUNT[itemId]
|
||||
@@ -263,57 +250,49 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
target.status = nil
|
||||
cureActiveToxic(battle, target)
|
||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
||||
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||
return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) }
|
||||
end
|
||||
if not target or target.hp <= 0 or target.hp >= target.stats.hp then
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
end
|
||||
-- wHPBarOldHP: the bar animation starts from the HP the mon had BEFORE
|
||||
-- the item landed (item_effects.asm latches it with the party menu still
|
||||
-- up), so latch it here and hand it back as extra.healedFrom for the
|
||||
-- party-menu fill (#252)
|
||||
local before = target.hp
|
||||
if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
|
||||
target.hp = target.stats.hp
|
||||
else
|
||||
target.hp = math.min(target.stats.hp, target.hp + heal)
|
||||
end
|
||||
local msgs = { Strings("%s's HP\nwas restored!", monName(data, target)) }
|
||||
local msgs = { ("%s's HP\nwas restored!"):format(monName(data, target)) }
|
||||
if itemId == "FULL_RESTORE" then
|
||||
target.status = nil
|
||||
cureActiveToxic(battle, target)
|
||||
end
|
||||
require("src.core.Sound").play(data, "Heal_HP")
|
||||
return "consumed", msgs, { healedFrom = before }
|
||||
return "consumed", msgs
|
||||
end
|
||||
|
||||
local cures = STATUS_HEAL[itemId]
|
||||
if cures then
|
||||
if not target or not target.status or not cures[target.status] then
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
end
|
||||
target.status = nil
|
||||
cureActiveToxic(battle, target)
|
||||
require("src.core.Sound").play(data, "Heal_Ailment")
|
||||
return "consumed", { Strings("%s's\nstatus returned\nto normal!", monName(data, target)) }
|
||||
return "consumed", { ("%s's\nstatus returned\nto normal!"):format(monName(data, target)) }
|
||||
end
|
||||
|
||||
if itemId == "REVIVE" or itemId == "MAX_REVIVE" then
|
||||
if not target or target.hp > 0 then
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
end
|
||||
target.status = nil
|
||||
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
|
||||
require("src.core.Sound").play(data, "Heal_HP")
|
||||
-- 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 }
|
||||
return "consumed", { ("%s\nis revitalized!"):format(monName(data, target)) }
|
||||
end
|
||||
|
||||
if itemId == "RARE_CANDY" then
|
||||
if not target or target.level >= 100 then
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
end
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
@@ -323,56 +302,56 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
local old = target.stats
|
||||
target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp)
|
||||
target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp))
|
||||
return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) },
|
||||
return "consumed", { ("%s grew\nto level %d!"):format(monName(data, target), target.level) },
|
||||
{ leveledTo = target.level }
|
||||
end
|
||||
|
||||
if STONES[itemId] then
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
if not target then return "failed", { "It won't have\nany effect." } end
|
||||
local speciesDef = data.pokemon[target.species]
|
||||
for _, evo in ipairs(speciesDef.evolutions) do
|
||||
if evo.method == "ITEM" and evo.item == itemId then
|
||||
return "consumed", nil, { evolveTo = evo.species }
|
||||
end
|
||||
end
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
end
|
||||
|
||||
-- vitamins: +2560 stat exp, refused at 25600+ (ItemUseVitamin,
|
||||
-- engine/items/item_effects.asm)
|
||||
local vitaminStat = VITAMINS[itemId]
|
||||
if vitaminStat then
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
if not target then return "failed", { "It won't have\nany effect." } end
|
||||
target.statExp = target.statExp or {}
|
||||
local cur = target.statExp[vitaminStat] or 0
|
||||
if cur >= 25600 then
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
end
|
||||
target.statExp[vitaminStat] = math.min(65535, cur + 2560)
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
target.stats = Stats.calc(data.pokemon[target.species], target.level,
|
||||
target.dvs, target.statExp)
|
||||
target.hp = math.min(target.hp, target.stats.hp)
|
||||
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
|
||||
return "consumed", { ("%s's %s\nrose!"):format(monName(data, target),
|
||||
vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
|
||||
end
|
||||
|
||||
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
||||
if itemId == "PP_UP" then
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
if not target then return "failed", { "It won't have\nany effect." } end
|
||||
local mv = target.moves[moveIndex or 1]
|
||||
local mdef = mv and data.moves[mv.id]
|
||||
if mdef and (mv.ppUps or 0) < 3 then
|
||||
mv.ppUps = (mv.ppUps or 0) + 1
|
||||
-- each PP UP adds maxPP/5 uses on top of the base maximum
|
||||
mv.pp = mv.pp + math.floor(mdef.pp / 5)
|
||||
return "consumed", { Strings("%s's PP\nincreased!", mdef.name) }
|
||||
return "consumed", { ("%s's PP\nincreased!"):format(mdef.name) }
|
||||
end
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
return "failed", { "It won't have\nany effect." }
|
||||
end
|
||||
|
||||
if itemDef and itemDef.machine then
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
if not target then return "failed", { "It won't have\nany effect." } end
|
||||
local speciesDef = data.pokemon[target.species]
|
||||
local ok = false
|
||||
for _, m in ipairs(speciesDef.tmhm) do
|
||||
@@ -383,11 +362,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- plays SFX_DENIED before MonCannotLearnMachineMoveText (the generic
|
||||
-- ItemUseNotTime/NoCyclingAllowedHere paths are silent)
|
||||
require("src.core.Sound").play(data, "Denied")
|
||||
return "failed", { Strings("%s can't\nlearn that move!", monName(data, target)) }
|
||||
return "failed", { ("%s can't\nlearn that move!"):format(monName(data, target)) }
|
||||
end
|
||||
for _, mv in ipairs(target.moves) do
|
||||
if mv.id == itemDef.machine.move then
|
||||
return "failed", { Strings("It knows that\nmove already!") }
|
||||
return "failed", { "It knows that\nmove already!" }
|
||||
end
|
||||
end
|
||||
-- HMs are never consumed; TMs are single-use
|
||||
@@ -396,14 +375,14 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
|
||||
if itemId == "OLD_ROD" or itemId == "GOOD_ROD" or itemId == "SUPER_ROD" then
|
||||
if battle then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
||||
end
|
||||
return "fish", itemId
|
||||
end
|
||||
|
||||
if itemId == "BICYCLE" then
|
||||
if battle then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
||||
end
|
||||
return "bicycle"
|
||||
end
|
||||
@@ -413,26 +392,26 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
end
|
||||
if itemId == "TOWN_MAP" then
|
||||
if battle then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
||||
end
|
||||
return "townmap"
|
||||
end
|
||||
if itemId == "ITEMFINDER" then
|
||||
if battle then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
||||
end
|
||||
return "itemfinder"
|
||||
end
|
||||
if itemId == "COIN_CASE" then
|
||||
return "failed", { Strings("Coin count:\n%d", save.coins or 0) }
|
||||
return "failed", { ("Coin count:\n%d"):format(save.coins or 0) }
|
||||
end
|
||||
if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then
|
||||
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
|
||||
save.repelSteps = steps
|
||||
return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
|
||||
return "consumed", { ("%s used\n%s!"):format(save.player.name, name) }
|
||||
end
|
||||
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
return "failed", { "OAK: " .. save.player.name .. "!\nThis isn't the\ntime to use that!" }
|
||||
end
|
||||
|
||||
return ItemEffects
|
||||
|
||||
+28
-88
@@ -21,7 +21,6 @@ local Logger = require("src.core.Logger")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local LinkBattle = {}
|
||||
|
||||
@@ -112,19 +111,6 @@ local function scalar(v)
|
||||
return tostring(v)
|
||||
end
|
||||
|
||||
-- A volatile slot is "off" three interchangeable ways -- absent, false, or
|
||||
-- a counter sitting at 0 -- because every reader tests `if b.key then` or
|
||||
-- `key > 0`. They have to hash the same, or a match ends over a difference
|
||||
-- that does not exist. They routinely disagree: the menu-phase flinch
|
||||
-- clear (BattleState:update) and the FIGHT-branch boundTurns mirror
|
||||
-- (fightLockedAction) are written by whichever machine is sitting at ITS
|
||||
-- OWN menu, and in a link battle that is a different battler on each side,
|
||||
-- so one peer holds flinched=false / boundTurns=0 where the other still
|
||||
-- holds nil with two identical simulations underneath.
|
||||
local function off(v)
|
||||
return v == nil or v == false or v == 0
|
||||
end
|
||||
|
||||
local function stageStr(b)
|
||||
local out = {}
|
||||
for i, stat in ipairs(STAGES) do
|
||||
@@ -144,7 +130,7 @@ end
|
||||
local function volStr(b)
|
||||
local out = {}
|
||||
for _, key in ipairs(VOLATILE) do
|
||||
if not off(b[key]) then
|
||||
if b[key] ~= nil then
|
||||
out[#out + 1] = key .. "=" .. scalar(b[key])
|
||||
end
|
||||
end
|
||||
@@ -181,17 +167,6 @@ end
|
||||
|
||||
local PARTS = { "actives", "volatile", "bench" }
|
||||
|
||||
-- Which components are allowed to end a match. `actives` and `bench` carry
|
||||
-- what decides one -- species, HP, status, stat stages, PP, the rest of the
|
||||
-- party -- so a divergence there is a real split between the two
|
||||
-- simulations and stays a draw. `volatile` is per-turn bookkeeping that
|
||||
-- both sides recompute from the authoritative state every turn (see `off`
|
||||
-- above): it can disagree for a turn without either side being wrong, and
|
||||
-- when it does mean something real it lands in `actives` as damage or
|
||||
-- status within a turn or two, where it is caught. Ending a match on it
|
||||
-- alone cost players games they were winning, over nothing.
|
||||
local FATAL_PART = { actives = true, bench = true }
|
||||
|
||||
-- opts: { myParty = packed, theirParty = packed, theirName, role =
|
||||
-- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when
|
||||
-- the handshake says the two link surfaces don't match: a lockstep
|
||||
@@ -202,18 +177,18 @@ function LinkBattle.new(game, net, opts)
|
||||
local theirName = opts.theirName or "FOE"
|
||||
|
||||
if not Handshake.battleAllowed(opts.verdict) then
|
||||
return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
|
||||
return nil, "Link battle needs\nthe same mods on\nboth games."
|
||||
end
|
||||
|
||||
-- both parties pass through the same pack->unpack clamp on both
|
||||
-- machines, so the copies are identical everywhere
|
||||
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
|
||||
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
|
||||
return Strings("Your %s can't\nbattle on the\nother game.", tostring(p.species))
|
||||
return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species))
|
||||
end)
|
||||
if not myParty then return nil, myErr end
|
||||
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why)
|
||||
return Strings("Their %s isn't\nin this game.\n(%s)", tostring(p.species), tostring(why))
|
||||
return ("Their %s isn't\nin this game.\n(%s)"):format(tostring(p.species), tostring(why))
|
||||
end)
|
||||
if not theirParty then return nil, theirErr end
|
||||
if #myParty == 0 or #theirParty == 0 then
|
||||
@@ -257,7 +232,7 @@ function LinkBattle.new(game, net, opts)
|
||||
self.enemyParty = theirParty
|
||||
self.playerParty = myParty -- intro ball row uses the clamped copies
|
||||
self.opponentName = theirName
|
||||
self.introText = Strings("%s wants\nto battle!", theirName)
|
||||
self.introText = ("%s wants\nto battle!"):format(theirName)
|
||||
self.remoteHashes = {}
|
||||
self.localHashes = {}
|
||||
self.remoteParts = {}
|
||||
@@ -317,7 +292,7 @@ function LinkBattle.new(game, net, opts)
|
||||
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
||||
})
|
||||
s.enemySendingOut = true
|
||||
s:sayNext(Strings("%s sent\nout %s!", theirName, s.enemy.name))
|
||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
||||
s:actNext(function()
|
||||
s.enemySendingOut = false
|
||||
s:startGrowIn(s.enemy)
|
||||
@@ -339,21 +314,9 @@ function LinkBattle.new(game, net, opts)
|
||||
Logger.warn("link: desync turn %s component=%s (%s vs %s)",
|
||||
tostring(turn), component, tostring(localH), tostring(remoteH))
|
||||
Runtime.emit("link.desync", { turn = turn, component = component,
|
||||
localHash = localH, remoteHash = remoteH,
|
||||
fatal = true })
|
||||
endAsDraw(s, Strings(
|
||||
"Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
|
||||
component))
|
||||
end
|
||||
|
||||
-- a non-fatal component split: both sides log it and carry on, so the
|
||||
-- match is decided by the battle rather than by bookkeeping
|
||||
local function noteDrift(s, turn, component, localH, remoteH)
|
||||
Logger.warn("link: %s drift on turn %s (%s vs %s) -- match continues",
|
||||
component, tostring(turn), tostring(localH), tostring(remoteH))
|
||||
Runtime.emit("link.desync", { turn = turn, component = component,
|
||||
localHash = localH, remoteHash = remoteH,
|
||||
fatal = false })
|
||||
localHash = localH, remoteHash = remoteH })
|
||||
endAsDraw(s, ("Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?")
|
||||
:format(component))
|
||||
end
|
||||
|
||||
-- a verified turn stays recorded: consuming it here left a finished
|
||||
@@ -368,11 +331,8 @@ function LinkBattle.new(game, net, opts)
|
||||
if mine and theirs then
|
||||
for _, component in ipairs(PARTS) do
|
||||
if mine[component] ~= theirs[component] then
|
||||
if FATAL_PART[component] then
|
||||
reportDesync(s, turn, component, mine[component], theirs[component])
|
||||
return
|
||||
end
|
||||
noteDrift(s, turn, component, mine[component], theirs[component])
|
||||
reportDesync(s, turn, component, mine[component], theirs[component])
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -389,7 +349,7 @@ function LinkBattle.new(game, net, opts)
|
||||
local function resolveLockstep(s, myMsg, theirMsg)
|
||||
if myMsg.kind == "run" or theirMsg.kind == "run" then
|
||||
local who = myMsg.kind == "run" and game.save.player.name or theirName
|
||||
endAsDraw(s, Strings("%s ran from\nthe battle!", who))
|
||||
endAsDraw(s, ("%s ran from\nthe battle!"):format(who))
|
||||
return
|
||||
end
|
||||
s.phase = "messages"
|
||||
@@ -519,9 +479,9 @@ function LinkBattle.new(game, net, opts)
|
||||
party = myParty,
|
||||
onSwitch = function(mon)
|
||||
if mon == s.player.mon then
|
||||
s:say(Strings("%s is\nalready out!", s.player.name))
|
||||
s:say(("%s is\nalready out!"):format(s.player.name))
|
||||
elseif mon.hp <= 0 then
|
||||
s:say(Strings("There's no will\nto fight!"))
|
||||
s:say("There's no will\nto fight!")
|
||||
else
|
||||
s:resolveSwitch(mon)
|
||||
end
|
||||
@@ -531,7 +491,7 @@ function LinkBattle.new(game, net, opts)
|
||||
end
|
||||
|
||||
self.openItems = function(s)
|
||||
s:say(Strings("Items can't be\nused in a link\nbattle!"))
|
||||
s:say("Items can't be\nused in a link\nbattle!")
|
||||
s.phase = "messages"
|
||||
s.afterQueue = "menu"
|
||||
end
|
||||
@@ -549,7 +509,7 @@ function LinkBattle.new(game, net, opts)
|
||||
return
|
||||
end
|
||||
end
|
||||
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", game.save.player.name,
|
||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(game.save.player.name,
|
||||
theirName))
|
||||
s.result = "lose"
|
||||
s.afterQueue = "finish"
|
||||
@@ -562,7 +522,7 @@ function LinkBattle.new(game, net, opts)
|
||||
return
|
||||
end
|
||||
end
|
||||
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", theirName,
|
||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(theirName,
|
||||
game.save.player.name))
|
||||
s.result = "win"
|
||||
s.afterQueue = "finish"
|
||||
@@ -583,13 +543,13 @@ function LinkBattle.new(game, net, opts)
|
||||
-- only a draw if our own simulation hasn't already decided
|
||||
-- (the winner's bye can arrive while we're still animating)
|
||||
if not s.result then
|
||||
endAsDraw(s, Strings("%s left the\nbattle.", theirName))
|
||||
endAsDraw(s, ("%s left the\nbattle."):format(theirName))
|
||||
end
|
||||
elseif msg.type == "forfeit" then
|
||||
-- the peer's own shot clock ran out; unlike a mutual RUN/desync
|
||||
-- draw, this has a definite winner (us)
|
||||
if not s.result then
|
||||
endWithResult(s, "win", Strings("%s ran out of\ntime!", theirName))
|
||||
endWithResult(s, "win", ("%s ran out of\ntime!"):format(theirName))
|
||||
end
|
||||
else
|
||||
-- a tournament control message (bracket_update, the next
|
||||
@@ -603,19 +563,10 @@ function LinkBattle.new(game, net, opts)
|
||||
if net.closed and not s.linkEnded and not s.result then
|
||||
endAsDraw(s)
|
||||
end
|
||||
-- Both early returns below skip baseUpdate, which is where the
|
||||
-- presentational clock normally advances -- so tick it here, in the same
|
||||
-- order baseUpdate would (fx, then the queue). Without this an
|
||||
-- animation caught mid-flight froze for the whole wait: a flash stopped
|
||||
-- on its inverted BGP step and repainted the UI in inverted shades, and
|
||||
-- a pic part-way through a slide or grow-in stayed off screen, which is
|
||||
-- the "the screen went inverted" / "a Pokemon just vanished" pair.
|
||||
if s.phase == "waitRemote" then
|
||||
s:tickFx()
|
||||
return -- the other side is still choosing
|
||||
end
|
||||
if s.phase == "messages" and s.afterQueue == "linkNext" then
|
||||
s:tickFx()
|
||||
if not s:updateQueue() then
|
||||
s.afterQueue = "menu"
|
||||
s.phase = "menu"
|
||||
@@ -631,7 +582,7 @@ function LinkBattle.new(game, net, opts)
|
||||
if s.turnClock <= 0 then
|
||||
s.turnClockActive = false
|
||||
send({ type = "forfeit" })
|
||||
endWithResult(s, "lose", Strings("Time's up! You\nforfeit the match."))
|
||||
endWithResult(s, "lose", "Time's up! You\nforfeit the match.")
|
||||
end
|
||||
elseif opts.turnLimit then
|
||||
s.turnClockActive = false
|
||||
@@ -692,16 +643,17 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
local guestName = opts.guestName or "GUEST"
|
||||
|
||||
if not Handshake.battleAllowed(opts.verdict) then
|
||||
return nil, Strings("Link battle needs\nthe same mods on\nboth games.")
|
||||
return nil, "Link battle needs\nthe same mods on\nboth games."
|
||||
end
|
||||
|
||||
local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel }
|
||||
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p)
|
||||
return Strings("%s's %s can't\nbattle on this\ngame.", hostName, tostring(p.species))
|
||||
return ("%s's %s can't\nbattle on this\ngame."):format(hostName, tostring(p.species))
|
||||
end)
|
||||
if not hostParty then return nil, hostErr end
|
||||
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why)
|
||||
return Strings("%s's %s can't\nbattle on this\ngame.\n(%s)", guestName, tostring(p.species), tostring(why))
|
||||
return ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format(
|
||||
guestName, tostring(p.species), tostring(why))
|
||||
end)
|
||||
if not guestParty then return nil, guestErr end
|
||||
if #hostParty == 0 or #guestParty == 0 then
|
||||
@@ -720,7 +672,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
self.enemyParty = guestParty
|
||||
self.playerParty = hostParty
|
||||
self.opponentName = guestName
|
||||
self.introText = Strings("%s vs %s!", hostName, guestName)
|
||||
self.introText = ("%s vs %s!"):format(hostName, guestName)
|
||||
|
||||
local function orderMove(action)
|
||||
if action and action.id then return game.data.moves[action.id] end
|
||||
@@ -760,7 +712,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
||||
})
|
||||
s.enemySendingOut = true
|
||||
s:sayNext(Strings("%s sent\nout %s!", guestName, s.enemy.name))
|
||||
s:sayNext(("%s sent\nout %s!"):format(guestName, s.enemy.name))
|
||||
s:actNext(function()
|
||||
s.enemySendingOut = false
|
||||
s:startGrowIn(s.enemy)
|
||||
@@ -789,13 +741,6 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
-- the two real players cleared their flinch flags when their move
|
||||
-- menu opened; a spectator has no menu, so it does it here instead,
|
||||
-- at the same point in the turn (see BattleState:clearTurnFlinches).
|
||||
-- Without this a flinch survived into the next turn and ate a move
|
||||
-- that landed in the real match, and the replay -- sharing the RNG
|
||||
-- stream -- was watching a different battle from that point on.
|
||||
s:clearTurnFlinches()
|
||||
local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run"
|
||||
and decodeWireAction(s, hostMsg, s.player) or nil
|
||||
local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run"
|
||||
@@ -844,7 +789,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
return
|
||||
end
|
||||
end
|
||||
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", hostName, guestName))
|
||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(hostName, guestName))
|
||||
s.result = "guestWin"
|
||||
s.afterQueue = "finish"
|
||||
end
|
||||
@@ -856,7 +801,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
return
|
||||
end
|
||||
end
|
||||
s:sayNext(Strings("%s is out of\nPOKéMON!\f%s wins!", guestName, hostName))
|
||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(guestName, hostName))
|
||||
s.result = "hostWin"
|
||||
s.afterQueue = "finish"
|
||||
end
|
||||
@@ -891,11 +836,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
if net.closed and not s.linkEnded and not s.result then
|
||||
endSpectate(s)
|
||||
end
|
||||
-- same as the real-participant loop: every path that skips baseUpdate
|
||||
-- still has to advance the presentational clock, and a spectator sits in
|
||||
-- waitBoth between every single turn
|
||||
if s.phase == "messages" and s.afterQueue == "linkNext" then
|
||||
s:tickFx()
|
||||
if not s:updateQueue() then
|
||||
s.afterQueue = "waitBoth"
|
||||
s.phase = "waitBoth"
|
||||
@@ -903,7 +844,6 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
return
|
||||
end
|
||||
if s.phase == "menu" or s.phase == "waitBoth" then
|
||||
s:tickFx()
|
||||
return -- frozen between resolved turns; never a real decision here
|
||||
end
|
||||
baseUpdate(s, dt)
|
||||
|
||||
+45
-91
@@ -11,7 +11,6 @@ local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local LinkState = {}
|
||||
LinkState.__index = LinkState
|
||||
@@ -73,9 +72,6 @@ end
|
||||
function LinkState.new(game)
|
||||
local self = setmetatable({}, LinkState)
|
||||
self.game = game
|
||||
-- a link session runs at 1X on both machines whatever either player set
|
||||
-- GAME SPEED to (see Game:logicSpeed); cleared in exitWith
|
||||
game.linkSession = true
|
||||
self.stage = "menu"
|
||||
self.index = 1
|
||||
self.addr = ipDigits(Net.lanIP())
|
||||
@@ -94,14 +90,13 @@ function LinkState.newJoinOnline(game, code)
|
||||
self.stage = "onlineJoining"
|
||||
else
|
||||
self.stage = "menu" -- exitWith below needs a real stage to unwind from
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
end
|
||||
return self
|
||||
end
|
||||
|
||||
function LinkState:exitWith(message, reason)
|
||||
DiscordPresence.setJoinCode(nil)
|
||||
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
||||
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
|
||||
if self.net then self.net:close() end
|
||||
self.game.stack:pop()
|
||||
@@ -110,46 +105,6 @@ function LinkState:exitWith(message, reason)
|
||||
end
|
||||
end
|
||||
|
||||
-- Online play meets strangers, so it requires vanilla on both ends
|
||||
-- (Handshake.onlineAllowed). Mods merge into the shared Data registries at
|
||||
-- boot and there is no unmerge, so switching them off has to go through a
|
||||
-- relaunch -- but the player should not have to go find the mod manager and
|
||||
-- work out which mods count. This turns every enabled mod off, records
|
||||
-- them so the mod manager can put them back, and relaunches. The restart
|
||||
-- is confirmed rather than silent: it drops unsaved progress.
|
||||
function LinkState:offerVanillaRestart()
|
||||
local game = self.game
|
||||
local loader = game.mods
|
||||
local mods = Handshake.mods(game)
|
||||
local names = {}
|
||||
for i, mod in ipairs(mods) do
|
||||
if i > 2 then break end
|
||||
names[#names + 1] = tostring(mod.id):upper():sub(1, 12)
|
||||
end
|
||||
local list = table.concat(names, ", ")
|
||||
if #mods > #names then list = list .. (" +%d"):format(#mods - #names) end
|
||||
local text = Strings(
|
||||
"Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?", list)
|
||||
self.game.linkSession = nil
|
||||
Runtime.emit("link.ended", { reason = "error" })
|
||||
if self.net then self.net:close() end
|
||||
game.stack:pop()
|
||||
game.stack:push(TextBox.new(game, text, nil, { choice = function(yes)
|
||||
if not yes then return end
|
||||
-- setEnabled persists the toggle itself (Loader:_saveState), so the
|
||||
-- relaunch comes up vanilla and the mod manager lists them as disabled
|
||||
-- for the player to switch back on afterwards
|
||||
for _, mod in ipairs(mods) do
|
||||
if loader and loader.setEnabled then loader:setEnabled(mod.id, false) end
|
||||
end
|
||||
if game.restartWithMods then
|
||||
game:restartWithMods()
|
||||
elseif love.event and love.event.quit then
|
||||
love.event.quit("restart")
|
||||
end
|
||||
end }))
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- handshake v2 (D8): both peers announce engine version, api version and
|
||||
-- a fingerprint of their link surface, and the verdict comes from the two
|
||||
@@ -219,7 +174,7 @@ function LinkState:update(dt)
|
||||
if self.net then
|
||||
self.net:update()
|
||||
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
|
||||
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
|
||||
return
|
||||
end
|
||||
-- the peer vanished without a bye (only once the inbox is drained,
|
||||
@@ -228,7 +183,7 @@ function LinkState:update(dt)
|
||||
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
|
||||
and self.stage ~= "codeEntry" and self.stage ~= "notice"
|
||||
and self.stage ~= "battleRunning" then
|
||||
self:exitWith(Strings("The link was\nbroken."))
|
||||
self:exitWith("The link was\nbroken.")
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -246,7 +201,7 @@ function LinkState:update(dt)
|
||||
self.index = 1
|
||||
elseif self.index == 2 or self.index == 3 then
|
||||
if not Handshake.onlineAllowed(self.game) then
|
||||
self:offerVanillaRestart()
|
||||
self:exitWith("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.")
|
||||
return
|
||||
end
|
||||
if self.index == 2 then
|
||||
@@ -272,7 +227,7 @@ function LinkState:update(dt)
|
||||
if self.net:host() then
|
||||
self.stage = "hosting"
|
||||
else
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
end
|
||||
else
|
||||
self.stage = "addrEntry"
|
||||
@@ -291,7 +246,7 @@ function LinkState:update(dt)
|
||||
if self.net:hostOnline() then
|
||||
self.stage = "onlineHosting"
|
||||
else
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
end
|
||||
else
|
||||
self.stage = "codeEntry"
|
||||
@@ -329,7 +284,7 @@ function LinkState:update(dt)
|
||||
if self.net:joinOnline(nil, code) then
|
||||
self.stage = "onlineJoining"
|
||||
else
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -368,7 +323,7 @@ function LinkState:update(dt)
|
||||
if self.net:join(table.concat(octets, ".")) then
|
||||
self.stage = "joining"
|
||||
else
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -478,7 +433,7 @@ function LinkState:update(dt)
|
||||
end
|
||||
if not battle then
|
||||
self.net:send({ type = "bye" })
|
||||
self:exitWith(why or Strings("Link battle\ncan't start."), "error")
|
||||
self:exitWith(why or "Link battle\ncan't start.", "error")
|
||||
return
|
||||
end
|
||||
self.game.stack:push(battle)
|
||||
@@ -536,8 +491,8 @@ function LinkState:updateTrade(input)
|
||||
local t = self.trade
|
||||
|
||||
if t.stage == "cancelled" then
|
||||
self:exitWith(t.error and Strings("The trade stopped:\n%s.", t.error)
|
||||
or Strings("The trade was\ncancelled."))
|
||||
self:exitWith(t.error and ("The trade stopped:\n%s."):format(t.error)
|
||||
or "The trade was\ncancelled.")
|
||||
return
|
||||
end
|
||||
if t.stage == "done" then
|
||||
@@ -553,7 +508,6 @@ function LinkState:updateTrade(input)
|
||||
if self.game.writeSave then self.game:writeSave() end
|
||||
local name = received.nickname or self.game.data.pokemon[received.species].name
|
||||
Runtime.emit("link.ended", { reason = "done" })
|
||||
self.game.linkSession = nil -- this path pops without exitWith
|
||||
self.net:close()
|
||||
self.game.stack:pop()
|
||||
local game = self.game
|
||||
@@ -566,7 +520,7 @@ function LinkState:updateTrade(input)
|
||||
enemyOtId = received.otId,
|
||||
onDone = function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("Trade completed!\f%s received\n%s!", game.save.player.name, name),
|
||||
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
|
||||
function()
|
||||
if evoTo then
|
||||
-- via="TRADE": a trade evolution cannot be B-cancelled
|
||||
@@ -593,7 +547,7 @@ function LinkState:updateTrade(input)
|
||||
-- the trade) -- B is dead after that, matching the A branch's own
|
||||
-- self.confirmed == nil guard
|
||||
self.net:send({ type = "bye" })
|
||||
self:exitWith(Strings("The trade was\ncancelled."))
|
||||
self:exitWith("The trade was\ncancelled.")
|
||||
elseif t.stage == "picking" and input:wasPressed("a") then
|
||||
if t:canPick(self.index) then
|
||||
self.net:send(t:pick(self.index))
|
||||
@@ -620,30 +574,30 @@ end
|
||||
function LinkState:draw()
|
||||
if self.stage == "menu" then
|
||||
drawTitle("BOIS CLUB LIVE")
|
||||
Font.draw(Strings("LINK CABLE (LAN)"), 32, 44)
|
||||
Font.draw(Strings("ONLINE MATCH"), 32, 60)
|
||||
Font.draw(Strings("TOURNAMENT"), 32, 76)
|
||||
Font.draw("LINK CABLE (LAN)", 32, 44)
|
||||
Font.draw("ONLINE MATCH", 32, 60)
|
||||
Font.draw("TOURNAMENT", 32, 76)
|
||||
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
|
||||
|
||||
elseif self.stage == "lanMenu" then
|
||||
drawTitle("LINK CABLE (LAN)")
|
||||
Font.draw(Strings("HOST A GAME"), 32, 48)
|
||||
Font.draw(Strings("JOIN A GAME"), 32, 68)
|
||||
Font.draw("HOST A GAME", 32, 48)
|
||||
Font.draw("JOIN A GAME", 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
Font.draw(Strings("UDP port %s", Net.defaultPort()), 8, 128)
|
||||
Font.draw("UDP port " .. Net.defaultPort(), 8, 128)
|
||||
|
||||
elseif self.stage == "onlineMenu" then
|
||||
drawTitle("ONLINE MATCH")
|
||||
Font.draw(Strings("HOST ONLINE"), 32, 48)
|
||||
Font.draw(Strings("JOIN ONLINE"), 32, 68)
|
||||
Font.draw("HOST ONLINE", 32, 48)
|
||||
Font.draw("JOIN ONLINE", 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
|
||||
elseif self.stage == "onlineHosting" then
|
||||
drawTitle("HOSTING ONLINE")
|
||||
Font.draw(Strings("Tell your friend"), 16, 40)
|
||||
Font.draw(Strings("the code:"), 16, 52)
|
||||
Font.draw("Tell your friend", 16, 40)
|
||||
Font.draw("the code:", 16, 52)
|
||||
Font.draw(self.net.code or "??????", 32, 68)
|
||||
Font.draw(Strings("Waiting for join..."), 8, 96)
|
||||
Font.draw("Waiting for join...", 8, 96)
|
||||
|
||||
elseif self.stage == "codeEntry" then
|
||||
drawTitle("ENTER CODE")
|
||||
@@ -655,18 +609,18 @@ function LinkState:draw()
|
||||
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
|
||||
end
|
||||
end
|
||||
Font.draw(Strings("A: connect B: back"), 8, 128)
|
||||
Font.draw("A: connect B: back", 8, 128)
|
||||
|
||||
elseif self.stage == "onlineJoining" then
|
||||
drawTitle("CONNECTING...")
|
||||
Font.draw(Strings("Calling..."), 8, 56)
|
||||
Font.draw("Calling...", 8, 56)
|
||||
Font.draw(self.net.target or "", 8, 72)
|
||||
|
||||
elseif self.stage == "hosting" then
|
||||
drawTitle("HOSTING")
|
||||
Font.draw(Strings("Friend joins at:"), 16, 48)
|
||||
Font.draw("Friend joins at:", 16, 48)
|
||||
Font.draw(self.net.address or "?", 16, 64)
|
||||
Font.draw(Strings("Waiting for join..."), 8, 96)
|
||||
Font.draw("Waiting for join...", 8, 96)
|
||||
|
||||
elseif self.stage == "addrEntry" then
|
||||
drawTitle("ENTER HOST ADDRESS")
|
||||
@@ -681,34 +635,34 @@ function LinkState:draw()
|
||||
for octet = 1, 3 do
|
||||
Font.draw(".", 16 + octet * 32 - 8, 64)
|
||||
end
|
||||
Font.draw(Strings("Port: %s", Net.defaultPort()), 16, 96)
|
||||
Font.draw(Strings("A: connect B: back"), 8, 128)
|
||||
Font.draw("Port: " .. Net.defaultPort(), 16, 96)
|
||||
Font.draw("A: connect B: back", 8, 128)
|
||||
|
||||
elseif self.stage == "joining" then
|
||||
drawTitle("JOINING...")
|
||||
Font.draw(Strings("Calling..."), 8, 56)
|
||||
Font.draw("Calling...", 8, 56)
|
||||
Font.draw(self.net.target or "", 8, 72)
|
||||
|
||||
elseif self.stage == "modeSelect" then
|
||||
drawTitle("CONNECTED!")
|
||||
Font.draw(Strings("TRADE"), 32, 48)
|
||||
Font.draw(Strings("BATTLE"), 32, 68)
|
||||
Font.draw("TRADE", 32, 48)
|
||||
Font.draw("BATTLE", 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
|
||||
elseif self.stage == "battleOptions" then
|
||||
drawTitle("BATTLE OPTIONS")
|
||||
Font.draw(Strings("LEVELS:"), 16, 56)
|
||||
Font.draw("LEVELS:", 16, 56)
|
||||
Font.draw(forceLevelLabel(self.levelChoice or ANY), 88, 56)
|
||||
Font.draw(Strings("A: continue B: back"), 8, 128)
|
||||
Font.draw("A: continue B: back", 8, 128)
|
||||
|
||||
elseif self.stage == "waitMode" or self.stage == "waitHello" then
|
||||
drawTitle("CONNECTED!")
|
||||
if self.stage == "waitHello" then
|
||||
Font.draw(Strings("Checking the"), 16, 56)
|
||||
Font.draw(Strings("other game..."), 16, 72)
|
||||
Font.draw("Checking the", 16, 56)
|
||||
Font.draw("other game...", 16, 72)
|
||||
else
|
||||
Font.draw(Strings("Waiting for the"), 16, 56)
|
||||
Font.draw(Strings("host to choose..."), 16, 72)
|
||||
Font.draw("Waiting for the", 16, 56)
|
||||
Font.draw("host to choose...", 16, 72)
|
||||
end
|
||||
|
||||
elseif self.stage == "notice" then
|
||||
@@ -717,12 +671,12 @@ function LinkState:draw()
|
||||
if i > 8 then break end -- what fits above the prompt row
|
||||
Font.draw(line, 8, 24 + (i - 1) * 12)
|
||||
end
|
||||
Font.draw(self.noticeExits and "A: back" or Strings("A: trade anyway"), 8, 128)
|
||||
Font.draw(self.noticeExits and "A: back" or "A: trade anyway", 8, 128)
|
||||
|
||||
elseif self.stage == "trade" then
|
||||
drawTitle("TRADE")
|
||||
local t = self.trade
|
||||
Font.draw(Strings("YOURS"), 8, 20)
|
||||
Font.draw("YOURS", 8, 20)
|
||||
for i, mon in ipairs(self.game.save.party) do
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local label = (mon.nickname or def.name):sub(1, 8)
|
||||
@@ -730,7 +684,7 @@ function LinkState:draw()
|
||||
Font.draw(label, 16, 20 + i * 12)
|
||||
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end
|
||||
end
|
||||
Font.draw(Strings("THEIRS"), 84, 20)
|
||||
Font.draw("THEIRS", 84, 20)
|
||||
for i, mon in ipairs(t.theirParty or {}) do
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
|
||||
@@ -741,16 +695,16 @@ function LinkState:draw()
|
||||
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
||||
elseif t.stage == "picking" then
|
||||
hint = t:canPick(self.index) and "Pick one to trade"
|
||||
or Strings("X: not on theirs")
|
||||
or "X: not on theirs"
|
||||
elseif t.stage == "waitPick" then hint = "Waiting for them..."
|
||||
elseif t.stage == "confirming" then
|
||||
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
|
||||
hint = self.confirmed and "Waiting..." or "A: trade B: cancel"
|
||||
end
|
||||
Font.draw(hint or "", 8, 132)
|
||||
|
||||
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
|
||||
drawTitle("LINK BATTLE")
|
||||
Font.draw(Strings("Exchanging data..."), 16, 64)
|
||||
Font.draw("Exchanging data...", 16, 64)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+9
-13
@@ -26,7 +26,6 @@
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local hasEnet, enet = pcall(require, "enet")
|
||||
if not hasEnet then enet = nil end
|
||||
@@ -174,7 +173,7 @@ function Net:connectTCP(addr)
|
||||
tcp:settimeout(5)
|
||||
local ok, err = tcp:connect(host, port)
|
||||
if not ok then
|
||||
self.error = Strings("can't reach relay %s:%d\n(%s)", host, port, tostring(err))
|
||||
self.error = ("can't reach relay %s:%d\n(%s)"):format(host, port, tostring(err))
|
||||
return false
|
||||
end
|
||||
tcp:settimeout(0)
|
||||
@@ -237,14 +236,11 @@ local function handleGenericRelayControl(self, msg)
|
||||
self.paired = true
|
||||
return true
|
||||
elseif msg.type == "join_error" then
|
||||
self.error = Strings(({
|
||||
not_found = Strings.source("That code wasn't\nfound."),
|
||||
full = Strings.source("That game already\nhas two players."),
|
||||
expired = Strings.source("That code has\nexpired."),
|
||||
})[msg.reason] or "")
|
||||
if self.error == "" then
|
||||
self.error = Strings("Couldn't join:\n%s", tostring(msg.reason))
|
||||
end
|
||||
self.error = ({
|
||||
not_found = "That code wasn't\nfound.",
|
||||
full = "That game already\nhas two players.",
|
||||
expired = "That code has\nexpired.",
|
||||
})[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason))
|
||||
self.closed = true
|
||||
return true
|
||||
elseif msg.type == "peer_gone" then
|
||||
@@ -332,7 +328,7 @@ function Net:update()
|
||||
-- an unreachable join target surfaces as a service error
|
||||
-- (ICMP unreachable on the connected UDP socket)
|
||||
if self.mode == "joining" and not self.paired then
|
||||
self.error = Strings("no answer from\n%s", self.target or "the host")
|
||||
self.error = ("no answer from\n%s"):format(self.target or "the host")
|
||||
else
|
||||
self.error = tostring(event)
|
||||
end
|
||||
@@ -362,14 +358,14 @@ function Net:update()
|
||||
self.closed = true
|
||||
if not self.paired then
|
||||
self.error = self.error or
|
||||
Strings("no answer from\n%s", self.target or "the host")
|
||||
("no answer from\n%s"):format(self.target or "the host")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if self.mode == "joining" and not self.paired
|
||||
and self.joinDeadline and now() > self.joinDeadline then
|
||||
self.error = Strings("no answer from\n%s", self.target or "the host")
|
||||
self.error = ("no answer from\n%s"):format(self.target or "the host")
|
||||
self.closed = true
|
||||
pcall(function() self.peer:disconnect_now() end)
|
||||
end
|
||||
|
||||
+35
-41
@@ -18,7 +18,6 @@ local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Sound = require("src.core.Sound")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Tournament = {}
|
||||
Tournament.__index = Tournament
|
||||
@@ -91,11 +90,6 @@ function Tournament.new(game)
|
||||
self.settingsIndex = 1
|
||||
self.roster = {}
|
||||
self.spectatorRoster = {}
|
||||
-- everything from here to exitWith runs at 1X regardless of the GAME
|
||||
-- SPEED option (see Game:logicSpeed): a tournament's shot clock counts
|
||||
-- down on the logic step, so fast-forward would hand one player less
|
||||
-- real time to choose than the opponent they are racing
|
||||
game.linkSession = true
|
||||
Sound.startLoop(game.data, MUSIC)
|
||||
return self
|
||||
end
|
||||
@@ -118,7 +112,6 @@ end
|
||||
|
||||
function Tournament:exitWith(message)
|
||||
DiscordPresence.setJoinCode(nil)
|
||||
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
||||
Sound.stopLoop(MUSIC)
|
||||
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
|
||||
if self.net then self.net:close() end
|
||||
@@ -135,7 +128,7 @@ end
|
||||
function Tournament:startHosting()
|
||||
self.net = Net.new()
|
||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
return
|
||||
end
|
||||
local size, minL, maxL = partyStats(self.game.save.party)
|
||||
@@ -158,7 +151,7 @@ end
|
||||
function Tournament:startJoining(code)
|
||||
self.net = Net.new()
|
||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
return
|
||||
end
|
||||
local size, minL, maxL = partyStats(self.game.save.party)
|
||||
@@ -178,9 +171,9 @@ end
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local JOIN_ERROR_TEXT = {
|
||||
not_found = Strings.source("That code wasn't\nfound."),
|
||||
already_started = Strings.source("That tournament\nhas already begun."),
|
||||
expired = Strings.source("That code has\nexpired."),
|
||||
not_found = "That code wasn't\nfound.",
|
||||
already_started = "That tournament\nhas already begun.",
|
||||
expired = "That code has\nexpired.",
|
||||
}
|
||||
|
||||
function Tournament:handleMessage(msg)
|
||||
@@ -205,17 +198,17 @@ function Tournament:handleMessage(msg)
|
||||
end
|
||||
elseif msg.type == "tournament_host_error" then
|
||||
if msg.reason == "party_ineligible" then
|
||||
self:exitWith(Strings("Can't host:\nneed %d Pokemon\nLv %s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||
self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format(
|
||||
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||
else
|
||||
self:exitWith(Strings("Couldn't host\nthat tournament."))
|
||||
self:exitWith("Couldn't host\nthat tournament.")
|
||||
end
|
||||
elseif msg.type == "tournament_join_error" then
|
||||
if msg.reason == "party_ineligible" then
|
||||
self:exitWith(Strings("Your party needs\n%d Pokemon, Lv\n%s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||
self:exitWith(("Your party needs\n%d Pokemon, Lv\n%s-%s."):format(
|
||||
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||
else
|
||||
self:exitWith(JOIN_ERROR_TEXT[msg.reason]
|
||||
and Strings(JOIN_ERROR_TEXT[msg.reason])
|
||||
or Strings("Couldn't join\nthat tournament."))
|
||||
self:exitWith(JOIN_ERROR_TEXT[msg.reason] or "Couldn't join\nthat tournament.")
|
||||
end
|
||||
elseif msg.type == "tournament_roster" then
|
||||
self.roster = msg.players
|
||||
@@ -288,7 +281,7 @@ function Tournament:beginMatchBattle()
|
||||
-- shouldn't happen (both sides already passed the online-play mods
|
||||
-- gate), but a mismatched engine/build is still possible -- bail out
|
||||
-- of just this match rather than crash the tournament
|
||||
self:exitWith(Strings("Link error:\nversion mismatch\nwith opponent."))
|
||||
self:exitWith("Link error:\nversion mismatch\nwith opponent.")
|
||||
return
|
||||
end
|
||||
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
|
||||
@@ -358,11 +351,11 @@ function Tournament:update(dt)
|
||||
self.net:update()
|
||||
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
|
||||
and self.stage ~= "codeEntry" then
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
|
||||
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
|
||||
return
|
||||
end
|
||||
if self.net.closed and self.stage ~= "done" then
|
||||
self:exitWith(Strings("The tournament\nconnection was\nlost."))
|
||||
self:exitWith("The tournament\nconnection was\nlost.")
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -388,7 +381,7 @@ function Tournament:update(dt)
|
||||
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
||||
if not battle then
|
||||
self:exitWith(why or Strings("Link battle\ncan't start."))
|
||||
self:exitWith(why or "Link battle\ncan't start.")
|
||||
return
|
||||
end
|
||||
-- anything after `party` in this same batch belongs to the
|
||||
@@ -425,7 +418,7 @@ function Tournament:update(dt)
|
||||
forceLevel = levelForWire(self.settings.forceLevel),
|
||||
})
|
||||
if not battle then
|
||||
self:exitWith(why or Strings("Can't watch this\nmatch."))
|
||||
self:exitWith(why or "Can't watch this\nmatch.")
|
||||
return
|
||||
end
|
||||
for j = #msgs, i + 1, -1 do
|
||||
@@ -548,8 +541,8 @@ local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLA
|
||||
function Tournament:draw()
|
||||
if self.stage == "menu" then
|
||||
drawTitle("TOURNAMENT")
|
||||
Font.draw(Strings("HOST"), 32, 48)
|
||||
Font.draw(Strings("JOIN"), 32, 68)
|
||||
Font.draw("HOST", 32, 48)
|
||||
Font.draw("JOIN", 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
|
||||
elseif self.stage == "hostSettings" then
|
||||
@@ -568,7 +561,7 @@ function Tournament:draw()
|
||||
Font.draw(values[i], 96, y)
|
||||
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
|
||||
end
|
||||
Font.draw(Strings("START: create"), 8, 128)
|
||||
Font.draw("START: create", 8, 128)
|
||||
|
||||
elseif self.stage == "codeEntry" then
|
||||
drawTitle("ENTER CODE")
|
||||
@@ -580,27 +573,28 @@ function Tournament:draw()
|
||||
Font.drawCode(0xEE, x, 76)
|
||||
end
|
||||
end
|
||||
Font.draw(Strings("A: join B: back"), 8, 128)
|
||||
Font.draw("A: join B: back", 8, 128)
|
||||
|
||||
elseif self.stage == "registering" then
|
||||
drawTitle("CONNECTING...")
|
||||
Font.draw(Strings("B: cancel"), 8, 128)
|
||||
Font.draw("B: cancel", 8, 128)
|
||||
|
||||
elseif self.stage == "bracket" or self.stage == "matchHello"
|
||||
or self.stage == "matchWaitParty" or self.stage == "spectateWait" then
|
||||
drawTitle(Strings("TOURNAMENT %s", self.code or "??????"))
|
||||
drawTitle(("TOURNAMENT %s"):format(self.code or "??????"))
|
||||
if self.bracket then
|
||||
local y = 20
|
||||
for _, round in ipairs(self.bracket.rounds) do
|
||||
Font.draw(Strings("ROUND %d", round.round), 8, y)
|
||||
Font.draw(("ROUND %d"):format(round.round), 8, y)
|
||||
y = y + 10
|
||||
for _, m in ipairs(round.matches) do
|
||||
local line
|
||||
if m.bye then
|
||||
line = Strings("%s (bye)", m.a or m.b or "?")
|
||||
line = ("%s (bye)"):format(m.a or m.b or "?")
|
||||
else
|
||||
local mark = m.state == "live" and "*" or (m.winner and "" or "")
|
||||
line = Strings("%s%s vs %s%s", m.winner == m.a and ">" or " ", m.a or "?",
|
||||
line = ("%s%s vs %s%s"):format(
|
||||
m.winner == m.a and ">" or " ", m.a or "?",
|
||||
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
|
||||
end
|
||||
Font.draw(line, 12, y)
|
||||
@@ -610,11 +604,11 @@ function Tournament:draw()
|
||||
end
|
||||
else
|
||||
if self.participating == false then
|
||||
Font.draw(Strings("(organizing --"), 16, 32)
|
||||
Font.draw(Strings("not playing)"), 16, 42)
|
||||
Font.draw("(organizing --", 16, 32)
|
||||
Font.draw("not playing)", 16, 42)
|
||||
end
|
||||
Font.draw(Strings("Waiting for"), 16, 48)
|
||||
Font.draw(Strings("players to join:"), 16, 60)
|
||||
Font.draw("Waiting for", 16, 48)
|
||||
Font.draw("players to join:", 16, 60)
|
||||
local y = 60
|
||||
for i, name in ipairs(self.roster) do
|
||||
y = 60 + i * 10
|
||||
@@ -626,18 +620,18 @@ function Tournament:draw()
|
||||
end
|
||||
end
|
||||
if self.isCreator and #self.roster >= 2 and not self.bracket then
|
||||
Font.draw(Strings("A: START B: cancel"), 8, 132)
|
||||
Font.draw("A: START B: cancel", 8, 132)
|
||||
else
|
||||
Font.draw(Strings("B: cancel"), 8, 132)
|
||||
Font.draw("B: cancel", 8, 132)
|
||||
end
|
||||
|
||||
elseif self.stage == "done" then
|
||||
drawTitle("TOURNAMENT OVER")
|
||||
if self.champion then
|
||||
Font.draw(Strings("%s is the", self.champion), 16, 56)
|
||||
Font.draw(Strings("champion!"), 16, 68)
|
||||
Font.draw(("%s is the"):format(self.champion), 16, 56)
|
||||
Font.draw("champion!", 16, 68)
|
||||
end
|
||||
Font.draw(Strings("A: continue"), 8, 128)
|
||||
Font.draw("A: continue", 8, 128)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -3,29 +3,17 @@
|
||||
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
|
||||
-- load at boot; this reads the same options.mods enable-state the loader
|
||||
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
|
||||
-- installs a dropped/chosen .zip into a "mods/<id>/" tree, and uninstalls a
|
||||
-- mod by removing that tree + clearing options.mods[id].
|
||||
--
|
||||
-- Where that tree lives is CacheFs's call, not love.filesystem's: a portable
|
||||
-- install (portable.txt beside the executable) keeps its mods in the game
|
||||
-- folder like everything else it owns, and only the OS save directory
|
||||
-- otherwise (#330 -- love.filesystem.write always resolves to the save dir,
|
||||
-- so the installer used to strand every mod in appdata). Reads stay on
|
||||
-- love.filesystem: the portable folder is on the physfs read path either way
|
||||
-- (it IS the source for a `love <gamedir>` run, and CacheFs mounts it for a
|
||||
-- fused build), which is why those mods still loaded while landing in the
|
||||
-- wrong place.
|
||||
-- and installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree.
|
||||
--
|
||||
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
|
||||
-- no filesystem, so the engine tier can table-drive it; the discovery,
|
||||
-- install, and uninstall paths reach for love.filesystem and SaveData.
|
||||
-- no filesystem, so the engine tier can table-drive it; the discovery and
|
||||
-- install paths reach for love.filesystem and SaveData.
|
||||
|
||||
local Manifest = require("src.mods.Manifest")
|
||||
local ManagerState = require("src.mods.ManagerState")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
|
||||
local LauncherMods = {}
|
||||
|
||||
@@ -154,13 +142,6 @@ local function discover()
|
||||
local fs = love and love.filesystem
|
||||
local out = {}
|
||||
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
|
||||
-- A fused portable build keeps its mods in the game folder next to the
|
||||
-- executable; resolving the cache root is what mounts that folder onto the
|
||||
-- physfs read path, so this is what makes those mods enumerable at all
|
||||
-- (#330). A source run needs nothing (the game folder IS the source), and
|
||||
-- the launcher's readiness check has usually resolved it already; the call
|
||||
-- is cached and idempotent.
|
||||
CacheFs.root()
|
||||
if not fs.getInfo("mods") then return out end
|
||||
local seen = {}
|
||||
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
||||
@@ -251,14 +232,11 @@ local function topLevelPaths(mount)
|
||||
return paths
|
||||
end
|
||||
|
||||
-- Copy the mounted archive subtree at `src` to the install path `dst`. Reads
|
||||
-- come from love.filesystem (the .zip is mounted there); every write goes
|
||||
-- through CacheFs so it lands in the portable game folder when portable.txt is
|
||||
-- in play and in the OS save directory otherwise (#330). No explicit mkdir:
|
||||
-- CacheFs.write creates the parent chain on both paths, which also means an
|
||||
-- empty folder inside the .zip is simply not carried over (it holds nothing).
|
||||
local function copyTree(src, dst)
|
||||
local fs = love.filesystem
|
||||
if not fs.createDirectory(dst) then
|
||||
return nil, "could not create " .. dst
|
||||
end
|
||||
for _, name in ipairs(fs.getDirectoryItems(src)) do
|
||||
local s = src .. "/" .. name
|
||||
local d = dst .. "/" .. name
|
||||
@@ -269,18 +247,13 @@ local function copyTree(src, dst)
|
||||
else
|
||||
local data = fs.read(s)
|
||||
if data == nil then return nil, "could not read " .. name end
|
||||
local ok, err = CacheFs.write(d, data)
|
||||
local ok, err = fs.write(d, data)
|
||||
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
|
||||
-- portable game folder is on its read path), but the deletes go through
|
||||
-- CacheFs so a portable install's real files actually go away instead of
|
||||
-- love.filesystem no-opping outside the save directory (#330). Directories
|
||||
-- are removed after their children, since rmdir refuses a non-empty one.
|
||||
local function removeTree(path)
|
||||
local fs = love.filesystem
|
||||
local info = fs.getInfo(path)
|
||||
@@ -289,15 +262,7 @@ local function removeTree(path)
|
||||
for _, child in ipairs(fs.getDirectoryItems(path)) do
|
||||
removeTree(path .. "/" .. child)
|
||||
end
|
||||
CacheFs.removeDir(path)
|
||||
else
|
||||
CacheFs.remove(path)
|
||||
end
|
||||
-- A portable install can still be carrying a pre-#330 copy in the OS save
|
||||
-- directory, which is where every install used to land and which physfs
|
||||
-- searches first. CacheFs only touched the game folder, so clear the
|
||||
-- save-directory twin too or that copy would keep the mod alive; outside
|
||||
-- portable mode this repeats the delete CacheFs just did and no-ops.
|
||||
fs.remove(path)
|
||||
end
|
||||
|
||||
@@ -356,19 +321,10 @@ function LauncherMods.installZip(source)
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
end
|
||||
|
||||
-- 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 = ""
|
||||
fs.createDirectory("mods")
|
||||
local copied, copyErr = copyTree(root, dest)
|
||||
if not copied then removeTree(dest) end
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not copied then
|
||||
removeTree(dest)
|
||||
cleanup()
|
||||
return nil, copyErr or "could not copy the mod files"
|
||||
end
|
||||
@@ -376,39 +332,4 @@ function LauncherMods.installZip(source)
|
||||
return true, manifest.id
|
||||
end
|
||||
|
||||
-- uninstall(id) -> true | nil, errString
|
||||
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
||||
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
|
||||
-- so the loader and in-game manager no longer see it. Rejects missing ids.
|
||||
-- Does not touch other mods' enable state.
|
||||
function LauncherMods.uninstall(id)
|
||||
if type(id) ~= "string" or id == "" then
|
||||
return nil, "missing mod id"
|
||||
end
|
||||
if id:find("[/\\]") or id == "." or id == ".." then
|
||||
return nil, "invalid mod id"
|
||||
end
|
||||
if not (love and love.filesystem) then
|
||||
return nil, "mod uninstall needs LOVE"
|
||||
end
|
||||
local fs = love.filesystem
|
||||
local dest = "mods/" .. id
|
||||
if not fs.getInfo(dest) then
|
||||
return nil, "mod '" .. id .. "' is not installed"
|
||||
end
|
||||
-- same root pin as installZip: the mods tree is not version-prefixed (#330)
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
removeTree(dest)
|
||||
CacheFs.prefix = savedPrefix
|
||||
-- Drop the enable flag so a reinstall of the same id starts from the
|
||||
-- loader's default (enabled) rather than a stale false.
|
||||
local options = SaveData.loadOptions()
|
||||
if options.mods and options.mods[id] ~= nil then
|
||||
options.mods[id] = nil
|
||||
SaveData.saveOptions(options)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return LauncherMods
|
||||
|
||||
+1
-6
@@ -68,7 +68,6 @@ local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
|
||||
local SUPPORTED_REQUIRES = {
|
||||
["src.mods.Semver"] = true,
|
||||
["src.audio.ChipAsm"] = true,
|
||||
["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods
|
||||
}
|
||||
|
||||
local function scanRequire(name)
|
||||
@@ -162,11 +161,7 @@ function Loader:_loadState()
|
||||
if next(options.mods or {}) == nil and self.fs.getInfo
|
||||
and self.fs.getInfo(MOD_STATE_FILE) then
|
||||
local chunk = self.fs.load(MOD_STATE_FILE)
|
||||
-- `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
|
||||
local ok, state = chunk and pcall(chunk)
|
||||
if ok and type(state) == "table" then
|
||||
for id, disabled in pairs(state) do
|
||||
if disabled then
|
||||
|
||||
+20
-21
@@ -10,7 +10,6 @@ local Semver = require("src.mods.Semver")
|
||||
local Version = require("src.core.Version")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local ManagerState = {}
|
||||
ManagerState.__index = ManagerState
|
||||
@@ -270,7 +269,7 @@ function ManagerState:modRows()
|
||||
end
|
||||
end
|
||||
if #rows == 0 then
|
||||
rows[1] = { header = true, label = Strings("NO MODS INSTALLED") }
|
||||
rows[1] = { header = true, label = "NO MODS INSTALLED" }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
@@ -282,7 +281,7 @@ function ManagerState:profileRows()
|
||||
rows[#rows + 1] = { profile = p, label = p.name,
|
||||
glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
|
||||
end
|
||||
rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") }
|
||||
rows[#rows + 1] = { saveAs = true, label = "SAVE CURRENT AS.." }
|
||||
rows[#rows + 1] = { adhoc = true,
|
||||
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
|
||||
return rows
|
||||
@@ -317,30 +316,30 @@ function ManagerState:detailRows(m)
|
||||
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
|
||||
action = function() self:beginToggle(m) end }
|
||||
if self:schemaFor(m) then
|
||||
rows[#rows + 1] = { label = Strings("OPTIONS.."),
|
||||
rows[#rows + 1] = { label = "OPTIONS..",
|
||||
action = function() self:openOptions(m) end }
|
||||
end
|
||||
if m.permissions and #m.permissions > 0 then
|
||||
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
|
||||
rows[#rows + 1] = { label = "PERMISSIONS..",
|
||||
action = function() self:goTo("permissions") end }
|
||||
end
|
||||
if m.error then
|
||||
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
|
||||
rows[#rows + 1] = { label = "VIEW ERROR..",
|
||||
action = function() self:goTo("errors") end }
|
||||
end
|
||||
rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
|
||||
rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end }
|
||||
return rows
|
||||
end
|
||||
|
||||
function ManagerState:applyRows()
|
||||
local rows = {}
|
||||
rows[#rows + 1] = { label = Strings("APPLY & RESTART"), action = function()
|
||||
rows[#rows + 1] = { label = "APPLY & RESTART", action = function()
|
||||
self:openConfirm({ "RESTART NOW?" }, function() self:restartGame() end)
|
||||
end }
|
||||
rows[#rows + 1] = { label = Strings("DISCARD CHANGES"), action = function()
|
||||
rows[#rows + 1] = { label = "DISCARD CHANGES", action = function()
|
||||
self:discardChanges()
|
||||
end }
|
||||
rows[#rows + 1] = { label = Strings("BACK"), action = function() self:goBack() end }
|
||||
rows[#rows + 1] = { label = "BACK", action = function() self:goBack() end }
|
||||
return rows
|
||||
end
|
||||
|
||||
@@ -353,7 +352,7 @@ function ManagerState:permissionRows(m)
|
||||
label = info and info.text or name }
|
||||
end
|
||||
if #rows == 0 then
|
||||
rows[1] = { inert = true, label = Strings("DATA & API ONLY") }
|
||||
rows[1] = { inert = true, label = "DATA & API ONLY" }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
@@ -565,7 +564,7 @@ function ManagerState:openCascade(r, m, want)
|
||||
else
|
||||
local dep = self.byId[r.alsoDisable[1]]
|
||||
lines[#lines + 1] = (dep and dep.name or r.alsoDisable[1]) .. " NEEDS THIS."
|
||||
lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or Strings("DISABLE BOTH?")
|
||||
lines[#lines + 1] = #r.alsoDisable > 1 and "DISABLE ALL?" or "DISABLE BOTH?"
|
||||
end
|
||||
self.overlay = { kind = "confirm", lines = lines, index = 1,
|
||||
onYes = function() self:commitToggle(r.apply) end }
|
||||
@@ -701,7 +700,7 @@ end
|
||||
function ManagerState:saveCurrentAs()
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
self.game.stack:push(NamingScreen.new(self.game, {
|
||||
title = Strings("PROFILE NAME?"),
|
||||
title = "PROFILE NAME?",
|
||||
maxLen = 10,
|
||||
onDone = function(name)
|
||||
local opts = self:optionsTable()
|
||||
@@ -727,7 +726,7 @@ end
|
||||
function ManagerState:renameProfile(p)
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
self.game.stack:push(NamingScreen.new(self.game, {
|
||||
title = Strings("RENAME?"),
|
||||
title = "RENAME?",
|
||||
maxLen = 10,
|
||||
default = p.name,
|
||||
onDone = function(name)
|
||||
@@ -887,7 +886,7 @@ function ManagerState:buildOptionRows(m, schema)
|
||||
end }
|
||||
end
|
||||
end
|
||||
rows[#rows + 1] = { id = "__reset", label = Strings("RESET DEFAULTS"),
|
||||
rows[#rows + 1] = { id = "__reset", label = "RESET DEFAULTS",
|
||||
value = function() return "" end,
|
||||
activate = function()
|
||||
for _, row in ipairs(schema) do
|
||||
@@ -1048,7 +1047,7 @@ function ManagerState:drawApply()
|
||||
y = y + 1
|
||||
end
|
||||
if #staged == 0 then
|
||||
Font.draw(Runtime.safeMode and "SAFE MODE" or Strings("NO CHANGES"), 16, y * 8)
|
||||
Font.draw(Runtime.safeMode and "SAFE MODE" or "NO CHANGES", 16, y * 8)
|
||||
y = y + 1
|
||||
end
|
||||
local rows = self:rowsForScreen()
|
||||
@@ -1077,12 +1076,12 @@ function ManagerState:drawOverlay()
|
||||
end
|
||||
if overlay.kind == "confirm" then
|
||||
local yesY = ty + #lines + 1
|
||||
Font.draw(Strings("YES"), 5 * 8, yesY * 8)
|
||||
Font.draw(Strings("NO"), 5 * 8, (yesY + 1) * 8)
|
||||
Font.draw("YES", 5 * 8, yesY * 8)
|
||||
Font.draw("NO", 5 * 8, (yesY + 1) * 8)
|
||||
Font.drawCode(Theme.cursor, 4 * 8,
|
||||
(overlay.index == 1 and yesY or yesY + 1) * 8)
|
||||
else
|
||||
Font.draw(Strings("A:OK"), 5 * 8, (ty + #lines + 1) * 8)
|
||||
Font.draw("A:OK", 5 * 8, (ty + #lines + 1) * 8)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1091,7 +1090,7 @@ function ManagerState:draw()
|
||||
OptionRows.draw(self.game, self.optionRows or {}, self.cursor,
|
||||
self.scroll or 0)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.notice or Strings("B:DONE (NO RESTART)"), 8, 136)
|
||||
Font.draw(self.notice or "B:DONE (NO RESTART)", 8, 136)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if self.overlay then self:drawOverlay() end
|
||||
return
|
||||
@@ -1100,7 +1099,7 @@ function ManagerState:draw()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.drawBox(0, 0, 20, 18)
|
||||
Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8)
|
||||
Font.draw(self.banner or "MOD MANAGER", 16, 8)
|
||||
if self.screen == "list" then
|
||||
self:drawList()
|
||||
elseif self.screen == "detail" then
|
||||
|
||||
+2
-31
@@ -584,17 +584,6 @@ R.text = {
|
||||
example = 'mod.content.text:override("_PalletTownText1", "HELLO!")',
|
||||
}
|
||||
|
||||
-- The engine's own authored text, the half of the game `text` does not
|
||||
-- cover (src/core/Strings.lua). The id is the English source string, so a
|
||||
-- translation supplies one entry per literal the engine draws and anything
|
||||
-- it has not reached yet keeps rendering in English. Ids that collide in
|
||||
-- English but not elsewhere carry a "context|source" prefix.
|
||||
R.strings = {
|
||||
semantics = "record", target = "strings",
|
||||
value = f.str,
|
||||
example = 'mod.content.strings:override("But, it failed!", "Echec !")',
|
||||
}
|
||||
|
||||
-- the self-contained bytecode blob ChipAsm.song/sfx emit (13.1 shape 2);
|
||||
-- shared by every namespace that plays a chip program
|
||||
local chipProgram = f.rec{
|
||||
@@ -929,20 +918,8 @@ R.battle_sprite_scales = {
|
||||
-- 1 = native pixels; the drawn size relative to the pic's own pixels
|
||||
scale = f.numRange(0.25, 4.0),
|
||||
},
|
||||
example = [[
|
||||
mod.content.battle_sprite_scales:register("abra_back", {
|
||||
path = "assets/generated/battle/back/abrab.png",
|
||||
scale = 1.5,
|
||||
})]],
|
||||
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.]],
|
||||
example = 'mod.content.battle_sprite_scales:register("abra_back", ' ..
|
||||
'{ path = "assets/generated/battle/back/abrab.png", scale = 1.5 })',
|
||||
}
|
||||
|
||||
-- ------- progression
|
||||
@@ -1169,12 +1146,6 @@ R.field = {
|
||||
name = f.opt(f.str) })),
|
||||
nest = f.opt(f.any) },
|
||||
flyOrder = f.list(f.str),
|
||||
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
|
||||
-- back pic, the catch tutorial's old man, and the front pic the intro,
|
||||
-- trainer card and Hall of Fame share. Every key is optional so a
|
||||
-- conversion can replace one pic and inherit the rest.
|
||||
playerPics = f.rec{
|
||||
back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) },
|
||||
-- the new-game and boot config a total conversion replaces
|
||||
boot = f.rec{
|
||||
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
|
||||
|
||||
@@ -14,7 +14,6 @@ local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Evolution = {}
|
||||
|
||||
@@ -24,7 +23,7 @@ Evolution.METHODS = {
|
||||
return trigger.kind == "levelup" and mon.level >= (evo.level or 0)
|
||||
end,
|
||||
describe = function(evo)
|
||||
return Strings("Level %d", evo.level or 0)
|
||||
return ("Level %d"):format(evo.level or 0)
|
||||
end,
|
||||
},
|
||||
ITEM = {
|
||||
@@ -140,7 +139,7 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("%s learned\n%s!", name, mdef.name), nextStep))
|
||||
("%s learned\n%s!"):format(name, mdef.name), nextStep))
|
||||
else
|
||||
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
||||
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
||||
@@ -161,8 +160,8 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
Evolution.apply(game, mon, newSpecies, via)
|
||||
local msg = Strings("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!",
|
||||
oldName, oldName, game.data.pokemon[newSpecies].name)
|
||||
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!")
|
||||
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
Music.restoreMap(game.data)
|
||||
-- re-run the evolved species' level-up learn check before onDone
|
||||
@@ -187,7 +186,7 @@ end
|
||||
|
||||
-- After-battle hook: evolve mons that leveled this battle and still
|
||||
-- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle
|
||||
-- only considers mons that gained a level during the fight -- a B-cancel
|
||||
-- only considers mons that gained a level during the fight — a B-cancel
|
||||
-- means "not this time", and the next offer waits for the next level-up
|
||||
-- (or Rare Candy / stone, which call Evolution.evolve directly).
|
||||
-- leveledUp is a set of party mon tables; nil/empty yields no evolutions.
|
||||
|
||||
@@ -31,7 +31,7 @@ end
|
||||
|
||||
-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare):
|
||||
-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting
|
||||
-- the oldest slot out when full. Silent -- no LearnMove prompts.
|
||||
-- the oldest slot out when full. Silent — no LearnMove prompts.
|
||||
function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel)
|
||||
if not (speciesDef and speciesDef.learnset and mon) then return end
|
||||
mon.moves = mon.moves or {}
|
||||
@@ -75,10 +75,6 @@ function Pokemon.new(data, species, level, rng)
|
||||
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = stats,
|
||||
hp = stats.hp,
|
||||
-- the Gen1 catch-rate byte freezes at catch time: evolution does NOT
|
||||
-- update it, so PKHeX expects a preevolution's rate on an evolved mon
|
||||
-- (#206). Evolution.apply mutates in place and never touches this.
|
||||
catchRate = def.catchRate,
|
||||
status = nil, -- "SLP"|"PSN"|"BRN"|"FRZ"|"PAR"
|
||||
moves = moves,
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
-- Runtime Pokémon art resolution. Content registries freeze after load,
|
||||
-- so a mod that lets the player pick an alternate skin mid-session cannot
|
||||
-- patch pokemon.spriteFront / icons.bySpecies. These helpers are the
|
||||
-- sanctioned seam: every battle pic and party icon load goes through
|
||||
-- pokemon.sprite / pokemon.icon, which stay live for the whole process.
|
||||
--
|
||||
-- playerPath is the same seam for the player's own trainer art, whose
|
||||
-- vanilla paths are data (field.playerPics) rather than a species record.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
|
||||
local Sprites = {}
|
||||
|
||||
local function samePath(path) return path end
|
||||
|
||||
-- Resolve a battle / menu front or back pic path for `species`.
|
||||
-- side: "front" | "back"
|
||||
-- opts.mon: the live mon when available (per-instance skins)
|
||||
-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade"
|
||||
-- | "title" | "oak" | "credits" (informational for wrappers)
|
||||
-- Returns path, trueColor.
|
||||
function Sprites.path(data, species, side, opts)
|
||||
opts = opts or {}
|
||||
local def = data and data.pokemon and data.pokemon[species]
|
||||
if not def then return nil, false end
|
||||
local path = side == "back" and def.spriteBack or def.spriteFront
|
||||
local ctx = {
|
||||
species = species,
|
||||
side = side == "back" and "back" or "front",
|
||||
kind = opts.kind or "battle",
|
||||
mon = opts.mon,
|
||||
trueColor = def.trueColor and true or false,
|
||||
data = data,
|
||||
}
|
||||
if path and Runtime.wantsHook("pokemon.sprite") then
|
||||
local hooked = Runtime.call("pokemon.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 the player's own trainer pic path.
|
||||
-- side: "back" (the battle intro pic) | "front" (intro / card / Hall of Fame)
|
||||
-- opts.kind: "battle" | "intro" | "trainer_card" | "hof"
|
||||
-- opts.demo: the catch tutorial, where the old man fights in the player's
|
||||
-- place and stands in for the back pic
|
||||
-- opts.battle: the live battle, for kind == "battle"
|
||||
-- Returns path, trueColor.
|
||||
function Sprites.playerPath(data, side, opts)
|
||||
opts = opts or {}
|
||||
side = side == "back" and "back" or "front"
|
||||
-- one key per pic, so a conversion can replace the back and inherit the
|
||||
-- rest; fieldValue folds data.field over FieldDefaults per key
|
||||
local key = side == "front" and "front"
|
||||
or (opts.demo and "demoBack" or "back")
|
||||
local path = FieldDefaults.fieldValue(data, "playerPics", key)
|
||||
local ctx = {
|
||||
side = side,
|
||||
kind = opts.kind or "battle",
|
||||
demo = opts.demo and true or false,
|
||||
battle = opts.battle,
|
||||
trueColor = false,
|
||||
data = data,
|
||||
}
|
||||
if path and Runtime.wantsHook("player.sprite") then
|
||||
local hooked = Runtime.call("player.sprite", samePath, path, ctx)
|
||||
if type(hooked) == "string" and hooked ~= "" then path = hooked end
|
||||
end
|
||||
return path, ctx.trueColor and true or false
|
||||
end
|
||||
|
||||
-- Resolve a party-menu icon image path for `mon`.
|
||||
-- vanillaPath is the path PartyMenu already picked from icons.bySpecies /
|
||||
-- def.icon / icons.byDex; the hook may replace it.
|
||||
-- Returns path (possibly nil).
|
||||
function Sprites.iconPath(data, mon, vanillaPath, opts)
|
||||
opts = opts or {}
|
||||
if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then
|
||||
return vanillaPath
|
||||
end
|
||||
local species = mon and mon.species
|
||||
local ctx = {
|
||||
species = species,
|
||||
mon = mon,
|
||||
name = opts.name,
|
||||
data = data,
|
||||
kind = "icon",
|
||||
}
|
||||
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath end
|
||||
local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx)
|
||||
if type(hooked) == "string" and hooked ~= "" then return hooked end
|
||||
if hooked == nil or hooked == false then return nil end
|
||||
return vanillaPath
|
||||
end
|
||||
|
||||
return Sprites
|
||||
@@ -42,28 +42,6 @@ function Stats.calc(speciesDef, level, dvs, statExp)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Give a mon a stat block when it has none. A real Gen 1 box_struct is a
|
||||
-- byte-for-byte PREFIX of party_struct that stops before MON_LEVEL and
|
||||
-- MON_STATS (macros/ram.asm box_struct / party_struct), so mons decoded out
|
||||
-- of an imported .sav arrive without one (src/save_convert/GenSave.lua
|
||||
-- decodeMon, isParty = false). The original derives them on demand at
|
||||
-- exactly two moments: when a box or daycare mon's status screen opens
|
||||
-- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" ->
|
||||
-- CalcStats) and when one is moved back into the party
|
||||
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is
|
||||
-- kept (box_struct does hold it) but clamped to the recalculated maximum so
|
||||
-- a tampered save cannot overfill the bar. A mon that already has stats is
|
||||
-- returned untouched, so a vanilla save round-trips. #233, #304
|
||||
function Stats.ensure(speciesDef, mon)
|
||||
if type(mon) ~= "table" or type(mon.stats) == "table" then return mon end
|
||||
if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then
|
||||
return mon
|
||||
end
|
||||
mon.stats = Stats.calc(speciesDef, mon.level or 1, mon.dvs or {}, mon.statExp)
|
||||
mon.hp = math.max(0, math.min(tonumber(mon.hp) or mon.stats.hp, mon.stats.hp))
|
||||
return mon
|
||||
end
|
||||
|
||||
-- Battle stat stage multipliers (data/battle/stat_modifiers.asm): stages
|
||||
-- -6..+6 map to N/D pairs 25/100 .. 400/100.
|
||||
local STAGE_MULT = {
|
||||
@@ -79,20 +57,4 @@ function Stats.applyStage(value, stage)
|
||||
return math.max(1, math.min(999, v))
|
||||
end
|
||||
|
||||
-- Gen 2 shiny formula applied to Gen 1 DVs (the RBY "virtual shiny"):
|
||||
-- Defense/Speed/Special DV == 10 and Attack DV is even-high
|
||||
-- (2, 3, 6, 7, 10, 11, 14, or 15). Used by shiny-indicator mods.
|
||||
local SHINY_ATK = {
|
||||
[2] = true, [3] = true, [6] = true, [7] = true,
|
||||
[10] = true, [11] = true, [14] = true, [15] = true,
|
||||
}
|
||||
|
||||
function Stats.isShiny(dvs)
|
||||
if type(dvs) ~= "table" then return false end
|
||||
return (dvs.defense or 0) == 10
|
||||
and (dvs.speed or 0) == 10
|
||||
and (dvs.special or 0) == 10
|
||||
and SHINY_ATK[dvs.attack or 0] == true
|
||||
end
|
||||
|
||||
return Stats
|
||||
|
||||
@@ -23,20 +23,6 @@ local FLASH_STEPS = { 1 / 3, 2 / 3, 1, 2 / 3, 1 / 3, 0,
|
||||
local FLASH_HOLD = 2 -- frames per palette step
|
||||
local FLASH_CYCLES = 3
|
||||
|
||||
-- The screen stays black after the wipe lands. Shrink and Split ask for it
|
||||
-- outright (BattleTransition_BlackScreen, then `ld c, 10 / jp DelayFrames`:
|
||||
-- battle_transitions.asm:390-392 and 422-424); the other six get the same gap
|
||||
-- for free, because BattleTransition_BlackScreen has already set rBGP/rOBP0/
|
||||
-- rOBP1 to $ff (:168-174) while DoBattleTransitionAndInitBattleVariables
|
||||
-- reloads the HUD tile patterns and clears the screen (core.asm:6152-6185),
|
||||
-- InitBattleCommon decompresses the front pic (core.asm:6694-6730), and
|
||||
-- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between
|
||||
-- DisableLCD and EnableLCD (core.asm:9-49) before anything moves. This port
|
||||
-- has no load to hide behind, so the hold has to be explicit (#315). 30 is a
|
||||
-- frame budget for that work, not a number pokered states; retune this one
|
||||
-- constant against a reference recording if it reads long or short.
|
||||
local BLACK_HOLD = 30
|
||||
|
||||
local TILE = 8
|
||||
local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles
|
||||
|
||||
@@ -222,7 +208,7 @@ function BattleTransition:update(dt)
|
||||
self.t = 0
|
||||
end
|
||||
else
|
||||
if self.t >= self.wipeLen + BLACK_HOLD then
|
||||
if self.t >= self.wipeLen + 6 then
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
+20
-69
@@ -102,84 +102,35 @@ local function pageFor(code)
|
||||
return nil
|
||||
end
|
||||
|
||||
local SPACE = 0x7F
|
||||
|
||||
-- Segment text into glyph spans: `{ from, to, code }` byte ranges, one per
|
||||
-- drawn glyph, code nil when the charmap has nothing. A span is a whole
|
||||
-- charmap sequence, so a multi-byte char ("é", "♂") and an ASCII ligature
|
||||
-- ("<PK>", "'d") are each one glyph.
|
||||
--
|
||||
-- Every caller that *measures* or *cuts* text walks these instead of bytes.
|
||||
-- "POKéMON" is 8 bytes and 7 glyphs: measuring it as 8 wraps lines that fit
|
||||
-- (25 vanilla lines did), and cutting at byte 8 splits the é into two
|
||||
-- invalid bytes that both draw as spaces. That distinction is the whole
|
||||
-- reason a non-English font can be shipped as a mod (#186, #245).
|
||||
--
|
||||
-- Safe before Font.load: with no charmap it falls back to UTF-8 lead-byte
|
||||
-- boundaries, which is all a headless paginate needs.
|
||||
function Font.split(text)
|
||||
local spans = {}
|
||||
local i, n = 1, #text
|
||||
while i <= n do
|
||||
local span
|
||||
local candidates = state and state.byFirstByte[text:byte(i)]
|
||||
if candidates then
|
||||
for _, entry in ipairs(candidates) do
|
||||
local len = #entry.seq
|
||||
if text:sub(i, i + len - 1) == entry.seq then
|
||||
span = { from = i, to = i + len - 1, code = entry.code }
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not span then
|
||||
-- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never
|
||||
-- lands mid-character even for a glyph we cannot draw.
|
||||
local last = i
|
||||
if text:byte(i) >= 0xC0 then
|
||||
local k = i + 1
|
||||
while k <= n do
|
||||
local b = text:byte(k)
|
||||
if b < 0x80 or b > 0xBF then break end
|
||||
last, k = k, k + 1
|
||||
end
|
||||
end
|
||||
span = { from = i, to = last }
|
||||
end
|
||||
spans[#spans + 1] = span
|
||||
i = span.to + 1
|
||||
end
|
||||
return spans
|
||||
end
|
||||
|
||||
-- How many leading spans fit in `budget` pixels. Advances come from each
|
||||
-- glyph's own page, so a variable-width page measures correctly.
|
||||
function Font.spansFitting(spans, budget)
|
||||
local used, fit = 0, 0
|
||||
for _, span in ipairs(spans) do
|
||||
used = used + Font.advanceOf(span.code or SPACE)
|
||||
if used > budget then break end
|
||||
fit = fit + 1
|
||||
end
|
||||
return fit
|
||||
end
|
||||
|
||||
-- Convert a text string into a list of glyph codes. Unknown characters
|
||||
-- render as space (and are reported once).
|
||||
local reported = {}
|
||||
function Font.encode(text)
|
||||
local codes = {}
|
||||
for _, span in ipairs(Font.split(text)) do
|
||||
local code = span.code
|
||||
if not code then
|
||||
local ch = text:sub(span.from, span.to)
|
||||
if not reported[ch] and text:byte(span.from) >= 32 then
|
||||
local i = 1
|
||||
while i <= #text do
|
||||
local candidates = state.byFirstByte[text:byte(i)]
|
||||
local matched = false
|
||||
if candidates then
|
||||
for _, entry in ipairs(candidates) do
|
||||
local n = #entry.seq
|
||||
if text:sub(i, i + n - 1) == entry.seq then
|
||||
codes[#codes + 1] = entry.code
|
||||
i = i + n
|
||||
matched = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not matched then
|
||||
local ch = text:sub(i, i)
|
||||
if not reported[ch] and ch:byte() >= 32 then
|
||||
reported[ch] = true
|
||||
require("src.core.Logger").warn("font: no glyph for %q", ch)
|
||||
end
|
||||
code = SPACE
|
||||
codes[#codes + 1] = 0x7F -- space
|
||||
i = i + 1
|
||||
end
|
||||
codes[#codes + 1] = code
|
||||
end
|
||||
return codes
|
||||
end
|
||||
|
||||
@@ -25,16 +25,7 @@ local shader -- false = unavailable (headless / no shader support)
|
||||
-- Mobile GPUs often compile this pass but present a black frame, and the
|
||||
-- level persists in options.lua -- soft-bricking the APK until a manual
|
||||
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
|
||||
--
|
||||
-- POKEPORT_GBCFX overrides the platform default either way ("1" force on,
|
||||
-- "0" force off), same tri-state as POKEPORT_TOUCH. Handheld packs whose
|
||||
-- GPU is in the same class as a phone's but whose getOS() says "Linux" set
|
||||
-- it to 0 in their launcher (build-rg34xxsp.sh); it also lets a desktop
|
||||
-- checkout exercise the unsupported path without stubbing love.system.
|
||||
function GBCFX.isSupported()
|
||||
local env = os.getenv("POKEPORT_GBCFX")
|
||||
if env == "1" then return true end
|
||||
if env == "0" then return false end
|
||||
if not love or not love.system or not love.system.getOS then return true end
|
||||
local osName = love.system.getOS()
|
||||
return osName ~= "Android" and osName ~= "iOS"
|
||||
|
||||
+22
-80
@@ -2,11 +2,6 @@
|
||||
-- screen: pokered overlays the $62-$7F font area with the HP bar /
|
||||
-- status sheet (font_battle_extra -> $62) and the HUD line tiles
|
||||
-- (battle_hud_1 -> $6D, battle_hud_2+3 -> $73).
|
||||
--
|
||||
-- The two screens do NOT use the same overlay: the status screen scatters
|
||||
-- hud_2 and hud_3 instead of copying them contiguously, which is what keeps
|
||||
-- its № and <ID> glyphs alive. HudTiles.tile draws the battle layout,
|
||||
-- HudTiles.statusTile the status one -- see STATUS_PAGES below. #280
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
|
||||
@@ -28,62 +23,32 @@ local PAGES = {
|
||||
image = "assets/generated/battle/battle_hud_3.png", base = 0x76 },
|
||||
}
|
||||
|
||||
-- The STATUS SCREEN overlays the SAME sheets differently, and the layout
|
||||
-- above would break it: engine/pokemon/status_screen.asm:86-97 copies 3
|
||||
-- tiles of hud_1 to $6D, ONE tile of hud_2 to $78 and 2 tiles of hud_3 to
|
||||
-- $76, which leaves $70/$73/$74 as font_battle_extra's <to>, <ID> and № --
|
||||
-- the glyphs the screen prints "№." and "<ID>№/" from
|
||||
-- (constants/charmap.asm:69-73). The battle overlay instead copies
|
||||
-- hud_2+hud_3 contiguously over $73-$78 (engine/battle/core.asm:6520/6532),
|
||||
-- burying № under a line tile, so the status screen needs its own table.
|
||||
-- The line glyphs land identically either way -- $76 ─, $77 ┘, $6F the
|
||||
-- halfarrow -- only the vertical bar moves ($73 in battle, $78 here). #280
|
||||
local STATUS_PAGES = {
|
||||
{ id = "font_battle_extra",
|
||||
image = "assets/generated/battle/font_battle_extra.png", base = 0x62 },
|
||||
{ id = "battle_hud_1",
|
||||
image = "assets/generated/battle/battle_hud_1.png", base = 0x6D },
|
||||
{ id = "battle_hud_3",
|
||||
image = "assets/generated/battle/battle_hud_3.png", base = 0x76, count = 2 },
|
||||
{ id = "battle_hud_2",
|
||||
image = "assets/generated/battle/battle_hud_2.png", base = 0x78, count = 1 },
|
||||
}
|
||||
|
||||
local tiles, statusTiles
|
||||
|
||||
-- Build one code -> {img, quad} map from a page list. `count` caps a page
|
||||
-- at the number of tiles the asm actually copies (the extracted sheets all
|
||||
-- carry 3 tiles; the status overlay uses fewer). A mod's registered page
|
||||
-- swaps the image in either table, but only the battle table honors its
|
||||
-- `base`: the status layout is the asm's own placement, and sliding hud_2
|
||||
-- there would bury № again.
|
||||
local function build(pages, fixedBase)
|
||||
local out = {}
|
||||
local registered = require("src.core.Data").font
|
||||
registered = registered and registered.pages or nil
|
||||
for _, page in ipairs(pages) do
|
||||
local override = registered and registered[page.id]
|
||||
local path, base = page.image, page.base
|
||||
if override and override.image then path = override.image end
|
||||
if not fixedBase and override and override.base then base = override.base end
|
||||
local ok, img = pcall(Assets.image, path)
|
||||
if ok then
|
||||
local tiles
|
||||
function HudTiles.tile(code, x, y, tint)
|
||||
if not tiles then
|
||||
tiles = {}
|
||||
local registered = require("src.core.Data").font
|
||||
registered = registered and registered.pages or nil
|
||||
local function add(path, base)
|
||||
local ok, img = pcall(Assets.image, path)
|
||||
if not ok then return end
|
||||
local iw, ih = img:getDimensions()
|
||||
local per = iw / 8
|
||||
local count = page.count or per * (ih / 8)
|
||||
for i = 0, count - 1 do
|
||||
out[base + i] = {
|
||||
for i = 0, per * (ih / 8) - 1 do
|
||||
tiles[base + i] = {
|
||||
img = img,
|
||||
quad = love.graphics.newQuad((i % per) * 8,
|
||||
math.floor(i / per) * 8, 8, 8, iw, ih),
|
||||
}
|
||||
end
|
||||
end
|
||||
for _, page in ipairs(PAGES) do
|
||||
local override = registered and registered[page.id]
|
||||
add(override and override.image or page.image,
|
||||
override and override.base or page.base)
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function put(t, x, y, tint)
|
||||
local t = tiles[code]
|
||||
if not t then return end
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
love.graphics.setColor(tint or { 1, 1, 1, 1 })
|
||||
@@ -91,23 +56,9 @@ local function put(t, x, y, tint)
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
end
|
||||
|
||||
function HudTiles.tile(code, x, y, tint)
|
||||
if not tiles then tiles = build(PAGES) end
|
||||
put(tiles[code], x, y, tint)
|
||||
end
|
||||
|
||||
-- The same sheets under the status screen's overlay (STATUS_PAGES). The HP
|
||||
-- bar codes $62-$6D are identical in both layouts, so drawHPBar below keeps
|
||||
-- using the battle table. #280
|
||||
function HudTiles.statusTile(code, x, y, tint)
|
||||
if not statusTiles then statusTiles = build(STATUS_PAGES, true) end
|
||||
put(statusTiles[code], x, y, tint)
|
||||
end
|
||||
|
||||
-- lazy: the next tile() rebuilds every page from the search path
|
||||
function HudTiles.invalidate()
|
||||
tiles = nil
|
||||
statusTiles = nil
|
||||
end
|
||||
|
||||
Assets.register(HudTiles.invalidate)
|
||||
@@ -127,11 +78,6 @@ end
|
||||
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
|
||||
-- red).
|
||||
--
|
||||
-- segments: how many 8px cells the bar spans (6, the hardware width,
|
||||
-- unless a caller asks for more -- the widescreen battle layout has room
|
||||
-- for a longer bar in the same tiles). The color thresholds scale with
|
||||
-- it so a wider bar turns yellow and red at the same fractions of full.
|
||||
--
|
||||
-- grayFill (#229): when the caller will colorize this bar with an SGB
|
||||
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
|
||||
-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip
|
||||
@@ -141,22 +87,18 @@ end
|
||||
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
|
||||
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
|
||||
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments)
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
|
||||
local x, y = tx * 8, ty * 8
|
||||
segments = math.max(1, math.floor(segments or 6))
|
||||
HudTiles.tile(0x71, x, y)
|
||||
HudTiles.tile(0x62, x + 8, y)
|
||||
local px = 0
|
||||
if mon.stats.hp > 0 and mon.hp > 0 then
|
||||
px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp))
|
||||
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
|
||||
end
|
||||
local tint
|
||||
if not grayFill then
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
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 name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||
local colors = PaletteFX.pal(data, name)
|
||||
if colors then
|
||||
local c = colors[3] -- GB color 2 is the fill shade
|
||||
@@ -166,11 +108,11 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments)
|
||||
math.min(1, c[3] / 170), 1 }
|
||||
end
|
||||
end
|
||||
for i = 0, segments - 1 do
|
||||
for i = 0, 5 do
|
||||
local seg = math.min(8, math.max(0, px - i * 8))
|
||||
HudTiles.tile(seg >= 8 and 0x6B or 0x63 + seg, x + 16 + i * 8, y, tint)
|
||||
end
|
||||
HudTiles.tile(HudTiles.capTile(barType), x + 16 + segments * 8, y)
|
||||
HudTiles.tile(HudTiles.capTile(barType), x + 64, y)
|
||||
end
|
||||
|
||||
return HudTiles
|
||||
|
||||
+33
-121
@@ -5,11 +5,10 @@
|
||||
-- then drawn once per zone through a shader that remaps the four DMG
|
||||
-- shades to that zone's palette.
|
||||
--
|
||||
-- Port display option: COLORS (OG RED / SGB / ADVANCED / OG / OG INV /
|
||||
-- SGB INV / CLASSIC) transforms every zone's palette at send time via
|
||||
-- effectiveColors. ADVANCED (the `redpp` id below) swaps the named-palette
|
||||
-- pack for pokered-gbc SuperPalettes (data/palettes_gbc.lua), including
|
||||
-- per-species mon colors.
|
||||
-- Port display option: COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC)
|
||||
-- transforms every zone's palette at send time via effectiveColors.
|
||||
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
|
||||
-- (data/palettes_gbc.lua), including per-species mon colors.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
@@ -19,18 +18,14 @@ local shader -- false = unavailable (headless / no shader support)
|
||||
local gbcPack -- false = missing; nil = not loaded yet
|
||||
|
||||
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
|
||||
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, ADVANCED =
|
||||
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, RED++ =
|
||||
-- pokered-gbc per-tile), then the DMG-shade novelty modes.
|
||||
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
|
||||
-- `gbc`/`gbc_inv`/`redpp` keep their save-value ids for back-compat while
|
||||
-- their LABELS say what the mode actually is: "SGB"/"SGB INV" because the old
|
||||
-- "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.
|
||||
-- `gbc`/`gbc_inv` keep their save-value ids for back-compat; their LABELS are
|
||||
-- "SGB"/"SGB INV" because that is what the mode actually is (the old "GBC"
|
||||
-- label was a misnomer -- it never was the real Game Boy Color palette).
|
||||
PaletteFX.MODE_LABELS = {
|
||||
ogred = "OG RED", gbc = "SGB", redpp = "ADVANCED", og = "OG",
|
||||
ogred = "OG RED", gbc = "SGB", redpp = "RED++", og = "OG",
|
||||
og_inv = "OG INV", gbc_inv = "SGB INV", classic = "CLASSIC",
|
||||
}
|
||||
PaletteFX.mode = "gbc"
|
||||
@@ -93,25 +88,6 @@ function PaletteFX.ogObj()
|
||||
return PaletteFX.GBC_OBJ, "gbcobj"
|
||||
end
|
||||
|
||||
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
|
||||
-- plus its cache group (same two-value contract as ogObj). Entry 1 is never
|
||||
-- read -- SpriteRenderer.getObpImage keys OBJ color 0 to alpha, the hardware's
|
||||
-- unconditional OBJ transparency -- and entries 2..4 are OBJ colors 1..3 sent
|
||||
-- through rOBP0 = $D0 (home/fade.asm FadePal4 `dc 3,1,0,0`, the entry
|
||||
-- LoadGBPal reads while wMapPalOffset is 0): color 1 -> DMG shade 0, color 2
|
||||
-- -> shade 1, color 3 -> shade 3. Leaving the result in DMG shades is the
|
||||
-- whole point: the zone shader then colors a character out of the same map
|
||||
-- palette it colors the ground with, which is all the Super Game Boy can do to
|
||||
-- an OBJ (#301), and the OBP0 lift is what puts Red's cap on the ROUTE
|
||||
-- palette's grass green instead of its light-blue (#150).
|
||||
PaletteFX.OBP0_SHADES = {
|
||||
{ 255, 255, 255 }, { 255, 255, 255 }, { 170, 170, 170 }, { 0, 0, 0 },
|
||||
}
|
||||
|
||||
function PaletteFX.dmgObj()
|
||||
return PaletteFX.OBP0_SHADES, "obp0"
|
||||
end
|
||||
|
||||
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
||||
|
||||
function PaletteFX.shader()
|
||||
@@ -226,45 +202,32 @@ function PaletteFX.usesGbcPack(mode)
|
||||
end
|
||||
|
||||
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
||||
-- (the OBP bake + post-zone redraw path). OG RED alone does: the Game Boy
|
||||
-- Color boot ROM hands the game one global object palette (PaletteFX.ogObj --
|
||||
-- green over Red's red background, pink over Blue's blue background), so on
|
||||
-- that machine the player and NPCs carry a fixed object color instead of
|
||||
-- tinting with whatever region palette their feet stand over. An EARLIER
|
||||
-- attempt at per-sprite color baked GBC_BG (the RED background ramp) onto
|
||||
-- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters
|
||||
-- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red
|
||||
-- background, pink over Blue's blue background), so the player and NPCs carry a
|
||||
-- fixed object color instead of tinting with whatever region palette their
|
||||
-- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ
|
||||
-- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it
|
||||
-- overlaps -- so Red's cap must stay green in tall grass, not turn the ROUTE
|
||||
-- palette's light-blue (issue #150: SGB region-tinting sent the cap to shade-2
|
||||
-- = light-blue and the character clashed with the grass it should blend into).
|
||||
-- Terrain is unaffected -- pal() below still hands SGB its per-map BG palette;
|
||||
-- only OG RED short-circuits BG to the one global red palette. An EARLIER
|
||||
-- attempt at per-sprite SGB color baked GBC_BG (the RED background ramp) onto
|
||||
-- characters -- that was the "reds coloring on the player/NPCs" bug; the object
|
||||
-- palette is GBC_OBJ (green), so baking that here is the fix, not that
|
||||
-- regression. Terrain is unaffected -- pal() below still hands SGB its per-map
|
||||
-- BG palette; only OG RED short-circuits BG to the one global red palette.
|
||||
--
|
||||
-- SGB does NOT. The Super Game Boy colorizes the composited DMG picture it is
|
||||
-- handed and cannot tell an OBJ pixel from a BG one; pokered never sends the
|
||||
-- OBJ_TRN packet that would enable SGB sprite mode (data/sgb/sgb_packets.asm
|
||||
-- defines ATTR_BLK / PAL_SET / PAL_TRN / MLT_REQ / CHR_TRN / PCT_TRN and
|
||||
-- nothing else), so a character there wears the very palette its map does.
|
||||
-- Baking GBC_OBJ over it was issue #301 ("people in SGB mode are green": the
|
||||
-- boot-ROM greens sat on top of ROUTE's own greens and blues). What issue
|
||||
-- #150 actually caught was the missing rOBP0 step, not a missing object
|
||||
-- palette: overworld OBJs run through OBP0 = $D0 (home/fade.asm FadePal4
|
||||
-- `dc 3,1,0,0`), which lifts OBJ color 1 to DMG shade 0 and color 2 to shade 1,
|
||||
-- so Red's cap lands on the ROUTE palette's shade-1 GRASS GREEN and blends into
|
||||
-- the grass exactly as #150's reference shot shows. Drawn with an identity
|
||||
-- shade map it landed on shade 2 = light-blue instead, which is the clash #150
|
||||
-- reported. SpriteRenderer bakes that OBP0 ramp (PaletteFX.dmgObj) and lets
|
||||
-- the zone shader color the result. RED++ colors sprites through the
|
||||
-- usesGbcPack() path in SpriteRenderer instead.
|
||||
-- palette is GBC_OBJ (green), so baking it here is the fix, not that
|
||||
-- regression. RED++ colors sprites through the usesGbcPack() path in
|
||||
-- SpriteRenderer instead.
|
||||
function PaletteFX.usesSpriteObp(mode)
|
||||
mode = mode or PaletteFX.mode
|
||||
return mode == "ogred"
|
||||
return mode == "ogred" or mode == "gbc"
|
||||
end
|
||||
|
||||
-- ------- post-zone sprite redraw (OG RED)
|
||||
-- ------- post-zone sprite redraw (GBC mode)
|
||||
--
|
||||
-- In OG RED the world canvas still runs through the whole-screen zone
|
||||
-- In GBC mode the world canvas still runs through the per-map zone
|
||||
-- shade-remap shader, which would corrupt an OBP-baked sprite's true-color
|
||||
-- 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. So SpriteRenderer draws the baked sprite into the canvas (its
|
||||
-- pixels come out zone-tinted there) AND records the draw here;
|
||||
-- Renderer:endFrame replays the list on top of the finished zone pass,
|
||||
-- scaled into screen space -- the GBC's OBJ-over-BG compositing, one draw
|
||||
@@ -566,34 +529,6 @@ function PaletteFX.permute(colors, map)
|
||||
colors[map[2] + 1], colors[map[3] + 1] }
|
||||
end
|
||||
|
||||
-- ------- global shade map (rBGP)
|
||||
--
|
||||
-- home/fade.asm's LoadGBPal writes ONE rBGP for the whole screen, indexing
|
||||
-- FadePal4 - wMapPalOffset. A dark cave sets wMapPalOffset = 6
|
||||
-- (home/overworld.asm's ROCK_TUNNEL_1F check; the value rides in the extracted
|
||||
-- field.darkMaps.palOffset), which lands on FadePal2 = `dc 3,3,3,2`: DMG white
|
||||
-- drops to shade 2 and every darker shade goes to shade 3. So the WHOLE
|
||||
-- screen darkens -- the original never cuts a window of light around the
|
||||
-- player (#322) -- and FLASH clears wMapPalOffset again
|
||||
-- (engine/menus/start_sub_menus.asm .flash).
|
||||
--
|
||||
-- Renderer:beginFrame clears this every frame and the state that draws a dark
|
||||
-- map re-arms it while it draws, so it can never outlive the map it belongs
|
||||
-- to: a battle or a full-screen menu draws with no map beneath it and comes
|
||||
-- out lit, exactly like init_battle_variables.asm's `ld [wMapPalOffset], a`
|
||||
-- leaves the original.
|
||||
PaletteFX.DARK_BGP = { [0] = 2, [1] = 3, [2] = 3, [3] = 3 }
|
||||
|
||||
local shadeMap = nil
|
||||
|
||||
function PaletteFX.setShadeMap(map)
|
||||
shadeMap = map
|
||||
end
|
||||
|
||||
function PaletteFX.shadeMap()
|
||||
return shadeMap
|
||||
end
|
||||
|
||||
function PaletteFX.setMode(mode)
|
||||
local prev = PaletteFX.mode
|
||||
local ok = false
|
||||
@@ -663,42 +598,19 @@ end
|
||||
-- Transform a 4-color palette for the active COLORS display mode.
|
||||
-- GBC and RED++ pass the zone colors through (RED++ already swapped the
|
||||
-- pack in pal/monPal); OG* / CLASSIC replace; GBC INV permutes shades.
|
||||
-- The "paper" shade of the active display mode: what a DMG-white background
|
||||
-- pixel ends up as once colorization has run. Every palette in the SGB pack
|
||||
-- carries the same off-white (255,239,255) as color 0, so this is well
|
||||
-- defined for the whole screen rather than per zone. Goes through
|
||||
-- PaletteFX.pal so OG RED short-circuits to the one global boot-ROM BG
|
||||
-- palette and RED++ falls back to the ROM pack, then through effectiveColors
|
||||
-- so the mono and inverted modes get their own paper (CLASSIC's pea green,
|
||||
-- and the dark paper the inverted modes should have). Returns r, g, b in
|
||||
-- 0..1, white if nothing resolves.
|
||||
function PaletteFX.paperShade(data)
|
||||
local base = PaletteFX.pal(data, "GREENBAR") or PaletteFX.GRAYS
|
||||
local colors = PaletteFX.effectiveColors(base) or base
|
||||
local c = colors and colors[1]
|
||||
if not c then return 1, 1, 1 end
|
||||
return c[1] / 255, c[2] / 255, c[3] / 255
|
||||
end
|
||||
|
||||
function PaletteFX.effectiveColors(c)
|
||||
if not c then return nil end
|
||||
local mode = PaletteFX.mode or "gbc"
|
||||
local out = c
|
||||
if mode == "og" then
|
||||
out = PaletteFX.GRAYS
|
||||
return PaletteFX.GRAYS
|
||||
elseif mode == "og_inv" then
|
||||
out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
|
||||
return PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
|
||||
elseif mode == "classic" then
|
||||
out = PaletteFX.CLASSIC
|
||||
return PaletteFX.CLASSIC
|
||||
elseif mode == "gbc_inv" then
|
||||
out = PaletteFX.permute(c, INV_MAP)
|
||||
return PaletteFX.permute(c, INV_MAP)
|
||||
end
|
||||
-- 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)
|
||||
return c
|
||||
end
|
||||
|
||||
-- send a 4-color (0-255 RGB) palette to the shade-remap shader, after
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
-- Render targets measured in real framebuffer pixels.
|
||||
--
|
||||
-- love.graphics.newCanvas defaults its `dpiscale` to
|
||||
-- love.graphics.getDPIScale(), so on a highdpi surface the canvas texture is
|
||||
-- NOT the size it was asked for: newCanvas(160, 144) on a device reporting a
|
||||
-- DPI scale of 2.755 allocates a 441x397 texture, and the 160x144 GB scene
|
||||
-- then renders into it at 2.755 texels per GB pixel. conf.lua sets
|
||||
-- t.window.highdpi on Android/iOS (required for Retina), and Android's
|
||||
-- DisplayMetrics.density is routinely non-integer (1.5, 2.75, ...), so this
|
||||
-- is the normal mobile case, not an edge case.
|
||||
--
|
||||
-- That fractional render breaks the whole premise of Renderer's integer
|
||||
-- scale pipeline. Renderer:fitScale() picks a whole number of physical
|
||||
-- pixels per GB pixel (7 on 1080p) and the composite blit lands on it
|
||||
-- exactly -- but if the *source* already holds 2.755 texels per GB pixel,
|
||||
-- some GB pixels are 2 texels wide and some are 3, and the nearest-neighbour
|
||||
-- upscale turns them into 5 / 7 / 8 physical pixels instead of a uniform 7.
|
||||
-- Measured on a 1080p density-2.755 fixture, only 43 of 160 columns came out
|
||||
-- the right width. Fonts show it worst: their strokes are single pixels.
|
||||
-- That is issue #208 ("some are noticeably stretched, especially fonts").
|
||||
--
|
||||
-- Forcing dpiscale = 1 makes canvas texels the pixels the renderer already
|
||||
-- believes it is drawing. getWidth()/getHeight() are unaffected (they always
|
||||
-- reported the requested size), so no geometry anywhere changes -- only the
|
||||
-- resolution of the target. Desktop is unchanged; dpiscale is already 1
|
||||
-- there because conf.lua sets highdpi on mobile only.
|
||||
--
|
||||
-- Exception: a canvas that is deliberately sized in LOVE *units* and blitted
|
||||
-- back at unit scale 1 (Renderer's presentCanvas) must keep the screen's DPI
|
||||
-- scale so its texture still covers the framebuffer; it is not built with
|
||||
-- this helper.
|
||||
|
||||
local PixelCanvas = {}
|
||||
|
||||
-- One framebuffer pixel per w/h unit, always. `filter` is applied only when
|
||||
-- given, so callers that relied on LOVE's default ("linear") keep it.
|
||||
function PixelCanvas.new(w, h, filter)
|
||||
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
|
||||
if filter and canvas and canvas.setFilter then
|
||||
canvas:setFilter(filter, filter)
|
||||
end
|
||||
return canvas
|
||||
end
|
||||
|
||||
return PixelCanvas
|
||||
+15
-74
@@ -10,20 +10,11 @@ local Zoom = require("src.render.Zoom")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local PixelCanvas = require("src.render.PixelCanvas")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Renderer = {}
|
||||
|
||||
-- The Game Boy surface. WIDTH/HEIGHT are the classic dimensions every
|
||||
-- screen is laid out in; uiWidth/uiHeight are the surface actually
|
||||
-- allocated this frame, which a state may widen through setUISize (the
|
||||
-- widescreen battle layout asks for 304x144). Anything drawing a normal
|
||||
-- 160x144 screen can keep reading WIDTH/HEIGHT.
|
||||
Renderer.WIDTH = 160
|
||||
Renderer.HEIGHT = 144
|
||||
Renderer.MAX_UI_WIDTH = 640
|
||||
Renderer.MAX_UI_HEIGHT = 576
|
||||
|
||||
-- Whether a value is a real Canvas we can composite. Real LOVE canvases are
|
||||
-- userdata answering typeOf("Canvas"); the headless test stub fakes them as
|
||||
@@ -87,11 +78,8 @@ local function displayMetrics()
|
||||
end
|
||||
|
||||
function Renderer:init()
|
||||
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
|
||||
-- (#208). Every canvas below is sized in framebuffer pixels for the same
|
||||
-- reason -- worldViewSize() already works in drawable pixels.
|
||||
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
|
||||
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
|
||||
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
|
||||
self.canvas:setFilter("nearest", "nearest")
|
||||
self.worldCanvas = nil
|
||||
self.worldActive = false
|
||||
-- tilt mode only: a transparent overlay canvas the size of the world
|
||||
@@ -123,31 +111,7 @@ end
|
||||
-- units via / dpiX and / dpiY when drawing.
|
||||
function Renderer:fitScale()
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
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")
|
||||
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
|
||||
end
|
||||
|
||||
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
|
||||
@@ -206,9 +170,6 @@ function Renderer:beginFrame(transparent)
|
||||
-- draws this one
|
||||
PaletteFX.clearTrueColor()
|
||||
PaletteFX.clearSpriteRedraws()
|
||||
-- rBGP is a per-frame register here: the state that draws a dark map
|
||||
-- re-arms it while it draws (#322), so nothing inherits last frame's
|
||||
PaletteFX.setShadeMap(nil)
|
||||
PaletteFX.setPass("ui")
|
||||
love.graphics.setCanvas(self.canvas)
|
||||
if transparent then
|
||||
@@ -269,7 +230,8 @@ function Renderer:beginWorldPass()
|
||||
-- the view size every frame, so without this the superseded canvases
|
||||
-- pile up in VRAM until a GC finalizer happens to run
|
||||
if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end
|
||||
self.worldCanvas = PixelCanvas.new(vw, vh, "nearest")
|
||||
self.worldCanvas = love.graphics.newCanvas(vw, vh)
|
||||
self.worldCanvas:setFilter("nearest", "nearest")
|
||||
end
|
||||
self.worldActive = true
|
||||
PaletteFX.setPass("world")
|
||||
@@ -297,7 +259,8 @@ function Renderer:beginUprightPass()
|
||||
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
|
||||
or self.uprightCanvas:getHeight() ~= ch then
|
||||
if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end
|
||||
self.uprightCanvas = PixelCanvas.new(cw, ch, "nearest")
|
||||
self.uprightCanvas = love.graphics.newCanvas(cw, ch)
|
||||
self.uprightCanvas:setFilter("nearest", "nearest")
|
||||
end
|
||||
self.uprightActive = true
|
||||
PaletteFX.setPass(nil)
|
||||
@@ -391,7 +354,8 @@ function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
|
||||
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
|
||||
or self.tiltCanvas:getHeight() ~= wvh then
|
||||
if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end
|
||||
self.tiltCanvas = PixelCanvas.new(wvw, wvh, "linear")
|
||||
self.tiltCanvas = love.graphics.newCanvas(wvw, wvh)
|
||||
self.tiltCanvas:setFilter("linear", "linear")
|
||||
end
|
||||
|
||||
love.graphics.setCanvas(self.tiltCanvas)
|
||||
@@ -475,11 +439,10 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
||||
local Sp = self:fitScale()
|
||||
local Sx, Sy = Sp / dpiX, Sp / dpiY
|
||||
local uiw, uih = self:uiSize()
|
||||
local vpw, vph = uiw * Sx, uih * Sy
|
||||
local vpw, vph = self.WIDTH * Sx, self.HEIGHT * Sy
|
||||
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
|
||||
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
|
||||
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX
|
||||
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpiY
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||
@@ -501,14 +464,6 @@ function Renderer:endFrame(zones, worldZones)
|
||||
if needPresent then
|
||||
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
||||
or self.presentCanvas:getHeight() ~= wh then
|
||||
-- The one canvas NOT built through PixelCanvas: it is sized in LOVE
|
||||
-- units and blitted back at unit scale 1 (and handed to mod present
|
||||
-- passes as ww x wh), so it has to keep the screen's DPI scale for its
|
||||
-- texture to cover the framebuffer. Everything composited into it is
|
||||
-- already native-resolution now, so #208's fractional source is gone;
|
||||
-- what remains here is the dpiX vs dpiY truncation gap (well under 1%,
|
||||
-- one seam across the window) that a single scalar dpiscale cannot
|
||||
-- express.
|
||||
self.presentCanvas = love.graphics.newCanvas(ww, wh)
|
||||
self.presentCanvas:setFilter("linear", "linear")
|
||||
end
|
||||
@@ -516,11 +471,8 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setCanvas(present)
|
||||
end
|
||||
-- Default letterbox is black. Battle (and any state that opts in via
|
||||
-- letterboxWhite) fills the voids with the display mode's paper shade, so
|
||||
-- the bars match the canvas they frame instead of showing black. Not a
|
||||
-- literal white: the battle canvas is colorized, and in SGB mode its paper
|
||||
-- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in
|
||||
-- a visibly brighter border.
|
||||
-- letterboxWhite) fills the voids white so the window matches the
|
||||
-- white battle canvas instead of showing black bars.
|
||||
local clearR, clearG, clearB = 0, 0, 0
|
||||
if not self.worldActive then
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
@@ -528,23 +480,12 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local base = stack and stack.visibleBase and stack:visibleBase()
|
||||
local state = base and stack.states and stack.states[base]
|
||||
if state and state.letterboxWhite then
|
||||
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
||||
clearR, clearG, clearB = 1, 1, 1
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- render.letterbox: SGB borders / custom void art in the bars around the
|
||||
-- 160x144 (or world) blit. Drawn after the clear and before the game
|
||||
-- canvas so the playfield sits on top of the border.
|
||||
if Runtime.wantsHook("render.letterbox") then
|
||||
Runtime.call("render.letterbox", function() end, {
|
||||
ww = ww, wh = wh, pw = pw, ph = ph,
|
||||
ox = ox, oy = oy, vpw = vpw, vph = vph,
|
||||
scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
||||
worldActive = self.worldActive and true or false,
|
||||
})
|
||||
end
|
||||
|
||||
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
|
||||
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
|
||||
|
||||
@@ -18,14 +18,10 @@ local function getImage(path)
|
||||
return imageCache[path]
|
||||
end
|
||||
|
||||
-- Overworld sprite OBJ-palette recolor, baked into an ImageData like
|
||||
-- BattleState's mon-pic palette bake (src/battle/BattleState.lua getImage):
|
||||
-- CPU-remap the 4 DMG shades to the resolved OBP colors, cached per
|
||||
-- (image path, group). Every colour mode goes through it now (#301): RED++
|
||||
-- resolves real per-sprite colours (color/sprites.asm ColorOverworldSprite),
|
||||
-- OG RED the one boot-ROM object palette, and everything else the plain
|
||||
-- rOBP0 = $D0 shade lift (PaletteFX.dmgObj) that leaves the sprite in DMG
|
||||
-- shades for the zone shader to colour.
|
||||
-- RED++ overworld sprite OBJ-palette recolor (color/sprites.asm
|
||||
-- ColorOverworldSprite), baked into an ImageData like BattleState's mon-pic
|
||||
-- palette bake (src/battle/BattleState.lua getImage): CPU-remap the 4 DMG
|
||||
-- shades to the resolved OBP colors, cached per (image path, group).
|
||||
--
|
||||
-- Sprite sheets carry no real alpha (every pixel, including the
|
||||
-- background, is opaque -- confirmed by sampling the extracted PNGs): the
|
||||
@@ -114,13 +110,7 @@ function SpriteRenderer:resolveImage()
|
||||
-- collide in obpCache) -- see issue #155
|
||||
return getObpImage(self.def.image, PaletteFX.ogObj())
|
||||
end
|
||||
-- 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())
|
||||
return self.image
|
||||
end
|
||||
|
||||
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
||||
@@ -162,17 +152,6 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
-- top.
|
||||
image = getObpImage(self.def.image, PaletteFX.ogObj())
|
||||
redraw = true
|
||||
else
|
||||
-- SGB and the mono/inverted modes (and OG RED's tilt upright pass, which
|
||||
-- has no post-zone replay to restore a bake): the sprite stays in DMG
|
||||
-- shades -- rOBP0 = $D0 baked in, OBJ color 0 keyed to alpha -- and the
|
||||
-- whole-canvas zone shader colors it with the map's palette. That is the
|
||||
-- only thing the Super Game Boy can do to an OBJ, since pokered never
|
||||
-- sends the OBJ_TRN packet that would give sprites palettes of their own
|
||||
-- (data/sgb/sgb_packets.asm defines ATTR_BLK / PAL_SET / PAL_TRN /
|
||||
-- MLT_REQ / CHR_TRN / PCT_TRN and nothing else). No redraw is queued:
|
||||
-- being colorized by the zone IS the point (#301).
|
||||
image = getObpImage(self.def.image, PaletteFX.dmgObj())
|
||||
end
|
||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||
-- still 3-frame sprites turn to face (the nurse at her machine,
|
||||
|
||||
+5
-51
@@ -27,11 +27,6 @@ local MAX_COLS = 18
|
||||
-- WaitForSoundToFinish; nil headless), then auto.delay frames pass
|
||||
-- (default 3, Delay3) and the box pops itself + calls onDone. No
|
||||
-- blinking cursor, no Press_AB beep.
|
||||
-- opts.auto.tick, when given, runs once per frame for as long as the box
|
||||
-- is held open. It is the only per-frame hook a script has while a box is
|
||||
-- up: StateStack updates the top state only, so the overworld and its
|
||||
-- ScriptRunner are frozen underneath (the Pewter JIGGLYPUFF dance drives
|
||||
-- its spin off it, data/scripts/story5.lua, #249).
|
||||
function TextBox.new(game, text, onDone, opts)
|
||||
local self = setmetatable({}, TextBox)
|
||||
self.game = game
|
||||
@@ -97,28 +92,13 @@ end
|
||||
-- pokered ContText waits for A/B + ▼ before scrolling that line in.
|
||||
function TextBox.paginate(text, maxCols)
|
||||
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
|
||||
-- maxCols is a column count, so the budget is that many vanilla 8px
|
||||
-- cells. Measuring in pixels rather than columns is what lets a mod's
|
||||
-- variable-advance page wrap correctly (#186).
|
||||
local budget = maxCols * 8
|
||||
local pages = {}
|
||||
local contBefore = {}
|
||||
-- Soft-wrap on glyph boundaries, never byte boundaries: a line is over
|
||||
-- budget by what it *draws*, and the cut falls between glyphs so a
|
||||
-- multi-byte char is never torn in half.
|
||||
local function pushLine(lines, conts, line, wait)
|
||||
while true do
|
||||
local spans = Font.split(line)
|
||||
local fit = Font.spansFitting(spans, budget)
|
||||
if fit >= #spans then break end
|
||||
-- a glyph wider than the whole box still has to advance by one
|
||||
fit = math.max(fit, 1)
|
||||
local cut = spans[fit].to
|
||||
for i = fit, 1, -1 do
|
||||
if line:sub(spans[i].from, spans[i].to) == " " then
|
||||
cut = spans[i].to
|
||||
break
|
||||
end
|
||||
while #line > maxCols do
|
||||
local cut = maxCols
|
||||
for i = maxCols, 1, -1 do
|
||||
if line:sub(i, i) == " " then cut = i break end
|
||||
end
|
||||
table.insert(lines, line:sub(1, cut))
|
||||
table.insert(conts, wait)
|
||||
@@ -184,28 +164,9 @@ function TextBox:update(dt)
|
||||
self.autoSrc = self.auto.sound and self.auto.sound() or nil
|
||||
self.autoTimer = 0
|
||||
end
|
||||
-- auto.tick: one call per frame for as long as the box is held open,
|
||||
-- run before the autoSrc gate so a tick-driven gate can clear itself
|
||||
-- on the same frame. It is the only per-frame hook a script gets
|
||||
-- while a box is up, since StateStack updates the top state only and
|
||||
-- the overworld underneath is frozen (the Pewter JIGGLYPUFF spin,
|
||||
-- #249).
|
||||
if self.auto.tick then self.auto.tick() end
|
||||
if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then
|
||||
return -- the cry is still sounding (WaitForSoundToFinish)
|
||||
end
|
||||
-- auto.wait: the pet-NPC cries (PewterNidoranHouseNidoranText,
|
||||
-- ViridianNicknameHouseSpearowText) have nothing queued behind the
|
||||
-- cry, so DisplayTextID's trailing WaitForTextScrollButtonPress still
|
||||
-- runs once it is over -- their maps enable auto text box drawing,
|
||||
-- which zeroes wDoNotWaitForButtonPressAfterDisplayingText
|
||||
-- (home/window.asm AutoTextBoxDrawingCommon). Drop the auto gate and
|
||||
-- hand the box to the plain A/B path, which also starts the blinking
|
||||
-- arrow, instead of popping it (#247, #251).
|
||||
if self.auto.wait then
|
||||
self.auto = nil
|
||||
return
|
||||
end
|
||||
self.autoTimer = self.autoTimer + 1
|
||||
local delay = self.auto.delay or 3
|
||||
-- auto.onOverlap: fired once when the delay elapses but before the
|
||||
@@ -306,17 +267,10 @@ function TextBox:draw()
|
||||
self.scrollPx = self.scrollPx - 2
|
||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||
end
|
||||
-- Only the retained line carries the offset: it slides up from where it
|
||||
-- already sat (line2Y) to line1Y. The incoming line is drawn at its home
|
||||
-- row instead, because offsetting it too put fresh glyphs 8px low -- on the
|
||||
-- box's bottom border -- whenever the typewriter beat the 4-frame slide
|
||||
-- (#314). The sub-tile slide is ours to begin with: ScrollTextUpOneLine
|
||||
-- (home/text.asm:283) copies the rows up whole and waits 5 frames, so
|
||||
-- nothing in the original is ever drawn between two rows.
|
||||
local off = self.scrollPx or 0
|
||||
local ys = { self.line1Y, self.line2Y }
|
||||
for i, line in ipairs(self.shown) do
|
||||
local y = (ys[i] or self.line2Y) + (i == 1 and off or 0)
|
||||
local y = (ys[i] or self.line2Y) + off
|
||||
for j, code in ipairs(line) do
|
||||
Font.drawCode(code, self.textX + (j - 1) * 8, y)
|
||||
end
|
||||
|
||||
+19
-61
@@ -11,9 +11,9 @@ TileRenderer.__index = TileRenderer
|
||||
local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks)
|
||||
|
||||
-- OVERWORLD maps fill beyond-edge space from save.options.voidFill:
|
||||
-- trees (default) -- solid tree wall $0F (Viridian/Cerulean/Celadon)
|
||||
-- water -- solid water $43 (Cinnabar/Route 19 border block)
|
||||
-- black -- solid black (no tiled metatile)
|
||||
-- trees (default) — solid tree wall $0F (Viridian/Cerulean/Celadon)
|
||||
-- water — solid water $43 (Cinnabar/Route 19 border block)
|
||||
-- black — solid black (no tiled metatile)
|
||||
-- Other tilesets keep their designated border (interiors stay black/void).
|
||||
local TREE_WALL_BLOCK = 0x0F
|
||||
local WATER_BORDER_BLOCK = 0x43
|
||||
@@ -475,13 +475,6 @@ function TileRenderer.new(map, data)
|
||||
-- match the atlas's static tiles instead of showing raw grayscale
|
||||
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id,
|
||||
groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) }
|
||||
-- ...and feeds the color-0-keyed single tiles the feet overdraw needs
|
||||
-- (see getKeyedTile): same source image and palette groups, so keep the
|
||||
-- context rather than re-deriving it per draw.
|
||||
gbcCtx.imagePath = map.tileset.image
|
||||
gbcCtx.perRow = map.tileset.tilesPerRow
|
||||
self.gbcCtx = gbcCtx
|
||||
self.gbcKeyed = {}
|
||||
end
|
||||
end
|
||||
-- a full-color atlas colors everything it paints, ring and border fill
|
||||
@@ -549,10 +542,7 @@ end
|
||||
local function bakeBorderFill(self, block)
|
||||
local border = self.map.tileset.blocks[block + 1]
|
||||
if not border then return end
|
||||
-- 32x32 real pixels: a DPI-scaled canvas would bake the border block at a
|
||||
-- fractional texel size and the repeat-wrapped image would then tile at
|
||||
-- non-square pixels (#208, see src/render/PixelCanvas.lua)
|
||||
local canvas = require("src.render.PixelCanvas").new(32, 32)
|
||||
local canvas = love.graphics.newCanvas(32, 32)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(1, 1, 1, 1)
|
||||
@@ -679,48 +669,17 @@ local function getColor0KeyShader()
|
||||
return color0KeyShader or nil
|
||||
end
|
||||
|
||||
-- RED++ (COLORS=ADVANCED): the color-0 key, BAKED instead of tested for.
|
||||
local function getKeyedTile(self, tile)
|
||||
local ctx = self.gbcCtx
|
||||
local cached = self.gbcKeyed[tile]
|
||||
if cached ~= nil then return cached or nil end
|
||||
local img = false
|
||||
if ctx.groupColors and love.image and love.image.newImageData then
|
||||
local group = PaletteFX.worldGroupAt(ctx.tilesetId, ctx.mapId, tile)
|
||||
local colors = group and ctx.groupColors[group + 1]
|
||||
local src = Assets.imageData(ctx.imagePath)
|
||||
local ox = (tile % ctx.perRow) * 8
|
||||
local oy = math.floor(tile / ctx.perRow) * 8
|
||||
local out = love.image.newImageData(8, 8)
|
||||
for py = 0, 7 do
|
||||
for px = 0, 7 do
|
||||
local r, g, b, a = src:getPixel(ox + px, oy + py)
|
||||
-- read shade 0 off the RAW sheet, on recolorSample's own cutoff, so
|
||||
-- the keyed pixels are exactly the ones the shader path keys
|
||||
local shade0 = r > 0.83
|
||||
r, g, b, a = recolorSample(r, g, b, a, colors)
|
||||
out:setPixel(px, py, r, g, b, shade0 and 0 or a)
|
||||
end
|
||||
end
|
||||
img = love.graphics.newImage(out)
|
||||
end
|
||||
self.gbcKeyed[tile] = img
|
||||
return img or nil
|
||||
end
|
||||
|
||||
-- draw a cell's bottom tile row without touching the shader (the caller
|
||||
-- owns it). drawCellBottom wraps this with the color-0 key; tilt mode's
|
||||
-- upright pass wraps it with a color-0-keyed palette shader instead
|
||||
-- (PaletteFX.keyedShader) so the feet patch is colorized like the ground.
|
||||
function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY)
|
||||
local ty = cy * 2 + 1
|
||||
for i = 0, 1 do
|
||||
local tx = cx * 2 + i
|
||||
local tile = self.map:tileAt(tx, ty)
|
||||
local keyed = tile and self.gbcCtx and getKeyedTile(self, tile)
|
||||
if keyed then
|
||||
love.graphics.draw(keyed, tx * 8 - camX, ty * 8 - camY)
|
||||
else
|
||||
local quad = self.quads[tile]
|
||||
if quad then
|
||||
love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY)
|
||||
end
|
||||
local quad = self.quads[self.map:tileAt(tx, ty)]
|
||||
if quad then
|
||||
love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -728,9 +687,7 @@ end
|
||||
-- redraw a cell's bottom tile row (tall grass hides the lower half of
|
||||
-- sprites standing in it, like the GB sprite-priority trick)
|
||||
function TileRenderer:drawCellBottom(cx, cy, camX, camY)
|
||||
-- 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
|
||||
local shader = getColor0KeyShader()
|
||||
if shader then love.graphics.setShader(shader) end
|
||||
self:drawCellBottomRaw(cx, cy, camX, camY)
|
||||
if shader then love.graphics.setShader() end
|
||||
@@ -752,6 +709,13 @@ function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors)
|
||||
end
|
||||
end
|
||||
|
||||
-- Window cover for the static tile layer. Refill the reusable window batch
|
||||
-- (and the per-entry animated batches) only when the camera has scrolled past
|
||||
-- what they already cover; a small margin keeps small scrolls free. Cost
|
||||
-- scales with the view, never the map -- crossing a seam or warping in builds
|
||||
-- nothing. The beyond-body area (what the old 3-block ring drew) is painted
|
||||
-- by :drawBorderFill, whose world-aligned border-block tiling is identical
|
||||
-- there, so only body tiles are gathered here.
|
||||
local WINDOW_MARGIN = 8 -- tiles of slack kept around the view between refills
|
||||
|
||||
function TileRenderer:ensureWindow(camX, camY, vw, vh)
|
||||
@@ -906,12 +870,6 @@ end
|
||||
-- atlas -- is unique to this map (gbcAtlasCache is keyed by map id).
|
||||
function TileRenderer:release()
|
||||
self:releaseBatches()
|
||||
if self.gbcKeyed then
|
||||
-- baked per instance, shared with nobody (see getKeyedTile)
|
||||
for _, img in pairs(self.gbcKeyed) do safeRelease(img) end
|
||||
self.gbcKeyed = nil
|
||||
self.gbcCtx = nil
|
||||
end
|
||||
if self.gbcAtlas and self.image then
|
||||
local key = self.map.tileset.image .. "#gbc:" .. self.map.id
|
||||
if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end
|
||||
|
||||
+7
-26
@@ -4,38 +4,19 @@
|
||||
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
|
||||
-- Spec: docs/new-features.md (survey zoom)
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Zoom = {}
|
||||
|
||||
Zoom.offset = 0
|
||||
|
||||
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
|
||||
-- through 2× fit). zoom.range may widen or shrink the window.
|
||||
function Zoom.offsetRange(S)
|
||||
S = math.max(1, math.floor(tonumber(S) or 1))
|
||||
local lo, hi = 1 - S, S
|
||||
if Runtime.wantsHook("zoom.range") then
|
||||
lo, hi = Runtime.call("zoom.range", function(a, b) return a, b end, lo, hi, S)
|
||||
lo = math.floor(tonumber(lo) or (1 - S))
|
||||
hi = math.floor(tonumber(hi) or S)
|
||||
if lo > hi then lo, hi = hi, lo end
|
||||
end
|
||||
return lo, hi
|
||||
-- effective integer scale s' in [1, 2*S]
|
||||
function Zoom.scale(S)
|
||||
return math.max(1, math.min(2 * S, S + Zoom.offset))
|
||||
end
|
||||
|
||||
-- effective scale s' = S + offset, clamped to the (possibly modded) range.
|
||||
-- Vanilla stays in [1, 2*S]. A zoom.range wrapper that lowers `lo` below
|
||||
-- 1-S permits sub-1 survey scales so the whole region can fit on screen.
|
||||
function Zoom.scale(S)
|
||||
local lo, hi = Zoom.offsetRange(S)
|
||||
local s = S + Zoom.offset
|
||||
local minScale = S + lo
|
||||
local maxScale = math.max(minScale, S + hi)
|
||||
if s < minScale then s = minScale end
|
||||
if s > maxScale then s = maxScale end
|
||||
if s < 0.25 then s = 0.25 end
|
||||
return s
|
||||
-- legal offset range for a given fit scale
|
||||
function Zoom.offsetRange(S)
|
||||
S = math.max(1, math.floor(tonumber(S) or 1))
|
||||
return 1 - S, S
|
||||
end
|
||||
|
||||
function Zoom.clampOffset(offset, S)
|
||||
|
||||
@@ -38,8 +38,6 @@ local NAME_LENGTH = 11
|
||||
local PARTY_LENGTH = 6
|
||||
local MONS_PER_BOX = 20
|
||||
local NUM_BADGES = 8
|
||||
local NUM_CITY_MAPS = 11 -- PALLET_TOWN..SAFFRON_CITY, the bit width of
|
||||
-- wTownVisitedFlag (constants/map_constants.asm)
|
||||
local BOX_STRUCT_SIZE = 33 -- Species,HP,Level,Status,Type1,Type2,CatchRate,
|
||||
-- Moves x4,OTID,Exp x3,HPExp,AtkExp,DefExp,
|
||||
-- SpdExp,SpcExp,DVs,PP x4 (macros/ram.asm box_struct)
|
||||
@@ -67,22 +65,6 @@ O.numPcItems = O.mainData + 579 -- 1B
|
||||
O.pcItems = O.mainData + 580 -- 101B (50 x (id,qty) + $FF term)
|
||||
O.currentBoxNum = O.mainData + 681 -- 1B (bits 0-6: box 0-11, bit 7: unused here)
|
||||
O.coins = O.mainData + 685 -- 2B BCD
|
||||
-- wTownVisitedFlag (ram/wram.asm:2057): the FLY destination set, a
|
||||
-- flag_array NUM_CITY_MAPS whose bit index IS the town's map index (see the
|
||||
-- decode note). Triangulated from both neighbours, which agree exactly:
|
||||
-- backwards from the checksum-covered, independently derived O.eventFlags
|
||||
-- below by summing every wram.asm declaration between the two labels --
|
||||
-- 2 (wTownVisitedFlag) + 2 (wSafariSteps) + 1 + 1 + 2 + 1 + 1 + 1 + 1 + 1
|
||||
-- + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 8 + 1 + 1 + 1 (wBeatGymFlags) + 1 + 1 + 1
|
||||
-- (wStatusFlags3, aliased wCableClubDestinationMap) + 1 + 1 + 1 + 1 + 1 + 1
|
||||
-- + 1 + 1 + 1 (wMovementFlags) + 2 + 2 + 1 + 1 + 2 + 1 + 1 + 2 + 1 + 1 + 2
|
||||
-- = 60, so 1104 - 60 = 1044; and forwards from O.coins (mainData + 685)
|
||||
-- with 2 (wPlayerCoins) + 32 (wToggleableObjectFlags, flag_array $100) + 7
|
||||
-- + 1 (wSavedSpriteImageIndex) + 33 (wToggleableObjectList) + 1 + 200
|
||||
-- (wGameProgressFlags..End) + 56 + 14 (wObtainedHiddenItemsFlags,
|
||||
-- flag_array MAX_HIDDEN_ITEMS = 112) + 2 (wObtainedHiddenCoinsFlags) + 1
|
||||
-- (wWalkBikeSurfState) + 10 = 359, so 685 + 359 = 1044 as well.
|
||||
O.townVisited = O.mainData + 1044 -- 2B (flag_array NUM_CITY_MAPS)
|
||||
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
|
||||
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||
@@ -250,17 +232,12 @@ local function encodeName(buf, off, len, text)
|
||||
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
|
||||
i, pos = i + 1, pos + clen
|
||||
end
|
||||
-- Write exactly ONE $50 terminator and then $50-pad the rest of the
|
||||
-- field: every real save the naming screen ever wrote fills the tail
|
||||
-- with $50, and a zero tail is what PKHeX renders as garbage glyphs
|
||||
-- after the name ("JOHN{}", #206). Template bytes that are NOT zero
|
||||
-- stay untouched, so an unchanged name still round-trips
|
||||
-- byte-identical and stale template data survives.
|
||||
-- Write exactly ONE $50 terminator and then STOP. The bytes after it are
|
||||
-- left untouched: when encoding over a template they stay as the original
|
||||
-- save's post-terminator padding (so an unchanged name round-trips
|
||||
-- byte-identical), and on a templateless export they stay zero-filled. The
|
||||
-- game reads a name only up to the first $50, so whatever follows is inert.
|
||||
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
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
@@ -398,40 +375,6 @@ function GenSave.crosswalks(data)
|
||||
}
|
||||
end
|
||||
|
||||
-- Gen1 has no "is nicknamed" bit. An un-nicknamed mon literally stores its
|
||||
-- species' standard name in the nickname slot: engine/menus/naming_screen.asm
|
||||
-- AskName's .declinedNickname copies wNameBuffer (the MonsterNames entry
|
||||
-- GetMonName just loaded) straight over the mon's nickname field. The game
|
||||
-- recovers "was it nicknamed?" by comparing the two --
|
||||
-- engine/pokemon/evos_moves.asm RenameEvolvedMon rewrites the name on
|
||||
-- evolution only while the stored one still equals the PRE-evolution
|
||||
-- species' standard name ("Renames the mon to its new, evolved form's
|
||||
-- standard name unless it had a nickname, in which case the nickname is
|
||||
-- kept"). This project models that state as mon.nickname == nil instead:
|
||||
-- every display site reads `mon.nickname or def.name` and
|
||||
-- src/pokemon/Evolution.lua deliberately never touches the field. So the
|
||||
-- two conventions must be translated at this boundary, or an imported
|
||||
-- SQUIRTLE still reads "SQUIRTLE" after it becomes a WARTORTLE and an
|
||||
-- engine-origin export writes the species CONSTANT ("NIDORAN_M", whose "_"
|
||||
-- has no charmap glyph and encodes as "?") where the cartridge keeps the
|
||||
-- display name. Both read back as a forced nickname (#257).
|
||||
--
|
||||
-- def.name is byte-for-byte what the cartridge stores: tools/extract/
|
||||
-- pokemon.py parse_names reads pokered's data/pokemon/names.asm, the very
|
||||
-- table GetMonName loads from, and all 151 names round-trip exactly through
|
||||
-- src/save_convert/data/charmap.lua, so the equality test below is exact
|
||||
-- and never mis-fires on a name the charmap mangles.
|
||||
local function speciesName(cw, species)
|
||||
local def = species and cw.speciesDefs[species]
|
||||
return (def and def.name) or species or ""
|
||||
end
|
||||
|
||||
-- stored fixed-length name -> save.lua nickname (nil when never nicknamed)
|
||||
local function importedNickname(cw, species, stored)
|
||||
if stored == speciesName(cw, species) then return nil end
|
||||
return stored
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Mon struct (box_struct is a byte-for-byte prefix of party_struct;
|
||||
-- decodeMon reads the box_struct fields, then Level+Stats if isParty).
|
||||
@@ -630,10 +573,7 @@ function GenSave.decode(bytes, data, opts)
|
||||
local mon = decodeMon(bytes, O.partyMons + i * PARTY_STRUCT_SIZE, true, cw)
|
||||
if mon then
|
||||
mon.ot = decodeName(bytes, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH)
|
||||
-- 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))
|
||||
mon.nickname = decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH)
|
||||
save.party[#save.party + 1] = mon
|
||||
end
|
||||
end
|
||||
@@ -646,8 +586,7 @@ function GenSave.decode(bytes, data, opts)
|
||||
local mon = decodeMon(bytes, base + 22 + i * BOX_STRUCT_SIZE, false, cw)
|
||||
if mon then
|
||||
mon.ot = decodeName(bytes, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH)
|
||||
mon.nickname = importedNickname(cw, mon.species,
|
||||
decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH))
|
||||
mon.nickname = decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH)
|
||||
table.insert(save.boxes[boxNum], mon)
|
||||
end
|
||||
end
|
||||
@@ -671,28 +610,6 @@ function GenSave.decode(bytes, data, opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
|
||||
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
|
||||
-- into de and rotates it right one bit per iteration with b counting up
|
||||
-- from 0, storing b ("the map number of the town if it has been visited"),
|
||||
-- so bit 0 = map 0 = PALLET_TOWN, LSB first; and
|
||||
-- engine/overworld/toggleable_objects.asm
|
||||
-- MarkTownVisitedAndLoadToggleableObjects sets bit [wCurMap] on entry for
|
||||
-- any map below FIRST_ROUTE_MAP. Map indices 0-10 in
|
||||
-- data/generated/maps.lua match PALLET_TOWN..SAFFRON_CITY one for one.
|
||||
-- This project keeps the same set as save.visited[mapId]
|
||||
-- (src/ui/FlyMenu.lua, src/ui/TownMap.lua, and the only writer,
|
||||
-- src/world/OverworldController.lua's mark-on-map-entry), which an import
|
||||
-- used to leave nil: FLY then listed only the town the player happened to
|
||||
-- be standing in when the save was loaded (#263).
|
||||
save.visited = {}
|
||||
for townIdx = 0, NUM_CITY_MAPS - 1 do
|
||||
if bitGet(bytes, O.townVisited, townIdx) then
|
||||
local townId = cw.mapsByIndex[townIdx]
|
||||
if townId then save.visited[townId] = true end
|
||||
end
|
||||
end
|
||||
|
||||
-- map + position
|
||||
local mapIdx = u8(bytes, O.curMap)
|
||||
local mapId = cw.mapsByIndex[mapIdx]
|
||||
@@ -739,19 +656,6 @@ function GenSave.encode(save, data, template)
|
||||
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED")
|
||||
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE")
|
||||
setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
|
||||
-- wOptions (engine/menus/main_menu.asm InitOptions): bit 7 = battle
|
||||
-- effects OFF, bit 6 = SET style, bits 2-0 = text speed -- the recomp's
|
||||
-- textSpeed 1/3/5 are pokered's exact FAST/MEDIUM/SLOW values
|
||||
-- (SaveData.defaultOptions). Templateless exports only: with a
|
||||
-- template the byte survives untouched (the round-trip invariant), and
|
||||
-- decode() never reads it back anyway.
|
||||
if not src then
|
||||
local opts = save.options or {}
|
||||
local ob = (tonumber(opts.textSpeed) or 3) % 8
|
||||
if opts.battleStyle == "set" then ob = bit.bor(ob, 0x40) end
|
||||
if opts.animations == false then ob = bit.bor(ob, 0x80) end
|
||||
setByte(buf, O.options, ob)
|
||||
end
|
||||
setBcd(buf, O.money, 3, math.min(save.money or 0, 999999))
|
||||
setBcd(buf, O.coins, 2, math.min(save.coins or 0, 9999))
|
||||
|
||||
@@ -796,18 +700,6 @@ function GenSave.encode(save, data, template)
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
|
||||
-- save exported from this port is flyable on hardware (#263). A save
|
||||
-- table with no `visited` key at all says nothing about the set, so leave
|
||||
-- the template's bits exactly as they are rather than blanking every town.
|
||||
if type(save.visited) == "table" then
|
||||
for townIdx = 0, NUM_CITY_MAPS - 1 do
|
||||
local townId = cw.mapsByIndex[townIdx]
|
||||
bitSet(buf, O.townVisited, townIdx,
|
||||
(townId and save.visited[townId]) and true or false)
|
||||
end
|
||||
end
|
||||
|
||||
-- party
|
||||
local party = save.party or {}
|
||||
local partyN = math.min(#party, PARTY_LENGTH)
|
||||
@@ -818,10 +710,8 @@ function GenSave.encode(save, data, template)
|
||||
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
|
||||
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.ot or (save.player and save.player.name) or "RED")
|
||||
-- no nickname stores the species' DISPLAY name, not its ROM constant id
|
||||
-- ("NIDORAN_M" would charmap the "_" to "?") (#257)
|
||||
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.nickname or speciesName(cw, mon.species))
|
||||
mon.nickname or mon.species or "")
|
||||
end
|
||||
-- $FF-terminate the species index list right after the last real mon. The
|
||||
-- struct, OT-name and nickname bytes of the empty slots past partyN are left
|
||||
@@ -844,7 +734,7 @@ function GenSave.encode(save, data, template)
|
||||
encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.ot or (save.player and save.player.name) or "RED")
|
||||
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.nickname or speciesName(cw, mon.species)) -- #257, as above
|
||||
mon.nickname or mon.species or "")
|
||||
end
|
||||
-- $FF-terminate the species list after the last real mon; empty slots past
|
||||
-- n keep their template bytes (byte-identical round-trip) or zero (fresh
|
||||
|
||||
@@ -107,7 +107,6 @@ local function defaultsSave()
|
||||
modData = {},
|
||||
options = {
|
||||
textSpeed = 3, animations = true, battleStyle = "shift",
|
||||
battleLayout = "og",
|
||||
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
|
||||
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
|
||||
videoMode = "windowed", mods = {},
|
||||
|
||||
+14
-29
@@ -10,7 +10,6 @@ local Flags = require("src.script.Flags")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Commands = {}
|
||||
|
||||
@@ -75,9 +74,6 @@ end
|
||||
-- <PROMPT> of its own; the box only closes once WaitForSoundToFinish's poll
|
||||
-- loop sees the cry channel go quiet, so it's a no-button-wait auto text
|
||||
-- gated on the cry, not a button-wait one -- see TextBox's opts.auto.
|
||||
-- play_cry's waitForButton form keeps that cry gate but hands the box back
|
||||
-- to the A/B path once the cry is over -- see TextBox's opts.auto.wait
|
||||
-- (#247, #251).
|
||||
function Commands.show_text(ctx, textId, subs)
|
||||
local text = ctx.game.data.text[textId]
|
||||
if not text and ctx.overworld then
|
||||
@@ -104,12 +100,10 @@ function Commands.show_text(ctx, textId, subs)
|
||||
local opts
|
||||
if ctx.pendingCry then
|
||||
local species = ctx.pendingCry
|
||||
local waitForButton = ctx.pendingCryWait
|
||||
ctx.pendingCry, ctx.pendingCryWait = nil, nil
|
||||
-- delay 0: WaitForSoundToFinish has no trailing Delay3 of its own
|
||||
ctx.pendingCry = nil
|
||||
opts = { auto = { sound = function()
|
||||
return require("src.core.Sound").playCry(ctx.game.data, species)
|
||||
end, delay = 0, wait = waitForButton } }
|
||||
end, delay = 0 } } -- WaitForSoundToFinish has no trailing Delay3 of its own
|
||||
end
|
||||
-- text_opts armed the next box: auto = true is the plain no-button-wait
|
||||
-- form, overlap folds under auto, everything else passes through
|
||||
@@ -195,7 +189,7 @@ function Commands.give_item(ctx, itemId, count, gotText)
|
||||
-- skips the received text entirely when AddItemToInventory refuses)
|
||||
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
|
||||
Commands.show_text(ctx, ctx.game.data.text
|
||||
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
|
||||
and ctx.game.data.text._BagFullText or "You can't carry\nany more items!")
|
||||
return math.huge
|
||||
end
|
||||
local def = ctx.game.data.items[itemId]
|
||||
@@ -208,7 +202,7 @@ function Commands.give_item(ctx, itemId, count, gotText)
|
||||
(def and def.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||
if gotText ~= false then
|
||||
Commands.show_text(ctx, gotText
|
||||
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
|
||||
or "{PLAYER} got\n" .. ctx.game.stringBuffer .. "!")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -475,26 +469,18 @@ function Commands.play_once(ctx, songId)
|
||||
runner:yield()
|
||||
end
|
||||
|
||||
-- play_cry <species> [waitForButton]: PlayCry (home/audio.asm). The
|
||||
-- text_asm bodies that use it run text_far (a no-button-wait "...@" string)
|
||||
-- -> PlayCry -> WaitForSoundToFinish, all within the same text ID -- the cry
|
||||
-- only starts once the box has finished typing, and the box then auto-closes
|
||||
-- (no A press) the instant the cry finishes, rather than firing immediately
|
||||
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
|
||||
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
|
||||
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
|
||||
-- once the box has finished typing, and the box then auto-closes (no A
|
||||
-- press) the instant the cry finishes, rather than firing immediately
|
||||
-- alongside the typewriter effect. Script rows run strictly in order, so
|
||||
-- this stashes the species on ctx for the show_text row that always
|
||||
-- immediately follows it (Power Plant Zapdos, Seafoam Articuno, Victory
|
||||
-- Road Moltres, Cerulean Cave Mewtwo battle text) to play once its box is
|
||||
-- done typing (see show_text's opts.auto). Headless-safe no-op there.
|
||||
--
|
||||
-- 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)
|
||||
function Commands.play_cry(ctx, species)
|
||||
ctx.pendingCry = species
|
||||
ctx.pendingCryWait = waitForButton or nil
|
||||
end
|
||||
|
||||
-- check_battle_result <r1> [r2 ...]: lastCheck = the last scripted
|
||||
@@ -531,7 +517,7 @@ local function askNickname(ctx, mon)
|
||||
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
|
||||
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
|
||||
else
|
||||
Commands.show_text(ctx, Strings("Do you want to\ngive a nickname\nto %s?", name))
|
||||
Commands.show_text(ctx, ("Do you want to\ngive a nickname\nto %s?"):format(name))
|
||||
end
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
|
||||
@@ -541,7 +527,7 @@ local function askNickname(ctx, mon)
|
||||
return
|
||||
end
|
||||
Screens.push(ctx.game, "NamingScreen", {
|
||||
title = Strings("NICKNAME?"), maxLen = 10,
|
||||
title = "NICKNAME?", maxLen = 10,
|
||||
onDone = function(nick)
|
||||
if nick and #nick > 0 then mon.nickname = nick end
|
||||
ctx.lastCheck = success
|
||||
@@ -612,9 +598,8 @@ function Commands.give_pokemon(ctx, species, level)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local raw = ctx.game.data.text and ctx.game.data.text._SentToBoxText
|
||||
ctx.game.stack:push(TextBox.new(ctx.game, raw or (
|
||||
Strings(
|
||||
"There's no more\nroom for POKéMON!\v%s was\vsent to POKéMON\vBOX %s on PC!",
|
||||
name, boxNum)),
|
||||
"There's no more\nroom for POKéMON!\v" .. name
|
||||
.. " was\vsent to POKéMON\vBOX " .. boxNum .. " on PC!"),
|
||||
function() end))
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user