mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a89d43f315 | |||
| 96a64d439e | |||
| 401f79877c | |||
| c52a35288a | |||
| a37d1a0676 | |||
| 0e56e0a539 | |||
| cc67be341f | |||
| ff829569b0 | |||
| bc4afcba71 | |||
| dde25ec7d0 | |||
| 9eb80575e5 | |||
| 590e082328 | |||
| 2f52a0651a | |||
| e393ec06e3 | |||
| 0dddb32305 | |||
| f3165ceb71 | |||
| d6e36d457f | |||
| 52e3a0d23f | |||
| f1950348d4 | |||
| 5603477787 | |||
| 0ca1defb66 | |||
| 370ddea0b8 | |||
| 6a9e77cde0 | |||
| a03f69926e | |||
| 04ece522d7 | |||
| 4943d3e5f4 | |||
| 5a48a61f2e | |||
| fd66a04686 | |||
| b69f8c27c9 | |||
| 66f0326492 | |||
| dbab4a2116 | |||
| 3e8da9b232 | |||
| 223202f230 | |||
| 3078c20e1d | |||
| 617a87a47b | |||
| 8539a6b268 | |||
| f9f38d161f | |||
| 8b85cda84c | |||
| 1deba07106 | |||
| c24c7cbcb7 | |||
| c2334f2f91 | |||
| bfac5fe327 | |||
| 3b1032cc63 | |||
| 72f83760b7 | |||
| 63a179b77a | |||
| 37ba5c3879 | |||
| 00f86ca192 | |||
| b0868e7571 | |||
| 9ec3ff26d8 | |||
| 726e1d4ec5 | |||
| 5b3c5f5da8 | |||
| c45f6be8f2 |
@@ -18,6 +18,19 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: game
|
||||
attributes:
|
||||
label: Which game were you playing
|
||||
description: Pick every version you saw the bug in.
|
||||
multiple: true
|
||||
options:
|
||||
- Red
|
||||
- Blue
|
||||
- Yellow
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
@@ -31,6 +44,26 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: mods_enabled
|
||||
attributes:
|
||||
label: Were any mods on
|
||||
description: Check the MODS tab in the launcher if you're not sure.
|
||||
options:
|
||||
- "No"
|
||||
- "Yes"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: mods_which
|
||||
attributes:
|
||||
label: Which mods (if any were on)
|
||||
description: List the enabled mods. Leave blank if none were on.
|
||||
placeholder: nuzlocke 1.0.0, running-shoes 0.3
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
|
||||
@@ -11,7 +11,7 @@ body:
|
||||
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
|
||||
could ship — running shoes, alternate sprites, day/night, shiny indicators,
|
||||
Gen 2-like battle toggles, soundtrack packs — open a
|
||||
**[Mod request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=mod_request.yml)**
|
||||
**[Mod request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=mod_request.yml)**
|
||||
instead.
|
||||
|
||||
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
|
||||
@@ -26,6 +26,20 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: game
|
||||
attributes:
|
||||
label: Which game is this about
|
||||
description: Pick every version it applies to.
|
||||
multiple: true
|
||||
options:
|
||||
- Red
|
||||
- Blue
|
||||
- Yellow
|
||||
- Not version-specific
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: discord
|
||||
attributes:
|
||||
|
||||
@@ -10,7 +10,7 @@ body:
|
||||
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
|
||||
|
||||
The engine already exposes a lot of this through registries and hooks
|
||||
([modding wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki)).
|
||||
([modding wiki](https://github.com/bryanthaboi/gen1recomp/wiki)).
|
||||
Use a **Feature request** instead for launcher / ports / video options /
|
||||
networking / save tooling / new API seams.
|
||||
|
||||
@@ -23,6 +23,20 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: game
|
||||
attributes:
|
||||
label: Which game is this for
|
||||
description: Pick every version the mod should cover.
|
||||
multiple: true
|
||||
options:
|
||||
- Red
|
||||
- Blue
|
||||
- Yellow
|
||||
- Not version-specific
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: discord
|
||||
attributes:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
name: Release
|
||||
|
||||
# Builds the macOS, Windows, and Linux desktop apps plus an Android APK
|
||||
# on the self-hosted Mac runner, and publishes them as a GitHub Release.
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
|
||||
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
|
||||
# Mac runner, and publishes them as a GitHub Release.
|
||||
#
|
||||
# Versioning:
|
||||
# - First ever release is 0.1.0.
|
||||
@@ -169,6 +170,13 @@ jobs:
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
|
||||
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
||||
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Notarize & staple macOS app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -218,6 +226,12 @@ jobs:
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
|
||||
# Anbernic handheld port (suffix names the CFW it targets, so a
|
||||
# future RG35XX/other-CFW pack can ship alongside it).
|
||||
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
||||
[ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; }
|
||||
cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||
|
||||
# Platform-independent update payload, built alongside the desktop
|
||||
# apps above (same game.love that gets fused into each of them).
|
||||
love_file=".bazinga/work/game.love"
|
||||
@@ -240,19 +254,37 @@ jobs:
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
tag="${{ steps.ver.outputs.tag }}"
|
||||
|
||||
# Issues this release closes, per GitHub's own "closing issues" links
|
||||
# (works for squash, rebase, and merge commits alike). Scans every
|
||||
# commit since the previous tag so a skipped release run doesn't drop
|
||||
# issues on the floor.
|
||||
# Issues this release closes. Three sources, deduped by number:
|
||||
# 1. GitHub's own "closing issues" links on every PR whose
|
||||
# commits are in the range (works for squash, rebase, and
|
||||
# merge commits alike) -- including PRs that were merged
|
||||
# into a branch this release PR is itself merging in.
|
||||
# 2. CLOSES/fixes/resolves text in those PRs' titles + bodies
|
||||
# that GitHub didn't turn into a closing link (a PR into a
|
||||
# non-default branch never gets one).
|
||||
# 3. The same text in raw commit messages, so a direct push
|
||||
# with "CLOSES #N #M" still lands in the notes.
|
||||
# Scans every commit since the previous tag so a skipped release
|
||||
# run doesn't drop issues on the floor.
|
||||
prev_tag="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${tag}$" | head -1 || true)"
|
||||
range="${prev_tag:+${prev_tag}..}$GITHUB_SHA"
|
||||
|
||||
closed=""
|
||||
for pr in $(git log --pretty=%H "$range" \
|
||||
| xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \
|
||||
--jq '.[].number' 2>/dev/null \
|
||||
| sort -un || true); do
|
||||
closed+="$(gh api graphql \
|
||||
# every #N on a line that carries a closing keyword (handles the
|
||||
# multi-issue "CLOSES #1 #2 #3" form the tracker uses)
|
||||
scan_closes() {
|
||||
grep -iE '\b(close[sd]?|fix(es|ed)?|resolve[sd]?)\b' \
|
||||
| grep -oE '#[0-9]+' | tr -d '#' || true
|
||||
}
|
||||
|
||||
nums=""
|
||||
nums+=" $(git log --pretty=%B "$range" | scan_closes | tr '\n' ' ')"
|
||||
|
||||
prs="$(git log --pretty=%H "$range" \
|
||||
| xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \
|
||||
--jq '.[].number' 2>/dev/null \
|
||||
| sort -un || true)"
|
||||
for pr in $prs; do
|
||||
nums+=" $(gh api graphql \
|
||||
-f owner="${GITHUB_REPOSITORY%/*}" \
|
||||
-f name="${GITHUB_REPOSITORY#*/}" \
|
||||
-F pr="$pr" \
|
||||
@@ -260,19 +292,48 @@ jobs:
|
||||
query($owner:String!, $name:String!, $pr:Int!) {
|
||||
repository(owner:$owner, name:$name) {
|
||||
pullRequest(number:$pr) {
|
||||
closingIssuesReferences(first:50) { nodes { number title } }
|
||||
closingIssuesReferences(first:50) { nodes { number } }
|
||||
}
|
||||
}
|
||||
}' \
|
||||
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[]
|
||||
| "- #\(.number) \(.title)"' 2>/dev/null || true)"$'\n'
|
||||
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[].number' \
|
||||
2>/dev/null | grep -E '^[0-9]+$' | tr '\n' ' ' || true)"
|
||||
nums+=" $(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr" \
|
||||
--jq '.title + " " + (.body // "")' 2>/dev/null \
|
||||
| scan_closes | tr '\n' ' ' || true)"
|
||||
done
|
||||
|
||||
# dedupe, drop anything that is a PR or does not exist, keep
|
||||
# titles (gh api prints the response body to stdout on an HTTP
|
||||
# error, so only a zero exit counts)
|
||||
closed=""
|
||||
for n in $(printf '%s' "$nums" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -un); do
|
||||
if title="$(gh api "repos/$GITHUB_REPOSITORY/issues/$n" \
|
||||
--jq 'if .pull_request then empty else .title end' \
|
||||
2>/dev/null)"; then
|
||||
[ -n "$title" ] && closed+="- #$n $title"$'\n'
|
||||
fi
|
||||
done
|
||||
closed="$(printf '%s' "$closed" | grep . | sort -t'#' -k2 -n || true)"
|
||||
|
||||
# Everyone whose commits are in the range: GitHub login when the
|
||||
# commit is linked to an account, the raw git author name when not;
|
||||
# CI bots filtered out.
|
||||
base="${prev_tag:-$(git rev-list --max-parents=0 "$GITHUB_SHA" | tail -1)}"
|
||||
contributors="$(gh api --paginate \
|
||||
"repos/$GITHUB_REPOSITORY/compare/${base}...${GITHUB_SHA}" \
|
||||
--jq '.commits[] | if .author and .author.login
|
||||
then "@" + .author.login
|
||||
else .commit.author.name end' 2>/dev/null \
|
||||
| grep -viE '\[bot\]$' | sort -uf | sed 's/^/- /' || true)"
|
||||
|
||||
notes="Download the correct version for your computer below."
|
||||
if [ -n "$closed" ]; then
|
||||
notes+=$'\n\n## Issues closed\n\n'"$closed"
|
||||
fi
|
||||
if [ -n "$contributors" ]; then
|
||||
notes+=$'\n\n## Contributors\n\n'"$contributors"
|
||||
fi
|
||||
printf 'Release notes:\n%s\n' "$notes"
|
||||
|
||||
gh release create "$tag" \
|
||||
@@ -283,6 +344,7 @@ jobs:
|
||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||
"dist/release/gen1recomp-${v}-android.apk" \
|
||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
|
||||
"dist/release/gen1recomp-${v}.love" \
|
||||
"dist/release/sha256sums.txt"
|
||||
|
||||
|
||||
@@ -35,3 +35,7 @@ mobile/ios/build/
|
||||
|
||||
# Legacy manual convenience-copy location (superseded by /dist/android/)
|
||||
mobile/dist/
|
||||
|
||||
# Tiled map-editing workspace (tools/tiled_export.py). Derived from the imported
|
||||
# ROM cache exactly like data/generated/, so it is never committable.
|
||||
/build/tiled/
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# Pokemon Gen 1 Recompilation Project
|
||||
# Gen1Recomp
|
||||
|
||||
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
|
||||
A native LÖVE2D recreation of Poke Red and Blue. The engine and map
|
||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||
supplied by the player.
|
||||
|
||||
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
|
||||
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
|
||||
game content input.
|
||||
|
||||
The ROM is verified, used during import, and then released from memory. It is
|
||||
@@ -33,46 +33,82 @@ audio channel programs copied out of the verified ROM.
|
||||
|
||||
## Controls
|
||||
|
||||
| Action | Keyboard | Controller |
|
||||
|--------|----------|------------|
|
||||
| Move | Arrow keys / WASD | D-pad / left stick |
|
||||
| A | Z / Enter / Space | A |
|
||||
| B | X / Backspace | B |
|
||||
| Start | Escape | Start |
|
||||
| Select | Tab / Shift | Back / Select |
|
||||
|
||||
| Action | Keyboard | Controller |
|
||||
| ------ | ----------------- | ------------------ |
|
||||
| Move | Arrow keys / WASD | D-pad / left stick |
|
||||
| A | Z / Enter / Space | A |
|
||||
| B | X / Backspace | B |
|
||||
| Start | Escape | Start |
|
||||
| Select | Tab / Shift | Back / Select |
|
||||
|
||||
|
||||
Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are
|
||||
supported out of the box.
|
||||
|
||||
### Hotkeys
|
||||
|
||||
| Key | What it does |
|
||||
|-----|----------------|
|
||||
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
| `5` | Cycle GBC FX |
|
||||
| `F1` | Save |
|
||||
| `F2` | Load |
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
| Key | What it does |
|
||||
| --------- | ---------------------------------------------------- |
|
||||
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
| `5` | Cycle GBC FX |
|
||||
| `F1` | Save |
|
||||
| `F2` | Load |
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
|
||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||
and persist in `options.lua`.
|
||||
|
||||
### Rulesets
|
||||
|
||||
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
|
||||
Both rulesets share the same damage formulas; they differ only in whether
|
||||
the original's quirks are kept. The setting persists in `options.lua`, and
|
||||
mods can register their own.
|
||||
|
||||
`gen1_faithful` is the default and reproduces the original cartridge,
|
||||
famous bugs included:
|
||||
|
||||
| Rule | Behavior |
|
||||
| --------------------------- | ----------------------------------------------------- |
|
||||
| `oneIn256Miss` | A 100%-accurate move still misses on a roll of 255 |
|
||||
| `critUsesBaseSpeed` | Crit rate reads base speed, not the current stat |
|
||||
| `critIgnoresStages` | Crit rate ignores stat stages |
|
||||
| `focusEnergyBug` | FOCUS ENERGY quarters the crit rate instead of x4 |
|
||||
| `enemyUnlimitedPP` | Enemies never spend PP, so they never Struggle |
|
||||
| `hyperBeamSkipRechargeOnKO` | HYPER BEAM skips its recharge when the target faints |
|
||||
| `randMin` / `randMax` | Damage random factor 217-255 |
|
||||
|
||||
`modern_clean` keeps the formulas but removes the notorious quirks:
|
||||
|
||||
| Rule | Behavior |
|
||||
| --------------------------- | ----------------------------------------------------- |
|
||||
| `oneIn256Miss` | Off: a 100%-accurate move always hits |
|
||||
| `critUsesBaseSpeed` | Unchanged: crit rate still reads base speed |
|
||||
| `critIgnoresStages` | Off: stat stages count toward the crit rate |
|
||||
| `focusEnergyBug` | Off: FOCUS ENERGY raises the crit rate as intended |
|
||||
| `enemyUnlimitedPP` | Off: enemies deplete PP and Struggle when empty |
|
||||
| `hyperBeamSkipRechargeOnKO` | Off: HYPER BEAM always recharges, like Gen 2+ |
|
||||
| `randMin` / `randMax` | Damage random factor 217-255, same as faithful |
|
||||
|
||||
## Running From Source
|
||||
|
||||
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
|
||||
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||
|
||||
```sh
|
||||
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
|
||||
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
|
||||
scripts/run.sh
|
||||
```
|
||||
|
||||
then `love .` for later launches. Windows PowerShell scripts, the optional
|
||||
developer data build, test suites, and cache management are covered in
|
||||
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
|
||||
[Developer Setup](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Developer-Setup).
|
||||
|
||||
## Portable Mode
|
||||
|
||||
@@ -80,7 +116,7 @@ By default the game keeps your save, options, and the private ROM-derived
|
||||
data cache in your OS's normal per-user app data folder. To keep everything
|
||||
next to the game instead (handy for a USB stick or portable drive you carry
|
||||
between computers), drop an empty file named `portable.txt` next to the app
|
||||
(next to `PokemonRed.app`/`.exe`, or next to `main.lua`/`conf.lua` when
|
||||
(next to `gen1recomp.app`/`.exe`, or next to `main.lua`/`conf.lua` when
|
||||
running from source), then launch the game. Portable mode is desktop-only
|
||||
(Windows, Linux, macOS); it has no effect on Android or iOS, where the app
|
||||
runs from a read-only package.
|
||||
@@ -88,14 +124,21 @@ runs from a read-only package.
|
||||
With `portable.txt` present:
|
||||
|
||||
- `save.lua`, `save.lua.bak`, and `options.lua` are read from and written to
|
||||
that same folder instead of the OS save directory.
|
||||
that same folder instead of the OS save directory.
|
||||
- A ROM import writes the generated `data/generated` and `assets/generated`
|
||||
cache straight into that folder too (nothing is left in the OS save
|
||||
directory), so a later launch reuses it without asking for the ROM again
|
||||
even on a different computer, as long as the same folder comes along.
|
||||
cache straight into that folder too (nothing is left in the OS save
|
||||
directory), so a later launch reuses it without asking for the ROM again
|
||||
even on a different computer, as long as the same folder comes along.
|
||||
- Deleting `portable.txt` switches back to the normal OS save directory; nothing
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
|
||||
## Handhelds
|
||||
|
||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||
ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`.
|
||||
Install steps, controls, and troubleshooting live in
|
||||
[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md).
|
||||
|
||||
## Modding
|
||||
|
||||
@@ -103,36 +146,43 @@ The game ships a native mod platform: content registries, events and hooks,
|
||||
per-mod saves and options, and an in-game manager. The full modding book —
|
||||
getting started, a twelve-rung tutorial ladder, a cookbook, and the generated
|
||||
reference — lives on the
|
||||
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
|
||||
[project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
|
||||
|
||||
Shipped example mods, one per kind of author, live in [`mods/`](mods/).
|
||||
Shipped example mods, one per kind of author, live in `[mods/](mods/)`.
|
||||
|
||||
Maps can be edited in our own build of [Tiled](https://www.mapeditor.org),
|
||||
[bryanthaboi/tiled_gen1recomp](https://github.com/bryanthaboi/tiled_gen1recomp/releases),
|
||||
and exported back out as a mod; see
|
||||
[docs/tiled-map-editing.md](docs/tiled-map-editing.md).
|
||||
|
||||
## Bugs and Ideas
|
||||
|
||||
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
|
||||
that looks wrong, text in the wrong box, anything that does not match the
|
||||
original game.
|
||||
[Open a bug report](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=bug_report.yml).
|
||||
[Open a bug report](https://github.com/bryanthaboi/gen1recomp/issues/new?template=bug_report.yml).
|
||||
Attach a screenshot if you can. It saves a lot of back and forth, and if you
|
||||
can't get one, the form asks you to describe what you saw instead.
|
||||
|
||||
Thought of a feature that could be good, or a way to improve one that already
|
||||
exists?
|
||||
[Open a feature request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=feature_request.yml).
|
||||
[Open a feature request](https://github.com/bryanthaboi/gen1recomp/issues/new?template=feature_request.yml).
|
||||
Say what you want, why it is worth doing, and how you picture it working. A
|
||||
request with real detail is one that can actually get built.
|
||||
|
||||
## More
|
||||
|
||||
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play)
|
||||
— START > LINK connects two copies directly over UDP.
|
||||
- [Save editor](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Save-Editor)
|
||||
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
||||
- [Link play](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Link-Play)
|
||||
— START > LINK connects two copies directly over UDP.
|
||||
- [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
|
||||
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
||||
- `docs/architecture.md` — runtime details;
|
||||
`docs/behavior-porting-notes.md` — formula provenance.
|
||||
`docs/behavior-porting-notes.md` — formula provenance.
|
||||
|
||||
|
||||
|
||||
## Special Thanks
|
||||
|
||||
This project would not be possible without [pret](https://github.com/pret) >
|
||||
the pret band of decompiling maniacs > and their
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
Executable
+333
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a PortMaster-style aarch64 port of gen1recomp for Anbernic RG34XXSP
|
||||
# on Stock OS 64-bit MOD (H700; cbepx-me StockOS MOD / official V1.0.6 base
|
||||
# + PortMaster).
|
||||
#
|
||||
# The stock Anbernic firmware resolves ports relative to the launch script
|
||||
# (roms/PORTS/...), not PortMaster's /$directory/ports/... path. This pack
|
||||
# uses SHDIR-relative paths and bundles the LÖVE 11.5 aarch64 runtime so the
|
||||
# device does not need a separate love_11.5 runtime download on first launch.
|
||||
#
|
||||
# Usage:
|
||||
# ./build-rg34xxsp.sh [--version X.Y.Z]
|
||||
#
|
||||
# Output:
|
||||
# dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
|
||||
#
|
||||
# Install on device:
|
||||
# 1. Flash / run Stock OS 64-bit MOD with PortMaster installed.
|
||||
# 2. Unzip into the SD card's roms/PORTS/ folder so you have:
|
||||
# roms/PORTS/Gen1recomp.sh
|
||||
# roms/PORTS/gen1recomp/...
|
||||
# 3. Copy a legal US Red or Blue .gb into roms/PORTS/gen1recomp/lovegame/
|
||||
# 4. Launch "Gen1recomp" from the Ports list; press Choose ROM (scans that
|
||||
# folder when zenity is missing).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
HERE="$ROOT/.bazinga"
|
||||
CACHE="$HERE/cache/rg34xxsp"
|
||||
WORK="$HERE/work/rg34xxsp"
|
||||
DIST="$ROOT/dist/rg34xxsp"
|
||||
|
||||
APP_NAME="gen1recomp"
|
||||
# Artifact suffix matches the CFW this port targets (Anbernic H700 Stock OS
|
||||
# 64-bit MOD for RG34XXSP). Release uploads stage it as
|
||||
# gen1recomp-<ver>-rg34xxsp-stockos64-mod.zip.
|
||||
ARTIFACT_SUFFIX="rg34xxsp-stockos64-mod"
|
||||
PORT_DIR_NAME="gen1recomp"
|
||||
LAUNCHER_NAME="Gen1recomp.sh"
|
||||
LOVE_VERSION="11.5"
|
||||
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||
|
||||
# Official PortMaster LÖVE 11.5 aarch64 runtime (small love stub + liblove).
|
||||
PM_RUNTIME_BASE="https://raw.githubusercontent.com/PortsMaster/PortMaster-GUI/main/PortMaster/runtimes/love_${LOVE_VERSION}"
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) VERSION="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,24p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
command -v curl >/dev/null || fail "curl is required"
|
||||
command -v zip >/dev/null || fail "zip is required"
|
||||
command -v unzip >/dev/null || fail "unzip is required"
|
||||
|
||||
mkdir -p "$CACHE" "$WORK" "$DIST"
|
||||
|
||||
download() {
|
||||
local url="$1" dest="$2"
|
||||
if [ -f "$dest" ] && [ -s "$dest" ]; then
|
||||
return 0
|
||||
fi
|
||||
say "downloading $(basename "$dest")"
|
||||
curl -fL --progress-bar "$url" -o "$dest.tmp" \
|
||||
|| fail "download failed: $url"
|
||||
mv "$dest.tmp" "$dest"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- game tree
|
||||
# Unpacked directory (not a .love zip) so the player can drop a .gb next to
|
||||
# main.lua and RomImporter can scan it without zenity/kdialog.
|
||||
say "staging lovegame/"
|
||||
GAME_SRC="$WORK/lovegame"
|
||||
rm -rf "$GAME_SRC"
|
||||
mkdir -p "$GAME_SRC"
|
||||
|
||||
# Same payload as scripts/build.sh's game.love — never ship ROM-derived cache.
|
||||
# tools/save-editor is part of that payload: the launcher's Edit button on a
|
||||
# save row opens it in-process (main.lua).
|
||||
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "payload unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||
rm -f "$WORK/game-payload.zip"
|
||||
|
||||
# Stamp release version into the staged tree only (never the working tree).
|
||||
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
say "stamping engine version $VERSION"
|
||||
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
|
||||
"$ROOT/src/core/Version.lua" > "$GAME_SRC/src/core/Version.lua"
|
||||
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||
grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||
"$GAME_SRC/src/core/Version.lua" \
|
||||
|| fail "version stamp failed"
|
||||
else
|
||||
say "version '$VERSION' is not X.Y.Z — shipping default engine (no stamp)"
|
||||
fi
|
||||
|
||||
# Portable marker: saves + ROM cache live next to the game on the SD card.
|
||||
: > "$GAME_SRC/portable.txt"
|
||||
|
||||
# --------------------------------------------------------------- love runtime
|
||||
say "fetching LÖVE $LOVE_VERSION aarch64 runtime"
|
||||
LOVE_BIN="$CACHE/love.aarch64"
|
||||
LOVE_LIB="$CACHE/liblove-11.5.so"
|
||||
LUAJIT_LIB="$CACHE/libluajit-5.1.so.2"
|
||||
MODPLUG_LIB="$CACHE/libmodplug.so.1"
|
||||
OGG_LIB="$CACHE/libogg.so.0"
|
||||
|
||||
download "$PM_RUNTIME_BASE/love.aarch64" "$LOVE_BIN"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/liblove-11.5.so" "$LOVE_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libluajit-5.1.so.2" "$LUAJIT_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libmodplug.so.1" "$MODPLUG_LIB"
|
||||
download "$PM_RUNTIME_BASE/libs.aarch64/libogg.so.0" "$OGG_LIB"
|
||||
|
||||
# Sanity: love stub must be an aarch64 ELF.
|
||||
file "$LOVE_BIN" | grep -qi 'aarch64\|ARM aarch64' \
|
||||
|| fail "love.aarch64 does not look like an aarch64 ELF (got: $(file "$LOVE_BIN"))"
|
||||
|
||||
# --------------------------------------------------------------- port tree
|
||||
say "assembling port package"
|
||||
PORT_ROOT="$WORK/port"
|
||||
rm -rf "$PORT_ROOT"
|
||||
mkdir -p "$PORT_ROOT/$PORT_DIR_NAME/bin" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/licenses" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/conf"
|
||||
|
||||
cp -R "$GAME_SRC" "$PORT_ROOT/$PORT_DIR_NAME/lovegame"
|
||||
cp "$LOVE_BIN" "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
|
||||
|
||||
# Drop a short license pointer for the bundled LÖVE bits.
|
||||
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
|
||||
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
|
||||
(https://github.com/PortsMaster/PortMaster-GUI). LÖVE is zlib-licensed;
|
||||
see https://love2d.org/ for full terms.
|
||||
EOF
|
||||
|
||||
# --------------------------------------------------------------- launcher
|
||||
# Stock OS fix: resolve GAMEDIR from this script's directory (SHDIR), not
|
||||
# PortMaster's $directory variable — Anbernic uses roms/PORTS with different
|
||||
# casing and mount points (mmc / sdcard / TF1 / TF2).
|
||||
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
|
||||
#!/bin/bash
|
||||
# gen1recomp — Anbernic RG34XXSP stock OS / PortMaster launcher
|
||||
# Uses SHDIR-relative paths so stock firmware finds the game folder.
|
||||
|
||||
export HOME="${HOME:-/root}"
|
||||
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
|
||||
if [ -d "/opt/system/Tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/system/Tools/PortMaster"
|
||||
elif [ -d "/opt/tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/tools/PortMaster"
|
||||
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
|
||||
controlfolder="$XDG_DATA_HOME/PortMaster"
|
||||
elif [ -d "/mnt/mmc/Roms/PORTS/PortMaster" ]; then
|
||||
controlfolder="/mnt/mmc/Roms/PORTS/PortMaster"
|
||||
elif [ -d "/mnt/sdcard/Roms/PORTS/PortMaster" ]; then
|
||||
controlfolder="/mnt/sdcard/Roms/PORTS/PortMaster"
|
||||
elif [ -d "/roms/ports/PortMaster" ]; then
|
||||
controlfolder="/roms/ports/PortMaster"
|
||||
else
|
||||
controlfolder="/roms/PORTS/PortMaster"
|
||||
fi
|
||||
|
||||
SHDIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck disable=SC1090
|
||||
source "$controlfolder/control.txt"
|
||||
get_controls
|
||||
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||
|
||||
GAMEDIR="$SHDIR/gen1recomp"
|
||||
CONFDIR="$GAMEDIR/conf"
|
||||
mkdir -p "$CONFDIR"
|
||||
|
||||
cd "$GAMEDIR" || exit 1
|
||||
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
|
||||
|
||||
export XDG_DATA_HOME="$CONFDIR"
|
||||
export XDG_CONFIG_HOME="$CONFDIR"
|
||||
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
|
||||
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
||||
# Mali / H700: prefer GLES where available
|
||||
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
||||
# Same GPU class as a phone, but getOS() here says "Linux", so the Android
|
||||
# gate (issue #136) would not fire on its own: refuse GBC FX explicitly.
|
||||
# Hides the OPTIONS row, pins the level to OFF, and heals a level already
|
||||
# persisted in options.lua.
|
||||
export POKEPORT_GBCFX="${POKEPORT_GBCFX:-0}"
|
||||
|
||||
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
|
||||
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||
|
||||
if [ -n "${GPTOKEYB:-}" ]; then
|
||||
$GPTOKEYB "love.aarch64" &
|
||||
fi
|
||||
if type pm_platform_helper >/dev/null 2>&1; then
|
||||
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
|
||||
fi
|
||||
|
||||
./bin/love.aarch64 "$GAMEDIR/lovegame"
|
||||
|
||||
if type pm_finish >/dev/null 2>&1; then
|
||||
pm_finish
|
||||
else
|
||||
if [ -n "${ESUDO:-}" ]; then
|
||||
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
else
|
||||
kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
EOF
|
||||
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
|
||||
|
||||
# --------------------------------------------------------------- metadata
|
||||
cat > "$PORT_ROOT/port.json" <<EOF
|
||||
{
|
||||
"version": 2,
|
||||
"name": "gen1recomp.zip",
|
||||
"items": [
|
||||
"$LAUNCHER_NAME",
|
||||
"$PORT_DIR_NAME"
|
||||
],
|
||||
"items_opt": null,
|
||||
"attr": {
|
||||
"title": "gen1recomp",
|
||||
"desc": "Native LÖVE2D recreation of Pokemon Red and Blue. Supply your own legal US Red or Blue ROM.",
|
||||
"inst": "Requires Anbernic RG34XXSP Stock OS 64-bit MOD with PortMaster. Copy a canonical US Red or Blue .gb into gen1recomp/lovegame/, then launch and press Choose ROM.",
|
||||
"genres": ["adventure", "rpg"],
|
||||
"porter": ["gen1recomp"],
|
||||
"image": {},
|
||||
"rtr": true,
|
||||
"runtime": null,
|
||||
"reqs": [],
|
||||
"arch": ["aarch64"]
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > "$PORT_ROOT/gameinfo.xml" <<EOF
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<gameList>
|
||||
<game>
|
||||
<path>./$LAUNCHER_NAME</path>
|
||||
<name>gen1recomp</name>
|
||||
<desc>Native LÖVE2D recreation of Pokemon Red and Blue. Requires your own legal US Red or Blue ROM.</desc>
|
||||
<releasedate>20250101T000000</releasedate>
|
||||
<developer>the bois club</developer>
|
||||
<publisher>the bois club</publisher>
|
||||
<genre>RPG</genre>
|
||||
</game>
|
||||
</gameList>
|
||||
EOF
|
||||
|
||||
cat > "$PORT_ROOT/README.md" <<'EOF'
|
||||
## gen1recomp (RG34XXSP / Stock OS 64-bit MOD)
|
||||
|
||||
Native LÖVE 11.5 port of gen1recomp for Anbernic RG34XXSP on
|
||||
**Stock OS 64-bit MOD** (H700, PortMaster).
|
||||
|
||||
### Install
|
||||
|
||||
1. Use **Stock OS 64-bit MOD** with PortMaster installed (TF1).
|
||||
2. Unzip so `Gen1recomp.sh` and the `gen1recomp/` folder sit in `roms/PORTS/`.
|
||||
3. Copy a legal US Pokemon Red or Blue `.gb` into `gen1recomp/lovegame/`.
|
||||
4. Refresh Ports / restart EmulationStation and launch **Gen1recomp**.
|
||||
|
||||
### Controls (launcher)
|
||||
|
||||
| Input | Action |
|
||||
|--|--|
|
||||
| D-pad / left stick | Move cursor |
|
||||
| A | Click |
|
||||
| L1 / R1 | Switch tabs |
|
||||
| Right stick | Scroll lists |
|
||||
| Start / Select | Play or Choose ROM |
|
||||
|
||||
In-game controls use the normal PortMaster / SDL pad map (rebind under OPTIONS → CONTROLS).
|
||||
|
||||
### Display options
|
||||
|
||||
GBC FX is disabled on this device (the H700's Mali GPU compiles that present
|
||||
pass and then shows a black frame), so the OPTIONS row is hidden. COLORS,
|
||||
TILT, ZOOM, VOID FILL and MAX FPS all work. To try it anyway, launch with
|
||||
`POKEPORT_GBCFX=1`.
|
||||
|
||||
### First run
|
||||
|
||||
Stock OS has no zenity file picker. Put the `.gb` in `lovegame/`, then press
|
||||
**Choose ROM** — the game scans that folder. After import, the ROM-derived
|
||||
cache and saves stay beside the game (`portable.txt`).
|
||||
|
||||
Canonical SHA-1 (1 MiB US carts only):
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
|
||||
### Thanks
|
||||
|
||||
LÖVE runtime binaries from [PortMaster](https://portmaster.games/).
|
||||
Stock OS 64-bit MOD: [cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
|
||||
EOF
|
||||
|
||||
# --------------------------------------------------------------- zip
|
||||
ZIP_OUT="$DIST/$APP_NAME-$ARTIFACT_SUFFIX.zip"
|
||||
rm -f "$ZIP_OUT" "$DIST/$APP_NAME-rg34xxsp.zip"
|
||||
say "packing $ZIP_OUT"
|
||||
(cd "$PORT_ROOT" && zip -q -9 -r "$ZIP_OUT" \
|
||||
"$LAUNCHER_NAME" "$PORT_DIR_NAME" port.json gameinfo.xml README.md)
|
||||
|
||||
say "done."
|
||||
say "artifact: $ZIP_OUT ($(du -h "$ZIP_OUT" | cut -f1))"
|
||||
say "copy onto the RG34XXSP SD card under roms/PORTS/ (Stock OS 64-bit MOD), then drop your .gb into gen1recomp/lovegame/"
|
||||
@@ -13,7 +13,12 @@ function love.conf(t)
|
||||
_G.POKEPORT_DEV_MODE = developer
|
||||
|
||||
if editor then
|
||||
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
|
||||
-- Same identity as the game, deliberately: the editor edits the game's
|
||||
-- saves and reads the game's ROM cache, both of which live under this
|
||||
-- folder. A private editor identity would point love.filesystem at an
|
||||
-- empty directory in a packaged build, so `--editor` could not find
|
||||
-- data/generated at all (SaveIO.defaultPath already assumed this name).
|
||||
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d"
|
||||
t.window.title = "Pokemon Save Editor"
|
||||
t.window.width = 1280
|
||||
t.window.height = 800
|
||||
@@ -30,6 +35,14 @@ function love.conf(t)
|
||||
-- starting size, not the game's resolution.
|
||||
t.window.width = 1024
|
||||
t.window.height = 768
|
||||
-- Floor for the resizable desktop window. The launcher's single-column
|
||||
-- layout is laid out against ~420 logical px of content, and the game
|
||||
-- canvas letterboxes fine below that, so this only stops a drag that would
|
||||
-- squeeze the cards past the point where their buttons still read. Kept
|
||||
-- well under the smallest supported desktop display; mobile ignores it
|
||||
-- (fullscreen), so the handheld ports are unaffected.
|
||||
t.window.minwidth = 480
|
||||
t.window.minheight = 360
|
||||
end
|
||||
t.version = "11.5"
|
||||
t.window.vsync = 1
|
||||
|
||||
@@ -1,33 +1,88 @@
|
||||
-- Celadon Mansion 3F Game Designer (pokered/scripts/CeladonMansion3F.asm
|
||||
-- CeladonMansion3FGameDesignerText): text_asm counts set bits in
|
||||
-- wPokedexOwned and compares against NUM_POKEMON - 1 (discounts Mew).
|
||||
-- If the player owns >= 150 species, shows the "completed" text
|
||||
-- (originally followed by DisplayDiploma, which has no equivalent UI
|
||||
-- in this port, so we just show the congratulatory text); otherwise
|
||||
-- shows the normal encouragement text.
|
||||
-- Celadon Mansion 3F, the GAME FREAK dev floor
|
||||
-- (pokered+pokeyellow/scripts/CeladonMansion3F.asm). Every dev's
|
||||
-- text_asm counts set bits in wPokedexOwned against NUM_POKEMON - 1
|
||||
-- (150, discounting Mew). The game designer shows the diploma
|
||||
-- (DisplayDiploma -> src/ui/Diploma.lua) on a completed dex; Yellow's
|
||||
-- graphic artist then offers the printed copy (PrintDiploma, stood in
|
||||
-- by src/core/Printer.lua's PNG export like the Pokédex PRNT item).
|
||||
|
||||
local function ownedCount(game)
|
||||
local dex = game.save.pokedex
|
||||
local owned = 0
|
||||
if dex and dex.owned then
|
||||
for _ in pairs(dex.owned) do owned = owned + 1 end
|
||||
end
|
||||
return owned
|
||||
end
|
||||
|
||||
return {
|
||||
CELADON_MANSION_3F = {
|
||||
talk = {
|
||||
TEXT_CELADONMANSION3F_GAME_DESIGNER = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
local dex = game.save.pokedex
|
||||
local owned = 0
|
||||
if dex and dex.owned then
|
||||
for _ in pairs(dex.owned) do
|
||||
owned = owned + 1
|
||||
end
|
||||
end
|
||||
-- NUM_POKEMON - 1 = 150 (discounts Mew, per pokered)
|
||||
local label, fallback
|
||||
if owned >= 150 then
|
||||
label, fallback = "_CeladonMansion3FGameDesignerCompletedDexText",
|
||||
"Wow! Excellent!\nYou completed\nyour POKeDEX!\nCongratulations!"
|
||||
else
|
||||
label, fallback = "_CeladonMansion3FGameDesignerText",
|
||||
"Is that right?\nI'm the game\ndesigner!\nFilling up your\nPOKeDEX is tough,\nbut don't quit!\nWhen you finish,\ncome tell me!"
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, t[label] or fallback, done))
|
||||
if ownedCount(game) < 150 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._CeladonMansion3FGameDesignerText
|
||||
or "Is that right?\nI'm the game\ndesigner!\fFilling up your\nPOKéDEX is tough,\nbut don't quit!",
|
||||
done))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._CeladonMansion3FGameDesignerCompletedDexText
|
||||
or "Wow! Excellent!\nYou completed\nyour POKéDEX!\nCongratulations!",
|
||||
function()
|
||||
local Diploma = require("src.ui.Diploma")
|
||||
game.stack:push(Diploma.new(game, function()
|
||||
-- Yellow tags on the unlocked-printing line (CompletedDexText2)
|
||||
local after = require("src.core.GameVersion").isYellow()
|
||||
and (t._CeladonMansion3FGameDesignerCompletedDexText2
|
||||
or "You can print out\nyour diploma with\nthe GAME BOY\nPrinter!")
|
||||
or nil
|
||||
if after then
|
||||
game.stack:push(TextBox.new(game, after, done))
|
||||
else
|
||||
done()
|
||||
end
|
||||
end))
|
||||
end))
|
||||
end,
|
||||
|
||||
TEXT_CELADONMANSION3F_GRAPHIC_ARTIST = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local yellow = require("src.core.GameVersion").isYellow()
|
||||
if not (yellow and ownedCount(game) >= 150) then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._CeladonMansion3FGraphicArtistText
|
||||
or "I'm the graphic\nartist!\nI drew you!", done))
|
||||
return
|
||||
end
|
||||
-- _CeladonMansion3FGraphicArtistText2: offer to print the diploma
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._CeladonMansion3FGraphicArtistText2
|
||||
or "I'm the graphic\nartist!\fShould I print\nyour diploma?",
|
||||
function()
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._CeladonMansion3FGraphicArtistText3
|
||||
or "Oh. But it's a\nspecial diploma!", done))
|
||||
return
|
||||
end
|
||||
local Printer = require("src.core.Printer")
|
||||
local Diploma = require("src.ui.Diploma")
|
||||
local Strings = require("src.core.Strings")
|
||||
local saved, err = Printer.save("diploma", 160, 144, function()
|
||||
Diploma.render(game)
|
||||
end)
|
||||
-- the PRNT stand-in always reports where the PNG landed
|
||||
game.stack:push(TextBox.new(game, saved
|
||||
and Strings("There you go!\fSaved as\n%s\vin the save\nfolder.", saved)
|
||||
or Strings("Printer error!\n%s", tostring(err)), done))
|
||||
end))
|
||||
end))
|
||||
end,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -28,7 +28,29 @@ local function badgeGuard(badge, passFlag)
|
||||
}
|
||||
end
|
||||
|
||||
-- Route23SetVictoryRoadBoulders (pokered scripts/Route23.asm:8): every entry
|
||||
-- to Route 23 resets the Victory Road boulder puzzle behind you. The 2F/3F
|
||||
-- switch events clear (so those barriers are closed again on the next floor
|
||||
-- load) and the boulder that fell through 3F's hole goes back upstairs:
|
||||
-- ShowObject TOGGLE_VICTORY_ROAD_3F_BOULDER / HideObject
|
||||
-- TOGGLE_VICTORY_ROAD_2F_BOULDER, which are VICTORYROAD3F_BOULDER4 and
|
||||
-- VICTORYROAD2F_BOULDER3 (data/maps/toggleable_objects.asm). 1F's event is
|
||||
-- reset by the Indigo Plateau lobby and by Victory Road 2F, not here. This
|
||||
-- is what makes the puzzle "completely reset" on a return trip (#258).
|
||||
-- onEnter is the port's BIT_CUR_MAP_LOADED_2 equivalent: setMap runs it on
|
||||
-- every map entry, connection crossings included, like EnterMap.
|
||||
M.ROUTE_23 = {
|
||||
onEnter = function(game, ow)
|
||||
local f = game.save.flags
|
||||
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 = nil
|
||||
f.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 = nil
|
||||
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = nil
|
||||
f.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = nil
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, overworld = ow, game = game }
|
||||
Commands.show_object(ctx, "VICTORY_ROAD_3F", "VICTORYROAD3F_BOULDER4")
|
||||
Commands.hide_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
|
||||
end,
|
||||
talk = {
|
||||
-- Route23Guard1Text: EventFlagBit ..., EVENT_PASSED_EARTHBADGE_CHECK
|
||||
-- -> wWhichBadge = EARTHBADGE
|
||||
|
||||
@@ -7,19 +7,19 @@
|
||||
-- CheckFightingMapTrainers/TalkToTrainer via the existing trainer
|
||||
-- system, and the item pickups carry no talk text of their own here.
|
||||
--
|
||||
-- TEXT_SSANNE2FROOMS_GENTLEMAN3 also opens the POKéDEX entry for
|
||||
-- SNORLAX after the text box (DisplayPokedex in the original), which
|
||||
-- has no equivalent talk-script hook in this port (DexEntryMenu has
|
||||
-- no done-callback the way other pushed UI states do) -- only the
|
||||
-- flavor line is ported here.
|
||||
-- TEXT_SSANNE2FROOMS_GENTLEMAN3 opens SNORLAX's POKéDEX entry after its
|
||||
-- text box, like DisplayPokedex in the original. The preview marks it seen
|
||||
-- but does not mark it owned.
|
||||
return {
|
||||
SS_ANNE_2F_ROOMS = {
|
||||
talk = {
|
||||
-- SSAnne2FRoomsGentleman3Text: PrintText(_SSAnne2FRoomsGentleman3Text)
|
||||
-- (then DisplayPokedex SNORLAX -- not ported, see note above)
|
||||
-- SSAnne2FRoomsGentleman3Text: PrintText(_SSAnne2FRoomsGentleman3Text),
|
||||
-- then DisplayPokedex SNORLAX.
|
||||
TEXT_SSANNE2FROOMS_GENTLEMAN3 = {
|
||||
{ "face_player" },
|
||||
{ "show_text", "_SSAnne2FRoomsGentleman3Text" },
|
||||
{ "mark_seen", "SNORLAX" },
|
||||
{ "push_screen", "DexEntryMenu", "SNORLAX" },
|
||||
},
|
||||
|
||||
-- SSAnne2FRoomsGentleman4Text: PrintText(_SSAnne2FRoomsGentleman4Text)
|
||||
|
||||
+20
-1
@@ -12,11 +12,18 @@
|
||||
-- different files can each add NPCs to the same map), and mod
|
||||
-- contributions from the map_scripts registry compose on top of it.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
|
||||
-- OaksLab is a full Yellow rewrite (one Eevee ball + forced Pikachu);
|
||||
-- Red/Blue keep the three-starter choose flow.
|
||||
local oaksLab = GameVersion.isYellow()
|
||||
and "data.scripts.oaks_lab_yellow"
|
||||
or "data.scripts.oaks_lab"
|
||||
|
||||
for _, mapEntry in ipairs({
|
||||
{ "PALLET_TOWN", "data.scripts.pallet_town" },
|
||||
{ "OAKS_LAB", "data.scripts.oaks_lab" },
|
||||
{ "OAKS_LAB", oaksLab },
|
||||
{ "REDS_HOUSE_1F", "data.scripts.reds_house" },
|
||||
{ "CELADON_MANSION_ROOF_HOUSE", "data.scripts.celadon_eevee" },
|
||||
}) do
|
||||
@@ -36,6 +43,18 @@ for _, file in ipairs({ "data.scripts.story", "data.scripts.story2",
|
||||
end
|
||||
end
|
||||
|
||||
-- Yellow-only content on top of the shared tables (talk keys merge per
|
||||
-- TEXT constant): the Kanto-starter gift quests and Jessie & James.
|
||||
if GameVersion.isYellow() then
|
||||
for _, file in ipairs({ "data.scripts.yellow_gifts",
|
||||
"data.scripts.yellow_jessie_james",
|
||||
"data.scripts.yellow_beach_house" }) do
|
||||
for mapId, mod in pairs(require(file)) do
|
||||
MapScripts.attachBase(mapId, mod)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local M = {}
|
||||
|
||||
function M.get(mapId)
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
-- Hand-ported from pret/pokeyellow scripts/OaksLab.asm.
|
||||
-- Yellow: one Eevee ball on the table. Rival snatches it; Oak then
|
||||
-- gives the player the wild Pikachu he caught earlier (STARTER_PIKACHU).
|
||||
-- Object indices differ from Red (no Charmander/Squirtle/Bulbasaur balls):
|
||||
-- 1 RIVAL (4,3), 2 EEVEE_POKE_BALL (7,3), 3 OAK1 (5,2),
|
||||
-- 4-5 POKEDEX (2,1)/(3,1), 6 OAK2 (door), 7 GIRL, 8-9 SCIENTIST.
|
||||
--
|
||||
-- wRivalStarter rides in save.rivalStarter (RIVAL_STARTER_* 1 JOLTEON /
|
||||
-- 2 FLAREON / 3 VAPOREON): JOLTEON baseline at the snatch
|
||||
-- (OaksLabRivalTakesPokeballScript), FLAREON on a lab win / VAPOREON on
|
||||
-- a lab loss (OaksLabRivalEndBattleScript), and Route 22's first battle
|
||||
-- upgrades FLAREON back to JOLTEON (Route22Rival1AfterBattleScript).
|
||||
|
||||
local OAK1 = 3
|
||||
local RIVAL = 1
|
||||
|
||||
return {
|
||||
talk = {
|
||||
TEXT_OAKSLAB_OAK1 = {
|
||||
{ "face_player" },
|
||||
-- Yellow's OaksLabOak1Text leads with the dex-rating branch: once
|
||||
-- EVENT_PALLET_AFTER_GETTING_POKEBALLS is set (converted saves) or
|
||||
-- 2+ species are owned, Oak asks how the Pokédex is coming and
|
||||
-- rates it (predef DisplayDexRating)
|
||||
{ "check_flag", "EVENT_PALLET_AFTER_GETTING_POKEBALLS" },
|
||||
{ "jump_if_true", "dex_rating" },
|
||||
{ "check_dex_owned", 2 },
|
||||
{ "jump_if_true", "dex_rating" },
|
||||
{ "check_item", "POKE_BALL" },
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
|
||||
{ "jump_if_true", "give_balls" },
|
||||
{ "check_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "jump_if_true", "around_world" },
|
||||
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
|
||||
{ "jump_if_false", "pre_lab_battle" },
|
||||
{ "check_item", "OAKS_PARCEL" },
|
||||
{ "jump_if_false", "raise_young" },
|
||||
-- .DeliverParcelText: parcel handover, then the Pokédex scene
|
||||
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||
{ "take_item", "OAKS_PARCEL", 1 },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "show_text", "_OaksLabRivalGrampsText" },
|
||||
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
|
||||
{ "place_npc", RIVAL, 4, 7, "up" },
|
||||
{ "move_npc_to", RIVAL, 4, 3 },
|
||||
{ "play_music", "Music_OaksLab" },
|
||||
{ "face_object", RIVAL, "up" },
|
||||
{ "face_object", OAK1, "down" },
|
||||
-- Yellow opens with the rival bragging, not Red's "what did you
|
||||
-- call me for" (OaksLabOakGivesPokedexScript text order)
|
||||
{ "show_text", "_OaksLabRivalMyPokemonHasGrownStrongerText" },
|
||||
{ "face_object", RIVAL, "up" },
|
||||
{ "face_object", OAK1, "down" },
|
||||
{ "show_text", "_OaksLabOakIHaveARequestText" },
|
||||
{ "face_object", RIVAL, "up" },
|
||||
{ "face_object", OAK1, "down" },
|
||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||
{ "face_object", RIVAL, "up" },
|
||||
{ "face_object", OAK1, "down" },
|
||||
{ "show_text", "_OaksLabOakThatWasMyDreamText" },
|
||||
{ "face_object", RIVAL, "right" },
|
||||
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
|
||||
{ "set_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
|
||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
|
||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "move_npc_to", RIVAL, 4, 7 },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
|
||||
{ "play_music", "Music_OaksLab" },
|
||||
{ "set_flag", "EVENT_1ST_ROUTE22_RIVAL_BATTLE" },
|
||||
{ "clear_flag", "EVENT_2ND_ROUTE22_RIVAL_BATTLE" },
|
||||
{ "set_flag", "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" },
|
||||
{ "show_object", "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "raise_young" },
|
||||
-- Yellow: talk-to-it (starter Pikachu) instead of Red raise-young line.
|
||||
{ "show_text", "_OaksLabOak1YouShouldTalkToIt" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "pre_lab_battle" },
|
||||
{ "check_flag", "EVENT_GOT_STARTER" },
|
||||
{ "jump_if_true", "can_fight" },
|
||||
{ "show_text", "_OaksLabOak1GoAheadItsYours" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "can_fight" },
|
||||
{ "show_text", "_OaksLabOak1YourPokemonCanFightText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "around_world" },
|
||||
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "give_balls" },
|
||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||
{ "give_item", "POKE_BALL", 5, false },
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "come_see" },
|
||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "dex_rating" },
|
||||
{ "show_text", "_OaksLabOak1HowIsYourPokedexComingText" },
|
||||
{ "dex_rating" },
|
||||
},
|
||||
|
||||
-- OaksLabEeveePokeBallText / OaksLabRivalExclamationScript ->
|
||||
-- OaksLabChoseStarterScript..OaksLabPlayerReceivesPikachuScript:
|
||||
-- before Oak's choose speech the ball is just flavor; after it, the
|
||||
-- rival "!"s, shoves the player off the table, snatches the ball,
|
||||
-- then the player is walked over to Oak and handed Pikachu.
|
||||
TEXT_OAKSLAB_EEVEE_POKE_BALL = function(game, ow, npc, done)
|
||||
local flags = game.save.flags
|
||||
if flags.EVENT_GOT_STARTER then
|
||||
done()
|
||||
return
|
||||
end
|
||||
if not flags.EVENT_OAK_ASKED_TO_CHOOSE_MON then
|
||||
ow.runner:run({
|
||||
{ "show_text", "_OaksLabThatsAPokeball" },
|
||||
}, { onDone = done })
|
||||
return
|
||||
end
|
||||
local px, py = ow.player.cellX, ow.player.cellY
|
||||
local rows = {
|
||||
-- OaksLabRivalExclamationScript: "!" over the rival
|
||||
{ "emote", RIVAL, "shock" },
|
||||
}
|
||||
-- .RivalPushesPlayerAwayFromEeveeBall + the PAD_RIGHT x2 shove:
|
||||
-- the rival cuts across to the ball WHILE the player standing
|
||||
-- below it is bumped two tiles right (both movements run in the
|
||||
-- same beat, so the walk overlaps the shove like the original)
|
||||
if py == 4 then
|
||||
rows[#rows + 1] = { "walk_npc", RIVAL,
|
||||
{ "down", "right", "right", "right" }, { wait = false } }
|
||||
rows[#rows + 1] = { "face_player_dir", "left" }
|
||||
rows[#rows + 1] = { "move_player", "right", 2 }
|
||||
-- let the rival finish the last stretch to (7,4)
|
||||
rows[#rows + 1] = { "wait", 40 }
|
||||
else
|
||||
rows[#rows + 1] = { "move_npc_to", RIVAL, 7, 4 }
|
||||
end
|
||||
rows[#rows + 1] = { "face_object", RIVAL, "up" }
|
||||
rows[#rows + 1] = { "hide_object", "OAKS_LAB", "OAKSLAB_EEVEE_POKE_BALL" }
|
||||
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
|
||||
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" }
|
||||
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText5" }
|
||||
-- OaksLabRLE_PlayerWalksToOak: the simulated-joypad buffer plays
|
||||
-- its RLE list BACKWARDS (StartSimulatingJoypadStates consumes
|
||||
-- from wSimulatedJoypadStatesEnd), so the real order is LEFT 1,
|
||||
-- DOWN 1, LEFT 3, UP 2 -- from the shove spot (9,4) the player
|
||||
-- rounds the BOTTOM of the table to (5,3), directly below Oak
|
||||
if py == 4 then
|
||||
rows[#rows + 1] = { "walk_npc", "player",
|
||||
{ "left", "down", "left", "left", "left", "up", "up" } }
|
||||
else
|
||||
rows[#rows + 1] = { "walk_npc", "player", { "left" } }
|
||||
end
|
||||
rows[#rows + 1] = { "face_player_dir", "up" }
|
||||
rows[#rows + 1] = { "face_object", OAK1, "down" }
|
||||
-- OaksLabPlayerReceivedMonText: no nickname prompt -- the starter
|
||||
-- Pikachu keeps its species name
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
|
||||
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
|
||||
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5, true }
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" }
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" }
|
||||
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||
end,
|
||||
|
||||
TEXT_OAKSLAB_RIVAL = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_GOT_STARTER" },
|
||||
{ "jump_if_false", "pre_starter" },
|
||||
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "pre_starter" },
|
||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB_2" },
|
||||
{ "jump_if_false", "gramps_gone" },
|
||||
{ "show_text", "_OaksLabRivalIllGetABetterPokemonThanYou" },
|
||||
{ "jump", "end" },
|
||||
|
||||
{ "label", "gramps_gone" },
|
||||
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" },
|
||||
},
|
||||
},
|
||||
|
||||
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, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX2")
|
||||
end,
|
||||
|
||||
onStep = function(game, ow, x, y)
|
||||
local flags = game.save.flags
|
||||
-- OaksLabPlayerDontGoAwayScript: y==6 without the starter walks the
|
||||
-- player back up a tile
|
||||
if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER
|
||||
and y >= 6 then
|
||||
ow.runner:run({
|
||||
{ "face_object", OAK1, "down" },
|
||||
{ "face_object", RIVAL, "down" },
|
||||
{ "show_text", "_OaksLabOakDontGoAwayYetText" },
|
||||
{ "move_player", "up", 1 },
|
||||
}, {})
|
||||
return true
|
||||
end
|
||||
-- OaksLabRivalChallengesPlayerScript..OaksLabPikachuDislikesPokeballsScript:
|
||||
-- heading for the door with Pikachu starts the rival battle, his
|
||||
-- exit walk, then Pikachu pops out of its ball.
|
||||
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB
|
||||
and y >= 6 then
|
||||
local rival = ow:npcByIndex(RIVAL)
|
||||
if not rival then return false end
|
||||
local rows = {
|
||||
{ "face_player_dir", "up" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "show_text", "_OaksLabRivalIllTakeYouOnText" },
|
||||
}
|
||||
-- FindPathToPlayer with the Y distance decremented: the rival
|
||||
-- stops one tile above the player
|
||||
local target
|
||||
for _, c in ipairs({ { x, y - 1 }, { x - 1, y }, { x + 1, y },
|
||||
{ x, y + 1 } }) do
|
||||
if ow.map:inBounds(c[1], c[2]) and ow.map:isWalkableCell(c[1], c[2]) then
|
||||
target = c
|
||||
break
|
||||
end
|
||||
end
|
||||
if target then
|
||||
table.insert(rows, { "move_npc_to", RIVAL, target[1], target[2] })
|
||||
end
|
||||
table.insert(rows, { "face_object", RIVAL,
|
||||
target and target[2] < y and "down"
|
||||
or target and target[2] > y and "up"
|
||||
or target and target[1] < x and "right" or "left" })
|
||||
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", 1 })
|
||||
table.insert(rows, { "heal_party" })
|
||||
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
||||
-- OaksLabRivalEndBattleScript: his Eevee's future evolution is
|
||||
-- decided here -- FLAREON if the player won, VAPOREON otherwise
|
||||
table.insert(rows, { "check_battle_result", "win" })
|
||||
table.insert(rows, { "jump_if_false", "lost_lab" })
|
||||
table.insert(rows, { "set_field", "rivalStarter", 2 })
|
||||
table.insert(rows, { "jump", "exit" })
|
||||
table.insert(rows, { "label", "lost_lab" })
|
||||
table.insert(rows, { "set_field", "rivalStarter", 3 })
|
||||
table.insert(rows, { "label", "exit" })
|
||||
-- OaksLabRivalStartsExitScript: parting shot, walk out past the
|
||||
-- player, restore the lab theme
|
||||
table.insert(rows, { "wait", 20 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
-- OaksLabPikachuEscapesPokeballScript: Pikachu hates its ball.
|
||||
-- The overworld follower itself is still an open port
|
||||
-- (docs/yellow-version.md runtime backlog); the story beat plays.
|
||||
table.insert(rows, { "play_cry", "PIKACHU" })
|
||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
|
||||
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
|
||||
ow.runner:run(rows, { npc = rival })
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
}
|
||||
+54
-8
@@ -19,19 +19,60 @@ local FEE = 500
|
||||
local BALLS = 30
|
||||
local STEPS = 502
|
||||
|
||||
local function startGame(game, t, done)
|
||||
game.save.money = game.save.money - FEE
|
||||
game.save.safari = { balls = BALLS, steps = STEPS }
|
||||
local function startGame(game, t, done, balls, introText)
|
||||
game.save.safari = { balls = balls or BALLS, steps = STEPS }
|
||||
game.save.safariNags = nil
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local paid = (t._SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText
|
||||
or "That'll be ¥500\nplease!\f{PLAYER} received\n30 SAFARI BALLs!")
|
||||
:gsub("{PLAYER}", game.save.player.name)
|
||||
local paid = introText
|
||||
or (t._SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText
|
||||
or "That'll be ¥500\nplease!\f{PLAYER} received\n30 SAFARI BALLs!")
|
||||
:gsub("{NUM:[^}]*}", "500")
|
||||
paid = paid:gsub("{PLAYER}", game.save.player.name)
|
||||
local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText
|
||||
or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!"
|
||||
local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!"
|
||||
game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, done))
|
||||
end
|
||||
|
||||
-- Yellow's soft-lock fix (scripts/SafariZoneGate_2.asm): a player short of
|
||||
-- the full fee still gets in.
|
||||
-- 0 < money < 500: SafariZoneEntranceCalculateLowCostAdmission takes
|
||||
-- everything and hands over min(money/23 + 1, 29) balls.
|
||||
-- money == 0: SafariZoneEntranceGetLowCostAdmissionText nags four times
|
||||
-- (LowCostText5/6/7/8), then relents -- free entry, one ball.
|
||||
local function yellowLowCost(game, ow, t, done, back)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
if game.save.money > 0 then
|
||||
local balls = math.min(math.floor(game.save.money / 23) + 1, 29)
|
||||
game.save.money = 0
|
||||
local intro =
|
||||
(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText
|
||||
or "Oops! Not enough\nmoney!")
|
||||
.. (t._SafariZoneLowCostText1
|
||||
or "\fOh, all right, pay\nme what you have.")
|
||||
.. "\f" .. (t._SafariZoneLowCostText2
|
||||
or "But, I can't give\nyou all 30 BALLs.")
|
||||
startGame(game, t, done, balls, intro)
|
||||
return
|
||||
end
|
||||
local nag = game.save.safariNags or 0
|
||||
game.save.safariNags = nag + 1
|
||||
if nag >= 3 then
|
||||
local intro =
|
||||
(t._SafariZoneLowCostText8 or "Read my lips, NO!\nGet it?")
|
||||
.. (t._SafariZoneLowCostText3
|
||||
or "\fYou're persistent,\naren't you?\fOK, you can go in\nfor free, but\njust this once!")
|
||||
startGame(game, t, done, 1, intro)
|
||||
return
|
||||
end
|
||||
local nags = {
|
||||
t._SafariZoneLowCostText5 or "I'm sorry, but you\nhave to pay to\nenter.",
|
||||
t._SafariZoneLowCostText6 or "You can't enter\nwithout paying!",
|
||||
t._SafariZoneLowCostText7 or "I said, no money,\nno entry!",
|
||||
}
|
||||
back(nags[nag + 1])
|
||||
end
|
||||
|
||||
local function joinPrompt(game, ow, done)
|
||||
done = done or function() end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
@@ -51,9 +92,14 @@ local function joinPrompt(game, ow, done)
|
||||
back(t._SafariZoneGateSafariZoneWorker1PleaseComeAgainText
|
||||
or "OK! Please come\nagain!")
|
||||
elseif game.save.money < FEE then
|
||||
back(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText
|
||||
or "Oops! Not enough\nmoney!")
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
yellowLowCost(game, ow, t, done, back)
|
||||
else
|
||||
back(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText
|
||||
or "Oops! Not enough\nmoney!")
|
||||
end
|
||||
else
|
||||
game.save.money = game.save.money - FEE
|
||||
startGame(game, t, done)
|
||||
end
|
||||
end))
|
||||
|
||||
+57
-15
@@ -94,6 +94,27 @@ M.VIRIDIAN_CITY = {
|
||||
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
|
||||
},
|
||||
},
|
||||
-- Re-apply the Pokedex old-man swap for a save that already holds the flag
|
||||
-- but was never standing here when it fired: a vanilla .sav imported
|
||||
-- through src/save_convert, whose codec does not model pokered's
|
||||
-- wToggleableObjectFlags array, so save.objectToggles arrives empty and
|
||||
-- both objects fall back to their compiled-in defaults -- the sleeper ON
|
||||
-- (data/maps/toggleable_objects.asm toggleable_objects_for VIRIDIAN_CITY),
|
||||
-- still lying across the north path (#234).
|
||||
-- OaksLabOakGivesPokedexScript (scripts/OaksLab.asm) sets EVENT_GOT_POKEDEX
|
||||
-- and, with no branch in between, runs HideObject TOGGLE_LYING_OLD_MAN /
|
||||
-- ShowObject TOGGLE_OLD_MAN, so that one flag settles both toggles exactly.
|
||||
-- Same shape as M.OAKS_LAB.onEnter in data/scripts/oaks_lab.lua, which
|
||||
-- re-applies the same script's other two HideObjects on entry (#106).
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY")
|
||||
Commands.show_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN")
|
||||
end,
|
||||
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
|
||||
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
|
||||
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
|
||||
@@ -719,11 +740,20 @@ local function boulderAt(ow, x, y)
|
||||
return npc and npc.def.sprite == "SPRITE_BOULDER" and npc or nil
|
||||
end
|
||||
|
||||
-- Each barrier is stamped BOTH ways on entry. pokered only ever stamps the
|
||||
-- OPEN block (ReplaceTileBlock edits the loaded block map, which the next map
|
||||
-- load throws away, so a fresh entry always shows the .blk's closed barrier),
|
||||
-- but OverworldState:replaceBlock writes through Map:setBlock into the SHARED
|
||||
-- map record in Game.data, so the port has to put the closed block back
|
||||
-- itself or a solved barrier stays open for the rest of the session (#258).
|
||||
-- The closed ids are the shipped .blk bytes: VictoryRoad1F.blk (4,6) = $25,
|
||||
-- VictoryRoad2F.blk (3,4) = $37 and (11,7) = $25, VictoryRoad3F.blk
|
||||
-- (3,5) = $25. Same both-ways pattern as the card-key doors in
|
||||
-- OverworldState:setMap.
|
||||
M.VICTORY_ROAD_1F = {
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH then
|
||||
ow:replaceBlock(4, 6, 0x1D)
|
||||
end
|
||||
ow:replaceBlock(4, 6,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH and 0x1D or 0x25)
|
||||
end,
|
||||
onBoulderMoved = function(game, ow, npc)
|
||||
if npc.cellX == 17 and npc.cellY == 13
|
||||
@@ -736,12 +766,14 @@ M.VICTORY_ROAD_1F = {
|
||||
|
||||
M.VICTORY_ROAD_2F = {
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 then
|
||||
ow:replaceBlock(3, 4, 0x15)
|
||||
end
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 then
|
||||
ow:replaceBlock(11, 7, 0x1D)
|
||||
end
|
||||
-- VictoryRoad2FResetBoulderEventScript (scripts/VictoryRoad2F.asm:19):
|
||||
-- entering 2F clears the 1F switch event, so 1F's barrier is closed
|
||||
-- again the next time you climb down (#258)
|
||||
game.save.flags.EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH = nil
|
||||
ow:replaceBlock(3, 4,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 and 0x15 or 0x37)
|
||||
ow:replaceBlock(11, 7,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 and 0x1D or 0x25)
|
||||
end,
|
||||
onBoulderMoved = function(game, ow, npc)
|
||||
if npc.cellX == 1 and npc.cellY == 16
|
||||
@@ -759,9 +791,8 @@ M.VICTORY_ROAD_2F = {
|
||||
|
||||
M.VICTORY_ROAD_3F = {
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 then
|
||||
ow:replaceBlock(3, 5, 0x1D)
|
||||
end
|
||||
ow:replaceBlock(3, 5,
|
||||
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 and 0x1D or 0x25)
|
||||
end,
|
||||
onBoulderMoved = function(game, ow, npc)
|
||||
if npc.cellX == 3 and npc.cellY == 5
|
||||
@@ -769,12 +800,23 @@ M.VICTORY_ROAD_3F = {
|
||||
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = true
|
||||
ow:replaceBlock(3, 5, 0x1D)
|
||||
end
|
||||
-- the hole at (23,15): the boulder drops to 2F next to switch 2
|
||||
if npc.cellX == 23 and npc.cellY == 15 then
|
||||
-- the hole at (23,15): the boulder drops to 2F next to switch 2.
|
||||
-- VictoryRoad3FDefaultScript .handle_hole gates the swap on
|
||||
-- CheckAndSetEvent EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2, then
|
||||
-- HideObject TOGGLE_VICTORY_ROAD_3F_BOULDER / ShowObject
|
||||
-- TOGGLE_VICTORY_ROAD_2F_BOULDER. Those two toggles are
|
||||
-- VICTORYROAD3F_BOULDER4 and VICTORYROAD2F_BOULDER3
|
||||
-- (data/maps/toggleable_objects.asm); the old "VICTORYROAD2F_BOULDER"
|
||||
-- matched no object_event name, so the toggle landed under a key
|
||||
-- objectVisible never reads: harmless only while nothing hid that
|
||||
-- boulder, and fatal once Route 23's reset does (#258).
|
||||
if npc.cellX == 23 and npc.cellY == 15
|
||||
and not game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 then
|
||||
game.save.flags.EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = true
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, overworld = ow, game = game }
|
||||
Commands.hide_object(ctx, "VICTORY_ROAD_3F", npc.def.name)
|
||||
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
|
||||
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3")
|
||||
end
|
||||
end,
|
||||
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
|
||||
|
||||
+105
-91
@@ -1,6 +1,10 @@
|
||||
-- More hand-ported events: the Pallet Town intro, the thirsty Saffron
|
||||
-- gate guards, the Bike Voucher chain, fossils and the day-care.
|
||||
-- Registered via data/scripts/init.lua; each cites its pokered source.
|
||||
-- Registered via data/scripts/init.lua; Red/Blue cite pokered, Yellow
|
||||
-- cites pokeyellow (Pallet stop row, Oak spawn, walk RLE, and the
|
||||
-- wild-Pikachu beat before the lab escort).
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -35,72 +39,79 @@ function escort.findPath(fromX, fromY, toX, toY)
|
||||
return path
|
||||
end
|
||||
|
||||
-- PalletTownOakWalksToPlayerScript: Oak appears at his object spot
|
||||
-- (8,5) and zigzags to one tile below the player (hNPCPlayerYDistance
|
||||
-- is pre-decremented before predef FindPathToPlayer).
|
||||
-- Red: Oak object (8,5) -> one tile below the player at y=1.
|
||||
-- Yellow: Oak object (10,4); stop fires at y=0 (pokeyellow PalletTown).
|
||||
function escort.oakApproach(playerX)
|
||||
if GameVersion.isYellow() then
|
||||
return escort.findPath(10, 4, playerX, 1)
|
||||
end
|
||||
return escort.findPath(8, 5, playerX, 2)
|
||||
end
|
||||
|
||||
-- RLEList_ProfOakWalkToLab (engine/overworld/auto_movement.asm):
|
||||
-- DOWN x5, LEFT, DOWN x5, RIGHT x3, UP -- Oak's last step lands on the
|
||||
-- lab door (12,11). (The trailing NPC_CHANGE_FACING is a march-in-
|
||||
-- place beat on the mat; here Oak just stands his final beat.)
|
||||
escort.oakSteps = {
|
||||
"down", "down", "down", "down", "down",
|
||||
"left",
|
||||
"down", "down", "down", "down", "down",
|
||||
"right", "right", "right",
|
||||
"up",
|
||||
}
|
||||
-- RLEList_ProfOakWalkToLab (engine/overworld/auto_movement.asm).
|
||||
-- Yellow differs: first DOWN is x6 (Oak starts one tile farther north).
|
||||
local function buildOakSteps()
|
||||
if GameVersion.isYellow() then
|
||||
return {
|
||||
"down", "down", "down", "down", "down", "down",
|
||||
"left",
|
||||
"down", "down", "down", "down", "down",
|
||||
"right", "right", "right",
|
||||
"up",
|
||||
}
|
||||
end
|
||||
return {
|
||||
"down", "down", "down", "down", "down",
|
||||
"left",
|
||||
"down", "down", "down", "down", "down",
|
||||
"right", "right", "right",
|
||||
"up",
|
||||
}
|
||||
end
|
||||
|
||||
-- RLEList_PlayerWalkToLab decodes to UP x2, RIGHT x3, DOWN x5, LEFT,
|
||||
-- DOWN x6 and plays in REVERSE buffer order (wSimulatedJoypadStatesEnd
|
||||
-- grows downward): DOWN x6, LEFT, DOWN x5, RIGHT x3, UP x2 -- Oak's
|
||||
-- exact path one step behind. The 17th press (the second UP) is eaten
|
||||
-- by the door-warp frame (WarpFound clears hJoyHeld via EnterMap), so
|
||||
-- only 16 real steps happen; the walk ends on the door at (12,11).
|
||||
escort.oakSteps = buildOakSteps()
|
||||
|
||||
-- Player stays one step behind Oak (simplified reverse-RLE port).
|
||||
escort.playerSteps = { "down" }
|
||||
for _, d in ipairs(escort.oakSteps) do
|
||||
escort.playerSteps[#escort.playerSteps + 1] = d
|
||||
end
|
||||
|
||||
local function npcNamed(ow, name)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == name then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
M.PALLET_TOWN = {
|
||||
talk = require("data.scripts.pallet_town").talk,
|
||||
escort = escort,
|
||||
-- Oak stops you at the north row (PalletTownDefaultScript's
|
||||
-- `wYCoord == 1` check), walks up from (8,5), and leads you to his
|
||||
-- lab with the player one step behind (scripts/PalletTown.asm +
|
||||
-- PalletMovementScriptPointerTable in
|
||||
-- engine/overworld/auto_movement.asm), then the lab walk-in and the
|
||||
-- choose-mon exchange (scripts/OaksLab.asm OaksLabDefaultScript ..
|
||||
-- OaksLabOakChooseMonSpeechScript).
|
||||
-- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4),
|
||||
-- then a wild Pikachu battle before the lab escort (pokeyellow
|
||||
-- PalletTownPikachuBattleScript).
|
||||
onStep = function(game, ow, x, y)
|
||||
if y ~= 1 or game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB
|
||||
local yellow = GameVersion.isYellow()
|
||||
local stopY = yellow and 0 or 1
|
||||
if y ~= stopY or game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB
|
||||
or game.save.flags.EVENT_GOT_STARTER then
|
||||
return false
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Commands = require("src.script.Commands")
|
||||
local Music = require("src.core.Music")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local t = game.data.text
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
|
||||
-- PalletTownDefaultScript: stop the player, turn them around
|
||||
-- (wPlayerMovingDirection = PLAYER_DIR_DOWN applies on the very
|
||||
-- next frame, before the text box opens) and strike up the "oak
|
||||
-- appears" theme (MUSIC_MEET_PROF_OAK)
|
||||
ow.player.facing = "down"
|
||||
-- Red turns the player down; Yellow faces up at the north exit.
|
||||
ow.player.facing = yellow and "up" or "down"
|
||||
Music.play(game.data, "Music_MeetProfOak")
|
||||
|
||||
-- DelayFrames-style hold: the world pauses (input stays locked)
|
||||
-- for `frames` frames, then cb runs. Reuses the emote pause slot;
|
||||
-- with an `npc` the "!" bubble draws above it (EmotionBubble).
|
||||
local function hold(frames, npc, cb)
|
||||
ow.emote = { frames = frames, npc = npc, onDone = cb }
|
||||
end
|
||||
|
||||
-- chain single-tile scriptMoves through a direction list
|
||||
local function walkList(entity, steps, done)
|
||||
local i = 0
|
||||
local function nextStep()
|
||||
@@ -114,10 +125,8 @@ M.PALLET_TOWN = {
|
||||
nextStep()
|
||||
end
|
||||
|
||||
-- ---- Oak's Lab side (scripts/OaksLab.asm) ----------------------
|
||||
|
||||
-- OaksLabOakChooseMonSpeechScript: the fed-up / choose-mon /
|
||||
-- what-about-me / be-patient exchange, Delay3 between boxes
|
||||
-- OaksLabOakChooseMonSpeechScript. Yellow's OakChooseMon text is
|
||||
-- the single-ball speech, not Red's "there are 3 POKéMON".
|
||||
local function chooseMonSpeech()
|
||||
local function say(key, fb, next)
|
||||
game.stack:push(TextBox.new(game, t[key] or fb, next))
|
||||
@@ -126,7 +135,9 @@ M.PALLET_TOWN = {
|
||||
"{RIVAL}: Gramps!\nI'm fed up with\nwaiting!", function()
|
||||
hold(3, nil, function()
|
||||
say("_OaksLabOakChooseMonText",
|
||||
"OAK: Here, {PLAYER}!\fThere are 3\nPOKéMON here!\fYou can have one!\nChoose!", function()
|
||||
yellow
|
||||
and "OAK: Look, {PLAYER}! Do\nyou see that ball\non the table?"
|
||||
or "OAK: Here, {PLAYER}!\fThere are 3\nPOKéMON here!\fYou can have one!\nChoose!", function()
|
||||
hold(3, nil, function()
|
||||
say("_OaksLabRivalWhatAboutMeText",
|
||||
"{RIVAL}: Hey!\nGramps! What\nabout me?", function()
|
||||
@@ -143,30 +154,18 @@ M.PALLET_TOWN = {
|
||||
end)
|
||||
end
|
||||
|
||||
-- entering the lab: the door Oak (OAKSLAB_OAK2, (5,10)) walks up 3
|
||||
-- ahead of the player (OaksLabOakEntersLabScript OakEntryMovement),
|
||||
-- swaps for the desk Oak (OAKSLAB_OAK1, (5,2)), then the player
|
||||
-- walks up 8 from the mat (PlayerEntryMovementRLE) while the rival
|
||||
-- and Oak turn with them (OaksLabPlayerEntersLabScript /
|
||||
-- OaksLabFollowedOakScript)
|
||||
-- Door Oak is OAKSLAB_OAK2: Red index 8, Yellow index 6 (one ball).
|
||||
local function labWalkIn()
|
||||
local oak2 = ow:npcByIndex(8)
|
||||
local oak2 = npcNamed(ow, "OAKSLAB_OAK2") or ow:npcByIndex(yellow and 6 or 8)
|
||||
local function swapOaks()
|
||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
|
||||
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK1")
|
||||
hold(3, nil, function() -- Delay3
|
||||
Commands.face_object(ctx, 1, "down") -- rival watches you pass
|
||||
hold(3, nil, function()
|
||||
Commands.face_object(ctx, 1, "down")
|
||||
ow:scriptMove(ow.player, "up", 8, function()
|
||||
-- OaksLabFollowedOakScript: flags only after the walk-in, so a
|
||||
-- stray step on the door mat can't fire the "don't go away"
|
||||
-- push-up (oaks_lab.lua onStep) mid-cutscene. Outdoor escort
|
||||
-- still re-arms on F1 mid-escort -- these flags stay clear
|
||||
-- until the lab walk finishes.
|
||||
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
|
||||
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB_2 = true
|
||||
Commands.face_object(ctx, 1, "up")
|
||||
-- res BIT_NO_MAP_MUSIC + PlayDefaultMusic: the lab theme
|
||||
-- only starts once the walk-in is done
|
||||
Music.playMap(game.data, "OAKS_LAB")
|
||||
chooseMonSpeech()
|
||||
end)
|
||||
@@ -179,9 +178,6 @@ M.PALLET_TOWN = {
|
||||
end
|
||||
end
|
||||
|
||||
-- PalletMovementScript_Done + the door warp: Oak is hidden as the
|
||||
-- player steps into the doorway; PALLET_TOWN warp 3 -> OAKS_LAB
|
||||
-- warp 2 = (5,11), with the door SFX (WarpFound -> SFX_GO_INSIDE)
|
||||
local function enterLab()
|
||||
Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
|
||||
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
|
||||
@@ -190,10 +186,6 @@ M.PALLET_TOWN = {
|
||||
{ keepMusic = true })
|
||||
end
|
||||
|
||||
-- PalletMovementScript_WalkToLab: Oak's NPC movement and the
|
||||
-- player's simulated joypad run simultaneously, in lockstep; the
|
||||
-- player retraces Oak's path one step behind and follows him into
|
||||
-- the doorway on the final beat
|
||||
local function walkToLab(oak)
|
||||
local i = 0
|
||||
local function tick()
|
||||
@@ -206,9 +198,6 @@ M.PALLET_TOWN = {
|
||||
if oak and escort.oakSteps[i] then
|
||||
ow:scriptMove(oak, escort.oakSteps[i], 1)
|
||||
elseif oak then
|
||||
-- RLEList_ProfOakWalkToLab's trailing NPC_CHANGE_FACING beat:
|
||||
-- Oak marches in place on the door mat while the player takes
|
||||
-- the final step up behind him (movement.asm ChangeFacingDirection)
|
||||
ow:marchInPlace(oak)
|
||||
end
|
||||
ow:scriptMove(ow.player, playerStep, 1, tick)
|
||||
@@ -216,9 +205,6 @@ M.PALLET_TOWN = {
|
||||
tick()
|
||||
end
|
||||
|
||||
-- PalletMovementScript_OakMoveLeft/_PlayerMoveLeft: from the right
|
||||
-- tile (x == 11) Oak sidesteps left first, then the player follows
|
||||
-- left (wNumStepsToTake = wXCoord - 10), and only then both walk
|
||||
local function escortToLab(oak)
|
||||
local numSteps = x - 10
|
||||
if oak and numSteps > 0 then
|
||||
@@ -232,37 +218,65 @@ M.PALLET_TOWN = {
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- Pallet Town side (scripts/PalletTown.asm) -----------------
|
||||
-- After Oak reaches the player: Red goes straight to "It's unsafe!".
|
||||
-- Yellow (PalletTownOakGreetsPlayerScript..AfterPikachuBattleScript):
|
||||
-- ThatWasClose -> Oak faces the grass patch -> BATTLE_TYPE_PIKACHU
|
||||
-- (the old-man-style simulated battle where PROF.OAK throws the ball
|
||||
-- and always catches the lv5 Pikachu) -> Whew -> ComeWithMe.
|
||||
local function afterOakArrives(oak)
|
||||
if not yellow then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PalletTownOakItsUnsafeText
|
||||
or "OAK: It's unsafe!\nWild POKéMON\nlive in tall grass!",
|
||||
function() escortToLab(oak) end))
|
||||
return
|
||||
end
|
||||
local function comeWithMe()
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PalletTownOakComeWithMe
|
||||
or "OAK: Here, come with\nme!",
|
||||
function() escortToLab(oak) end))
|
||||
end
|
||||
local function afterPikaBattle()
|
||||
if oak then oak.facing = "up" end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PalletTownOakWhewText or "OAK: Whew...",
|
||||
comeWithMe))
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PalletTownOakThatWasCloseText
|
||||
or "OAK: That was\nclose!\fWild POKéMON live\nin tall grass!",
|
||||
function()
|
||||
-- Oak turns toward the horizontally adjacent grass (left exit
|
||||
-- looks right, right exit looks left -- the
|
||||
-- EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN branch)
|
||||
if oak then oak.facing = x == 10 and "right" or "left" end
|
||||
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
||||
battle:makeOldManDemo("PROF.OAK")
|
||||
battle.onFinish = function()
|
||||
afterPikaBattle()
|
||||
end
|
||||
game.stack:push(battle)
|
||||
end))
|
||||
end
|
||||
|
||||
-- PalletTownOakWalksToPlayerScript: Oak appears at (8,5), faces up
|
||||
-- (SetSpriteFacingDirectionAndDelay + Delay3), then zigzags to the
|
||||
-- player; the "It's unsafe!" text follows and the escort begins
|
||||
local function oakAppearsAndWalks()
|
||||
Commands.show_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
|
||||
local oak = ow:npcByIndex(1)
|
||||
local oak = npcNamed(ow, "PALLETTOWN_OAK") or ow:npcByIndex(1)
|
||||
if oak then oak.facing = "up" end
|
||||
hold(6, nil, function()
|
||||
walkList(oak, escort.oakApproach(x), function()
|
||||
-- PalletTownOakNotSafeComeWithMeScript: the second text waits
|
||||
-- for a button, then the escort starts
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PalletTownOakItsUnsafeText
|
||||
or "OAK: It's unsafe!\nWild POKéMON\nlive in tall grass!",
|
||||
function() escortToLab(oak) end))
|
||||
afterOakArrives(oak)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
-- The "Hey! Wait!" box ends without a button wait (auto), then the
|
||||
-- "!" bubble shows over the player WHILE the box is still on screen
|
||||
-- (PalletTownOakText: DelayFrames 10 then EmotionBubble, box not yet
|
||||
-- cleared). onOverlap sets the bubble during the box's last frames;
|
||||
-- the box pops after `overlap`, and the bubble's 60-frame hold then
|
||||
-- runs to Oak's appearance (the bubble is static while the box is up,
|
||||
-- since the overworld pauses under it, so 10 overlap + 50 = 60).
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PalletTownOakHeyWaitDontGoOutText or "OAK: Hey! Wait!\nDon't go out!",
|
||||
nil, { auto = { delay = 10, overlap = 10, onOverlap = function()
|
||||
-- .HeyWaitDontGoOutText turns the player to face down (toward
|
||||
-- the approaching Oak) before the exclamation bubble
|
||||
ow.player.facing = "down"
|
||||
ow.emote = { npc = ow.player, frames = 50, onDone = oakAppearsAndWalks }
|
||||
end } }))
|
||||
return true
|
||||
@@ -761,7 +775,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,
|
||||
|
||||
@@ -37,16 +37,15 @@ local function countOwned(save)
|
||||
return n
|
||||
end
|
||||
|
||||
local function oaksAide(threshold, itemId)
|
||||
local function oaksAide(threshold, itemId, repeatText)
|
||||
return function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
local flags = game.save.flags
|
||||
local itemName = game.data.items[itemId].name
|
||||
local flag = "EVENT_GOT_" .. itemId
|
||||
if flags[flag] then
|
||||
push(game, fill(t._OaksAideComeBackText or
|
||||
"I already gave\nyou the {RAM:}!",
|
||||
{ num = threshold, ram = itemName }), done)
|
||||
push(game, t[repeatText] or
|
||||
fill("I already gave\nyou the {RAM:}!", { ram = itemName }), done)
|
||||
return
|
||||
end
|
||||
ask(game, fill(t._OaksAideHiText or
|
||||
@@ -83,13 +82,16 @@ local function oaksAide(threshold, itemId)
|
||||
end
|
||||
|
||||
M.ROUTE_2_GATE = {
|
||||
talk = { TEXT_ROUTE2GATE_OAKS_AIDE = oaksAide(10, "HM_FLASH") },
|
||||
talk = { TEXT_ROUTE2GATE_OAKS_AIDE = oaksAide(10, "HM_FLASH",
|
||||
"_Route2GateOaksAideFlashExplanationText") },
|
||||
}
|
||||
M.ROUTE_11_GATE_2F = {
|
||||
talk = { TEXT_ROUTE11GATE2F_OAKS_AIDE = oaksAide(30, "ITEMFINDER") },
|
||||
talk = { TEXT_ROUTE11GATE2F_OAKS_AIDE = oaksAide(30, "ITEMFINDER",
|
||||
"_Route11Gate2FOaksAideItemfinderDescriptionText") },
|
||||
}
|
||||
M.ROUTE_15_GATE_2F = {
|
||||
talk = { TEXT_ROUTE15GATE2F_OAKS_AIDE = oaksAide(50, "EXP_ALL") },
|
||||
talk = { TEXT_ROUTE15GATE2F_OAKS_AIDE = oaksAide(50, "EXP_ALL",
|
||||
"_Route15Gate2FOaksAideExpAllText") },
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
+147
-19
@@ -293,7 +293,11 @@ pewterEscort.guySteps = {
|
||||
"right", "right", "right",
|
||||
}
|
||||
|
||||
-- Walk home: reverse of guySteps with opposite facings (gym → spawn).
|
||||
-- Reverse of guySteps with opposite facings, i.e. the gym-to-spawn
|
||||
-- mirror of RLEList_PewterGymGuy. Kept as the documented inverse that
|
||||
-- tests/parity_pewter_escort.lua checks; the youngster does NOT walk it
|
||||
-- home any more, because its first step is LEFT through the player
|
||||
-- parked on (11,18) (#241). See walkHome below.
|
||||
do
|
||||
local opp = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local ret = {}
|
||||
@@ -371,20 +375,44 @@ local function pewterGymEscort(game, ow)
|
||||
local head = plan.guyHeadStart
|
||||
|
||||
-- After the walk: face the player, restore map music, "Go take on
|
||||
-- BROCK", then retrace RLEList_PewterGymGuy back to his spawn (35,16).
|
||||
-- (pokered teleports him via MovementData_PewterGymGuyExit; we walk
|
||||
-- the same route home instead. Brock victory still HideObject's him.)
|
||||
-- BROCK", then MovementData_PewterGymGuyExit -- five steps RIGHT out of
|
||||
-- (12,18), the cell PewterCityYoungsterShowsPlayerGymScript pins him to
|
||||
-- with SetSpritePosition1 (hSpriteMapXCoord 16 / hSpriteMapYCoord 22,
|
||||
-- minus the +4 border offset object_event coords carry per
|
||||
-- macros/scripts/maps.asm) and exactly where the escort leaves him.
|
||||
-- That lands him on (17,18), the last walkable cell before the fence at
|
||||
-- (18,18) and one column past the screen edge with the player parked on
|
||||
-- (11,18). PewterCityHideYoungsterScript then HideObject's him and
|
||||
-- PewterCityResetYoungsterScript's SetSpritePosition2 + ShowObject put
|
||||
-- him back on his object_event spawn (35,16) facing DOWN, which is the
|
||||
-- snap below (the vanish/reappear is off screen, same as the original).
|
||||
--
|
||||
-- The old code retraced guyReturnSteps instead, whose first step is
|
||||
-- LEFT into (11,18) -- the cell the player is standing on -- and
|
||||
-- scriptMove is a pure tween with no entity test, so he walked straight
|
||||
-- through Red (#241). There is no honest route home: (17,18) is a
|
||||
-- dead-end pocket ((18,18) fence, (17,17) wall), which is precisely why
|
||||
-- the original teleports. guyReturnSteps stays as the documented
|
||||
-- mirror of RLEList_PewterGymGuy that parity_pewter_escort asserts.
|
||||
-- Brock victory still HideObject's him.
|
||||
local function walkHome()
|
||||
if not guy then return end
|
||||
local ret = pewterEscort.guyReturnSteps
|
||||
local i = 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
if not ret[i] then
|
||||
if i > 5 then
|
||||
-- SetSpritePosition2: same field writes as Commands.place_npc,
|
||||
-- including the target clear -- a stale targetX/targetY would
|
||||
-- leave OverworldState:npcAtCell reserving (17,18) forever and
|
||||
-- silently wall the player out of that pocket.
|
||||
guy.cellX, guy.cellY = 35, 16
|
||||
guy.px, guy.py = 35 * 16, 16 * 16
|
||||
guy.moving = false
|
||||
guy.targetX, guy.targetY = nil, nil
|
||||
guy.facing = "down"
|
||||
return
|
||||
end
|
||||
ow:scriptMove(guy, ret[i], 1, tick)
|
||||
ow:scriptMove(guy, "right", 1, tick)
|
||||
end
|
||||
tick()
|
||||
end
|
||||
@@ -468,23 +496,48 @@ end
|
||||
-- Gym) and again in Viridian Gym; we derive the same windows from the
|
||||
-- surrounding story flags so old saves work too.
|
||||
-- Rival1Exit → Viridian (right/down); Rival2Exit → League (left).
|
||||
-- Both exit lists are keyed on wSavedCoordIndex -- WHICH entry of
|
||||
-- Route22DefaultScript.Route22RivalBattleCoords the player matched -- not
|
||||
-- on where the rival ended up. CheckCoords (home/map_objects.asm:107)
|
||||
-- zeroes wCoordIndex and `inc [hl]` BEFORE each compare, so the first
|
||||
-- entry reports 1: index 1 is (29,4), index 2 is (29,5).
|
||||
-- Route22Rival1AfterBattleScript (Route22.asm:175) takes
|
||||
-- ...ExitMovementData1 on index 1 and ...Data2 otherwise. From the top
|
||||
-- tile the rival stands BELOW the player on (29,5) and leaves east along
|
||||
-- row 5; from the bottom tile he stands LEFT of him on (28,5) and has to
|
||||
-- step UP to row 4 to get around him. The port had the two branches
|
||||
-- swapped, so the top tile started the walk with UP out of (28,4) into
|
||||
-- the cliff cell (28,3), which is not walkable (#236).
|
||||
local function route22ExitDirs(n, py)
|
||||
if n == 2 then
|
||||
if py == 5 then return { "left", "left", "left", "left" } end
|
||||
-- Route22Rival2ExitMovementData1 falls through into ...Data2: LEFT x4
|
||||
-- from (29,5), LEFT x3 from (28,5), both back onto the spawn (25,5).
|
||||
if py == 4 then return { "left", "left", "left", "left" } end
|
||||
return { "left", "left", "left" }
|
||||
end
|
||||
if py == 5 then
|
||||
-- Route22Rival1ExitMovementData1: (29,5) -> (31,5) -> (31,10)
|
||||
if py == 4 then
|
||||
return { "right", "right", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
-- Route22Rival1ExitMovementData2: (28,5) -> (28,4) -> (31,4) -> (31,10)
|
||||
return { "up", "right", "right", "right",
|
||||
"down", "down", "down", "down", "down", "down" }
|
||||
end
|
||||
|
||||
local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag, py)
|
||||
-- Route22MoveRivalRightScript (Route22.asm:39) walks him RIGHT along his
|
||||
-- own row from the object_event spawn (25,5): the full four-RIGHT
|
||||
-- Route22RivalMovementData on coord index 1 (player on (29,4)), so he
|
||||
-- stops BELOW the player on (29,5); `inc de` drops one RIGHT on index 2
|
||||
-- (player on (29,5)), so he stops LEFT of him on (28,5). He never
|
||||
-- leaves row 5. Route22Rival{1,2}StartBattleScript (Route22.asm:110)
|
||||
-- then faces him UP on index 1 and RIGHT otherwise (#236).
|
||||
local rx = (py == 4) and 29 or 28
|
||||
local rivalFacing = (py == 4) and "up" or "right"
|
||||
return {
|
||||
{ "show_object", "ROUTE_22", objName }, -- 1
|
||||
{ "move_npc_to", objIndex, 28, py }, -- 2
|
||||
{ "face_object", objIndex, "right" }, -- 3
|
||||
{ "move_npc_to", objIndex, rx, 5 }, -- 2
|
||||
{ "face_object", objIndex, rivalFacing }, -- 3
|
||||
{ "show_text", "_Route22RivalBeforeBattleText" .. n }, -- 4
|
||||
{ "rival_battle", oppClass, baseParty }, -- 5
|
||||
{ "jump_if_false", 11 }, -- 6
|
||||
@@ -496,20 +549,25 @@ local function route22Scene(n, objIndex, objName, oppClass, baseParty, beatFlag,
|
||||
}
|
||||
end
|
||||
|
||||
-- Route22Rival{1,2}StartBattleScript turns the player toward him too:
|
||||
-- PLAYER_DIR_DOWN on coord index 1 (the (29,4) tile, rival below him),
|
||||
-- and the PLAYER_DIR_LEFT Route22DefaultScript already set on index 2
|
||||
-- (the (29,5) tile, rival to his left) (#236).
|
||||
M.ROUTE_22 = {
|
||||
onStep = function(game, ow, x, y)
|
||||
if not inCoords({ { 29, 4 }, { 29, 5 } }, x, y) then return false end
|
||||
local f = game.save.flags
|
||||
local playerFacing = (y == 4) and "down" or "left"
|
||||
if f.EVENT_GOT_POKEDEX and not f.EVENT_BEAT_BROCK
|
||||
and not f.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE then
|
||||
return runAmbush(game, ow,
|
||||
route22Scene(1, 1, "ROUTE22_RIVAL1", "OPP_RIVAL1", 4,
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), "left")
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", y), playerFacing)
|
||||
end
|
||||
if f.EVENT_BEAT_GIOVANNI and not f.EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE then
|
||||
return runAmbush(game, ow,
|
||||
route22Scene(2, 2, "ROUTE22_RIVAL2", "OPP_RIVAL2", 10,
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), "left")
|
||||
"EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", y), playerFacing)
|
||||
end
|
||||
return false
|
||||
end,
|
||||
@@ -545,7 +603,7 @@ end
|
||||
-- scripts/CeruleanCity.asm CeruleanCityRocketText: fight the thief,
|
||||
-- then he returns TM28 (DIG) and hurries off. CeruleanHideRocket
|
||||
-- (CeruleanCity_2.asm) is GBFadeOutToBlack → Show GUARD1 / Hide GUARD2 /
|
||||
-- Hide ROCKET → GBFadeInFromBlack — not a bare hide_object.
|
||||
-- Hide ROCKET → GBFadeInFromBlack -- not a bare hide_object.
|
||||
local rocketRows = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
||||
@@ -564,7 +622,7 @@ local rocketRows = {
|
||||
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
||||
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
||||
-- door neighbour — the swap reconnects the city (Bill's ticket does
|
||||
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
||||
-- the same in story.lua; either route is enough).
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||
@@ -628,13 +686,83 @@ M.MUSEUM_1F = {
|
||||
}
|
||||
|
||||
-- The Pewter Center's singing JIGGLYPUFF (scripts/PewterPokecenter.asm
|
||||
-- plays MUSIC_JIGGLYPUFF_SONG, then the map theme resumes)
|
||||
-- PewterPokecenterJigglypuffText). The text_asm sets
|
||||
-- wDoNotWaitForButtonPressAfterDisplayingText before PrintText, so the box
|
||||
-- prints with no A prompt and the script keeps running underneath it:
|
||||
-- SFX_STOP_ALL_MUSIC, DelayFrames 32, PlayMusic MUSIC_JIGGLYPUFF_SONG,
|
||||
-- then .spinMovementLoop writes the next of DOWN -> LEFT -> UP -> RIGHT (a
|
||||
-- clockwise turn) every 24 frames for as long as the song's CHAN1/CHAN2
|
||||
-- still sound, DelayFrames 48, PlayDefaultMusic, TextScriptEnd. Only that
|
||||
-- last step closes the box, so the whole dance plays out unskippably
|
||||
-- (#249). Music.playOnce's pendingRestore stands in for both the channel
|
||||
-- poll and PlayDefaultMusic, so the Center's theme comes back the frame
|
||||
-- the song ends rather than 48 frames later.
|
||||
local JIGGLYPUFF_SPIN = { "down", "left", "up", "right" }
|
||||
local JIGGLYPUFF_SILENCE, JIGGLYPUFF_STEP, JIGGLYPUFF_TAIL = 32, 24, 48
|
||||
|
||||
-- Built as a TextBox `auto` table: auto.sound fires the frame the last
|
||||
-- page has typed out (PrintText returning), and auto.tick then runs once
|
||||
-- per frame while the gate it returns still reads as playing.
|
||||
local function jigglypuffDance(game, npc)
|
||||
local Music = require("src.core.Music")
|
||||
-- .findMatchingFacingDirectionLoop: the rotation picks up at the entry
|
||||
-- matching the sprite's current facing (showMapText has just turned it
|
||||
-- toward the player), and the first write is that same facing, so the
|
||||
-- first visible quarter turn lands 24 frames in
|
||||
local step = 1
|
||||
for i, dir in ipairs(JIGGLYPUFF_SPIN) do
|
||||
if npc and npc.facing == dir then step = i end
|
||||
end
|
||||
local frames, phase = 0, "silence"
|
||||
return {
|
||||
sound = function()
|
||||
Music.stop() -- SFX_STOP_ALL_MUSIC
|
||||
return { isPlaying = function() return phase ~= "done" end }
|
||||
end,
|
||||
tick = function()
|
||||
frames = frames + 1
|
||||
if phase == "silence" then
|
||||
if frames < JIGGLYPUFF_SILENCE then return end
|
||||
frames = 0
|
||||
if Music.playOnce(game.data, "Music_JigglypuffSong") then
|
||||
phase = "spin"
|
||||
else
|
||||
-- no song def (or headless): Music.stop above already took the
|
||||
-- map theme down and nothing armed pendingRestore, so put it
|
||||
-- back by hand and fall through to the tail rather than hold
|
||||
-- the box on a poll that would never clear
|
||||
Music.restoreMap(game.data)
|
||||
phase = "tail"
|
||||
end
|
||||
return
|
||||
end
|
||||
if phase == "spin" then
|
||||
if frames < JIGGLYPUFF_STEP then return end
|
||||
frames = 0
|
||||
-- the loop tests the channels after the delay and before the next
|
||||
-- write, so a song that just ended costs no extra quarter turn
|
||||
if not Music.oneShotPlaying() then
|
||||
phase = "tail"
|
||||
return
|
||||
end
|
||||
step = step % #JIGGLYPUFF_SPIN + 1
|
||||
if npc then npc.facing = JIGGLYPUFF_SPIN[step] end
|
||||
return
|
||||
end
|
||||
if frames >= JIGGLYPUFF_TAIL then phase = "done" end
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
M.PEWTER_POKECENTER = {
|
||||
talk = {
|
||||
TEXT_PEWTERPOKECENTER_JIGGLYPUFF = function(game, ow, npc, done)
|
||||
require("src.core.Music").playOnce(game.data, "Music_JigglypuffSong")
|
||||
push(game, text(game)._PewterPokecenterJigglypuffText
|
||||
or "JIGGLYPUFF: Puu\npupuu!", done)
|
||||
-- not the local push() helper: this box needs opts.auto, which is
|
||||
-- also what suppresses the blinking arrow and the A dismissal
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
text(game)._PewterPokecenterJigglypuffText or "JIGGLYPUFF: Puu\npupuu!",
|
||||
done, { auto = jigglypuffDance(game, npc) }))
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -173,7 +173,46 @@ local function syncGymGatesAfterBattle(game, ow)
|
||||
applyGymGates(game, ow)
|
||||
end
|
||||
|
||||
-- Yellow's quiz-first rule (scripts/CinnabarGym.asm SuperNerd2..7): a
|
||||
-- gate guardian refuses to battle until his quiz was attempted -- a
|
||||
-- wrong answer sics him on you (the onInteract path below), a right one
|
||||
-- opens his gate; walking up and talking first just gets the room's
|
||||
-- CinnabarGymText_N lecture (Func_f2150's pointer table).
|
||||
local function yellowQuizTalk(machineIndex)
|
||||
return function(game, ow, npc, done)
|
||||
local yellow = require("src.core.GameVersion").isYellow()
|
||||
local defeated = ow:trainerDefeated(npc)
|
||||
local gateOpen = game.save.flags[gymGateFlag(machineIndex)]
|
||||
if yellow and not defeated and not gateOpen then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
game.stack:push(TextBox.new(game,
|
||||
t["_CinnabarGymText_" .. machineIndex]
|
||||
or "You have to take\nthe quiz first!", done))
|
||||
return
|
||||
end
|
||||
-- gate open (or Red/Blue): the ordinary trainer engagement /
|
||||
-- after-battle line, mirroring talkTo's generic trainer branch
|
||||
npc:facePlayer(ow.player)
|
||||
if not defeated then
|
||||
ow:engageTrainer(npc, done)
|
||||
return
|
||||
end
|
||||
local header = game.data:trainerHeader(ow.map.def.label, npc.def.index)
|
||||
local after = header and header.after and game.data.text[header.after]
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, after or "...", done))
|
||||
end
|
||||
end
|
||||
|
||||
M.CINNABAR_GYM = {
|
||||
talk = (function()
|
||||
local talk = {}
|
||||
for i = 1, 6 do
|
||||
talk["TEXT_CINNABARGYM_SUPER_NERD" .. (i + 1)] = yellowQuizTalk(i)
|
||||
end
|
||||
return talk
|
||||
end)(),
|
||||
onEnter = applyGymGates,
|
||||
onVictory = syncGymGatesAfterBattle,
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
-- `dialogue` is the end-battle + post-battle text chain each gym leader
|
||||
-- runs (SaveEndBattleTextPointers then the map's *PostBattle / ReceiveTM
|
||||
-- script). Leaders are not def_trainers entries, so engageTrainer has
|
||||
-- no header.won — checkVictoryRewards shows this chain instead of a
|
||||
-- no header.won -- checkVictoryRewards shows this chain instead of a
|
||||
-- synthetic "received badge/TM" stub.
|
||||
|
||||
local function range(prefix, first, last)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
-- Summer Beach House (scripts/SummerBeachHouse.asm), Yellow's Route 19
|
||||
-- surf shack. The Surfin' Dude only lets a party Pikachu that knows
|
||||
-- SURF ride (IsSurfingPikachuInParty, home/map_objects.asm); saying yes
|
||||
-- runs the Surfing Pikachu minigame (src/ui/SurfingMinigame.lua). The
|
||||
-- corner printer shows/prints the high score once you have surfed this
|
||||
-- visit (BIT_PIKACHU_MAP_SURF_SELECT is a per-map-load flag, so the
|
||||
-- session markers live on the overworld state, not the save).
|
||||
|
||||
local function surfingPikachu(game)
|
||||
for _, mon in ipairs(game.save.party or {}) do
|
||||
if mon.species == "PIKACHU" then
|
||||
for _, mv in ipairs(mon.moves or {}) do
|
||||
if mv.id == "SURF" then return mon end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function push(game, text, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, text, done))
|
||||
end
|
||||
|
||||
-- the two-variant posters: the surf-capable line once a surfing
|
||||
-- Pikachu is along, the plain one otherwise
|
||||
local function poster(n)
|
||||
return function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
local key = ("_SummerBeachHousePoster%dText%d"):format(
|
||||
n, surfingPikachu(game) and 1 or 2)
|
||||
push(game, t[key] or "A surfing poster.", done)
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
SUMMER_BEACH_HOUSE = {
|
||||
talk = {
|
||||
TEXT_SUMMERBEACHHOUSE_SURFINDUDE = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
if not surfingPikachu(game) then
|
||||
push(game, t._SummerBeachHouseSurfinDudeText4
|
||||
or "Dogs and burgers\non special today!", done)
|
||||
return
|
||||
end
|
||||
-- Text1 on the first ask each visit, the short Text3 after
|
||||
local ask = ow.surfinDudeAsked
|
||||
and (t._SummerBeachHouseSurfinDudeText3 or "Wanna go SURF?")
|
||||
or (t._SummerBeachHouseSurfinDudeText1
|
||||
or "Whoa!\nYour PIKACHU knows\nhow to SURF!\fGive it a go?")
|
||||
ow.surfinDudeAsked = true
|
||||
push(game, ask, function()
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
push(game, t._SummerBeachHouseSurfinDudeText2
|
||||
or "Come SURF anytime,\nmy friend!", done)
|
||||
return
|
||||
end
|
||||
local SurfingMinigame = require("src.ui.SurfingMinigame")
|
||||
game.stack:push(SurfingMinigame.new(game, function()
|
||||
ow.surfedThisVisit = true -- BIT_PIKACHU_MAP_SURF_SELECT
|
||||
require("src.core.Music").playMap(game.data, ow.map.id)
|
||||
done()
|
||||
end))
|
||||
end))
|
||||
end)
|
||||
end,
|
||||
|
||||
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
|
||||
function()
|
||||
require("src.core.Sound").playCry(game.data, "PIKACHU")
|
||||
done()
|
||||
end)
|
||||
end,
|
||||
|
||||
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
|
||||
TEXT_SUMMERBEACHHOUSE_POSTER2 = poster(2),
|
||||
TEXT_SUMMERBEACHHOUSE_POSTER3 = poster(3),
|
||||
|
||||
TEXT_SUMMERBEACHHOUSE_PRINTER = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
if not surfingPikachu(game) then
|
||||
push(game, t._SummerBeachHousePrinterText1
|
||||
or "It's some sort of\na machine...", done)
|
||||
return
|
||||
end
|
||||
push(game, t._SummerBeachHousePrinterText2
|
||||
or "SUMMER BEACH HOUSE\nPRINTER, it says.", function()
|
||||
if not ow.surfedThisVisit then
|
||||
done()
|
||||
return
|
||||
end
|
||||
push(game, t._SummerBeachHousePrinterText3
|
||||
or "The Hi-Score is\nshown.\fPRINT it out?", function()
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
done()
|
||||
return
|
||||
end
|
||||
-- PrintSurfingMinigameHighScore -> PNG stand-in
|
||||
local Printer = require("src.core.Printer")
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local hi = game.save.surfingHighScore or 0
|
||||
local name = game.save.player.name or "RED"
|
||||
local saved, err = Printer.save("surf_hiscore", 160, 64,
|
||||
function()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 64)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", 2.5, 2.5, 155, 59)
|
||||
Font.draw(Strings("SUMMER BEACH HOUSE"), 8, 10)
|
||||
Font.draw(Strings("SURFING Hi-Score"), 8, 24)
|
||||
Font.draw(name, 8, 40)
|
||||
Font.draw(Strings("%d pts", hi), 96, 40)
|
||||
end)
|
||||
push(game, saved
|
||||
and Strings("Printed!\fSaved as\n%s\vin the save\nfolder.",
|
||||
saved)
|
||||
or Strings("Printer error!\n%s", tostring(err)), done)
|
||||
end))
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
-- Yellow's three Kanto-starter side-quests, hand-ported from
|
||||
-- scripts/CeruleanMelaniesHouse.asm, scripts/Route24.asm
|
||||
-- (Route24CooltrainerM4Text) and scripts/VermilionCity_2.asm
|
||||
-- (VermilionCityPrintOfficerJennyText). Registered on top of the shared
|
||||
-- Red map scripts by data/scripts/init.lua on a Yellow boot only, so the
|
||||
-- table keys compose with story.lua / story4.lua's existing entries.
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Melanie hands over her Bulbasaur once the starter Pikachu trusts you
|
||||
-- (wPikachuHappiness >= 147).
|
||||
M.CERULEAN_MELANIES_HOUSE = {
|
||||
talk = {
|
||||
TEXT_CERULEANMELANIESHOUSE_MELANIE = function(game, ow, npc, done)
|
||||
local rows = { { "face_player" } }
|
||||
if game.save.flags.EVENT_GOT_BULBASAUR_IN_CERULEAN then
|
||||
rows[#rows + 1] = { "show_text", "MelanieText4" }
|
||||
elseif (game.save.pikachuHappiness or 90) < 147 then
|
||||
rows[#rows + 1] = { "show_text", "MelanieText1" }
|
||||
else
|
||||
rows[#rows + 1] = { "show_text", "MelanieText1" }
|
||||
rows[#rows + 1] = { "ask", "MelanieText2" }
|
||||
rows[#rows + 1] = { "jump_if_false", "declined" }
|
||||
rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 }
|
||||
rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full
|
||||
rows[#rows + 1] = { "show_text", "MelanieText3" }
|
||||
rows[#rows + 1] = { "hide_object", "CERULEAN_MELANIES_HOUSE",
|
||||
"CERULEANMELANIESHOUSE_BULBASAUR" }
|
||||
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "MelanieText5" }
|
||||
end
|
||||
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||
end,
|
||||
-- pet flavor: the text with the species' cry over it
|
||||
TEXT_CERULEANMELANIESHOUSE_BULBASAUR = {
|
||||
{ "play_cry", "BULBASAUR" },
|
||||
{ "show_text", "MelanieBulbasaurText" },
|
||||
},
|
||||
TEXT_CERULEANMELANIESHOUSE_ODDISH = {
|
||||
{ "play_cry", "ODDISH" },
|
||||
{ "show_text", "MelanieOddishText" },
|
||||
},
|
||||
TEXT_CERULEANMELANIESHOUSE_SANDSHREW = {
|
||||
{ "play_cry", "SANDSHREW" },
|
||||
{ "show_text", "MelanieSandshrewText" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- Damian gives away the Charmander he thinks is too weak (EVENT_54F).
|
||||
M.ROUTE_24 = {
|
||||
talk = {
|
||||
TEXT_ROUTE24_COOLTRAINER_M4 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_54F" },
|
||||
{ "jump_if_true", "after" },
|
||||
{ "ask", "_Route24DamianText1" },
|
||||
{ "jump_if_false", "declined" },
|
||||
{ "give_pokemon", "CHARMANDER", 10 },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_Route24DamianText2" },
|
||||
{ "set_flag", "EVENT_54F" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "declined" },
|
||||
{ "show_text", "_Route24DamianText3" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "after" },
|
||||
{ "show_text", "_Route24DamianText4" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- Officer Jenny's Squirtle: kept until you carry the Thunder Badge.
|
||||
M.VERMILION_CITY = {
|
||||
talk = {
|
||||
TEXT_VERMILIONCITY_OFFICER_JENNY = function(game, ow, npc, done)
|
||||
local rows = { { "face_player" } }
|
||||
if game.save.flags.EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY then
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText5" }
|
||||
elseif not (game.save.inventory
|
||||
and game.save.inventory.THUNDERBADGE) then
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText1" }
|
||||
else
|
||||
rows[#rows + 1] = { "ask", "_OfficerJennyText2" }
|
||||
rows[#rows + 1] = { "jump_if_false", "declined" }
|
||||
rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 }
|
||||
rows[#rows + 1] = { "jump_if_false", "end" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
|
||||
rows[#rows + 1] = { "set_flag",
|
||||
"EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }
|
||||
rows[#rows + 1] = { "jump", "end" }
|
||||
rows[#rows + 1] = { "label", "declined" }
|
||||
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
|
||||
end
|
||||
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,261 @@
|
||||
-- Jessie & James, Yellow's Team Rocket duo, at all four ambush sites:
|
||||
-- Mt Moon B2F (scripts/MtMoonB2F.asm), Rocket Hideout B4F
|
||||
-- (scripts/RocketHideoutB4F.asm), Pokemon Tower 7F
|
||||
-- (scripts/PokemonTower7F.asm) and Silph Co 11F (scripts/SilphCo11F.asm).
|
||||
-- Every site shares one shape: a coordinate trigger swaps the map theme
|
||||
-- for Music_MeetJessieJames, the motto plays, the duo closes in, one
|
||||
-- battle against the shared OPP_ROCKET party fights them both, and after
|
||||
-- their parting lines they vanish together under a second sting of the
|
||||
-- theme before the map theme resumes (PlayDefaultMusic ->
|
||||
-- play_default_music).
|
||||
--
|
||||
-- Registered on top of the shared tables by data/scripts/init.lua on a
|
||||
-- Yellow boot; MT_MOON_B2F's onStep chains story2's Super Nerd / fossil
|
||||
-- trigger and SILPH_CO_11F's chains story's Giovanni trigger, since
|
||||
-- non-talk hooks replace rather than merge.
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Capture the FUNCTION, not the table: attachBase stores the module
|
||||
-- table itself, so once this file's onStep is attached the table's slot
|
||||
-- points back here -- delegating through the table would self-recurse.
|
||||
local baseMtMoonStep = require("data.scripts.story2").MT_MOON_B2F.onStep
|
||||
local baseSilph11Step = require("data.scripts.story").SILPH_CO_11F.onStep
|
||||
|
||||
M.MT_MOON_B2F = {
|
||||
talk = {
|
||||
TEXT_MTMOONB2F_JESSIE = {
|
||||
{ "face_player" }, { "show_text", "_MtMoonJessieJamesText1" },
|
||||
},
|
||||
TEXT_MTMOONB2F_JAMES = {
|
||||
{ "face_player" }, { "show_text", "_MtMoonJessieJamesText1" },
|
||||
},
|
||||
},
|
||||
|
||||
onStep = function(game, ow, x, y)
|
||||
if baseMtMoonStep and baseMtMoonStep(game, ow, x, y) then
|
||||
return true
|
||||
end
|
||||
local f = game.save.flags
|
||||
if x ~= 3 or y ~= 5 then return false end
|
||||
if f.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES then return false end
|
||||
if not (f.EVENT_GOT_DOME_FOSSIL or f.EVENT_GOT_HELIX_FOSSIL) then
|
||||
return false
|
||||
end
|
||||
ow.runner:run({
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
|
||||
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
||||
{ "show_text", "_MtMoonJessieJamesText1" },
|
||||
{ "face_player_dir", "right" },
|
||||
{ "show_text", "_MtMoonJessieJamesText2" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_MtMoonJessieJamesText3" },
|
||||
{ "show_text", "_MtMoonJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
|
||||
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
||||
{ "play_default_music" },
|
||||
{ "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" },
|
||||
}, {})
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Rocket Hideout B4F (RocketHideoutB4FScript_455a5..Script13): the
|
||||
-- motto plays from off-screen FIRST, then the duo pops in at (25,10) /
|
||||
-- (24,10) and whichever of them shares the player's column ($18=24 or
|
||||
-- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three
|
||||
-- tiles down to loom over the player while the other steps one. A loss
|
||||
-- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the
|
||||
-- trigger re-arms clean.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.ROCKET_HIDEOUT_B4F = {
|
||||
talk = {
|
||||
TEXT_ROCKETHIDEOUTB4F_JESSIE = {
|
||||
{ "face_player" }, { "show_text", "_RocketHideoutJessieJamesText1" },
|
||||
},
|
||||
TEXT_ROCKETHIDEOUTB4F_JAMES = {
|
||||
{ "face_player" }, { "show_text", "_RocketHideoutJessieJamesText1" },
|
||||
},
|
||||
},
|
||||
|
||||
onStep = function(game, ow, x, y)
|
||||
local f = game.save.flags
|
||||
if y ~= 14 or (x ~= 24 and x ~= 25) then return false end
|
||||
if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end
|
||||
-- ON_LEFT: player under James's column (25); movement data pairs
|
||||
-- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the
|
||||
-- column-mate walks 3, the other 1.
|
||||
local onLeft = (x == 25)
|
||||
ow.runner:run({
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText1" },
|
||||
{ "face_player_dir", "up" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||
-- James (object 2) then Jessie (object 3), Script4..Script9 order
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
|
||||
{ "face_object", 2, onLeft and "down" or "left" },
|
||||
{ "walk_npc", 3, onLeft and { "down" } or { "down", "down", "down" } },
|
||||
{ "face_object", 3, onLeft and "right" or "down" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText2" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 43 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "lost" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText3" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "fade", "out" },
|
||||
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||
{ "fade", "in" },
|
||||
{ "play_default_music" },
|
||||
{ "set_flag", "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "lost" },
|
||||
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||
}, {})
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Pokemon Tower 7F (PokemonTower7FScript_60d2a..Script10): same beat
|
||||
-- one floor below Fuji, except the duo pops in BEFORE the motto and
|
||||
-- Jessie ($a=10 column) leads the walk-down; ON_LEFT ($b=11) hands the
|
||||
-- three-tile walk to James instead. On a loss vanilla only resets the
|
||||
-- script counter (the blackout warp reloads the map anyway).
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.POKEMON_TOWER_7F = {
|
||||
talk = {
|
||||
TEXT_POKEMONTOWER7F_JESSIE = {
|
||||
{ "face_player" }, { "show_text", "_PokemonTowerJessieJamesText1" },
|
||||
},
|
||||
TEXT_POKEMONTOWER7F_JAMES = {
|
||||
{ "face_player" }, { "show_text", "_PokemonTowerJessieJamesText1" },
|
||||
},
|
||||
},
|
||||
|
||||
onStep = function(game, ow, x, y)
|
||||
local f = game.save.flags
|
||||
if y ~= 12 or (x ~= 10 and x ~= 11) then return false end
|
||||
if f.EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES then return false end
|
||||
local onLeft = (x == 11) -- EVENT_POKEMONTOWER_7_JESSIE_JAMES_ON_LEFT
|
||||
ow.runner:run({
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "show_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JESSIE" },
|
||||
{ "show_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JAMES" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText1" },
|
||||
{ "face_player_dir", "up" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
-- Jessie (object 1) then James (object 2), Script1..Script6 order
|
||||
{ "walk_npc", 1, onLeft and { "down" } or { "down", "down", "down" } },
|
||||
{ "face_object", 1, onLeft and "right" or "down" },
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
|
||||
{ "face_object", 2, onLeft and "down" or "left" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText2" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 44 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText3" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "fade", "out" },
|
||||
{ "hide_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JESSIE" },
|
||||
{ "hide_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JAMES" },
|
||||
{ "fade", "in" },
|
||||
{ "play_default_music" },
|
||||
{ "set_flag", "EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES" },
|
||||
}, {})
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Silph Co 11F (SilphCo11FScript_6229c..Script14): the only site where
|
||||
-- the duo starts VISIBLE (toggleable_objects.asm keeps SILPHCO11F_JAMES
|
||||
-- / _JESSIE ON), flanking Giovanni at (2,8)/(3,8), so they are talkable
|
||||
-- before the ambush (SilphCo11FJessieJamesText = the full motto). The
|
||||
-- trigger is the top row (y=3, x<4); EVENT_780/EVENT_781 pick one of
|
||||
-- three approach paths (SilphCo11FMovementData_622f5..62311, $5=up
|
||||
-- $6=left) that route James then Jessie up to the player without ever
|
||||
-- crossing the player's tile. Their duo text lives in
|
||||
-- text/SilphCo10F.asm (_SilphCoJessieJamesText*).
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.SILPH_CO_11F = {
|
||||
talk = {
|
||||
TEXT_SILPHCO11F_JESSIE = {
|
||||
{ "face_player" }, { "show_text", "_SilphCoJessieJamesText1" },
|
||||
},
|
||||
TEXT_SILPHCO11F_JAMES = {
|
||||
{ "face_player" }, { "show_text", "_SilphCoJessieJamesText1" },
|
||||
},
|
||||
},
|
||||
|
||||
onStep = function(game, ow, x, y)
|
||||
if baseSilph11Step and baseSilph11Step(game, ow, x, y) then
|
||||
return true
|
||||
end
|
||||
local f = game.save.flags
|
||||
if y ~= 3 or x > 3 then return false end
|
||||
if f.EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES then return false end
|
||||
-- x==3 -> base path, x==2 -> EVENT_780 variant, x<=1 -> EVENT_781
|
||||
local jamesDirs, jamesFace, jessieDirs, jessieFace
|
||||
if x == 3 then
|
||||
jamesDirs, jamesFace = { "up", "up", "up", "up", "up" }, "right"
|
||||
jessieDirs, jessieFace = { "up", "up", "up", "up" }, "up"
|
||||
elseif x == 2 then
|
||||
jamesDirs, jamesFace = { "up", "up", "up", "up" }, "up"
|
||||
jessieDirs, jessieFace = { "up", "up", "up", "up", "up" }, "left"
|
||||
else
|
||||
jamesDirs = { "up", "up", "left", "up", "up" }
|
||||
jamesFace = "up"
|
||||
jessieDirs = { "up", "up", "up", "left", "up", "up" }
|
||||
jessieFace = "left"
|
||||
end
|
||||
ow.runner:run({
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "show_text", "_SilphCoJessieJamesText1" },
|
||||
{ "face_player_dir", "down" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
-- James (object 4) then Jessie (object 6), Script5..Script10 order
|
||||
{ "walk_npc", 4, jamesDirs },
|
||||
{ "face_object", 4, jamesFace },
|
||||
{ "walk_npc", 6, jessieDirs },
|
||||
{ "face_object", 6, jessieFace },
|
||||
{ "show_text", "_SilphCoJessieJamesText2" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 45 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_SilphCoJessieJamesText3" },
|
||||
{ "show_text", "_SilphCoJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
{ "fade", "out" },
|
||||
{ "hide_object", "SILPH_CO_11F", "SILPHCO11F_JAMES" },
|
||||
{ "hide_object", "SILPH_CO_11F", "SILPHCO11F_JESSIE" },
|
||||
{ "fade", "in" },
|
||||
{ "play_default_music" },
|
||||
{ "set_flag", "EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES" },
|
||||
}, {})
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,91 @@
|
||||
# Anbernic RG34XXSP (Stock OS 64-bit MOD)
|
||||
|
||||
Download `gen1recomp-*-rg34xxsp-stockos64-mod.zip` from
|
||||
[Releases](https://github.com/bryanthaboi/gen1recomp/releases). This build
|
||||
targets **Stock OS 64-bit MOD** on the RG34XXSP with PortMaster installed
|
||||
(TF1).
|
||||
|
||||
## Install
|
||||
|
||||
1. Unzip the release on your computer. You get `Gen1recomp.sh` and a
|
||||
`gen1recomp/` folder.
|
||||
2. Copy **both** onto the SD card under **`Roms/PORTS/`** so the layout is:
|
||||
|
||||
```
|
||||
Roms/PORTS/Gen1recomp.sh
|
||||
Roms/PORTS/gen1recomp/
|
||||
```
|
||||
|
||||
On the device that path is `/mnt/mmc/Roms/PORTS/`. Keep the launcher and
|
||||
the `gen1recomp/` folder as siblings — do not nest the `.sh` inside the
|
||||
folder.
|
||||
3. Put your legal US Red and/or Blue `.gb` files inside the game folder:
|
||||
|
||||
```
|
||||
Roms/PORTS/gen1recomp/lovegame/
|
||||
```
|
||||
|
||||
Example: `Roms/PORTS/gen1recomp/lovegame/Pokemon - Red Version.gb`
|
||||
4. Eject the card, boot the handheld, open **Ports → Gen1recomp**.
|
||||
5. On the launcher, move the cursor with the D-pad or left stick, press **A**
|
||||
to click. Choose the Red or Blue tab, then **Choose ROM** — with no file
|
||||
picker on stock OS, that scans `lovegame/` for the `.gb` you dropped in.
|
||||
|
||||
The port ships with `portable.txt` already in place, so after import the
|
||||
saves and ROM-derived cache stay next to the game on the SD card. See
|
||||
[Portable Mode](../README.md#portable-mode) for what that means.
|
||||
|
||||
Only the canonical 1 MiB US carts import:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
|
||||
## Launcher controls
|
||||
|
||||
| Input | Action |
|
||||
| ------------------ | ------------------ |
|
||||
| D-pad / left stick | Move cursor |
|
||||
| A | Click |
|
||||
| L1 / R1 | Switch tabs |
|
||||
| Right stick | Scroll lists |
|
||||
| Start / Select | Play or Choose ROM |
|
||||
|
||||
In-game controls use the normal PortMaster / SDL pad map, rebindable under
|
||||
**OPTIONS → CONTROLS**.
|
||||
|
||||
## Notes
|
||||
|
||||
**GBC FX is off on this device.** The launcher exports `POKEPORT_GBCFX=0`,
|
||||
which hides the GBC FX row from OPTIONS, pins the level to OFF, and clears a
|
||||
level carried over in an `options.lua` from another machine. The H700's Mali
|
||||
GPU is in the same class as the phone GPUs that compile that present pass and
|
||||
then show a black frame (issue #136), and `love.system.getOS()` reports
|
||||
`"Linux"` here, so the Android gate would not have caught it. Every other
|
||||
display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
|
||||
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
|
||||
put the row back.
|
||||
|
||||
The pack bundles the LÖVE 11.5 aarch64 runtime from
|
||||
[PortMaster](https://portmaster.games/), so the device does not need a
|
||||
separate `love_11.5` runtime download on first launch. The launcher resolves
|
||||
paths relative to its own directory rather than PortMaster's `$directory`,
|
||||
because stock Anbernic firmware runs ports out of `roms/PORTS/` with its own
|
||||
casing and mount points.
|
||||
|
||||
If a launch fails, `Roms/PORTS/gen1recomp/log.txt` holds the output of the
|
||||
last run.
|
||||
|
||||
## Building the port
|
||||
|
||||
Release runs build this automatically (see `.github/workflows/release.yml`).
|
||||
To build it by hand:
|
||||
|
||||
```sh
|
||||
./build-rg34xxsp.sh --version 0.1.0 # -> dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip
|
||||
```
|
||||
|
||||
`install-rg34xxsp.sh` copies that pack straight onto a mounted SD card for
|
||||
local testing.
|
||||
|
||||
Stock OS 64-bit MOD comes from
|
||||
[cbepx-me](https://github.com/cbepx-me/Anbernic-H700-RG-xx-StockOS-Modification).
|
||||
@@ -59,7 +59,7 @@ the same core data and graphics into the source tree for verification.
|
||||
| | `src/battle/Experience.lua`, `Catching.lua`, `TrainerAI.lua` | exp/levels, Gen 1 catch algorithm, AI |
|
||||
| | `src/battle/rulesets/` | `gen1_faithful` (default) vs `modern_clean` |
|
||||
| ui | `src/ui/*` | start menu, generic menu, yes/no box, party/bag lists |
|
||||
| | `tools/save-editor/` | Standalone save editor (`love . --editor`) |
|
||||
| | `tools/save-editor/` | Save editor: shipped in every build, opened from the launcher's Edit button or standalone with `love . --editor` |
|
||||
|
||||
## Map scripts
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# Building a Blue Version of the port
|
||||
|
||||
pokered builds Red and Blue from one source tree: the Makefile
|
||||
assembles everything twice with `rgbasm -D _RED` or `-D _BLUE`, and
|
||||
every in-game difference sits in an `IF DEF(_RED)` / `IF DEF(_BLUE)`
|
||||
block. Our extraction pipeline resolves those conditionals the same
|
||||
way (`tools/extract/util.py` `ASM_DEFINES`), so most of a Blue build
|
||||
falls out of re-running the extractors. Only three things are
|
||||
hand-ported on the Lua side and need swapping by hand.
|
||||
|
||||
## What differs between Red and Blue
|
||||
|
||||
| Where (pokered) | What |
|
||||
| --- | --- |
|
||||
| `data/wild/maps/*.asm` (34 files) | Version-exclusive encounters (Red: Ekans, Oddish, Growlithe, Mankey, Scyther, Electabuzz, Blue: Sandshrew, Bellsprout, Vulpix, Meowth, Pinsir, Magmar) |
|
||||
| `data/pokemon/title_mons.asm` | The 16 title-screen Pokémon |
|
||||
| `engine/movie/title.asm` + `gfx/version.asm` | The "Red Version" / "Blue Version" ribbon |
|
||||
| `constants/player_constants.asm` | Preset names (Red: RED/ASH/JACK + BLUE/GARY/JOHN) |
|
||||
| `data/sgb/sgb_palettes.asm`, `sgb_border.asm` | Super Game Boy palettes and border |
|
||||
| `data/events/prizes.asm`, `prize_mon_levels.asm` | Game Corner prize mons, costs and levels |
|
||||
| `engine/movie/intro.asm`, `data/credits/credits_text.asm`, `engine/slots/slot_machine.asm`, `engine/battle/animations.asm`, `audio/sfx/save_3.asm` | Small gated tweaks (credits say "BLUE VERSION STAFF", etc.) |
|
||||
|
||||
## Step 1, flip the extraction define
|
||||
|
||||
`tools/extract/util.py`:
|
||||
|
||||
```python
|
||||
ASM_DEFINES = {"_RED"} # -> {"_BLUE"}
|
||||
```
|
||||
|
||||
Then regenerate everything (same as scripts/setup.sh does):
|
||||
|
||||
```sh
|
||||
cd tools
|
||||
../.venv/bin/python3 build_data.py \
|
||||
--pokered /Users/bryanbassett/Documents/development/pokered \
|
||||
--out ../data/generated --assets ../assets/generated
|
||||
```
|
||||
|
||||
This alone switches the wild encounters, preset names, SGB palettes
|
||||
and credits text. **Caveat:** `parse_preset_names` sanity-checks in
|
||||
`tools/extract/field.py:1193` expect "RED" in the player presets,
|
||||
relax that check for a Blue build (Blue's presets are BLUE/GARY/JOHN
|
||||
for the player and RED/ASH/JACK for the rival).
|
||||
|
||||
`tools/extract/palettes.py` uses its own raw reader with hardcoded
|
||||
`IF DEF(_BLUE)` skipping (around line 44), invert that too, or port
|
||||
it to `read_asm` so `ASM_DEFINES` covers it.
|
||||
|
||||
## Step 2, title screen (hand-ported)
|
||||
|
||||
`src/ui/TitleState.lua`:
|
||||
|
||||
1. **Ribbon art.** The extractor writes
|
||||
`assets/generated/title/red_version.png`; add `blue_version.png`
|
||||
to `gfx.extract_title` in `tools/extract/gfx.py` (source:
|
||||
`gfx/title/blue_version.png`, 64×8). In `TitleState:draw()`, the
|
||||
Red strip needs two quads (tiles 0–1 "Red", skip, tiles 5–9
|
||||
"Version", `title.asm` `VersionOnTitleScreenText`). Blue's strip
|
||||
prints its tiles contiguously (`db $61..$68`), so draw the whole
|
||||
64×8 image at px (56, 64) and verify with the title driver
|
||||
screenshot.
|
||||
|
||||
2. **Title mons.** Replace `CYCLE_SPECIES` with Blue's list from
|
||||
`data/pokemon/title_mons.asm`:
|
||||
|
||||
```lua
|
||||
local CYCLE_SPECIES = {
|
||||
"SQUIRTLE", "CHARMANDER", "BULBASAUR", "MANKEY", "HITMONLEE",
|
||||
"VULPIX", "CHANSEY", "AERODACTYL", "JOLTEON", "SNORLAX",
|
||||
"GLOOM", "POLIWAG", "DODUO", "PORYGON", "GENGAR", "RAICHU",
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3, Game Corner prizes (hand-ported)
|
||||
|
||||
`data/scripts/story3.lua` (~line 209) carries the Red prize tables.
|
||||
Blue's values (`prizes.asm` + `prize_mon_levels.asm`):
|
||||
|
||||
| Prize | Cost | Level |
|
||||
| --- | --- | --- |
|
||||
| ABRA | 120 | 6 |
|
||||
| CLEFAIRY | 750 | 12 |
|
||||
| NIDORINO | 1200 | 17 |
|
||||
| PINSIR | 2500 | 20 |
|
||||
| DRATINI | 4600 | 24 |
|
||||
| PORYGON | 6500 | 18 |
|
||||
|
||||
(TM prizes are identical in both versions.)
|
||||
|
||||
## Step 4, verify
|
||||
|
||||
```sh
|
||||
luajit tests/run_tests.lua
|
||||
```
|
||||
|
||||
Plus two spot checks:
|
||||
|
||||
```sh
|
||||
# every grass table must have exactly 10 slots (a conditional-parsing
|
||||
# regression shows up as 19–20 slots)
|
||||
luajit -e 'local e=dofile("data/generated/encounters.lua")
|
||||
for m,d in pairs(e) do if type(d)=="table" and d.grass and #d.grass.slots>0
|
||||
and #d.grass.slots~=10 then print("BAD",m,#d.grass.slots) end end'
|
||||
|
||||
# a Blue exclusive should now appear (and Growlithe should not)
|
||||
grep -c VULPIX data/generated/encounters.lua
|
||||
grep -c GROWLITHE data/generated/encounters.lua
|
||||
```
|
||||
|
||||
Title screenshot: run the driver in
|
||||
`tests/drivers/` style (`SHOT_DIR=... POKEPORT_DRIVER=... love .`) and
|
||||
eyeball the ribbon, title mon, and copyright row.
|
||||
|
||||
## What you get for free / what to skip
|
||||
|
||||
- Free after re-extraction: encounters (incl. Super Rod groups), SGB
|
||||
palettes, preset names, credits text, the gated sfx/animation
|
||||
tweaks.
|
||||
- Trades, gift Pokémon, story scripts, gym data: identical in Western
|
||||
Red/Blue, nothing to touch.
|
||||
- Save files: a Red save loads fine, but dex AREA nests and new
|
||||
encounters will be Blue's. Trainer parties are identical.
|
||||
|
||||
## Making it a runtime toggle instead
|
||||
|
||||
If you want one build with both versions, extraction would need to
|
||||
emit both branches keyed by version (e.g. `slots` / `slotsBlue`) and
|
||||
the three hand-ported spots would read a `save.version` or
|
||||
`conf.lua` flag. That is a bigger change than the rebuild above,
|
||||
the flip-and-regenerate route needs no engine changes at all.
|
||||
@@ -69,6 +69,15 @@ keeps working unchanged.
|
||||
- **Registry.** The ordered slot list and which one is active persist in
|
||||
`options.lua` (via the existing `SaveData.loadOptions`/`saveOptions`):
|
||||
`options.saveSlots = { [version] = { list = {"slot1", ...}, active = "slot1" } }`.
|
||||
Custom slot labels (#205) live alongside them in the same registry:
|
||||
`options.saveSlots[version].names = { slot1 = "Nuzlocke" }`, written by
|
||||
`SaveData.renameSlot` (trimmed; an empty label clears it) and surfaced on
|
||||
each `listSlots` row as `label` (the launcher row shows `label`, falling
|
||||
back to the player name). `deleteSlot` drops the label with the slot.
|
||||
Renaming never touches the save file, so an empty slot can be labeled.
|
||||
On desktop, right-clicking a slot row opens the inline rename modal
|
||||
(Enter commits, Esc cancels); touch has no secondary button, so the
|
||||
affordance is desktop-only.
|
||||
- **Active slot resolution.** `saveNames(version)`, the function every
|
||||
existing caller (`TitleState` hasSave/load/save, recovery order) already
|
||||
goes through, now resolves the *active* slot instead of a fixed flat name.
|
||||
@@ -193,3 +202,64 @@ column, slot card below Play, when the window is too narrow for both
|
||||
(wheel, or drag on touch/desktop) clamped to their own content extent,
|
||||
recomputed every draw. The tab bar labels only the active chip so it stays
|
||||
narrow-safe, and content caps out at `~1440 * s` wide, centered.
|
||||
|
||||
The desktop window has a floor of 480x360 (`conf.lua` `minwidth`/`minheight`),
|
||||
under which the cards stop being readable at all. Mobile ignores it: those
|
||||
windows are fullscreen.
|
||||
|
||||
### Page scroll
|
||||
|
||||
Two columns fit any window the launcher is likely to open in; one stacked
|
||||
column does not. On a phone-shaped window the ROM card, SAVE FILES, Play and
|
||||
SAVE SLOT together run past the bottom, and a footer pinned to the window
|
||||
bottom painted over them with the overflow unreachable.
|
||||
|
||||
So the whole column under the tab bar -- panel, updater banner, footer --
|
||||
scrolls as one page whenever it is taller than the room below the tab bar:
|
||||
|
||||
- The strip, logo and tab bar stay pinned, so navigation is always on screen.
|
||||
Everything else draws at `contentTop - pageScroll` inside a scissor, and the
|
||||
footer is laid out downward from `footerTop` right after the content instead
|
||||
of upward from the window bottom.
|
||||
- `RomImporter.pageScrollFor(naturalH, viewportH, scroll)` is the whole
|
||||
decision, pure and pinned by `tests/engine/launcher_page_scroll.lua`. A
|
||||
window that grows back drags the offset down with it, so the page can never
|
||||
stay parked past its own end.
|
||||
- The panels report their natural height as they draw (`_drawGamePanel` and
|
||||
`_drawModsPanel` return it), so the decision reads the previous frame's
|
||||
measurement -- the same one-frame settle the two lists already rely on.
|
||||
- **One scroll axis at a time.** While the page scrolls, the panels draw
|
||||
`paged`: the slot and mod lists take their natural height, keep no inner
|
||||
scroll region and report a max of 0, so the wheel, the right stick and a drag
|
||||
all move the page and never fight a list for the same gesture. Two-column
|
||||
layouts do not overflow, `paged` stays false, and every one of these behaves
|
||||
exactly as it did before.
|
||||
- Hit testing follows the clip: `inside` (clicks) and `_ptIn` (hover) reject a
|
||||
rect that scrolled out of the viewport, so a control that slid under the tab
|
||||
bar cannot be clicked through it. Tab chips carry `pinned = true` and are
|
||||
exempt. `pageScroll` resets on a tab change, each tab being a different
|
||||
length.
|
||||
- A press on empty background pans the page, resolved in `_updateSlotDrag` like
|
||||
every other drag here.
|
||||
|
||||
### Dragging on Android
|
||||
|
||||
The launcher is handed no move events on any platform: `main.lua` forwards
|
||||
neither `touchmoved` nor `mousemoved` while it is up, which is why every drag
|
||||
here is resolved by polling inside `draw` instead. Desktop polls the mouse;
|
||||
Android used to poll nothing at all ("no reliable pointer polling" meant its
|
||||
mouse emulation), so it had no scroll gesture whatsoever -- fine while every
|
||||
scroll region was an inner list with a wheel alternative, useless the moment
|
||||
the page itself became the thing that scrolls, since a phone is exactly where
|
||||
it overflows.
|
||||
|
||||
`love.touch` is pollable, so `_pointerHold` reads the first active touch there
|
||||
and hands `_updateSlotDrag` the same (held, y) pair the mouse gives on desktop.
|
||||
Consequences:
|
||||
|
||||
- Slot rows and mod toggles ARM on press and commit on release on Android too,
|
||||
matching desktop, so a swipe that starts on a card scrolls instead of
|
||||
selecting the row it started on.
|
||||
- `touchPollable` (set once in `new`) gates all of it. Where `love.touch` is
|
||||
missing, every Android path is exactly what it was: act on press, never arm,
|
||||
no drag.
|
||||
|
||||
+23
-6
@@ -1,23 +1,40 @@
|
||||
# Native modding
|
||||
|
||||
The modding book lives on the
|
||||
[project wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki).
|
||||
[project wiki](https://github.com/bryanthaboi/gen1recomp/wiki).
|
||||
|
||||
- [Getting started](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Getting-Started)
|
||||
- [Getting started](https://github.com/bryanthaboi/gen1recomp/wiki/Getting-Started)
|
||||
— install a mod, write a first one, enable and disable it.
|
||||
- [Tutorials](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Tutorials)
|
||||
- [Tutorials](https://github.com/bryanthaboi/gen1recomp/wiki/Tutorials)
|
||||
— twelve dependency-ordered rungs, each a runnable mod.
|
||||
- [Cookbook](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Cookbook)
|
||||
- [Cookbook](https://github.com/bryanthaboi/gen1recomp/wiki/Cookbook)
|
||||
— task-sized recipes.
|
||||
- [Registry reference](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Reference-Registries)
|
||||
- [Registry reference](https://github.com/bryanthaboi/gen1recomp/wiki/Reference-Registries)
|
||||
— every registry, generated from `src/mods/Schemas.lua`.
|
||||
|
||||
Regenerate the reference straight into a wiki checkout:
|
||||
|
||||
```sh
|
||||
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
|
||||
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
|
||||
```
|
||||
|
||||
## Editing maps in Tiled
|
||||
|
||||
Maps are data, not assets, so they can be authored in a real map editor and
|
||||
exported as a mod. `tools/tiled_export.py` builds a
|
||||
[Tiled](https://www.mapeditor.org) workspace out of the imported ROM cache:
|
||||
|
||||
```sh
|
||||
python3 tools/tiled_export.py # -> build/tiled/ (gitignored)
|
||||
```
|
||||
|
||||
Open `build/tiled/gen1.tiled-project`, edit any of the 222 maps (or
|
||||
`kanto.world` for the stitched overworld), and export with the
|
||||
`gen1-mod-export` extension — one map file, or a whole loadable mod folder.
|
||||
An edited vanilla map becomes a `mod.content.maps:patch` carrying only the
|
||||
fields that moved; a new map becomes a `:register`. See
|
||||
`docs/new-features.md` and the extension's own README.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
|
||||
+140
-6
@@ -86,7 +86,7 @@ Game Boy equivalent:
|
||||
## Colors mode
|
||||
|
||||
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||
through **OG RED → SGB → RED++ → OG → OG INV → SGB INV → CLASSIC → OG RED**.
|
||||
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**.
|
||||
The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||
|
||||
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
|
||||
@@ -97,8 +97,9 @@ The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||
- **SGB** (default): the per-map Super Game Boy region palettes
|
||||
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
||||
real SGB. (This is the mode formerly mislabeled "GBC".)
|
||||
- **RED++**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
||||
per-species mon colors (`data/palettes_gbc.lua`).
|
||||
- **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
||||
per-species mon colors (`data/palettes_gbc.lua`). (Formerly labeled
|
||||
"RED++"; it is the richest colorization rather than anything Red-specific.)
|
||||
- **OG**: force the four DMG grays (colorization off).
|
||||
- **OG INV**: inverted DMG grays.
|
||||
- **SGB INV**: each SGB zone palette with shade order reversed.
|
||||
@@ -109,7 +110,8 @@ The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
|
||||
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
|
||||
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
|
||||
override in the overworld pass). Persisted as `save.options.colors`; the
|
||||
`gbc` / `gbc_inv` save ids are kept for back-compat under the new labels.
|
||||
`gbc` / `gbc_inv` / `redpp` save ids are kept for back-compat under the new
|
||||
labels.
|
||||
|
||||
## GBC FX
|
||||
|
||||
@@ -127,6 +129,14 @@ It runs as a final present pass after world + UI composite in
|
||||
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
|
||||
Default OFF; persisted as `save.options.gbcfx`.
|
||||
|
||||
Mobile GPUs often compile the pass but present a black frame, so Android and
|
||||
iOS hide the row entirely, pin the level to OFF, and rewrite a level already
|
||||
persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that
|
||||
decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the
|
||||
effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
|
||||
its launcher because the device reports `"Linux"` while its GPU is in the
|
||||
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
|
||||
|
||||
## Peer-to-peer link play (lua-enet)
|
||||
|
||||
Trades and link battles connect two copies of the game directly over
|
||||
@@ -141,6 +151,30 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
|
||||
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
||||
also exercises real UDP pairing.
|
||||
|
||||
## Fair play in link and online matches
|
||||
|
||||
A link session is decided by the battle and nothing else, so for its
|
||||
duration:
|
||||
|
||||
- **Game speed is pinned to normal.** The GAME SPEED option and
|
||||
`POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it
|
||||
closes, and apply again after. Fast-forward otherwise runs one peer's
|
||||
queue faster than the peer it is locked to and drains a tournament shot
|
||||
clock faster than the opponent racing it.
|
||||
- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with
|
||||
mods enabled offers to switch them all off and relaunch (mods merge at
|
||||
boot, so a restart is the only way). The restart is confirmed, not
|
||||
silent. They stay listed as disabled, ready to switch back on.
|
||||
- **Only a meaningful split ends a match.** The per-turn state signature
|
||||
both peers exchange is split three ways: `actives` and `bench` carry
|
||||
species, HP, status, stat stages, PP and the rest of the party, and a
|
||||
divergence there ends the match as a draw. `volatile` carries per-turn
|
||||
flags both sides recompute anyway - a divergence there is logged and
|
||||
reported to mods, and play continues.
|
||||
|
||||
The relay logs which component diverged on which turn, so a desync report
|
||||
names something specific.
|
||||
|
||||
## Custom boot text
|
||||
|
||||
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
|
||||
@@ -161,8 +195,9 @@ migrated once into `options.lua` on load.
|
||||
- Music / SFX volume
|
||||
- Music Filter
|
||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||
- COLORS (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also
|
||||
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
|
||||
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
||||
- COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also
|
||||
hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc
|
||||
SuperPalettes + per-species mon colors)
|
||||
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
|
||||
@@ -181,6 +216,36 @@ void outside the OG wipe fills in lockstep. Once the battle state is up,
|
||||
letterbox voids around the battle canvas fill **white** instead of black
|
||||
so the whole window reads as one continuous battle screen.
|
||||
|
||||
## Widescreen battle layout
|
||||
|
||||
Options **BATTLE LAYOUT** picks the battle screen's composition: **OG**
|
||||
(the default: the original 160×144 arrangement, unchanged) or **WIDE**,
|
||||
which gives battles a 304×144 native-pixel surface and a Gen 3-style
|
||||
arrangement on it:
|
||||
|
||||
- the foe's status box upper left, the foe's picture upper right;
|
||||
- the player's picture lower left, the player's status box lower right,
|
||||
with a longer HP bar and the numeric HP under it;
|
||||
- a full-width message window;
|
||||
- a split "What will X do?" prompt / 2×2 command window;
|
||||
- a 2×2 move menu, navigated with all four directions, with a PP and type
|
||||
panel attached to its right.
|
||||
|
||||
Only the composition changes. Pictures, palettes, HP-bar colors, font
|
||||
pages, window borders, sounds, animations, timing and every battle rule
|
||||
stay the engine's, so a COLORS mode or an asset mod still owns the look.
|
||||
Each side's picture keeps its original pixels and placement math and is
|
||||
composited into its own region of the wider battlefield -- nothing is
|
||||
scaled or squeezed -- and animations, which are authored in the original
|
||||
160-pixel space, shift as one rigid group onto whichever side they play
|
||||
on. The whole screen is drawn at the window's integer fit scale for the
|
||||
wider surface, so a 304-pixel screen is drawn a step smaller than a
|
||||
160-pixel one in the same window.
|
||||
|
||||
The wide surface is live only while the battle itself is the screen on
|
||||
top: a party menu, the bag or a nickname prompt is a 160×144 screen and
|
||||
brings the classic surface back with it.
|
||||
|
||||
## On-screen touch controls (mobile)
|
||||
|
||||
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
|
||||
@@ -194,6 +259,7 @@ 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
|
||||
@@ -228,3 +294,71 @@ 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.
|
||||
|
||||
## Tiled map editing (mod authoring)
|
||||
|
||||
`tools/tiled_export.py` turns the imported ROM cache into a Tiled workspace,
|
||||
so maps can be edited in a real map editor and exported back out as a mod.
|
||||
It has its own document: docs/tiled-map-editing.md.
|
||||
|
||||
## Pokédex diploma (both versions)
|
||||
|
||||
The Celadon Mansion 3F game designer shows the dex-completion diploma
|
||||
once 150 species are owned. On Yellow, the graphic artist next to him
|
||||
then offers to print it, saving the certificate as a PNG under `prints/`
|
||||
in the save directory, and Bill's PC gains Yellow's PRINT BOX item which
|
||||
exports the current box list the same way.
|
||||
|
||||
## Pokédex printing (Yellow)
|
||||
|
||||
Yellow's Game Boy Printer PRNT option in the Pokédex side menu is stood in
|
||||
for by an image export: choosing PRNT renders the mon's entry page (sprite,
|
||||
kind, number, height/weight, dex text) to a PNG at 4x scale under
|
||||
`prints/` in the save directory, then reports the filename in a dialog.
|
||||
No printer hardware or link cable emulation involved; the file is the
|
||||
printout.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Tiled map editing (mod authoring)
|
||||
|
||||
`tools/tiled_export.py` turns the imported ROM cache into a
|
||||
[Tiled](https://www.mapeditor.org) workspace, so maps can be edited in a
|
||||
real map editor and exported back out as a mod. The original had no map
|
||||
editor at all; the port's own map data is plain Lua, which is what makes
|
||||
this a data path rather than an asset path.
|
||||
|
||||
Editing is done in our own Tiled build,
|
||||
[bryanthaboi/tiled_gen1recomp](https://github.com/bryanthaboi/tiled_gen1recomp/releases),
|
||||
which ships the `gen1-mod-export` extension the workspace relies on. Grab it
|
||||
from that repo's releases; upstream Tiled opens the workspace but cannot
|
||||
export a mod out of it.
|
||||
|
||||
```sh
|
||||
python3 tools/tiled_export.py # -> build/tiled/ (gitignored)
|
||||
```
|
||||
|
||||
Then open `build/tiled/gen1.tiled-project` in that build of Tiled.
|
||||
|
||||
- **The overworld is one surface.** All 222 maps become `maps/*.tmj`, and
|
||||
`kanto.world` places the 36 connected overworld maps at their real
|
||||
connection offsets. That world is pre-loaded (seeded into the workspace's
|
||||
Tiled session), so opening any one overworld map draws its neighbors around
|
||||
it and you scroll and edit straight across the seams. Everything else is a
|
||||
double-click away in Tiled's project panel.
|
||||
- **Extending Kanto wires both ends.** A connection lives on both maps, so
|
||||
hooking a new map onto a base map also emits the return connection as a
|
||||
patch on that base map, keeping its other directions intact. The return
|
||||
offset is derived, not guessed: all 78 vanilla reciprocal pairs satisfy
|
||||
`back.offset == -offset`.
|
||||
- **A Tiled tile is a gen1 block.** Each of the 24 tilesets becomes a Tiled
|
||||
tileset whose tiles are its 32x32 blocks, composited from the 8x8 sheet,
|
||||
so a tile layer *is* the map's `blocks` array. Warps, signs and objects
|
||||
sit on the 16px cell grid in object layers, which is the grid the engine
|
||||
addresses them on.
|
||||
- **Collision is visible.** View > Show Tile Collision Shapes draws the real
|
||||
walkability: a rectangle covers each cell whose feet tile is not in the
|
||||
tileset's `walkable` list, which is the rule `src/world/Map.lua` applies.
|
||||
- **Maps are shown in their real colors.** Each map is atlased in the SGB
|
||||
palette it renders with, so Cerulean is blue and Lavender is purple in the
|
||||
editor exactly as in game. Vanilla resolves that through a cascade with
|
||||
interiors inheriting the last outdoor map, so the workspace mirrors the
|
||||
cascade and walks the warp graph to colour interiors. Changing a map's
|
||||
`palette` exports `palette = "..."` on the record, which beats the cascade,
|
||||
and the editor offers the real palette names as a dropdown.
|
||||
- **New blocks and new tilesets.** `blocksets/*.tmj` show a tileset's blocks
|
||||
as raw 8x8 tiles, four by four, so new blocks can be composed there;
|
||||
per-tile flags on `tilesets/tiles_*.tsj` become `walkable`, `waterTiles`,
|
||||
`doorTiles` and the rest.
|
||||
- **Export is a diff, not a fork of the data.** The `gen1-mod-export`
|
||||
extension (shipped in `tiled_gen1recomp`) writes either one map file or a whole
|
||||
loadable mod folder. An edited vanilla map diffs against the imported data
|
||||
and emits `mod.content.maps:patch` carrying *only* the fields that moved, so
|
||||
a mod covers the parts it changes and leaves the rest to the base game; a
|
||||
new map gets `:register` at an index of 1000 or above. An unchanged map
|
||||
exports nothing at all. Exports pass `tools/modkit.py validate` and `lint`.
|
||||
- **Or the whole record, on request.** Ticking `exactExport` on a map switches
|
||||
it to `mod.content.maps:override`, pinning the map to exactly what the
|
||||
editor shows. It is off by default because an override wins outright over
|
||||
any other mod patching that map, where a patch composes.
|
||||
|
||||
No ROM-derived art travels into an exported mod: a tileset still drawing on
|
||||
the player's own imported sheet references that path rather than shipping the
|
||||
pixels, and only a sheet the author supplied is copied in.
|
||||
+1
-1
@@ -65,7 +65,7 @@ Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||
|
||||
A release missing either asset is treated as "no in-place update available":
|
||||
`Check` reports `needs_full` and sends the player to `Check.releaseUrl()`
|
||||
(`https://github.com/bryanthaboi/pokemon-gen1-recomp-project/releases/latest`).
|
||||
(`https://github.com/bryanthaboi/gen1recomp/releases/latest`).
|
||||
|
||||
## Save-directory layout
|
||||
|
||||
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# After first boot of the freshly flashed Stock OS Mod, reinsert the SD card
|
||||
# and run this to install gen1recomp + Red/Blue ROMs into roms/PORTS.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
STAGE="$ROOT/.bazinga/work/rg34xxsp-install"
|
||||
DECPREP="$(cd "$ROOT/../decprep" && pwd)"
|
||||
ZIP="$ROOT/dist/rg34xxsp/gen1recomp-rg34xxsp.zip"
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Find the Anbernic user/ROMs volume (usually "NO NAME" after first-boot expand).
|
||||
find_roms_root() {
|
||||
local v candidate
|
||||
for v in /Volumes/*; do
|
||||
[ -d "$v" ] || continue
|
||||
# Prefer a volume that already has Roms/ or PORTS/
|
||||
if [ -d "$v/Roms" ] || [ -d "$v/roms" ] || [ -d "$v/PORTS" ] || [ -d "$v/ports" ]; then
|
||||
echo "$v"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
# Fallback: large FAT volume named NO NAME on disk8
|
||||
for v in "/Volumes/NO NAME" /Volumes/NO\ NAME /Volumes/ROMS /Volumes/EASYROMS; do
|
||||
if [ -d "$v" ]; then
|
||||
echo "$v"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
say "looking for Anbernic ROMs volume"
|
||||
ROMS_ROOT="$(find_roms_root)" || fail "no ROMs volume mounted. Boot the RG34XXSP once (wait for first-boot setup), power off, reinsert the SD, then rerun."
|
||||
|
||||
say "using: $ROMS_ROOT"
|
||||
# Resolve PORTS dir (stock uses Roms/PORTS)
|
||||
if [ -d "$ROMS_ROOT/Roms/PORTS" ]; then
|
||||
PORTS="$ROMS_ROOT/Roms/PORTS"
|
||||
elif [ -d "$ROMS_ROOT/roms/PORTS" ]; then
|
||||
PORTS="$ROMS_ROOT/roms/PORTS"
|
||||
elif [ -d "$ROMS_ROOT/Roms/ports" ]; then
|
||||
PORTS="$ROMS_ROOT/Roms/ports"
|
||||
elif [ -d "$ROMS_ROOT/PORTS" ]; then
|
||||
PORTS="$ROMS_ROOT/PORTS"
|
||||
else
|
||||
mkdir -p "$ROMS_ROOT/Roms/PORTS"
|
||||
PORTS="$ROMS_ROOT/Roms/PORTS"
|
||||
fi
|
||||
say "PORTS: $PORTS"
|
||||
|
||||
# Refresh staged payload
|
||||
mkdir -p "$STAGE/PORTS"
|
||||
if [ -f "$ZIP" ]; then
|
||||
rm -rf "$STAGE/PORTS/Gen1recomp.sh" "$STAGE/PORTS/gen1recomp" "$STAGE/PORTS/port.json" \
|
||||
"$STAGE/PORTS/gameinfo.xml" "$STAGE/PORTS/README.md"
|
||||
unzip -q -o "$ZIP" -d "$STAGE/PORTS"
|
||||
else
|
||||
fail "missing $ZIP — run ./build-rg34xxsp.sh first"
|
||||
fi
|
||||
|
||||
# Ensure ROMs are in lovegame (Choose ROM scans this folder on stock OS)
|
||||
[ -f "$DECPREP/Pokemon - Red Version.gb" ] || fail "missing Red ROM in $DECPREP"
|
||||
[ -f "$DECPREP/Pokemon - Blue Version.gb" ] || fail "missing Blue ROM in $DECPREP"
|
||||
cp -f "$DECPREP/Pokemon - Red Version.gb" "$STAGE/PORTS/gen1recomp/lovegame/"
|
||||
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$STAGE/PORTS/gen1recomp/lovegame/"
|
||||
|
||||
say "copying gen1recomp port"
|
||||
rm -rf "$PORTS/gen1recomp" "$PORTS/Gen1recomp.sh"
|
||||
cp -R "$STAGE/PORTS/gen1recomp" "$PORTS/"
|
||||
cp -f "$STAGE/PORTS/Gen1recomp.sh" "$PORTS/"
|
||||
cp -f "$STAGE/PORTS/port.json" "$PORTS/gen1recomp/" 2>/dev/null || true
|
||||
cp -f "$STAGE/PORTS/README.md" "$PORTS/gen1recomp/" 2>/dev/null || true
|
||||
chmod +x "$PORTS/Gen1recomp.sh" "$PORTS/gen1recomp/bin/love.aarch64"
|
||||
|
||||
# Also drop carts in the stock GB folder for the emulator library
|
||||
GB_DIR=""
|
||||
for candidate in "$ROMS_ROOT/Roms/GB" "$ROMS_ROOT/roms/GB" "$ROMS_ROOT/Roms/gb"; do
|
||||
if [ -d "$candidate" ]; then GB_DIR="$candidate"; break; fi
|
||||
done
|
||||
if [ -n "$GB_DIR" ]; then
|
||||
say "copying .gb into $GB_DIR"
|
||||
cp -f "$DECPREP/Pokemon - Red Version.gb" "$GB_DIR/"
|
||||
cp -f "$DECPREP/Pokemon - Blue Version.gb" "$GB_DIR/"
|
||||
fi
|
||||
|
||||
sync
|
||||
say "installed:"
|
||||
ls -lh "$PORTS/Gen1recomp.sh"
|
||||
ls -lh "$PORTS/gen1recomp/lovegame/"*.gb
|
||||
say "eject the SD, insert TF1 in the RG34XXSP, open Ports → Gen1recomp, Choose ROM."
|
||||
@@ -1,8 +1,12 @@
|
||||
-- Native LÖVE2D port of Pokemon Red. A packaged build creates its private
|
||||
-- game-data cache from a user-provided ROM on first boot.
|
||||
--
|
||||
-- Set POKEPORT_EDITOR=1 or pass `--editor` to `love .` to boot the save
|
||||
-- editor tool (tools/save-editor/) instead of the game.
|
||||
-- The save editor (tools/save-editor/) ships inside every build and is
|
||||
-- reachable two ways:
|
||||
-- * standalone: POKEPORT_EDITOR=1 or `love . --editor`, its own window
|
||||
-- * from the launcher: Edit on a save row, which suspends the launcher,
|
||||
-- opens the editor on that slot's file, and restores the launcher when
|
||||
-- the editor's Close button is pressed (openEditor / closeEditor below)
|
||||
|
||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||
|
||||
@@ -31,11 +35,110 @@ 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
|
||||
-- overlay its extracted cache BEFORE anything requires generated data, so
|
||||
-- data/generated + assets/generated resolve to that version's files.
|
||||
-- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and
|
||||
-- headless runs fall back to POKEPORT_VERSION, then Red. Set the active
|
||||
-- version and overlay its extracted cache BEFORE anything requires generated
|
||||
-- data, so data/generated + assets/generated resolve to that version's files.
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
|
||||
require("src.import.CacheFs").mountVersion(GameVersion.get())
|
||||
@@ -81,12 +184,17 @@ function love.load(args)
|
||||
end
|
||||
love.graphics.setDefaultFilter("nearest", "nearest")
|
||||
|
||||
-- Standalone editor. A bare `--editor` run has no launcher behind it, so
|
||||
-- Close quits; --save points it at a specific file, otherwise it opens the
|
||||
-- default save path for POKEPORT_VERSION (Red unless overridden), whose
|
||||
-- cache has to be mounted before the editor's Data:load.
|
||||
if editorMode then
|
||||
package.path = love.filesystem.getSource() .. "/tools/save-editor/?.lua;"
|
||||
.. love.filesystem.getSource() .. "/tools/save-editor/panels/?.lua;"
|
||||
.. package.path
|
||||
local version = os.getenv("POKEPORT_VERSION") or "red"
|
||||
require("src.core.GameVersion").set(version)
|
||||
require("src.import.CacheFs").mountVersion(version)
|
||||
addEditorRequirePath()
|
||||
EditorApp = require("App")
|
||||
EditorApp.load(savePath)
|
||||
EditorApp.load(savePath, { version = version })
|
||||
return
|
||||
end
|
||||
|
||||
@@ -123,14 +231,15 @@ function love.load(args)
|
||||
return
|
||||
end
|
||||
|
||||
-- Interactive: the launcher always runs. Red and Blue are each live: a
|
||||
-- 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.
|
||||
-- Interactive: the launcher always runs. Red, Blue, and Yellow are each
|
||||
-- live: a column shows Play when that game's ROM is already imported, or
|
||||
-- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its
|
||||
-- SHA-1 (GameVersion.forSha1); pressing Play boots that game. Edit on a
|
||||
-- save row opens the bundled editor on that slot (openEditor).
|
||||
Importer = RomImporter.new(function(version)
|
||||
Importer = nil
|
||||
bootGame(version)
|
||||
end, { launcher = true, forceImport = forceImport })
|
||||
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
@@ -206,19 +315,19 @@ end
|
||||
|
||||
function love.gamepadpressed(joystick, button)
|
||||
if editorMode then return end
|
||||
if Importer then return end
|
||||
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||
Game:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
function love.gamepadreleased(joystick, button)
|
||||
if editorMode then return end
|
||||
if Importer then return end
|
||||
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||
Game:gamepadreleased(joystick, button)
|
||||
end
|
||||
|
||||
function love.gamepadaxis(joystick, axis, value)
|
||||
if editorMode then return end
|
||||
if Importer then return end
|
||||
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||
Game:gamepadaxis(joystick, axis, value)
|
||||
end
|
||||
|
||||
@@ -302,7 +411,7 @@ function love.mousemoved(x, y)
|
||||
end
|
||||
|
||||
function love.textinput(text)
|
||||
if Importer then return end
|
||||
if Importer then return Importer:textinput(text) end
|
||||
if editorMode and EditorApp.textinput then
|
||||
return EditorApp.textinput(text)
|
||||
end
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" >
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<!-- Link play (src/link/): both the LAN backend (enet UDP) and the relay
|
||||
backend (luasocket TCP) are ordinary sockets, and Android denies those
|
||||
to a UID without INTERNET. Bind and connect then come back EPERM, which
|
||||
the link screen shows as "(Operation not permitted)" (issue #287).
|
||||
scripts/build_android.sh must not strip this again. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- OpenGL ES 2.0 -->
|
||||
<uses-feature android:glEsVersion="0x00020000" />
|
||||
<!-- Touchscreen support -->
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
return {
|
||||
summary = "Oak's Charmander gift becomes a level 20 Mew with inverted sprites.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "beginner", "cosmetic", "data-only", "legacy" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "Faster final starters, half-price TMs, a re-slotted Route 1.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "balance", "data-only", "beginner" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "A start-menu dex overlay with seen/owned counts and an inter-mod API.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "ui", "tool", "quality-of-life" },
|
||||
differences = {
|
||||
changed = { "the START menu gains a DEXNAV row above SAVE" },
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "An authored chip song for Pallet Town, a new Mew cry, and a jukebox screen.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "audio", "chiptune", "ui" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "A courier in Viridian lost a parcel in Pewter. Fetch it for a NUGGET.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "quest", "story", "scripting" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "Sable Cove: one town, three species, one badge. The smallest whole conversion.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "total-conversion", "capstone" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "A teal player recolor derived from your own cache, plus two palette records.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "cosmetic", "graphics", "beginner" },
|
||||
screenshots = {
|
||||
{ transform = "shots/pallet_town.lua", caption = "Pallet Town under the recolored palette" },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
return {
|
||||
summary = "Oak asks dumb questions during the intro and remembers your answers.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "intro", "ui", "oak", "hooks" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
return {
|
||||
summary = "Opt-in rain: WATER hits harder, FIRE hits softer, for five turns a battle.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "battle", "mechanic", "ruleset", "hardcore" },
|
||||
differences = {
|
||||
changed = {
|
||||
|
||||
+19
-1
@@ -56,16 +56,34 @@ done
|
||||
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
|
||||
|
||||
# --------------------------------------------------------------- game.love
|
||||
# tools/save-editor is part of the shipped app, not a dev-only script: the
|
||||
# launcher's Edit button on a save row opens it in-process (main.lua), and
|
||||
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
|
||||
# love.filesystem's require path, so it has to live inside the archive.
|
||||
say "packing game.love"
|
||||
LOVE_FILE="$WORK/game.love"
|
||||
rm -f "$LOVE_FILE"
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.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, and every version's import manifest has to ship or that game's
|
||||
# ROM import fails in the built app (dev reads them off the source tree, so
|
||||
# the miss only ever shows up in a build -- the Yellow manifest shipped this
|
||||
# way once).
|
||||
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
tools/save-editor/panels/Party.lua \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json; do
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|
||||
|| fail "game.love is missing $required"
|
||||
done
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
||||
|
||||
# ------------------------------------------------------- stamp release version
|
||||
|
||||
@@ -113,11 +113,12 @@ import pathlib, re, sys
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
text = path.read_text()
|
||||
|
||||
# Drop network / mic / legacy storage, not needed for offline play.
|
||||
# Keep VIBRATE (love.system.vibrate) and BLUETOOTH (optional gamepads).
|
||||
# Drop mic / legacy storage, not needed by this game.
|
||||
# Keep VIBRATE (love.system.vibrate), BLUETOOTH (optional gamepads) and
|
||||
# INTERNET: link play is not offline-only any more, and stripping INTERNET
|
||||
# made every LAN host and every relay connect fail with EPERM (issue #287).
|
||||
# Orientation / label come from gradle.properties placeholders.
|
||||
for perm in (
|
||||
"android.permission.INTERNET",
|
||||
"android.permission.RECORD_AUDIO",
|
||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
):
|
||||
@@ -136,14 +137,19 @@ pack_game_love() {
|
||||
say "packing game.love for love-android embed flavor"
|
||||
mkdir -p "$EMBED_ASSETS"
|
||||
rm -f "$LOVE_FILE"
|
||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||
# row opens it in-process, so it must be inside the archive (see build.sh).
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||
|
||||
# This script packs its own game.love (it does not reuse build.sh's), so it
|
||||
|
||||
@@ -164,15 +164,19 @@ pack_game_love() {
|
||||
say "packing game.love for love-ios resources"
|
||||
mkdir -p "$RESOURCES_DIR"
|
||||
rm -f "$LOVE_FILE"
|
||||
# Same payload as scripts/build.sh / build_android.sh: game sources only.
|
||||
# Same payload as scripts/build.sh / build_android.sh: game sources plus
|
||||
# tools/save-editor, which the launcher's Edit button opens in-process.
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
if unzip -Z1 "$LOVE_FILE" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||
}
|
||||
|
||||
|
||||
+7
-4
@@ -1,11 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build game data from a user-provided Pokemon Red ROM and install LÖVE.
|
||||
# Build game data from a user-provided Pokemon Red/Blue/Yellow ROM and install LÖVE.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/setup.sh --rom /path/to/pokemon-red.gb
|
||||
# scripts/setup.sh --rom /path/to/pokemon-yellow.gbc
|
||||
# ROM_PATH=/path/to/pokemon-red.gb scripts/setup.sh
|
||||
#
|
||||
# With no explicit path, the first *.gb file in the project root is used.
|
||||
# With no explicit path, the first *.gb / *.gbc file in the project root is used.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -34,15 +35,17 @@ command -v python3 >/dev/null 2>&1 \
|
||||
|| fail "Python 3 is required to decode the ROM"
|
||||
|
||||
if [ -z "$ROM" ]; then
|
||||
for candidate in "$ROOT"/*.gb; do
|
||||
shopt -s nullglob
|
||||
for candidate in "$ROOT"/*.gb "$ROOT"/*.gbc; do
|
||||
if [ -f "$candidate" ]; then
|
||||
ROM="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
fi
|
||||
[ -n "$ROM" ] && [ -f "$ROM" ] \
|
||||
|| fail "Pokemon Red ROM not found. Put your .gb file in $ROOT or pass --rom /path/to/file.gb"
|
||||
|| fail "Pokemon ROM not found. Put a .gb or .gbc in $ROOT or pass --rom /path/to/file"
|
||||
|
||||
if [ ! -x "$VENV/bin/python3" ]; then
|
||||
say "creating Python environment"
|
||||
|
||||
@@ -116,7 +116,13 @@ if [ -f data/generated/maps.lua ]; then
|
||||
echo "-- T3 content: skipped (--quick)"
|
||||
else
|
||||
run_tier "T3 content behavior (Red)" run_content_behavior
|
||||
# The save editor ships inside every build (the launcher's Edit button on
|
||||
# a save row opens it), so its panel suites run in CI rather than by hand.
|
||||
run_tier "T3 save editor" "$LUA" tests/run_save_editor_tests.lua
|
||||
run_tier "T3 save editor: boxes + items" "$LUA" tests/save_editor_task6_tests.lua
|
||||
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
|
||||
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
|
||||
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
|
||||
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
|
||||
fi
|
||||
else
|
||||
|
||||
@@ -22,7 +22,7 @@ local NOTES = {
|
||||
-- mirrors ChipAudio's snapTicks so authored drums land on the same sample
|
||||
-- grid as the ROM's own drum tables
|
||||
local function snapTicks(ticks)
|
||||
return math.floor((ticks * 735 + 256) / 512)
|
||||
return math.floor((ticks * 1470 + 256) / 512)
|
||||
end
|
||||
|
||||
-- ------- validation
|
||||
|
||||
+528
-89
@@ -27,6 +27,7 @@ local TrainerAI = require("src.battle.TrainerAI")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
local WideBattle = require("src.battle.WideBattle")
|
||||
|
||||
local BattleState = {}
|
||||
BattleState.__index = BattleState
|
||||
@@ -35,9 +36,33 @@ BattleState.isOpaque = true
|
||||
-- window reads as one continuous battle screen (no black bars).
|
||||
BattleState.letterboxWhite = true
|
||||
|
||||
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
|
||||
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
|
||||
-- differs; every battler, queue and animation below is shared. The wide
|
||||
-- layout is live only while this battle is the state being drawn on top --
|
||||
-- a party menu or bag pushed over it is a 160x144 screen, so the surface
|
||||
-- goes back with it and the battle underneath is not drawn at all.
|
||||
function BattleState:wideLayout()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
if not options or options.battleLayout ~= "wide" then return false end
|
||||
local stack = self.game.stack
|
||||
return (stack and stack.top and stack:top()) == self
|
||||
end
|
||||
|
||||
-- Renderer:setUISize asks the top state for its surface before anything draws
|
||||
function BattleState:uiSize()
|
||||
if self:wideLayout() then return WideBattle.WIDTH, WideBattle.HEIGHT end
|
||||
return 160, 144
|
||||
end
|
||||
|
||||
-- Battle colors itself per-pixel (species pics + HP bar tints), so the
|
||||
-- SGB whole-screen remap must not run over it.
|
||||
function BattleState.sgbPalettes() return nil end
|
||||
-- SGB whole-screen remap must not run over it. The wide layout still
|
||||
-- needs a zone list of its own: the invented 160x144 one would leave its
|
||||
-- extra columns unremapped in the forced-mono modes (WideBattle.zones).
|
||||
function BattleState:sgbPalettes()
|
||||
if self:wideLayout() then return WideBattle.zones() end
|
||||
return nil
|
||||
end
|
||||
|
||||
local Rulesets = {
|
||||
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
|
||||
@@ -53,16 +78,22 @@ local BALL_ANIMS = {
|
||||
}
|
||||
|
||||
local imageCache = {}
|
||||
-- The three tables below are keyed by the Image OBJECT, not by a path, and a
|
||||
-- running battle holds the pics it built at enter() (battler.sprite,
|
||||
-- playerBackPic, trainerPic). Weak keys let a dropped pic's row go with it,
|
||||
-- so invalidate() can drop the path cache without orphaning what is on
|
||||
-- screen right now (#316).
|
||||
local WEAK_KEYS = { __mode = "k" }
|
||||
-- fully transparent rows below a pic's content (the extracted 32x32 back
|
||||
-- pics carry baked-in padding); used to sit the pic flush on the text box
|
||||
local imagePadBottom = {}
|
||||
local imagePadBottom = setmetatable({}, WEAK_KEYS)
|
||||
-- fully transparent columns left of a pic's content; at 2x (back pics)
|
||||
-- this is subtracted from hlcoord 1,5 so opaque pixels match hardware,
|
||||
-- where those columns were white-on-white rather than shifted content
|
||||
local imagePadLeft = {}
|
||||
local imagePadLeft = setmetatable({}, WEAK_KEYS)
|
||||
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
|
||||
-- rebuilt for any battle pic, whatever code loaded it
|
||||
local imageMeta = {}
|
||||
local imageMeta = setmetatable({}, WEAK_KEYS)
|
||||
-- pal = { name, colors } recolors the 4 GB shades like the Super Game Boy.
|
||||
-- trueColor art (14 §the 4-shade contract) opts out of the quantize
|
||||
-- entirely, so its palette variant collapses back onto the plain path.
|
||||
@@ -118,10 +149,19 @@ local function getImage(path, pal, trueColor)
|
||||
return imageCache[key]
|
||||
end
|
||||
|
||||
-- hot reload: the next getImage re-resolves every pic through the asset
|
||||
-- search path and re-measures its ground padding
|
||||
-- Hot reload / COLORS change (PaletteFX.setMode calls this): the next
|
||||
-- getImage re-resolves every pic through the asset search path and
|
||||
-- re-measures its ground padding. ONLY the path->image cache is dropped.
|
||||
-- Wiping the three per-image tables as well orphaned the pics a running
|
||||
-- battle already holds: imagePadBottom went nil, so backPlacement lost the
|
||||
-- four transparent rows it grounds the back pic on and the pic jumped
|
||||
-- pad * 2x = 8px UP the frame COLORS changed (#316), and imageMeta went nil,
|
||||
-- so picImage's forced-mono grayImage (#207), fadeImage's BGP variants and
|
||||
-- imagePathOf's battle_sprite_scales lookup all silently stopped resolving.
|
||||
-- Those rows are weak-keyed, so entries for pics nothing references any more
|
||||
-- are collected on their own rather than leaking.
|
||||
function BattleState.invalidate()
|
||||
imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {}
|
||||
imageCache = {}
|
||||
end
|
||||
|
||||
Assets.register(BattleState.invalidate)
|
||||
@@ -174,6 +214,25 @@ local function grayImage(img)
|
||||
return getImage(meta.path) or img
|
||||
end
|
||||
|
||||
-- The blacked-out battle screen. HandlePlayerBlackOut (core.asm:1151) runs
|
||||
-- SET_PAL_BATTLE_BLACK, i.e. SetPal_BattleBlack sends PalPacket_Black --
|
||||
-- PAL_BLACK in all four slots of BlkPacket_Battle (engine/gfx/palettes.asm:
|
||||
-- 22-25). The mon pics are drawn OVER the zone pass with their palette
|
||||
-- already baked in, so darkening them means re-baking through PAL_BLACK the
|
||||
-- way fadeImage re-bakes through a BGP permutation (#292). Reads the palette
|
||||
-- out of the active pack, exactly like sgbBattlePals, so the zone pass and
|
||||
-- the pics can never disagree. trueColor art has no DMG shades to remap.
|
||||
local function blackImage(data, img)
|
||||
local meta = imageMeta[img]
|
||||
if not meta or meta.trueColor then return img end
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local pack = PaletteFX.pack(data)
|
||||
local colors = pack and pack.palettes and pack.palettes.BLACK
|
||||
if not colors then return img end
|
||||
local name = PaletteFX.usesGbcPack() and "redpp:BLACK" or "BLACK"
|
||||
return getImage(meta.path, { name = name, colors = colors }) or img
|
||||
end
|
||||
|
||||
-- the asset path a loaded battle image came from (nil for the headless
|
||||
-- stub images), so the battle_sprite_scales registry can be looked up by
|
||||
-- the same path data references
|
||||
@@ -200,6 +259,11 @@ function BattleState:picImage(img)
|
||||
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
|
||||
or PaletteFX.mode == "classic"
|
||||
if self.grayPics or mono then return grayImage(img) end
|
||||
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go
|
||||
-- dark with the HP bars while the blackout text is up (#292). Below the
|
||||
-- mono check on purpose: the forced-mono modes re-threshold the whole
|
||||
-- frame downstream, and the DMG had no SGB darkening to begin with.
|
||||
if self.blackedOut then return blackImage(self.data, img) end
|
||||
return fadeImage(img, self:activeBgp())
|
||||
end
|
||||
|
||||
@@ -566,8 +630,22 @@ end
|
||||
-- player mon; the battle menu appears under the OLD MAN's name and a
|
||||
-- scripted cursor hovers FIGHT, hops to ITEM and forces the item menu
|
||||
-- (one POKé BALL x50). The throw always catches; nothing is kept.
|
||||
function BattleState:makeOldManDemo()
|
||||
-- Yellow's Pallet intro (BATTLE_TYPE_PIKACHU) is the same simulated
|
||||
-- script under "PROF.OAK" (pokeyellow core.asm .profOakName), so the
|
||||
-- displayed thrower name is a parameter.
|
||||
function BattleState:makeOldManDemo(name)
|
||||
self.demo = true
|
||||
self.demoName = name or "OLD MAN"
|
||||
-- Yellow's Pallet intro runs this before the player owns any mon
|
||||
-- (BATTLE_TYPE_PIKACHU precedes the lab gift), so newWild flagged the
|
||||
-- battle dead for lack of a party. The demo never sends out, draws, or
|
||||
-- acts with the player side; a hidden placeholder battler keeps the
|
||||
-- shared battle phases nil-safe.
|
||||
if not self.player then
|
||||
self.dead = false
|
||||
self.player = makeBattler(self.game.data,
|
||||
Pokemon.new(self.game.data, self.enemy.mon.species, 5), true)
|
||||
end
|
||||
end
|
||||
|
||||
-- Safari Zone battles (engine/battle/core.asm safari sections +
|
||||
@@ -725,6 +803,7 @@ function BattleState:startMessage(item)
|
||||
-- it against self.total); the current line's revealed count is #shown[last]
|
||||
self.charIndex = 0
|
||||
self.msgWaiting = nil
|
||||
self.msgPrompt = nil
|
||||
self.scrollPx = nil
|
||||
self:beginMsgLine()
|
||||
end
|
||||
@@ -930,9 +1009,18 @@ function BattleState:updateQueue()
|
||||
end))
|
||||
return true
|
||||
end
|
||||
if not (item and item.choice)
|
||||
and (input:wasPressed("a") or input:wasPressed("b")) then
|
||||
self.current = nil
|
||||
if not (item and item.choice) then
|
||||
-- The page is typed out and waiting on the player: PromptText
|
||||
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
|
||||
-- blinks it until A/B, so the arrow belongs on a finished page and not
|
||||
-- only on a \v CONT hold (#317). A flag of its own, not msgWaiting:
|
||||
-- that branch above scrolls the NEXT line in, which this page has not
|
||||
-- got, so reusing it would call beginMsgLine on a drained message.
|
||||
self.msgPrompt = true
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.msgPrompt = nil
|
||||
self.current = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
@@ -972,6 +1060,10 @@ function BattleState:computeMusicKind()
|
||||
end
|
||||
end
|
||||
end
|
||||
-- init_battle.asm: challenging a gym leader (wGymLeaderNo, the badge
|
||||
-- fights only -- not Lance or the Champion) bumps the companion's
|
||||
-- happiness the moment the battle starts
|
||||
self.isGymLeader = isBoss
|
||||
if self.kind == "trainer" and self.trainer
|
||||
and self.trainer.id == "OPP_RIVAL3" then
|
||||
return "final"
|
||||
@@ -1012,6 +1104,10 @@ function BattleState:enter()
|
||||
end
|
||||
local Music = require("src.core.Music")
|
||||
self.musicKind = self:computeMusicKind()
|
||||
if self.isGymLeader then
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(self.game.save, "GYMLEADER")
|
||||
end
|
||||
-- normally already playing: the transition wipe starts the theme
|
||||
-- (audio/play_battle_music.asm runs before the transition, and
|
||||
-- Music.play no-ops on the same song); this covers battles pushed
|
||||
@@ -1021,6 +1117,17 @@ function BattleState:enter()
|
||||
-- sides slide in; the trainer pics stay up until the send-outs
|
||||
self.introSlide = 40
|
||||
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
|
||||
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
|
||||
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
||||
-- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with
|
||||
-- the intro text; _InitBattleCommon (core.asm:6755-6762) ClearScreenArea's
|
||||
-- both HUD blocks and ClearSprites's the balls the moment that text is
|
||||
-- dismissed. This flag is exactly that window: drawHUDs draws the intro
|
||||
-- chrome while it is up and holds the real enemy HUD back, since a wild
|
||||
-- battle's DrawEnemyHUDAndHPBar only runs after the text (#317). The
|
||||
-- draw is still gated on the slide having landed, so nothing shows while
|
||||
-- the silhouettes are still coming in.
|
||||
self.introBalls = true
|
||||
-- SGB: the player-side battle palette while the back pic is up is
|
||||
-- MonsterPalettes[0] = PAL_MEWMON (wBattleMonSpecies is still 0 when
|
||||
-- the intro's SET_PAL_BATTLE runs -- SetPal_Battle,
|
||||
@@ -1052,12 +1159,27 @@ function BattleState:enter()
|
||||
queueEnemyCry()
|
||||
end
|
||||
self:say(self.introText)
|
||||
-- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is
|
||||
-- dismissed both HUD blocks are cleared and ClearSprites drops the
|
||||
-- pokeball OAM, so the intro chrome never returns for the rest of the
|
||||
-- battle -- not on a switch, and not when the beaten trainer's pic
|
||||
-- scrolls back in (#317, #282)
|
||||
self:act(function() self.introBalls = nil end)
|
||||
if self.kind == "trainer" then
|
||||
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
|
||||
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
|
||||
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
|
||||
-- not blink out under the text (#317)
|
||||
self:act(function() self:slidePic("foe", 0, 64, 4) end)
|
||||
table.insert(self.queue, { wait = 16 })
|
||||
self:act(function()
|
||||
self.showEnemyTrainer = false
|
||||
self:slidePic("foe")
|
||||
end)
|
||||
self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
self:act(function()
|
||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||
self.showEnemyTrainer = false
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
queueEnemyCry()
|
||||
@@ -1075,13 +1197,20 @@ function BattleState:enter()
|
||||
queueEnemyCry()
|
||||
end
|
||||
if not self.safari and not self.demo then
|
||||
self:say(self:sendOutText(self.player.name))
|
||||
-- Red's pic clears, the POOF plays, then the mon appears with its
|
||||
-- cry (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
|
||||
-- StartBattle .playerSendOutFirstMon (core.asm:236-240): the back pic
|
||||
-- walks off the LEFT edge (SlideTrainerPicOffScreen, hlcoord 1,5,
|
||||
-- a = 9 tiles, one tile every 2 frames) BEFORE SendOutMon prints
|
||||
-- "Go! X!" -- Red does not simply vanish under the message (#317)
|
||||
self:act(function() self:slidePic("back", 0, -72, 4) end)
|
||||
table.insert(self.queue, { wait = 18 })
|
||||
self:act(function()
|
||||
self.showPlayerBack = false
|
||||
self.sendingOut = true
|
||||
self:slidePic("back")
|
||||
end)
|
||||
self:say(self:sendOutText(self.player.name))
|
||||
-- then the POOF plays and the mon appears with its cry
|
||||
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
|
||||
table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false })
|
||||
self:act(function()
|
||||
self.sendingOut = false
|
||||
@@ -1133,6 +1262,31 @@ local function clearTrapping(battler)
|
||||
battler.trapDamage = nil
|
||||
end
|
||||
|
||||
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
|
||||
-- selection opens, but the clear is skipped for a mon that must recharge or
|
||||
-- is locked into Rage (core.asm:293-295 -- the Hyper Beam flinch-recharge
|
||||
-- glitch).
|
||||
--
|
||||
-- A method rather than three lines inside the menu branch, because two
|
||||
-- other places need the identical rule at the identical point in the turn
|
||||
-- and both got it wrong by not having it:
|
||||
--
|
||||
-- * guarded PER BATTLER, not off self.player alone. This runs on
|
||||
-- whichever machine is looking at its own menu, and in a lockstep link
|
||||
-- battle "self.player" is the host's mon on one peer and the guest's on
|
||||
-- the other, so one shared guard let one peer clear both flags while
|
||||
-- the other cleared neither -- a bogus desync draw in a winnable match.
|
||||
-- * a tournament spectator never enters the menu phase at all (it has no
|
||||
-- decision to make), so nothing cleared a flinch in its replay: the
|
||||
-- flag survived into the next turn, ate a move the real players saw
|
||||
-- land, and from there the replay was watching a different battle.
|
||||
-- LinkBattle.newSpectator calls this at the head of every turn.
|
||||
function BattleState:clearTurnFlinches()
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b and not (b.mustRecharge or b.rageMove) then b.flinched = false end
|
||||
end
|
||||
end
|
||||
|
||||
-- Actions that skip DisplayBattleMenu entirely (core.asm:300-310):
|
||||
-- recharge, Rage, thrash, charge. Bide / trapping / being held do NOT
|
||||
-- skip the menu -- the player can still item/switch (and must press
|
||||
@@ -1153,11 +1307,17 @@ function BattleState:fightLockedAction(battler)
|
||||
end
|
||||
if battler.bideTurns then return { special = "bide" } end
|
||||
-- held while the OPPONENT's trapping bit is set (live mirror so a
|
||||
-- trap ended early by paralysis/faint frees the victim immediately)
|
||||
-- trap ended early by paralysis/faint frees the victim immediately).
|
||||
-- Read, not written: executeAction refreshes battler.boundTurns from the
|
||||
-- same expression when the action actually runs, and that site runs on
|
||||
-- both peers of a link battle. Storing it here instead wrote a hashed
|
||||
-- field on whichever machine happened to open its own FIGHT menu, which
|
||||
-- left the two peers holding boundTurns=0 against nil for the same
|
||||
-- battler and ended the match as a desync over a mirror of a mirror.
|
||||
local opp = battler.isPlayer and self.enemy or self.player
|
||||
battler.boundTurns = opp and opp.trappingTurns
|
||||
and math.max(1, opp.trappingTurns) or nil
|
||||
if battler.boundTurns then
|
||||
local bound = opp and opp.trappingTurns
|
||||
and math.max(1, opp.trappingTurns) or nil
|
||||
if bound then
|
||||
return { special = "bound" }
|
||||
end
|
||||
return nil
|
||||
@@ -1194,9 +1354,23 @@ function BattleState:swapMoves(i, j)
|
||||
require("src.core.Sound").play(self.data, "Swap")
|
||||
end
|
||||
|
||||
function BattleState:update(dt)
|
||||
-- One frame of the presentational clock: the BGP flash sequences, the
|
||||
-- per-battler pic slide/hide programs, the send-out grow-in, the intro
|
||||
-- slide and the screen-shake programs all advance in updateFx and nowhere
|
||||
-- else. It lives behind its own entry point because a caller that has to
|
||||
-- skip the rest of update() for a frame must still tick this, and a link
|
||||
-- battle does exactly that on two hot paths -- waiting on the peer's action,
|
||||
-- and draining a resolved lockstep turn. Skipping it there froze whatever
|
||||
-- was mid-flight: a flash stuck on its inverted BGP step repainted the whole
|
||||
-- UI in inverted shades, and a pic part-way through a slide-off or a grow-in
|
||||
-- simply stayed gone -- for as long as the opponent took to choose.
|
||||
function BattleState:tickFx()
|
||||
self.frame = self.frame + 1
|
||||
self:updateFx()
|
||||
end
|
||||
|
||||
function BattleState:update(dt)
|
||||
self:tickFx()
|
||||
local input = self.game.input
|
||||
|
||||
-- safety net: HP/status changed outside a queued drain (level-up heals,
|
||||
@@ -1211,6 +1385,15 @@ function BattleState:update(dt)
|
||||
end
|
||||
|
||||
if self.phase == "messages" then
|
||||
-- Nothing queued starts until the silhouettes have finished sliding in:
|
||||
-- SlidePlayerAndEnemySilhouettesOnScreen ends with `jpfar
|
||||
-- PrintBeginningBattleText` (engine/battle/core.asm:100), so the enemy
|
||||
-- cry and the "Wild X appeared!" box belong at the end of the slide, not
|
||||
-- on its first frame (#303). The hold sits here rather than in
|
||||
-- updateQueue because only this loop has a frame clock: updateFx above
|
||||
-- counts introSlide down, and a headless caller driving updateQueue on
|
||||
-- its own has no slide to wait for.
|
||||
if (self.introSlide or 0) > 0 then return end
|
||||
if not self:updateQueue() then
|
||||
if self.afterQueue == "menu" then
|
||||
self.phase = "menu"
|
||||
@@ -1270,13 +1453,7 @@ function BattleState:update(dt)
|
||||
end
|
||||
return
|
||||
end
|
||||
-- core.asm:297-300: both sides' FLINCHED bits are cleared during
|
||||
-- move selection, but the clear is skipped while the player must
|
||||
-- recharge or is locked into Rage (core.asm:293-295 -- the Hyper
|
||||
-- Beam flinch-recharge glitch)
|
||||
if not (self.player.mustRecharge or self.player.rageMove) then
|
||||
self.player.flinched, self.enemy.flinched = false, false
|
||||
end
|
||||
self:clearTurnFlinches()
|
||||
-- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping
|
||||
-- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312)
|
||||
local locked = self:menuLockedAction(self.player)
|
||||
@@ -1332,7 +1509,14 @@ function BattleState:update(dt)
|
||||
|
||||
if self.phase == "moveSelect" then
|
||||
local moves = self.player.curMoves
|
||||
if input:wasPressed("up") then
|
||||
-- The widescreen layout lays the four slots out as a 2x2 grid, so all
|
||||
-- four directions navigate it; nil means no direction was pressed and
|
||||
-- A / B / SELECT below behave the same in either layout.
|
||||
local grid = self:wideLayout()
|
||||
and WideBattle.navigate(self.moveIndex, #moves, input)
|
||||
if grid then
|
||||
self.moveIndex = grid
|
||||
elseif input:wasPressed("up") then
|
||||
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
|
||||
elseif input:wasPressed("down") then
|
||||
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
|
||||
@@ -1374,7 +1558,13 @@ function BattleState:update(dt)
|
||||
-- (core.asm:2553-2557), so there is no backing out with B.
|
||||
if self.phase == "mimicSelect" then
|
||||
local moves = self.mimicMoves
|
||||
if input:wasPressed("up") then
|
||||
-- the copy menu shares the widescreen move grid, so it navigates the
|
||||
-- same way there (the classic layout keeps the vertical list)
|
||||
local grid = self:wideLayout()
|
||||
and WideBattle.navigate(self.mimicIndex, #moves, input)
|
||||
if grid then
|
||||
self.mimicIndex = grid
|
||||
elseif input:wasPressed("up") then
|
||||
self.mimicIndex = self.mimicIndex > 1 and self.mimicIndex - 1 or #moves
|
||||
elseif input:wasPressed("down") then
|
||||
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
|
||||
@@ -1543,7 +1733,7 @@ function BattleState:oldManThrow()
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "finish"
|
||||
self.result = "run" -- nothing is kept; wBattleResult only ends the demo
|
||||
self:say(Strings("OLD MAN used\nPOKé BALL!"))
|
||||
self:say(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
|
||||
self:act(function()
|
||||
require("src.core.Sound").play(self.data, "Ball_Toss")
|
||||
-- ItemUseBall's beat before the toss chain (like throwBall)
|
||||
@@ -2156,19 +2346,34 @@ end
|
||||
-- battle (EndLowHealthAlarm sets wLowHealthAlarmDisabled, mirrored by
|
||||
-- playVictoryMusic) and every other outcome tears it down in
|
||||
-- end_of_battle.asm -- self.result covers those. The damage drain
|
||||
-- gates the start (the HUD redraw runs after UpdateHPBar finishes),
|
||||
-- but healing out of the red stops it at once (item_effects.asm clears
|
||||
-- the alarm before the bar animates). No alarm before the player HUD
|
||||
-- first draws (send-out), nor in the safari/old-man battles, which
|
||||
-- have no player mon HUD.
|
||||
-- gates the START (the HUD redraw runs after UpdateHPBar finishes) but
|
||||
-- never the stop, and healing out of the red silences it at once
|
||||
-- (item_effects.asm:991-994 clears the alarm before the bar animates).
|
||||
-- No alarm before the player HUD first draws (send-out), nor in the
|
||||
-- safari/old-man battles, which have no player mon HUD.
|
||||
function BattleState:lowHealthAlarmActive()
|
||||
local p = self.player
|
||||
if not p or self.safari or self.demo or self.result
|
||||
or self.lowHealthAlarmDisabled then return false end
|
||||
if self.showPlayerBack or (self.introSlide or 0) > 0 then return false end
|
||||
if p.fainted then return false end
|
||||
-- A siren that is ALREADY sounding follows the drawn bar, not the
|
||||
-- model: wLowHealthAlarm is a latch DrawPlayerHUDAndHPBar only revisits
|
||||
-- once UpdateHPBar2 has finished animating (core.asm:4727-4729 /
|
||||
-- core.asm:4845-4847 both drain first, then jp DrawHUDsAndHPBars), and
|
||||
-- a KO clears it in RemoveFaintedPlayerMon (core.asm:1011-1016), i.e.
|
||||
-- after the bar has drained empty. applyDamage takes the HP off the
|
||||
-- model while the turn is still being queued, so keying a running alarm
|
||||
-- off mon.hp cut it dead for the whole "used X!" line + move animation
|
||||
-- + drain window (#293). max() keeps a heal out of the red silencing
|
||||
-- it on the spot, the way item_effects.asm does.
|
||||
local hp = p.mon.hp
|
||||
if hp <= 0 or p.fainted then return false end
|
||||
if p.shownHP and p.shownHP > hp then return false end -- drain running
|
||||
if self.lowHealthAlarmOn then
|
||||
hp = math.max(hp, shownHP(p))
|
||||
elseif p.shownHP and p.shownHP > hp then
|
||||
return false -- drain running: the HUD redraw has not happened yet
|
||||
end
|
||||
if hp <= 0 then return false end
|
||||
local px = math.max(1, math.floor(hp * 48 / math.max(1, p.mon.stats.hp)))
|
||||
return px < 10
|
||||
end
|
||||
@@ -2184,10 +2389,47 @@ local function stepProgram(prog)
|
||||
return head
|
||||
end
|
||||
|
||||
-- Trainer-pic slides. SlideTrainerPicOffScreen (core.asm:1235) walks a
|
||||
-- trainer pic off its own screen edge one tile every 2 frames (9 tiles left
|
||||
-- for the player back pic, 8 tiles right for the foe), and
|
||||
-- _ScrollTrainerPicAfterBattle (engine/battle/scroll_draw_trainer_pic.asm)
|
||||
-- brings the beaten foe back in from the right one column every 4 frames.
|
||||
-- picOff holds the live programs by slot -- "foe" = the enemy trainer pic,
|
||||
-- "back" = the player's back pic -- as a screen-pixel x offset stepped
|
||||
-- toward `to`; updateFx advances them, drawPicsLayer adds them, and the
|
||||
-- queue rows that start them park a { wait } of the matching length. Call
|
||||
-- with no target to clear a slot (#317, #282).
|
||||
function BattleState:slidePic(slot, from, to, step)
|
||||
self.picOff = self.picOff or {}
|
||||
if to == nil then
|
||||
self.picOff[slot] = nil
|
||||
return
|
||||
end
|
||||
self.picOff[slot] = { x = from or 0, to = to, step = step or 4 }
|
||||
end
|
||||
|
||||
-- the live x offset for a pic slot, 0 when nothing is sliding
|
||||
function BattleState:picOffset(slot)
|
||||
local p = self.picOff and self.picOff[slot]
|
||||
return p and p.x or 0
|
||||
end
|
||||
|
||||
function BattleState:updateFx()
|
||||
if self.introSlide and self.introSlide > 0 then
|
||||
self.introSlide = self.introSlide - 1
|
||||
end
|
||||
-- step each live trainer-pic slide toward its target; a landed program
|
||||
-- holds its offset (the after-battle scroll-in rests two tiles right of
|
||||
-- the battle slot) until its owner clears the slot
|
||||
if self.picOff then
|
||||
for _, p in pairs(self.picOff) do
|
||||
if p.x < p.to then
|
||||
p.x = math.min(p.to, p.x + p.step)
|
||||
elseif p.x > p.to then
|
||||
p.x = math.max(p.to, p.x - p.step)
|
||||
end
|
||||
end
|
||||
end
|
||||
local fx = self.fx
|
||||
if fx then
|
||||
if fx.shake and fx.shake > 0 then fx.shake = fx.shake - 1 end
|
||||
@@ -2276,7 +2518,12 @@ function BattleState:updateFx()
|
||||
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
|
||||
-- loops while the player's bar is red; see lowHealthAlarmActive
|
||||
local Sound = require("src.core.Sound")
|
||||
if self:lowHealthAlarmActive() then
|
||||
-- self.lowHealthAlarmOn mirrors wLowHealthAlarm's bit 7: a latch read
|
||||
-- back inside lowHealthAlarmActive (the RHS sees last frame's value)
|
||||
-- so a sounding siren rides out the next hit's HP drain instead of
|
||||
-- dropping out mid-announcement (#293)
|
||||
self.lowHealthAlarmOn = self:lowHealthAlarmActive()
|
||||
if self.lowHealthAlarmOn then
|
||||
Sound.startLoop(self.data, "Low_Health_Alarm")
|
||||
else
|
||||
Sound.stopLoop("Low_Health_Alarm")
|
||||
@@ -2548,7 +2795,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
end
|
||||
|
||||
-- PP: not for continuations, struggle, called moves, or (under
|
||||
-- gen1_faithful) wild/trainer enemies — pokered DecrementPP only ever
|
||||
-- gen1_faithful) wild/trainer enemies -- pokered DecrementPP only ever
|
||||
-- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm).
|
||||
-- That rule doesn't apply in a link battle: "the enemy" there is a real
|
||||
-- human peer independently tracking their own PP the normal way, not an
|
||||
@@ -2761,6 +3008,17 @@ function BattleState:onFaint(battler)
|
||||
self.participants[battler.mon] = nil
|
||||
end
|
||||
Runtime.emit("battle.fainted", { battle = self, battler = battler })
|
||||
if battler.isPlayer then
|
||||
-- HandlePlayerMonFainted (core.asm:1070-1085): the companion loses
|
||||
-- happiness on its own faint; an enemy 30+ levels above it makes
|
||||
-- that the CARELESSTRAINER hit instead
|
||||
local enemyLevel = self.enemy and self.enemy.mon
|
||||
and self.enemy.mon.level or 0
|
||||
local reason = (enemyLevel - (battler.mon.level or 0)) >= 30
|
||||
and "CARELESSTRAINER" or "FAINTED"
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(self.game.save, reason, battler.mon)
|
||||
end
|
||||
-- the faint slide + cry ride the queue (after the move animation and
|
||||
-- the HP-bar drain, pokered's order); the slide finishes before the
|
||||
-- faint text via a queued hold
|
||||
@@ -2845,6 +3103,9 @@ function BattleState:enemyMonFainted()
|
||||
-- the move-learn checks (experience.asm:245-256)
|
||||
local game = self.game
|
||||
for _, lv in ipairs(levels) do
|
||||
-- experience.asm:248 fires per grew-level text
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(game.save, "LEVELUP", mon)
|
||||
self:sayNext(Strings("%s grew\nto level %d!", name, lv))
|
||||
self:uiNext(function()
|
||||
require("src.core.Sound").play(game.data, "Level_Up")
|
||||
@@ -2913,6 +3174,16 @@ function BattleState:enemyMonFainted()
|
||||
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
|
||||
:lower()
|
||||
local partyCount = #self.game.save.party
|
||||
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
|
||||
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
|
||||
-- down under it (draw_hud_pokeball_gfx.asm:9-11, 33-45, 134-141) -- into
|
||||
-- the block FaintEnemyPokemon just cleared, after the exp text and
|
||||
-- BEFORE the next send-out. It survives EnemySendOutFirstMon's
|
||||
-- SlideTrainerPicOffScreen (core.asm:1308-1310, 8 steps x DelayFrames 2)
|
||||
-- so SET style gets the brief flash, and stays up through the whole
|
||||
-- SHIFT prompt below (#283).
|
||||
self:act(function() self.showEnemyBalls = true end)
|
||||
table.insert(self.queue, { wait = 16 })
|
||||
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
|
||||
local shiftSwitchMon = nil
|
||||
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
|
||||
@@ -2948,6 +3219,10 @@ function BattleState:enemyMonFainted()
|
||||
})
|
||||
self.aiUses = self:aiUsesFor()
|
||||
markSeen(self.game, self.enemy.mon.species)
|
||||
-- EnemySendOutFirstMon .next4 (core.asm:1413-1417): ClearSprites and
|
||||
-- the 4x11 ClearScreenArea take the ball row away with the rest of
|
||||
-- the enemy HUD block, right before TrainerSentOutText (#283)
|
||||
self.showEnemyBalls = nil
|
||||
self:markParticipant()
|
||||
-- EnemySendOutFirstMon (core.asm:1413-1435): the enemy HUD area
|
||||
-- clears, TrainerSentOutText prints, THEN the pic appears
|
||||
@@ -2975,6 +3250,20 @@ function BattleState:enemyMonFainted()
|
||||
battle = self, side = self.sides[1],
|
||||
battler = self.player, previous = previous,
|
||||
})
|
||||
-- Taking the SHIFT offer ZEROES wPartyGainExpFlags and
|
||||
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon
|
||||
-- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon
|
||||
-- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without
|
||||
-- the reset the mon that was out when the enemy fainted -- marked by
|
||||
-- the send-out act above, which mirrors EnemySendOut's own re-flag
|
||||
-- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in
|
||||
-- enemyMonFainted counted two mons and the switch-in earned half the
|
||||
-- next KO (#275). Voluntary switches (resolveSwitch) and post-faint
|
||||
-- replacements (openReplacementMenu) must NOT do this: pokered's
|
||||
-- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is
|
||||
-- the deliberate exp-share, and a fainted mon is already dropped by
|
||||
-- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007).
|
||||
self.participants = {}
|
||||
self:markParticipant()
|
||||
self.nextInsert = 0
|
||||
self.sendingOut = true
|
||||
@@ -2990,16 +3279,39 @@ function BattleState:enemyMonFainted()
|
||||
end
|
||||
local prize = (self.trainer.baseMoney or 0) * self.enemy.mon.level
|
||||
self.game.save.money = self.game.save.money + prize
|
||||
-- the beaten trainer's pic returns for the defeat text (pokered
|
||||
-- DisplayBattleMenu's defeat flow)
|
||||
self:act(function() self.showEnemyTrainer = self.trainerPic ~= nil end)
|
||||
-- TrainerBattleVictory (core.asm:915-933): EndLowHealthAlarm, then
|
||||
-- the victory theme starts BEFORE TrainerDefeatedText and the
|
||||
-- prize money
|
||||
-- TrainerBattleVictory (core.asm:915-949) in order: EndLowHealthAlarm
|
||||
-- and the victory theme, TrainerDefeatedText, ScrollTrainerPicAfterBattle
|
||||
-- (the beaten trainer scrolls back in from the right, one column every 4
|
||||
-- frames, resting two tiles right of the battle slot), DelayFrames 40,
|
||||
-- PrintEndBattleText -- the trainer's OWN loss line, on the battle
|
||||
-- screen -- and only then MoneyForWinningText. Every row rides the
|
||||
-- *Next inserters so it keeps that order behind the running queue item;
|
||||
-- the plain act() the pic used to ride appended to the END of the queue,
|
||||
-- which is why the trainer only flashed up for a frame or two as the
|
||||
-- battle popped and the loss line had to be printed by the overworld
|
||||
-- afterwards, stranding any evolution between two cuts (#282).
|
||||
-- endBattleText is filled in by whoever started the battle
|
||||
-- (OverworldState:engageTrainer in src/world/OverworldController.lua);
|
||||
-- scripted battles that print their own follow-up leave it nil.
|
||||
self:actNext(function() self:playVictoryMusic() end)
|
||||
-- _TrainerDefeatedText: "<PLAYER> defeated\nTRAINER!"
|
||||
self:sayNext(Strings("%s defeated\n%s!", self.game.save.player.name,
|
||||
self.trainer.name))
|
||||
self:actNext(function()
|
||||
self.showEnemyTrainer = self.trainerPic ~= nil
|
||||
if self.showEnemyTrainer then self:slidePic("foe", 64, 16, 2) end
|
||||
end)
|
||||
-- the 24-frame scroll-in plus the DelayFrames 40 that follows it
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { wait = 64 })
|
||||
if self.endBattleText then
|
||||
-- PrintEndBattleText prints one text box; a `para` (\f) inside it
|
||||
-- starts a fresh page, which is a message row of its own here (five
|
||||
-- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText)
|
||||
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
||||
if page ~= "" then self:sayNext(page) end
|
||||
end
|
||||
end
|
||||
self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
|
||||
end
|
||||
self.result = "win"
|
||||
@@ -3067,6 +3379,15 @@ function BattleState:playerMonFainted()
|
||||
-- Oak's Lab starter rival: Rival1WinText only (no blackout lines).
|
||||
-- Any other wipe, including Route 22 RIVAL1, still blacks out.
|
||||
if not BattleState.isOaksLabStarterRival(self) then
|
||||
-- HandlePlayerBlackOut (core.asm:1150-1159): SET_PAL_BATTLE_BLACK runs
|
||||
-- BEFORE PlayerBlackedOutText2, so the enemy pic and both HP bars are
|
||||
-- already dark under the blackout lines (#292). The Oak's Lab starter
|
||||
-- rival returns one line above that call and never darkens. Set here
|
||||
-- rather than queued: this whole function already runs from a queued
|
||||
-- act after "<mon> fainted!" was dismissed, which is where the palette
|
||||
-- command sits. (The Route 22 RIVAL1 wipe darkens one box early, over
|
||||
-- Rival1WinText, which pokered prints just before the same command.)
|
||||
self.blackedOut = true
|
||||
self:sayNext(Strings("%s is out of\nuseable POKéMON!", self.game.save.player.name))
|
||||
self:sayNext(Strings("%s blacked\nout!", self.game.save.player.name))
|
||||
end
|
||||
@@ -3475,13 +3796,41 @@ end
|
||||
|
||||
-- called by BagMenu when a ball is thrown
|
||||
function BattleState:throwBall(ball)
|
||||
self:say(Strings("%s used\n%s!", self.game.save.player.name,
|
||||
self.data.items[ball].name))
|
||||
-- ItemUseBall branches to ThrowBallAtTrainerMon on wIsInBattle != 1
|
||||
-- (item_effects.asm:109-113) BEFORE it reaches `ld hl, ItemUseText00 /
|
||||
-- call PrintText` (:146-147), so a trainer battle never shows the
|
||||
-- "<PLAYER> used <ITEM>!" line (#291). Safari and the old man demo are
|
||||
-- still wIsInBattle == 1, and this port models both as kind == "wild".
|
||||
if self.kind == "wild" then
|
||||
self:say(Strings("%s used\n%s!", self.game.save.player.name,
|
||||
self.data.items[ball].name))
|
||||
end
|
||||
self:act(function()
|
||||
require("src.core.Sound").play(self.data, "Ball_Toss")
|
||||
if self.kind ~= "wild" then
|
||||
self:sayNext(Strings("The TRAINER\nblocked the BALL!"))
|
||||
self:sayNext(Strings("Don't be a thief!"))
|
||||
-- ThrowBallAtTrainerMon (item_effects.asm:2292-2303) still animates the
|
||||
-- toss: MoveAnimation routes TOSS_ANIM to TossBallAnimation, which takes
|
||||
-- its .BlockBall branch in a trainer battle (animations.asm:2582-2585,
|
||||
-- 2629-2637) -- the plain TOSS arc whatever the ball tier, then
|
||||
-- SFX_FAINT_THUD and BLOCKBALL_ANIM, and only then the two texts. The
|
||||
-- ball still counts as used: UseItem_ sets
|
||||
-- wActionResultOrTookBattleTurn = 1 (item_effects.asm:1-3) and this path
|
||||
-- never clears it, so UseBagItem does not fall back to the bag
|
||||
-- (core.asm:2257-2259) and the turn is spent -- the foe moves (#291).
|
||||
local t = self.data.text
|
||||
self:animNext("TOSS_ANIM", true, nil, ball)
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Faint_Thud")
|
||||
end)
|
||||
self:animNext("BLOCKBALL_ANIM", true)
|
||||
self:sayNext(t._ThrowBallAtTrainerMonText1
|
||||
or Strings("The trainer\nblocked the BALL!"))
|
||||
self:sayNext(t._ThrowBallAtTrainerMonText2
|
||||
or Strings("Don't be a thief!"))
|
||||
self:act(function()
|
||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||
end)
|
||||
self:act(function() self:endOfTurn() end)
|
||||
return
|
||||
end
|
||||
if self.ghost then
|
||||
@@ -3577,7 +3926,10 @@ function BattleState:finish()
|
||||
-- way back -- an unrecoverable state, not merely a wrong one.
|
||||
-- playerMonFainted is the path that should have caught this; if we land
|
||||
-- here it did not, so say so rather than silently papering over it.
|
||||
if self.result ~= "lose" and not Party.firstHealthy(self.game.save.party) then
|
||||
-- The old-man / PROF.OAK demo also skips it: the party never fought
|
||||
-- (Yellow's Pallet intro runs before the player owns a mon at all).
|
||||
if self.result ~= "lose" and not self.demo
|
||||
and not Party.firstHealthy(self.game.save.party) then
|
||||
Logger.warn("battle finished %s with no healthy party; forcing blackout",
|
||||
tostring(self.result))
|
||||
self.result = "lose"
|
||||
@@ -3802,7 +4154,12 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
|
||||
-- while an SE effect displaces the pic, confine it to its side's
|
||||
-- tile window like the GB tilemap does (the pic can never overwrite
|
||||
-- the HUD columns or the text box rows)
|
||||
-- ...except under the widescreen layout, where the side's own region
|
||||
-- scissor is already that window on a battlefield the classic tile
|
||||
-- columns do not describe (an 88..160 clip would fall entirely outside
|
||||
-- the enemy's region and erase the pic).
|
||||
local clip = love.graphics.setScissor and love.graphics.intersectScissor
|
||||
and not self.wideRegion
|
||||
local scx, scy, scw, sch
|
||||
if clip then
|
||||
scx, scy, scw, sch = love.graphics.getScissor()
|
||||
@@ -3932,6 +4289,16 @@ function BattleState:sgbBattlePals()
|
||||
local pack = PaletteFX.pack(self.data)
|
||||
local pals = pack and pack.palettes
|
||||
if not pals then return nil end
|
||||
-- HandlePlayerBlackOut (core.asm:1151) runs SET_PAL_BATTLE_BLACK:
|
||||
-- SetPal_BattleBlack sends PalPacket_Black, PAL_BLACK in all four slots of
|
||||
-- BlkPacket_Battle (engine/gfx/palettes.asm:22-25), so every zone of the
|
||||
-- battle screen -- both HP bars and both mon regions -- goes dark behind
|
||||
-- the blackout text. picImage re-bakes the pics through the same palette,
|
||||
-- since those draw over the zone pass rather than through it (#292).
|
||||
if self.blackedOut and pals.BLACK then
|
||||
local b = pals.BLACK
|
||||
return { [0] = b, [1] = b, [2] = b, [3] = b }
|
||||
end
|
||||
local function bar(b)
|
||||
if not b then return pals.GREENBAR end
|
||||
local hp = b.shownHP or b.mon.hp
|
||||
@@ -4006,9 +4373,10 @@ end
|
||||
|
||||
-- recolor the grayscale BG canvas per zone; an active BGP fade permutes
|
||||
-- the zone palette (the SGB colors the remapped DMG shade). A window
|
||||
-- shake draws a second, offset copy over the base one: the color
|
||||
-- regions themselves never move on the SGB, and the vacated strip
|
||||
-- shows the unshifted BG map like the hardware.
|
||||
-- shake draws only the offset copy: the baked canvas holds the HUDs and
|
||||
-- text box (window-layer content), so compositing an unshifted copy
|
||||
-- underneath ghosted every name in the vacated strip (#295). The strip
|
||||
-- shows blank color 0 instead, like the hardware revealing empty BG.
|
||||
function BattleState:drawZonePass(src, sx, sy)
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local shader = PaletteFX.shader()
|
||||
@@ -4016,13 +4384,17 @@ function BattleState:drawZonePass(src, sx, sy)
|
||||
local bgp = self:activeBgp()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setShader(shader)
|
||||
local shaking = sx ~= 0 or sy ~= 0
|
||||
for _, z in ipairs(BATTLE_ZONES) do
|
||||
PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp))
|
||||
love.graphics.setScissor(z[1] * 8, z[2] * 8,
|
||||
(z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8)
|
||||
love.graphics.draw(src, 0, 0)
|
||||
if sx ~= 0 or sy ~= 0 then
|
||||
local zx, zy = z[1] * 8, z[2] * 8
|
||||
local zw, zh = (z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8
|
||||
love.graphics.setScissor(zx, zy, zw, zh)
|
||||
if shaking then
|
||||
love.graphics.rectangle("fill", zx, zy, zw, zh)
|
||||
love.graphics.draw(src, sx, sy)
|
||||
else
|
||||
love.graphics.draw(src, 0, 0)
|
||||
end
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
@@ -4145,15 +4517,21 @@ end
|
||||
|
||||
-- the two mon pics (or the trainer/back pics), offset by the window
|
||||
-- shake -- on the GB the pics are BG tiles, so they move with it
|
||||
function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
-- onlySide ("player" / "enemy") draws one side's pic alone, and
|
||||
-- skipMenuClip drops the move-menu row clip below: the widescreen layout
|
||||
-- composites each side into its own region of a taller battlefield, where
|
||||
-- neither the other side's pixels nor the classic menu rows apply.
|
||||
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
||||
-- The move-select boxes are BG tiles on the GB, so they REPLACE the
|
||||
-- player pic's rows: the TYPE/PP box at (0,8) (PrintMenuItem) wipes
|
||||
-- pic rows 8+, and Mimic's copy menu at (0,7) (MoveSelectionMenu
|
||||
-- .mimicmenu) wipes rows 7+. The port draws pics above the menu
|
||||
-- layer in the colorized pipeline, so clip them to the visible rows.
|
||||
local g = love.graphics
|
||||
local clipY = self.phase == "mimicSelect" and 56
|
||||
or self.phase == "moveSelect" and 64 or nil
|
||||
local clipY = not skipMenuClip
|
||||
and (self.phase == "mimicSelect" and 56
|
||||
or self.phase == "moveSelect" and 64)
|
||||
or nil
|
||||
local clipped, cs1, cs2, cs3, cs4
|
||||
if clipY and g.getScissor and g.intersectScissor then
|
||||
cs1, cs2, cs3, cs4 = g.getScissor()
|
||||
@@ -4161,13 +4539,15 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
clipped = true
|
||||
end
|
||||
-- Enemy: front sprite in the 7x7 slot at hlcoord 12,0.
|
||||
if self.showEnemyTrainer and self.trainerPic then
|
||||
if onlySide ~= "player" and self.showEnemyTrainer and self.trainerPic then
|
||||
-- the enemy trainer pic holds the mon slot until the send-out
|
||||
local img = self:picImage(self.trainerPic)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local ex, ey = enemyPicXY(img, slide, sx, sy)
|
||||
love.graphics.draw(img, ex, ey)
|
||||
elseif self.enemy and self.enemy.sprite and not self.enemyHidden
|
||||
-- SlideTrainerPicOffScreen / _ScrollTrainerPicAfterBattle offset (#317)
|
||||
love.graphics.draw(img, ex + self:picOffset("foe"), ey)
|
||||
elseif onlySide ~= "player"
|
||||
and self.enemy and self.enemy.sprite and not self.enemyHidden
|
||||
and not self.enemySendingOut and not self:fxHidden(self.enemy) then
|
||||
local img = self:picImage(self.enemy.sprite)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -4196,7 +4576,7 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
-- Left transparent columns (matted white) are pulled back so opaque
|
||||
-- pixels land where hardware's white-on-white columns left them.
|
||||
local hidePlayer = self.safari or self.demo
|
||||
if self.showPlayerBack and self.playerBackPic then
|
||||
if onlySide ~= "enemy" and self.showPlayerBack and self.playerBackPic then
|
||||
-- Red's (or the old man's) back pic until "Go!"; it stays up for
|
||||
-- the whole safari / catch-demo battle like the original
|
||||
local img = self:picImage(self.playerBackPic)
|
||||
@@ -4209,8 +4589,11 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local dx, dy = BattleState.backPlacement(img:getWidth(), img:getHeight(),
|
||||
pad, padL, s)
|
||||
love.graphics.draw(img, dx + slide + sx, dy + sy, 0, s, s)
|
||||
elseif self.player and self.player.sprite and not hidePlayer
|
||||
-- picOffset: SlideTrainerPicOffScreen walking the back pic off the left
|
||||
love.graphics.draw(img, dx + slide + sx + self:picOffset("back"),
|
||||
dy + sy, 0, s, s)
|
||||
elseif onlySide ~= "enemy"
|
||||
and self.player and self.player.sprite and not hidePlayer
|
||||
and not self.sendingOut and not self:fxHidden(self.player) then
|
||||
local img = self:picImage(self.player.sprite)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -4262,9 +4645,13 @@ function BattleState:drawHUDs(slide)
|
||||
local hudShake = (fx and fx.hudShakeX) or 0
|
||||
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
|
||||
-- TrainerAboutToUseText until DrawEnemyHUDAndHPBar after the next send-out
|
||||
-- ...and it is not up yet during the intro text either: a wild battle's
|
||||
-- DrawEnemyHUDAndHPBar is called from _InitBattleCommon (core.asm:6763)
|
||||
-- AFTER PrintBeginningBattleText returns, so "Wild X appeared!" shows the
|
||||
-- player's ball row with no enemy HUD beside it (#317)
|
||||
if self.enemy and not self.showEnemyTrainer and not self.enemySendingOut
|
||||
and not self:growInScale(self.enemy) and slide == 0
|
||||
and not self.enemy.fainted then
|
||||
and not self.introBalls and not self.enemy.fainted then
|
||||
-- enemy HUD (DrawEnemyHUDAndHPBar): name row 0, <LV>+level (4,1),
|
||||
-- HP bar (2,2) with the vertical tick at (1,2), underline row 3;
|
||||
-- AnimationShakeEnemyHUD nudges just this block via SCX
|
||||
@@ -4292,28 +4679,60 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
end
|
||||
|
||||
-- ReplaceFaintedEnemyMon -> DrawEnemyPokeballs (core.asm:896,
|
||||
-- draw_hud_pokeball_gfx.asm:9-11 -> SetupEnemyPartyPokeballs :33-45):
|
||||
-- between a KO and the next send-out the foe's ball row sits in the enemy
|
||||
-- HUD block FaintEnemyPokemon cleared, over the chrome PlaceEnemyHUDTiles
|
||||
-- writes with it -- the same $73 (1,2) / $74 (1,3) / $76 run / $78 tiles
|
||||
-- the live HUD draws, minus the HP bar (#283). Its own block rather than
|
||||
-- a third arm of showIntroBalls below: that window is DrawAllPokeballs's
|
||||
-- (#317) and clears for the rest of the battle, this one reopens on every
|
||||
-- enemy faint. wBaseCoordX $48 / wBaseCoordY $20 stepping -8 is screen
|
||||
-- (64,16) leftward, the same row the intro draws.
|
||||
if self.showEnemyBalls and self.enemyParty and slide == 0 then
|
||||
hudTile(0x73, 8, 16)
|
||||
hudTile(0x74, 8, 24)
|
||||
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
||||
hudTile(0x78, 80, 24)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self:drawBallRow(self.enemyParty, 64, 16, -8)
|
||||
end
|
||||
|
||||
-- Safari shows only the ball count; the old man demo shows neither mon
|
||||
if self.safari then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
|
||||
end
|
||||
-- trainer/link party pokeball rows during the intro
|
||||
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
|
||||
local showIntroBalls = slide == 0 and (
|
||||
(self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack))
|
||||
or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut))
|
||||
)
|
||||
-- Party pokeball rows and the HUD chrome under them, for exactly the
|
||||
-- window DrawAllPokeballs owns (common_text.asm:27, with the intro text).
|
||||
-- SetupOwnPartyPokeballs runs in EVERY battle, so the player's row belongs
|
||||
-- on the wild intro too -- keying it off the enemy trainer pic meant a
|
||||
-- wild battle never drew one (#317) -- and SetupEnemyPartyPokeballs is
|
||||
-- skipped when wIsInBattle == 1, so only a trainer/link battle gets the
|
||||
-- foe's row. Gating on introBalls rather than on the pics also stops the
|
||||
-- rows coming back when the beaten trainer scrolls in (#282):
|
||||
-- _ScrollTrainerPicAfterBattle redraws tilemap columns and never touches
|
||||
-- OAM, which ClearSprites emptied when the intro text was dismissed.
|
||||
local showIntroBalls = self.introBalls and slide == 0
|
||||
if showIntroBalls then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if self.enemyParty and (
|
||||
(self.kind == "trainer" and self.showEnemyTrainer)
|
||||
or (self.kind == "link" and self.enemySendingOut)
|
||||
) then
|
||||
if self.enemyParty and (self.kind == "trainer" or self.kind == "link") then
|
||||
-- PlaceEnemyHUDTiles (hlcoord 1,2): $73, then $74 + 8x $76 + $78
|
||||
-- rightward along row 3 (draw_hud_pokeball_gfx.asm:133-165)
|
||||
hudTile(0x73, 8, 16)
|
||||
hudTile(0x74, 8, 24)
|
||||
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
||||
hudTile(0x78, 80, 24)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self:drawBallRow(self.enemyParty, 64, 16, -8)
|
||||
end
|
||||
if self.showPlayerBack then
|
||||
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
||||
end
|
||||
-- PlacePlayerHUDTiles (hlcoord 18,10): $73, then $77 + 8x $76 + $6F
|
||||
-- LEFTWARD along row 11 (draw_hud_pokeball_gfx.asm:119-131)
|
||||
hudTile(0x73, 144, 80)
|
||||
hudTile(0x77, 144, 88)
|
||||
for i = 10, 17 do hudTile(0x76, i * 8, 88) end
|
||||
hudTile(0x6F, 72, 88)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
||||
end
|
||||
local hidePlayer = self.safari or self.demo
|
||||
if self.player and not hidePlayer and not self.showPlayerBack
|
||||
@@ -4363,9 +4782,10 @@ function BattleState:drawTextArea()
|
||||
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
|
||||
end
|
||||
end
|
||||
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait holds the
|
||||
-- box, bottom-right of the box like TextBox / home/text.asm
|
||||
if self.msgWaiting and self.frame % 60 < 30 then
|
||||
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait
|
||||
-- (_ContText) or a typed-out page (PromptText) holds the box; both write
|
||||
-- it at (18,16), bottom-right, like TextBox / home/text.asm (#317)
|
||||
if (self.msgWaiting or self.msgPrompt) and self.frame % 60 < 30 then
|
||||
Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
|
||||
end
|
||||
elseif self.phase == "menu" and self.demo then
|
||||
@@ -4405,11 +4825,25 @@ function BattleState:drawTextArea()
|
||||
-- the move box's top border ('─' at (4,12), '┘' at (10,12)).
|
||||
Font.drawBox(0, 8, 11, 5)
|
||||
Font.drawBox(4, 12, 16, 6)
|
||||
-- Those two cells are REPLACED on hardware: MoveSelectionMenu writes them
|
||||
-- straight into the tilemap over the border it just laid down
|
||||
-- (core.asm:2492-2501), and PrintMenuItem's own TextBoxBorder then redraws
|
||||
-- the whole row on top (core.asm:2838-2844). Font.drawCode blits a
|
||||
-- black-on-transparent glyph instead, so the tile underneath survives: the
|
||||
-- move box's '┌' keeps its Poké Ball corner showing through the '─', and
|
||||
-- the '─' the move box drew at (10,12) pokes two dots out from under the
|
||||
-- '┘' (#240). Wipe each cell back to box white first, the way a tilemap
|
||||
-- write does.
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 32, 96, 8, 8)
|
||||
love.graphics.rectangle("fill", 80, 96, 8, 8)
|
||||
Font.drawCode(Font.BORDER.h, 32, 96)
|
||||
Font.drawCode(Font.BORDER.br, 80, 96)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, mv in ipairs(self.player.curMoves) do
|
||||
Font.draw(self.data.moves[mv.id].name, 48, 96 + i * 8)
|
||||
-- unknown ids (mod-injected moves) print raw instead of crashing
|
||||
local def = self.data.moves[mv.id]
|
||||
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
|
||||
end
|
||||
Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED,
|
||||
40, 96 + self.moveIndex * 8)
|
||||
@@ -4418,10 +4852,10 @@ function BattleState:drawTextArea()
|
||||
end
|
||||
local sel = self.player.curMoves[self.moveIndex]
|
||||
if sel then
|
||||
local def = self.data.moves[sel.id]
|
||||
if self.player.disabledSlot == self.moveIndex then
|
||||
Font.draw(Strings("disabled!"), 8, 80)
|
||||
else
|
||||
local def = self.data.moves[sel.id]
|
||||
elseif def then
|
||||
Font.draw(Strings("TYPE/"), 8, 72)
|
||||
-- the type record's display name (a mod type shows its name, and
|
||||
-- PSYCHIC_TYPE prints PSYCHIC like the original)
|
||||
@@ -4444,7 +4878,12 @@ function BattleState:drawTextArea()
|
||||
end
|
||||
|
||||
function BattleState:draw()
|
||||
-- AskName: ClearSprites + wild ClearScreenArea — white field under the
|
||||
if self:wideLayout() then return WideBattle.draw(self) end
|
||||
return self:drawClassic()
|
||||
end
|
||||
|
||||
function BattleState:drawClassic()
|
||||
-- AskName: ClearSprites + wild ClearScreenArea -- white field under the
|
||||
-- nickname TextBox / YES/NO (naming_screen.asm); overlays draw on top.
|
||||
if self.blankForAskName then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
|
||||
-- tied minima (core.asm:2971-3002). A non-minimal move is never
|
||||
-- selectable. Respects Disable (and PP only when the ruleset depletes
|
||||
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
|
||||
-- enemy PP -- Gen 1 AI never reads wEnemyMonPP).
|
||||
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
-- Widescreen battle layout (OPTION -> BATTLE LAYOUT -> WIDE).
|
||||
--
|
||||
-- The battle simulation, timing, animations and rules stay BattleState's;
|
||||
-- this module only replaces the composition and asks the renderer for a
|
||||
-- 304x144 native-pixel UI surface while it is up. Pictures, font pages,
|
||||
-- border glyphs, species palettes and HP tiles all resolve through the
|
||||
-- engine, so a COLORS mode or an asset mod still owns the look.
|
||||
--
|
||||
-- The extra 144 pixels of width buy a Gen 3-style arrangement: foe status
|
||||
-- upper left with its picture upper right, the player's picture lower left
|
||||
-- with their status lower right, a full-width message window, a split
|
||||
-- prompt/command window, and a 2x2 move menu with an attached PP/type panel.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Strings = require("src.core.Strings")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
local WideBattle = {
|
||||
WIDTH = 304,
|
||||
HEIGHT = 144,
|
||||
-- everything above this line is battlefield; the 40 rows below it are
|
||||
-- the message / command / move windows
|
||||
FIELD_BOTTOM = 104,
|
||||
}
|
||||
|
||||
-- The forced-mono display modes re-threshold the whole finished frame
|
||||
-- through the shade shader, and picImage hands them raw DMG grays for that
|
||||
-- (#207). The wide layout has to know: it exposes a matching whole-surface
|
||||
-- zone and leaves the HP bar fill gray, exactly like the zone pass does in
|
||||
-- the classic layout (#229). Keep in sync with picImage / ensureZones.
|
||||
local function monoMode()
|
||||
local m = PaletteFX.mode
|
||||
return m == "og" or m == "og_inv" or m == "classic"
|
||||
end
|
||||
|
||||
local function shownHP(battler)
|
||||
return math.max(0, math.floor(battler.shownHP or battler.mon.hp or 0))
|
||||
end
|
||||
|
||||
-- a name truncated to `pixels` with a trailing '.', measured through the
|
||||
-- font's own advances so a variable-width page still fits
|
||||
local function fitName(text, pixels)
|
||||
local spans = Font.split(text or "")
|
||||
local n = Font.spansFitting(spans, pixels)
|
||||
if n >= #spans then return text or "" end
|
||||
local out = {}
|
||||
for i = 1, math.max(0, n - 1) do
|
||||
out[#out + 1] = (text or ""):sub(spans[i].from, spans[i].to)
|
||||
end
|
||||
return table.concat(out) .. "."
|
||||
end
|
||||
|
||||
local function saveScissor()
|
||||
if not love.graphics.getScissor then return nil end
|
||||
local x, y, w, h = love.graphics.getScissor()
|
||||
if x == nil then return false end
|
||||
return { x, y, w, h }
|
||||
end
|
||||
|
||||
local function restoreScissor(saved)
|
||||
if not love.graphics.setScissor then return end
|
||||
if saved and saved ~= false then
|
||||
love.graphics.setScissor(saved[1], saved[2], saved[3], saved[4])
|
||||
else
|
||||
love.graphics.setScissor()
|
||||
end
|
||||
end
|
||||
|
||||
-- Draw fn's content translated by (dx, dy) and clipped to a surface rect.
|
||||
-- The scissor is in canvas space, so it bounds the region itself while the
|
||||
-- translate moves the classic 160x144 coordinates into it.
|
||||
local function inRegion(x, y, w, h, dx, dy, fn)
|
||||
local g = love.graphics
|
||||
local saved = saveScissor()
|
||||
g.setScissor(x, y, w, h)
|
||||
g.push()
|
||||
g.translate(dx, dy)
|
||||
fn()
|
||||
g.pop()
|
||||
restoreScissor(saved)
|
||||
end
|
||||
|
||||
local function levelAt(battle, battler, x, y)
|
||||
if battler.shownStatus then
|
||||
Font.draw(battle:statusLabel({ status = battler.shownStatus }), x, y)
|
||||
else
|
||||
HudTiles.tile(0x6E, x, y) -- '<LV>'
|
||||
Font.draw(tostring(battler.mon.level), x + 8, y)
|
||||
end
|
||||
end
|
||||
|
||||
-- One side's status box: name and level on the first line, a long HP bar
|
||||
-- under it, and the numeric HP on the player's box only (the foe's exact
|
||||
-- HP is never shown, like the original).
|
||||
local function drawStatusPanel(battle, battler, x, y, player)
|
||||
local tx, ty = math.floor(x / 8), math.floor(y / 8)
|
||||
local tw, th = player and 15 or 16, player and 5 or 4
|
||||
Font.drawBox(tx, ty, tw, th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
local nameWidth = player and 64 or 80
|
||||
Font.draw(fitName(battler.name, nameWidth), x + 8, y + 8)
|
||||
levelAt(battle, battler, x + tw * 8 - 40, y + 8)
|
||||
|
||||
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
|
||||
hp = shownHP(battler),
|
||||
stats = battler.mon.stats,
|
||||
}, nil, monoMode(), tw - 5)
|
||||
|
||||
if player then
|
||||
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
|
||||
x + tw * 8 - 64, y + 24)
|
||||
end
|
||||
end
|
||||
|
||||
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
|
||||
-- out to the wide screen's own corners
|
||||
local function drawIntroBalls(battle)
|
||||
if not battle.introBalls then return end
|
||||
if battle.enemyParty and
|
||||
(battle.kind == "trainer" or battle.kind == "link") then
|
||||
battle:drawBallRow(battle.enemyParty, 88, 40, -8)
|
||||
end
|
||||
battle:drawBallRow(battle.playerParty or battle.game.save.party, 216, 96, 8)
|
||||
end
|
||||
|
||||
local function drawHUDs(battle, slide)
|
||||
if battle.enemy and not battle.showEnemyTrainer
|
||||
and not battle.enemySendingOut and not battle:growInScale(battle.enemy)
|
||||
and slide == 0 and not battle.introBalls and not battle.enemy.fainted then
|
||||
drawStatusPanel(battle, battle.enemy, 0, 0, false)
|
||||
end
|
||||
|
||||
if battle.safari then
|
||||
Font.drawBox(23, 7, 15, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("BALLx%2d"):format(battle.safari.balls), 200, 72)
|
||||
elseif battle.player and not battle.demo and not battle.showPlayerBack
|
||||
and slide == 0 then
|
||||
drawStatusPanel(battle, battle.player, 184, 56, true)
|
||||
end
|
||||
drawIntroBalls(battle)
|
||||
end
|
||||
|
||||
local function drawMessageBox(battle)
|
||||
Font.drawBox(0, 13, 38, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if battle.scrollPx and battle.scrollPx > 0 then
|
||||
battle.scrollPx = battle.scrollPx - 2
|
||||
if battle.scrollPx <= 0 then battle.scrollPx = nil end
|
||||
end
|
||||
local off = battle.scrollPx or 0
|
||||
local ys = { 112, 128 }
|
||||
for li, line in ipairs(battle.shown or {}) do
|
||||
local y = (ys[li] or 128) + off
|
||||
for i = 1, #line do
|
||||
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
|
||||
end
|
||||
end
|
||||
if (battle.msgWaiting or battle.msgPrompt) and battle.frame % 60 < 30 then
|
||||
Font.drawCode(0xEE, 288, 132)
|
||||
end
|
||||
end
|
||||
|
||||
local function drawCommandMenu(battle)
|
||||
local col = (battle.menuIndex - 1) % 2
|
||||
local row = math.floor((battle.menuIndex - 1) / 2)
|
||||
if battle.safari then
|
||||
Font.drawBox(0, 13, 38, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("BALLx"), 16, 112)
|
||||
Font.draw(Strings("BAIT"), 168, 112)
|
||||
Font.draw(Strings("THROW ROCK"), 16, 128)
|
||||
Font.draw(Strings("RUN"), 168, 128)
|
||||
Font.drawCode(0xED, col == 0 and 8 or 160, 112 + row * 16)
|
||||
return
|
||||
end
|
||||
|
||||
-- the prompt on the left, the 2x2 commands on the right
|
||||
Font.drawBox(0, 13, 20, 5)
|
||||
Font.drawBox(20, 13, 18, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("What will"), 8, 112)
|
||||
local who = battle.player and battle.player.name or ""
|
||||
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
|
||||
Font.draw(Strings("FIGHT"), 176, 112)
|
||||
Font.drawCode(0xE1, 240, 112) -- 'PK'
|
||||
Font.drawCode(0xE2, 248, 112) -- 'MN'
|
||||
Font.draw(Strings("ITEM"), 176, 128)
|
||||
Font.draw(Strings("RUN"), 240, 128)
|
||||
Font.drawCode(0xED, col == 0 and 168 or 232, 112 + row * 16)
|
||||
end
|
||||
|
||||
local function drawMoveDetails(battle, move)
|
||||
Font.drawBox(28, 13, 10, 5)
|
||||
if not move then return end
|
||||
local def = battle.data.moves[move.id]
|
||||
if not def then return end
|
||||
local maxPP = def.pp + (move.ppUps or 0) * math.floor(def.pp / 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("PP %2d/%2d"):format(move.pp or 0, maxPP), 232, 112)
|
||||
Font.draw(fitName(TypeChart.displayName(def.type), 64), 232, 128)
|
||||
end
|
||||
|
||||
local function drawMoveGrid(battle, moves, selected)
|
||||
-- The 8px font needs 28 tiles for two complete twelve-character move
|
||||
-- names plus their cursors; the details panel gets the other ten.
|
||||
Font.drawBox(0, 13, 28, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, move in ipairs(moves or {}) do
|
||||
local col = (i - 1) % 2
|
||||
local row = math.floor((i - 1) / 2)
|
||||
local x, y = col == 0 and 16 or 120, 112 + row * 16
|
||||
local def = battle.data.moves[move.id]
|
||||
Font.draw(fitName(def and def.name or move.id or "", 96), x, y)
|
||||
end
|
||||
local col = (selected - 1) % 2
|
||||
local row = math.floor((selected - 1) / 2)
|
||||
Font.drawCode(0xED, col == 0 and 8 or 112, 112 + row * 16)
|
||||
drawMoveDetails(battle, moves and moves[selected])
|
||||
end
|
||||
|
||||
local function drawMoveMenu(battle)
|
||||
drawMoveGrid(battle, battle.player.curMoves, battle.moveIndex)
|
||||
if battle.moveSwapIndex then
|
||||
local col = (battle.moveSwapIndex - 1) % 2
|
||||
local row = math.floor((battle.moveSwapIndex - 1) / 2)
|
||||
Font.drawCode(0xEC, col == 0 and 8 or 112, 112 + row * 16)
|
||||
end
|
||||
end
|
||||
|
||||
local function drawTextArea(battle)
|
||||
if battle.phase == "messages" and (battle.current or battle.animPlaying) then
|
||||
drawMessageBox(battle)
|
||||
elseif battle.phase == "menu" then
|
||||
drawCommandMenu(battle)
|
||||
elseif battle.phase == "moveSelect" then
|
||||
drawMoveMenu(battle)
|
||||
elseif battle.phase == "mimicSelect" then
|
||||
drawMoveGrid(battle, battle.mimicMoves, battle.mimicIndex)
|
||||
else
|
||||
Font.drawBox(0, 13, 38, 5)
|
||||
end
|
||||
end
|
||||
|
||||
-- Battle animations are authored in the original 160px coordinate space.
|
||||
-- Shift each complete OAM frame as one rigid group between the new player
|
||||
-- and enemy anchors: drawing the whole animation through both side regions
|
||||
-- would duplicate any tiles overlapping the other side's source range (most
|
||||
-- visibly the send-out POOF reappearing on the far right).
|
||||
function WideBattle.animationOffset(sprites)
|
||||
if not sprites or #sprites == 0 then return 0, 0 end
|
||||
local minX, maxX = math.huge, -math.huge
|
||||
for _, sprite in ipairs(sprites) do
|
||||
minX = math.min(minX, sprite.x - 8)
|
||||
maxX = math.max(maxX, sprite.x)
|
||||
end
|
||||
local center = (minX + maxX) / 2
|
||||
local t = math.max(0, math.min(1, (center - 40) / 80))
|
||||
return math.floor(20 + 116 * t + 0.5),
|
||||
math.floor(8 * (1 - t) + 0.5)
|
||||
end
|
||||
|
||||
local function currentAnimationSprites(battle)
|
||||
if battle.animPlaying and battle.animPlayer then
|
||||
local step = battle.animPlayer.steps[battle.animPlayer.stepIndex]
|
||||
return step and step.sprites
|
||||
end
|
||||
if battle.lockedBall and battle.animPlayer then
|
||||
return battle.lockedBall
|
||||
end
|
||||
end
|
||||
|
||||
local function drawAnimationLayer(battle)
|
||||
local sprites = currentAnimationSprites(battle)
|
||||
if not sprites or #sprites == 0 then return end
|
||||
local dx, dy = WideBattle.animationOffset(sprites)
|
||||
inRegion(0, 0, WideBattle.WIDTH, WideBattle.FIELD_BOTTOM, dx, dy,
|
||||
function() battle:drawAnimLayer(false) end)
|
||||
end
|
||||
|
||||
-- The whole 304x144 composition for one frame.
|
||||
function WideBattle.draw(battle)
|
||||
local g = love.graphics
|
||||
-- The field is the display mode's paper. Under a forced-mono mode the
|
||||
-- whole surface is remapped downstream (WideBattle.zones), so the field
|
||||
-- goes down as DMG white and comes out of that pass as the mode's paper;
|
||||
-- painting the resolved shade there would run it through the remap twice
|
||||
-- and land a shade off the letterbox the renderer fills around it.
|
||||
if monoMode() then
|
||||
g.setColor(1, 1, 1, 1)
|
||||
else
|
||||
g.setColor(PaletteFX.paperShade(battle.data))
|
||||
end
|
||||
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||
-- AskName clears the field the same way the classic layout does
|
||||
if battle.blankForAskName then return end
|
||||
|
||||
local fx = battle.fx
|
||||
local sx = (fx and fx.shakeX) or 0
|
||||
local sy = (fx and fx.shakeY) or 0
|
||||
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
|
||||
sx = battle.frame % 4 < 2 and 2 or -2
|
||||
end
|
||||
local slide = (battle.introSlide or 0) * 4
|
||||
|
||||
-- Each side keeps its original sprite pixels and placement math: the two
|
||||
-- 160x144 OAM regions are translated apart and clipped into the wider
|
||||
-- battlefield rather than either monster being scaled. wideRegion tells
|
||||
-- drawBattlerPic its own side window is already the clip.
|
||||
battle.wideRegion = true
|
||||
inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
|
||||
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
|
||||
inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
|
||||
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
|
||||
battle.wideRegion = nil
|
||||
|
||||
drawHUDs(battle, slide)
|
||||
drawAnimationLayer(battle)
|
||||
drawTextArea(battle)
|
||||
|
||||
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
|
||||
g.setColor(1, 1, 1, 0.85)
|
||||
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||
end
|
||||
g.setColor(1, 1, 1, 1)
|
||||
if Runtime.wantsHook("battle.overlay") then
|
||||
Runtime.call("battle.overlay", function() end, battle)
|
||||
end
|
||||
end
|
||||
|
||||
-- The palette zones for the wide surface. The composition already resolves
|
||||
-- species colors, paper shade and HP-bar colors itself, so the colorized
|
||||
-- modes take the trueColor opt-out (`colors = false`) over the whole
|
||||
-- surface; the forced-mono modes still want their whole-screen remap, and
|
||||
-- get one sized to the wide surface instead of the 160x144 rectangle
|
||||
-- PaletteFX.ensureZones would invent (which would leave 144 columns raw).
|
||||
function WideBattle.zones()
|
||||
local w, h = WideBattle.WIDTH, WideBattle.HEIGHT
|
||||
if monoMode() then
|
||||
-- sendColors runs the mode's own substitution (CLASSIC's pea greens,
|
||||
-- the inverted permutation), exactly as it does for ensureZones' zone
|
||||
return { PaletteFX.zone(PaletteFX.GRAYS, 0, 0, w / 8 - 1, h / 8 - 1) }
|
||||
end
|
||||
return { { colors = false, x = 0, y = 0, w = w, h = h } }
|
||||
end
|
||||
|
||||
-- 2x2 move-grid navigation: LEFT/RIGHT cross the row, UP/DOWN the column,
|
||||
-- and a direction pointing at an empty slot holds the current one.
|
||||
function WideBattle.moveGridIndex(index, count, direction)
|
||||
if count < 1 then return nil end
|
||||
local row = math.floor((index - 1) / 2)
|
||||
local col = (index - 1) % 2
|
||||
if direction == "left" or direction == "right" then
|
||||
local other = row * 2 + (1 - col) + 1
|
||||
return other <= count and other or index
|
||||
end
|
||||
local otherRow = 1 - row
|
||||
local other = otherRow * 2 + col + 1
|
||||
return other <= count and other or index
|
||||
end
|
||||
|
||||
local DIRECTIONS = { "left", "right", "up", "down" }
|
||||
|
||||
-- the slot a directional press selects, or nil when none was pressed (the
|
||||
-- caller then runs its normal list navigation / A / B / SELECT handling)
|
||||
function WideBattle.navigate(index, count, input)
|
||||
for _, key in ipairs(DIRECTIONS) do
|
||||
if input:wasPressed(key) then
|
||||
return WideBattle.moveGridIndex(index, count, key)
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return WideBattle
|
||||
+52
-45
@@ -14,15 +14,15 @@ local bit = require("bit")
|
||||
|
||||
local ChipSynth = {}
|
||||
|
||||
local SAMPLE_RATE = 22050
|
||||
local SAMPLE_RATE = 44100
|
||||
local TICKS_PER_SECOND = 15360
|
||||
local FRAME_TICKS = 256
|
||||
local GB_CLOCK = 4194304
|
||||
|
||||
-- one 4096-sample stereo SoundData is the unit both the worker hands off and
|
||||
-- one 8192-sample stereo SoundData is the unit both the worker hands off and
|
||||
-- the synchronous fallback queues; the source keeps MUSIC_BUFFER_COUNT of them
|
||||
-- (~6s) for stall tolerance (window resize, a long GC pause)
|
||||
local MUSIC_BUFFER_SAMPLES = 4096
|
||||
-- (~6s at 44100) for stall tolerance (window resize, a long GC pause)
|
||||
local MUSIC_BUFFER_SAMPLES = 8192
|
||||
local MUSIC_BUFFER_COUNT = 32
|
||||
|
||||
ChipSynth.SAMPLE_RATE = SAMPLE_RATE
|
||||
@@ -33,7 +33,13 @@ local PITCHES = {
|
||||
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
|
||||
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
|
||||
}
|
||||
local DUTY = { [0] = 0.125, [1] = 0.25, [2] = 0.5, [3] = 0.75 }
|
||||
-- LuaGB / DMG 8-step duty tables (index 0-3); stored on channels as that index
|
||||
local WAVE_PATTERN_TABLES = {
|
||||
[0] = {0, 0, 0, 0, 0, 0, 0, 1},
|
||||
[1] = {1, 0, 0, 0, 0, 0, 0, 1},
|
||||
[2] = {1, 0, 0, 0, 0, 1, 1, 1},
|
||||
[3] = {0, 1, 1, 1, 1, 1, 1, 0},
|
||||
}
|
||||
local WAVE_LEVEL = { [0] = 0, [1] = 1, [2] = 0.5, [3] = 0.25 }
|
||||
local NOISE_DIVISORS = {
|
||||
[0] = 8, [1] = 16, [2] = 32, [3] = 48,
|
||||
@@ -41,7 +47,7 @@ local NOISE_DIVISORS = {
|
||||
}
|
||||
|
||||
local function snapTicks(ticks)
|
||||
return math.floor((ticks * 735 + 256) / 512)
|
||||
return math.floor((ticks * 1470 + 256) / 512)
|
||||
end
|
||||
|
||||
local cachedProgramFile
|
||||
@@ -143,7 +149,7 @@ function Channel.new(engine, spec, options)
|
||||
speed = 12,
|
||||
volume = 12,
|
||||
fade = 0,
|
||||
duty = 0.5,
|
||||
duty = 2,
|
||||
octave = 4,
|
||||
waveInstrument = 0,
|
||||
waveLevel = 1,
|
||||
@@ -317,7 +323,7 @@ function Channel:nextEvent()
|
||||
target = self:frequency(bit.band(packed, 0x0F), octave),
|
||||
}
|
||||
elseif command == 0xEC then
|
||||
self.duty = DUTY[bit.band(self:byte(), 3)] or 0.5
|
||||
self.duty = bit.band(self:byte(), 3)
|
||||
elseif command == 0xED then
|
||||
self.engine.tempo = self:byte() * 0x100 + self:byte()
|
||||
elseif command == 0xEE then
|
||||
@@ -329,10 +335,10 @@ function Channel:nextEvent()
|
||||
elseif command == 0xFC then
|
||||
local packed = self:byte()
|
||||
self.duty = {
|
||||
DUTY[bit.band(bit.rshift(packed, 6), 3)],
|
||||
DUTY[bit.band(bit.rshift(packed, 4), 3)],
|
||||
DUTY[bit.band(bit.rshift(packed, 2), 3)],
|
||||
DUTY[bit.band(packed, 3)],
|
||||
bit.band(bit.rshift(packed, 6), 3),
|
||||
bit.band(bit.rshift(packed, 4), 3),
|
||||
bit.band(bit.rshift(packed, 2), 3),
|
||||
bit.band(packed, 3),
|
||||
}
|
||||
elseif command == 0xFD then
|
||||
self.callStack[#self.callStack + 1] = self.address + 2
|
||||
@@ -423,27 +429,23 @@ function Channel:sampleNoise(parameter)
|
||||
parameter = parameter or 0
|
||||
local divisor = NOISE_DIVISORS[bit.band(parameter, 7)]
|
||||
local shift = bit.rshift(parameter, 4)
|
||||
local output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
|
||||
if shift >= 14 then return output end
|
||||
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE
|
||||
local width7 = bit.band(parameter, 8) ~= 0
|
||||
local remaining = cycles
|
||||
local area = 0
|
||||
|
||||
while remaining > 0 do
|
||||
local untilClock = 1 - self.noiseClock
|
||||
local span = math.min(remaining, untilClock)
|
||||
output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
|
||||
area = area + output * span
|
||||
self.noiseClock = self.noiseClock + span
|
||||
remaining = remaining - span
|
||||
if self.noiseClock >= 1 - 1e-12 then
|
||||
self.noiseClock = 0
|
||||
self:clockNoise(width7)
|
||||
if shift < 14 then
|
||||
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE
|
||||
local width7 = bit.band(parameter, 8) ~= 0
|
||||
local remaining = cycles
|
||||
while remaining > 0 do
|
||||
local untilClock = 1 - self.noiseClock
|
||||
local span = math.min(remaining, untilClock)
|
||||
self.noiseClock = self.noiseClock + span
|
||||
remaining = remaining - span
|
||||
if self.noiseClock >= 1 - 1e-12 then
|
||||
self.noiseClock = 0
|
||||
self:clockNoise(width7)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return area / cycles
|
||||
-- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0)
|
||||
return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
|
||||
end
|
||||
|
||||
local function sweepCalculation(register, sweep)
|
||||
@@ -481,7 +483,7 @@ function Channel:sampleDrum(event, sampleIndex)
|
||||
end
|
||||
local elapsed = (sampleIndex - segment.startSample) / SAMPLE_RATE
|
||||
local volume = envelopeVolume(segment.volume, segment.fade, elapsed)
|
||||
return self:sampleNoise(segment.parameter) * volume / 15 * 0.35
|
||||
return self:sampleNoise(segment.parameter) * volume / 15
|
||||
end
|
||||
|
||||
function Channel:sample()
|
||||
@@ -502,7 +504,7 @@ function Channel:sample()
|
||||
local volume = envelopeVolume(
|
||||
event.volume or 0, event.fade or 0, event.elapsed)
|
||||
if event.noise then
|
||||
return self:sampleNoise(event.noiseParameter) * volume / 15 * 0.35
|
||||
return self:sampleNoise(event.noiseParameter) * volume / 15
|
||||
end
|
||||
|
||||
local register = event.register
|
||||
@@ -537,13 +539,18 @@ function Channel:sample()
|
||||
-- a def-local program may omit its wave table entirely
|
||||
if not wave then return 0 end
|
||||
local index = math.min(32, math.floor(phase * 32) + 1)
|
||||
return wave[index] * event.waveLevel * 0.55
|
||||
return wave[index] * event.waveLevel
|
||||
end
|
||||
local duty = event.duty
|
||||
if type(duty) == "table" then
|
||||
duty = duty[frame % 4 + 1]
|
||||
end
|
||||
return (phase < duty and 1 or -1) * volume / 15 * 0.5
|
||||
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
|
||||
local step = math.floor(phase * 8) % 8
|
||||
if pattern[step + 1] == 0 then
|
||||
return -volume / 15
|
||||
end
|
||||
return volume / 15
|
||||
end
|
||||
|
||||
local Engine = {}
|
||||
@@ -597,8 +604,8 @@ local function readWaves(banks, audio, engineNumber)
|
||||
for byteIndex = 0, 15 do
|
||||
local packed = romByte(
|
||||
banks, spec.bank, spec.address + wave * 16 + byteIndex)
|
||||
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5
|
||||
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
|
||||
values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8
|
||||
values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8
|
||||
end
|
||||
waves[#waves + 1] = values
|
||||
end
|
||||
@@ -606,8 +613,8 @@ local function readWaves(banks, audio, engineNumber)
|
||||
for byteIndex = 0, 15 do
|
||||
local packed = romByte(
|
||||
banks, spec.bank, spec.address + 5 * 16 + byteIndex)
|
||||
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5
|
||||
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
|
||||
values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8
|
||||
values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8
|
||||
end
|
||||
for _ = 1, 4 do waves[#waves + 1] = values end
|
||||
return waves
|
||||
@@ -615,7 +622,7 @@ end
|
||||
|
||||
-- def-local waves are authored either as raw 0-15 nibbles (the ROM's own
|
||||
-- units) or as the -1..1 samples readWaves produces; the synth wants the
|
||||
-- latter
|
||||
-- latter (LuaGB: (nibble - 8) / 8)
|
||||
local function normalizeWaves(source)
|
||||
local waves = {}
|
||||
for index, values in ipairs(source) do
|
||||
@@ -625,7 +632,7 @@ local function normalizeWaves(source)
|
||||
end
|
||||
local wave = {}
|
||||
for position, value in ipairs(values) do
|
||||
wave[position] = nibbles and (value - 7.5) / 7.5 or value
|
||||
wave[position] = nibbles and (value - 8) / 8 or value
|
||||
end
|
||||
waves[index] = wave
|
||||
end
|
||||
@@ -690,7 +697,7 @@ end
|
||||
function Engine:sample()
|
||||
local value = 0
|
||||
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
|
||||
return math.max(-1, math.min(1, value * 0.5))
|
||||
return math.max(-1, math.min(1, value / 4))
|
||||
end
|
||||
|
||||
function Engine:sampleStereo()
|
||||
@@ -701,8 +708,8 @@ function Engine:sampleStereo()
|
||||
if not event or event.panLeft ~= false then left = left + value end
|
||||
if not event or event.panRight ~= false then right = right + value end
|
||||
end
|
||||
return math.max(-1, math.min(1, left * 0.5)),
|
||||
math.max(-1, math.min(1, right * 0.5))
|
||||
return math.max(-1, math.min(1, left / 4)),
|
||||
math.max(-1, math.min(1, right / 4))
|
||||
end
|
||||
|
||||
function Engine:sampleChannel(number)
|
||||
@@ -711,7 +718,7 @@ function Engine:sampleChannel(number)
|
||||
local value = channel:sample()
|
||||
if channel.number == number then selected = value end
|
||||
end
|
||||
return math.max(-1, math.min(1, selected * 0.5))
|
||||
return math.max(-1, math.min(1, selected / 4))
|
||||
end
|
||||
|
||||
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
|
||||
|
||||
+37
-4
@@ -85,6 +85,13 @@ function Data:seedDefaults()
|
||||
for key, value in pairs(BOOT_DEFAULTS) do
|
||||
if boot[key] == nil then boot[key] = copy(value) end
|
||||
end
|
||||
-- Yellow boots its own attract movie (engine/movie/intro_yellow.asm);
|
||||
-- only the un-overridden default flips, so a total conversion that set
|
||||
-- field.boot.screens.splash keeps its choice on any version.
|
||||
if boot.screens.splash == BOOT_DEFAULTS.screens.splash
|
||||
and require("src.core.GameVersion").isYellow() then
|
||||
boot.screens.splash = "YellowIntro"
|
||||
end
|
||||
-- the naming screen presets the importer already extracts but nothing
|
||||
-- ever read (field.presetNames)
|
||||
if boot.namePresets == nil then
|
||||
@@ -206,10 +213,15 @@ function Data:load()
|
||||
(function() local n = 0 for _ in pairs(self.moves) do n = n + 1 end return n end)())
|
||||
end
|
||||
|
||||
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
|
||||
-- mod merge created, then re-require the generated modules so base records
|
||||
-- return to their on-disk values even where a mod edited them in place
|
||||
function Data:reloadGenerated()
|
||||
-- Drop every namespace the mod merge created and evict the generated modules
|
||||
-- from package.loaded, so the next load() re-reads them off disk instead of
|
||||
-- handing back the cached tables. Two callers:
|
||||
-- * reloadGenerated below (dev hot reload)
|
||||
-- * main.lua, when the launcher closes the save editor -- the editor may
|
||||
-- have loaded the OTHER game's cache, and require would otherwise serve
|
||||
-- those modules to a subsequent Play (see CacheFs.unmountVersion, which
|
||||
-- clears the matching read-path overlay).
|
||||
function Data:unloadGenerated()
|
||||
local pristine = self._pristineKeys
|
||||
if pristine then
|
||||
for key in pairs(self) do
|
||||
@@ -222,6 +234,13 @@ function Data:reloadGenerated()
|
||||
for _, name in ipairs(OPTIONAL) do
|
||||
package.loaded["data.generated." .. name] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- dev-mode hot reload only (src/dev/HotReload.lua): drop every namespace the
|
||||
-- mod merge created, then re-require the generated modules so base records
|
||||
-- return to their on-disk values even where a mod edited them in place
|
||||
function Data:reloadGenerated()
|
||||
self:unloadGenerated()
|
||||
self:load()
|
||||
end
|
||||
|
||||
@@ -246,6 +265,20 @@ function Data:resolveText(mapLabel, textConst)
|
||||
local s = self.text[entry.text]
|
||||
if s then return s, entry.asm end
|
||||
end
|
||||
-- A text_asm entry names its wrapper label but carries no string, because
|
||||
-- the extractor cannot follow asm. A handful of those wrappers are plain
|
||||
-- `text_far <label> / text_end` pairs with no logic at all -- BoulderText
|
||||
-- (home/overworld_text.asm:16), MartSignText, PokeCenterSignText -- and
|
||||
-- for those the extracted _Label string IS the whole behavior, so the
|
||||
-- boulders and signs printed nothing at all (#318). Wrappers that really
|
||||
-- do run logic have no _Label string to find, so they still fall through
|
||||
-- to their hand-ported script in data/scripts/.
|
||||
-- needsAsm comes back false here on purpose: showMapText's warning tells
|
||||
-- the reader to go port a script, and for these there is nothing to port.
|
||||
if entry.label then
|
||||
local s = self.text["_" .. entry.label]
|
||||
if s then return s, false end
|
||||
end
|
||||
return nil, entry.asm
|
||||
end
|
||||
|
||||
|
||||
+27
-7
@@ -90,10 +90,13 @@ function Game:load()
|
||||
self.save.player.x, self.save.player.y, self.save.player.facing)
|
||||
else
|
||||
local titleState = self:makeTitleState()
|
||||
-- the copyright splash + Nidorino-vs-Gengar attract movie plays
|
||||
-- before the title (engine/movie/splash.asm + intro.asm); the ids come
|
||||
-- from field.boot.screens so a total conversion owns the whole boot
|
||||
Screens.push(self, bootScreens(self).splash or "IntroMovie", function()
|
||||
-- the copyright splash + attract movie plays before the title
|
||||
-- (engine/movie/splash.asm + intro.asm; Yellow swaps in its own
|
||||
-- 18-scene movie, engine/movie/intro_yellow.asm); the ids come from
|
||||
-- field.boot.screens so a total conversion owns the whole boot
|
||||
local splash = require("src.core.GameVersion").isYellow()
|
||||
and "YellowIntro" or "IntroMovie"
|
||||
Screens.push(self, bootScreens(self).splash or splash, function()
|
||||
StateStack:push(titleState)
|
||||
end)
|
||||
end
|
||||
@@ -191,6 +194,15 @@ end
|
||||
-- or screenshot run does not depend on whatever the player last chose.
|
||||
function Game:logicSpeed()
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
-- Link play is always 1X on both machines, and this wins over every other
|
||||
-- source including POKEPORT_SPEED. Fast-forward multiplies the logic
|
||||
-- clock, so a peer at 10X burned a tournament shot clock ten times faster
|
||||
-- than the opponent it is racing, and drove its own animation/message
|
||||
-- queue at a different rate than the peer it is locked to. Nothing about
|
||||
-- a match should depend on what either player set this to.
|
||||
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
||||
return 1
|
||||
end
|
||||
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
||||
local opts = self.save and self.save.options
|
||||
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
|
||||
@@ -238,6 +250,16 @@ function Game:draw()
|
||||
-- white clear
|
||||
local base = self.stack:visibleBase()
|
||||
local worldBelow = self.stack.states[base] == self.overworld
|
||||
-- The UI surface is resolved once, before any state draws: the top state
|
||||
-- may want more than the Game Boy's 160x144 (the widescreen battle layout
|
||||
-- asks for 304x144). Anything else keeps the classic surface, so a menu
|
||||
-- pushed over a wide battle brings the screen straight back to 160x144.
|
||||
local top = self.stack:top()
|
||||
if top and top.uiSize then
|
||||
Renderer:setUISize(top:uiSize())
|
||||
else
|
||||
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
||||
end
|
||||
Renderer:beginFrame(worldBelow)
|
||||
self.stack:draw()
|
||||
-- SGB colorization: the topmost state that knows its palette owns the
|
||||
@@ -381,9 +403,7 @@ end
|
||||
-- LÖVE process ensures scripts, registries, and assets are all rebuilt from
|
||||
-- the newly selected mod state.
|
||||
function Game:restartWithMods()
|
||||
if love.event and love.event.quit then
|
||||
love.event.quit("restart")
|
||||
end
|
||||
require("src.core.HostShell").restart()
|
||||
end
|
||||
|
||||
function Game:keyreleased(key)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
-- Which Gen-1 game this process is running: Red (the historical default) or
|
||||
-- Blue. One source of truth for everything that differs by version -- the
|
||||
-- accepted ROM hash, the import manifest, where the extracted cache lives,
|
||||
-- and the save-file suffix -- so the importer, cache mount, SaveData, title
|
||||
-- screen and palette all agree.
|
||||
-- Which Gen-1 game this process is running: Red (the historical default),
|
||||
-- Blue, or Yellow. One source of truth for everything that differs by
|
||||
-- version -- the accepted ROM hash, the import manifest, where the
|
||||
-- extracted cache lives, and the save-file suffix -- so the importer,
|
||||
-- cache mount, SaveData, title screen and palette all agree.
|
||||
--
|
||||
-- Red keeps every un-suffixed path it always used (save.lua, the root cache),
|
||||
-- so existing installs are untouched; Blue is namespaced under blue/ and
|
||||
-- _blue so both can be imported and played side by side.
|
||||
-- _blue, Yellow under yellow/ and _yellow, so all three can be imported and
|
||||
-- played side by side.
|
||||
--
|
||||
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
||||
-- and tests. The active version is a process-global set once at boot from
|
||||
@@ -19,6 +20,7 @@ GameVersion.VERSIONS = {
|
||||
id = "red",
|
||||
label = "Red",
|
||||
displayName = "Pokemon Red",
|
||||
launcherName = "Red", -- game-panel header in the launcher
|
||||
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
|
||||
manifest = "tools/rom_manifest.json",
|
||||
cachePrefix = "", -- Red owns the cache root (backwards compatible)
|
||||
@@ -28,15 +30,26 @@ GameVersion.VERSIONS = {
|
||||
id = "blue",
|
||||
label = "Blue",
|
||||
displayName = "Pokemon Blue",
|
||||
launcherName = "Blue",
|
||||
sha1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2",
|
||||
manifest = "tools/rom_manifest_blue.json",
|
||||
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
|
||||
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
|
||||
},
|
||||
yellow = {
|
||||
id = "yellow",
|
||||
label = "Yellow",
|
||||
displayName = "Pokemon Yellow",
|
||||
launcherName = "Yellow (alpha)",
|
||||
sha1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1",
|
||||
manifest = "tools/rom_manifest_yellow.json",
|
||||
cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated
|
||||
saveSuffix = "_yellow", -- save_yellow.lua / .bak / .tmp
|
||||
},
|
||||
}
|
||||
|
||||
-- Launcher column order (Yellow is still a placeholder, handled by the UI).
|
||||
GameVersion.ORDER = { "red", "blue" }
|
||||
-- Launcher column order.
|
||||
GameVersion.ORDER = { "red", "blue", "yellow" }
|
||||
|
||||
GameVersion.current = "red"
|
||||
|
||||
@@ -53,6 +66,10 @@ function GameVersion.isBlue()
|
||||
return GameVersion.current == "blue"
|
||||
end
|
||||
|
||||
function GameVersion.isYellow()
|
||||
return GameVersion.current == "yellow"
|
||||
end
|
||||
|
||||
-- Metadata for a version id, defaulting to the active one.
|
||||
function GameVersion.info(id)
|
||||
return GameVersion.VERSIONS[id or GameVersion.current]
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Helpers for calling host tools (curl, zenity/kdialog, ...).
|
||||
|
||||
local HostShell = {}
|
||||
|
||||
-- Our AppRun exports LD_LIBRARY_PATH="$APPDIR/lib:..." so every subprocess we
|
||||
-- spawn tries to link against the libraries we're shipping instead of the
|
||||
-- system ones. We want to unset the var so that any system tools can find
|
||||
-- their proper libraries. Only needed when running in an AppImage.
|
||||
function HostShell.envPrefix()
|
||||
if os.getenv("APPIMAGE") then
|
||||
return "env -u LD_LIBRARY_PATH "
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
|
||||
function HostShell.popen(command, mode)
|
||||
local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
|
||||
if not ok or not pipe then return nil end
|
||||
return pipe
|
||||
end
|
||||
|
||||
-- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's
|
||||
-- boot in-process, which calls love.filesystem.init a second time -- and inside
|
||||
-- an AppImage physfs is already initialized, so that second init throws
|
||||
-- ("Failed to initialize filesystem: already initialized") and the relaunch
|
||||
-- crashes. So on an AppImage we relaunch the executable; the fresh process's
|
||||
-- Boot step mounts any downloaded update exactly as a manual relaunch would.
|
||||
-- On every other platform the in-process restart works, so keep it.
|
||||
function HostShell.restart()
|
||||
if not (love and love.event and love.event.quit) then return end
|
||||
local appimage = os.getenv("APPIMAGE")
|
||||
if not appimage then
|
||||
love.event.quit("restart")
|
||||
return
|
||||
end
|
||||
|
||||
-- We have to restart the process with this cursed execv call to prevent the
|
||||
-- PID from changing, which might cause SteamOS and other Linux launchers to
|
||||
-- think the app has crashed.
|
||||
local ffi = require("ffi")
|
||||
pcall(ffi.cdef, [[
|
||||
int execv(const char *path, char *const argv[]);
|
||||
int unsetenv(const char *name);
|
||||
]])
|
||||
ffi.C.unsetenv("LD_LIBRARY_PATH")
|
||||
local argv = ffi.new("const char *[2]", appimage, nil)
|
||||
ffi.C.execv(appimage, ffi.cast("char *const *", argv))
|
||||
end
|
||||
|
||||
return HostShell
|
||||
+1
-1
@@ -144,7 +144,7 @@ function Input:step()
|
||||
elseif next(sources) ~= nil then
|
||||
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
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Game Boy Printer stand-in. Yellow's printer jobs
|
||||
-- (engine/printer/printer.asm: PrintPokedexEntry and friends) drove a
|
||||
-- serial thermal printer; this port renders the same printout into a PNG
|
||||
-- under prints/ in the save directory instead, and the caller shows a
|
||||
-- dialog with where it landed. Scaled up 4x so the "print" is legible
|
||||
-- on a modern screen.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
|
||||
local Printer = {}
|
||||
|
||||
local SCALE = 4
|
||||
|
||||
-- Render drawFn (which draws a w x h GB-pixel image at 0,0) into
|
||||
-- prints/<name>_<stamp>.png. Returns the save-dir-relative path, or nil
|
||||
-- and an error string (headless / no canvas support degrades gracefully).
|
||||
function Printer.save(name, w, h, drawFn)
|
||||
if not (love.graphics and love.graphics.newCanvas) then
|
||||
return nil, "no graphics"
|
||||
end
|
||||
local ok, canvas = pcall(love.graphics.newCanvas, w * SCALE, h * SCALE)
|
||||
if not ok then return nil, tostring(canvas) end
|
||||
love.graphics.push("all")
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.origin()
|
||||
love.graphics.scale(SCALE, SCALE)
|
||||
love.graphics.clear(1, 1, 1, 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local drawOk, drawErr = pcall(drawFn)
|
||||
love.graphics.pop()
|
||||
if not drawOk then return nil, tostring(drawErr) end
|
||||
local data
|
||||
ok, data = pcall(canvas.newImageData, canvas)
|
||||
if not ok then return nil, tostring(data) end
|
||||
love.filesystem.createDirectory("prints")
|
||||
local path = ("prints/%s_%s.png"):format(name, os.date("%Y-%m-%d_%H%M%S"))
|
||||
local encOk, err = pcall(data.encode, data, "png", path)
|
||||
if not encOk then return nil, tostring(err) end
|
||||
Logger.info("printed %s -> %s/%s",
|
||||
name, love.filesystem.getSaveDirectory(), path)
|
||||
return path
|
||||
end
|
||||
|
||||
return Printer
|
||||
+125
-33
@@ -16,6 +16,7 @@ local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Semver = require("src.mods.Semver")
|
||||
local Boxes = require("src.pokemon.Boxes")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Badges = require("src.inventory.Badges")
|
||||
|
||||
@@ -23,10 +24,11 @@ local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local SaveData = {}
|
||||
|
||||
-- Progress files carry the game-version suffix so Red and Blue saves coexist:
|
||||
-- Red keeps save.lua / .bak / .tmp exactly as before; Blue is save_blue.lua
|
||||
-- (+ .bak/.tmp). options.lua is deliberately shared across versions (it holds
|
||||
-- global preferences and the mod enable-state, not per-playthrough data).
|
||||
-- Progress files carry the game-version suffix so Red / Blue / Yellow saves
|
||||
-- coexist: Red keeps save.lua / .bak / .tmp exactly as before; Blue is
|
||||
-- save_blue.lua and Yellow is save_yellow.lua (+ .bak/.tmp). options.lua is
|
||||
-- deliberately shared across versions (it holds global preferences and the
|
||||
-- mod enable-state, not per-playthrough data).
|
||||
local OPTIONS_FILENAME = "options.lua"
|
||||
|
||||
-- Main / backup / staged-witness names for a version (defaults to the active
|
||||
@@ -108,11 +110,14 @@ local function makePortableFs(dir)
|
||||
}
|
||||
end
|
||||
|
||||
local function detectPortable()
|
||||
if portableChecked then return portableBase end
|
||||
portableChecked = true
|
||||
portableBase = false
|
||||
if not (love and love.filesystem) then return false end
|
||||
-- Every folder a player might reasonably call "the game folder", best first:
|
||||
-- the packaged-app container, the folder holding the executable, then the
|
||||
-- source itself. Portable mode is the case where one of these holds the
|
||||
-- marker; the list itself is just locations, marker or not, which is also
|
||||
-- what the mods panel needs to notice a mod dropped beside the game by hand
|
||||
-- (LauncherMods.strays). Empty on Android/iOS and outside LOVE.
|
||||
function SaveData.gameFolders()
|
||||
if not (love and love.filesystem) then return {} end
|
||||
-- Desktop only: portable mode carries the save (and, since issue #74, the
|
||||
-- ROM cache) in the game folder next to the executable/source. On
|
||||
-- Android/iOS the source is a read-only package with no such folder, so
|
||||
@@ -120,7 +125,7 @@ local function detectPortable()
|
||||
if love.system and love.system.getOS then
|
||||
local osName = love.system.getOS()
|
||||
if osName ~= "Windows" and osName ~= "Linux" and osName ~= "OS X" then
|
||||
return false
|
||||
return {}
|
||||
end
|
||||
end
|
||||
local src = love.filesystem.getSource and love.filesystem.getSource()
|
||||
@@ -132,22 +137,40 @@ local function detectPortable()
|
||||
-- Recover the folder containing the .app so a packaged app finds its
|
||||
-- marker. On Windows/Linux the executable is not a bundle, so this is nil
|
||||
-- and the plain source-base directory (next to the .exe/AppImage) is used.
|
||||
local function appContainer(path)
|
||||
local appPath = path and path:match("^(.*%.app)/Contents/")
|
||||
return appPath and appPath:match("^(.*)/[^/]+$") or nil
|
||||
local function parentDir(path)
|
||||
return path and path:match("^(.*)/[^/]+$") or nil
|
||||
end
|
||||
-- Order: the .app's containing folder (packaged macOS), then the
|
||||
-- source-base directory (next to a packaged .exe/AppImage), then the
|
||||
-- source itself (a `love <gamedir>` run drops portable.txt in the game
|
||||
-- folder). First one holding the marker wins. Built by appending so a
|
||||
-- nil (e.g. no .app in the path) never truncates the ipairs scan.
|
||||
local function appContainer(path)
|
||||
return parentDir(path and path:match("^(.*%.app)/Contents/"))
|
||||
end
|
||||
-- The Linux AppImage build works similarly to macOs, but the runtime mounts
|
||||
-- the squashfs at a temp path, and that becomes the base directory.
|
||||
-- The runtime exports the real .AppImage path in $APPIMAGE, so we get the
|
||||
-- base dir from there.
|
||||
local function appImageContainer()
|
||||
return parentDir(os.getenv("APPIMAGE"))
|
||||
end
|
||||
-- Order: the packaged-app containing folder (macOS .app / Linux AppImage),
|
||||
-- then the source-base directory (next to a packaged .exe), then the source
|
||||
-- itself (a `love <gamedir>` run drops portable.txt in the game folder).
|
||||
-- Built by appending so a nil (e.g. no .app in the path) never truncates
|
||||
-- the ipairs scan.
|
||||
local candidates = {}
|
||||
local appDir = appContainer(src) or appContainer(sbd)
|
||||
local appDir = appContainer(src) or appContainer(sbd) or appImageContainer()
|
||||
if appDir then candidates[#candidates + 1] = appDir end
|
||||
if sbd then candidates[#candidates + 1] = sbd end
|
||||
if src then candidates[#candidates + 1] = src end
|
||||
for _, base in ipairs(candidates) do
|
||||
if base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then
|
||||
if sbd and sbd ~= "" then candidates[#candidates + 1] = sbd end
|
||||
if src and src ~= "" then candidates[#candidates + 1] = src end
|
||||
return candidates
|
||||
end
|
||||
|
||||
-- The game folder carrying portable.txt, or false. First candidate holding
|
||||
-- the marker wins.
|
||||
local function detectPortable()
|
||||
if portableChecked then return portableBase end
|
||||
portableChecked = true
|
||||
portableBase = false
|
||||
for _, base in ipairs(SaveData.gameFolders()) do
|
||||
if pathExists(base .. SEP .. PORTABLE_MARKER) then
|
||||
portableBase = base
|
||||
break
|
||||
end
|
||||
@@ -193,6 +216,9 @@ function SaveData.defaultOptions()
|
||||
textSpeed = 3,
|
||||
animations = true,
|
||||
battleStyle = "shift",
|
||||
-- battle screen composition: og (the 160x144 original) | wide
|
||||
-- (304x144, src/battle/WideBattle.lua)
|
||||
battleLayout = "og",
|
||||
ruleset = "gen1_faithful",
|
||||
-- 0-7 like the GB's NR50 master volume
|
||||
musicVol = 7,
|
||||
@@ -310,8 +336,9 @@ end
|
||||
-- under options.saveSlots[version]; the active slot is also cached
|
||||
-- process-wide (like GameVersion.current) so the hot saveNames path does
|
||||
-- not re-read options every call. A false cache entry means "no slot in
|
||||
-- use" and the flat legacy path (save.lua / save_blue.lua) is used, which
|
||||
-- keeps a brand-new install and every pre-slots caller working unchanged.
|
||||
-- use" and the flat legacy path (save.lua / save_blue.lua / save_yellow.lua)
|
||||
-- is used, which keeps a brand-new install and every pre-slots caller
|
||||
-- working unchanged.
|
||||
local activeSlotCache = {} -- version -> slotId in use, or false when none
|
||||
local slotsChecked = {} -- version -> true once resolved this process
|
||||
|
||||
@@ -323,17 +350,18 @@ local function slotNames(version, id)
|
||||
end
|
||||
|
||||
-- the pre-slots flat names a version always used (save.lua for Red,
|
||||
-- save_blue.lua for Blue); still the destination before any slot exists
|
||||
-- save_blue.lua / save_yellow.lua for the others); still the destination
|
||||
-- before any slot exists
|
||||
local function legacyNames(version)
|
||||
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
|
||||
return main, main .. ".bak", main .. ".tmp"
|
||||
end
|
||||
|
||||
-- Slot resolution is only meaningful for versions GameVersion actually knows
|
||||
-- (red/blue). The launcher also renders a locked placeholder tab ("yellow")
|
||||
-- that has no info entry and therefore no saveSuffix; resolving its legacy
|
||||
-- names would index a nil info table and crash. Treat any unknown version as
|
||||
-- having no slots so the slot APIs degrade to empty/no-op instead.
|
||||
-- (red / blue / yellow). An unknown id has no info entry and therefore no
|
||||
-- saveSuffix; resolving its legacy names would index a nil info table and
|
||||
-- crash. Treat any unknown version as having no slots so the slot APIs
|
||||
-- degrade to empty/no-op instead.
|
||||
local function knownVersion(version)
|
||||
return GameVersion.info(version) ~= nil
|
||||
end
|
||||
@@ -442,6 +470,25 @@ function SaveData.slotSummary(save)
|
||||
}
|
||||
end
|
||||
|
||||
-- The absolute on-disk path of a slot's save file, for the one caller that
|
||||
-- cannot go through love.filesystem: the save editor reads and writes with
|
||||
-- raw io.* so it can also open a file the player dragged in from anywhere.
|
||||
-- Resolves against the same root persistFs would write to -- the portable
|
||||
-- game folder when portable mode is on, otherwise LOVE's save directory --
|
||||
-- so Edit on a launcher save row lands on the file the game actually plays.
|
||||
-- nil when neither root is available (headless tests with an injected fs).
|
||||
function SaveData.slotDiskPath(version, slotId)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) or not slotId then return nil end
|
||||
local base = SaveData.portableBaseDir()
|
||||
or (love and love.filesystem and love.filesystem.getSaveDirectory
|
||||
and love.filesystem.getSaveDirectory())
|
||||
if not base then return nil end
|
||||
local sep = package.config:sub(1, 1)
|
||||
local rel = select(1, slotNames(version, slotId))
|
||||
return base .. sep .. rel:gsub("/", sep)
|
||||
end
|
||||
|
||||
-- Slots visible to the launcher: every registered slot for a version, each
|
||||
-- with whether it holds a save and the cheap summary above. A fresh
|
||||
-- install with nothing registered returns an empty array; a legacy install
|
||||
@@ -458,11 +505,44 @@ function SaveData.listSlots(version)
|
||||
for _, id in ipairs(list) do
|
||||
local save = decodeSlot(fs, version, id)
|
||||
local name, meta = SaveData.slotSummary(save)
|
||||
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta }
|
||||
out[#out + 1] = { id = id, exists = save ~= nil, name = name, meta = meta,
|
||||
label = reg.names and reg.names[id] or nil }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Give a registered slot a custom label (#205: "a way to name save slots so
|
||||
-- you can see that in the launcher"). The label lives in the options
|
||||
-- registry next to list/active, never in the save file itself, so renaming
|
||||
-- needs no save rewrite and an empty slot can be labeled too. The label is
|
||||
-- trimmed; an empty (or whitespace-only) one clears it. Returns true, or
|
||||
-- false + an error string when the id is not registered.
|
||||
function SaveData.renameSlot(version, slotId, name)
|
||||
version = version or GameVersion.get()
|
||||
if not knownVersion(version) then return false, "unknown version" end
|
||||
if type(slotId) ~= "string" or slotId == "" then
|
||||
return false, "missing slot id"
|
||||
end
|
||||
local fs = persistFs(nil)
|
||||
local opts = SaveData.loadOptions(fs)
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
local reg = opts.saveSlots[version]
|
||||
if not reg or not reg.list then return false, "slot not registered" end
|
||||
local found = false
|
||||
for _, id in ipairs(reg.list) do
|
||||
if id == slotId then found = true break end
|
||||
end
|
||||
if not found then return false, "slot not registered" end
|
||||
local label = type(name) == "string" and name:match("^%s*(.-)%s*$") or nil
|
||||
if label == "" then label = nil end
|
||||
reg.names = reg.names or {}
|
||||
reg.names[slotId] = label
|
||||
if next(reg.names) == nil then reg.names = nil end
|
||||
opts.saveSlots[version] = reg
|
||||
SaveData.saveOptions(opts, fs)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Point the active slot at slotId (registering it if new) and persist the
|
||||
-- choice to options.lua; also update the process-global cache so the very
|
||||
-- next save/load lands in the chosen slot.
|
||||
@@ -580,6 +660,7 @@ function SaveData.deleteSlot(version, slotId)
|
||||
remove(fs, tmp)
|
||||
|
||||
table.remove(reg.list, idx)
|
||||
if reg.names then reg.names[slotId] = nil end
|
||||
if reg.active == slotId then
|
||||
reg.active = reg.list[1] -- may be nil when the list is now empty
|
||||
end
|
||||
@@ -826,7 +907,7 @@ end)
|
||||
-- a .tmp witness before the swap, so a crash mid-write is recoverable.
|
||||
function SaveData.save(data, mods)
|
||||
-- write to the file matching this save's own version, not just the active
|
||||
-- one, so a Blue playthrough always lands in save_blue.lua
|
||||
-- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua
|
||||
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
|
||||
if data.options then
|
||||
SaveData.saveOptions(data.options)
|
||||
@@ -972,6 +1053,17 @@ local function scrubKnownMon(mon, data)
|
||||
for stat, v in pairs(mon.statExp) do mon.statExp[stat] = clamp(v, 0, 65535, 0) end
|
||||
end
|
||||
mon.level = clamp(mon.level, 1, 100, 1)
|
||||
-- Box mons imported from a real .sav carry NO stat block: box_struct stops
|
||||
-- before MON_LEVEL/MON_STATS, so src/save_convert/GenSave.lua decodeMon
|
||||
-- only fills `stats` for party slots. Every HP-bar draw then nil-indexes
|
||||
-- mon.stats: the status screen opened in the box (#233) and the party list
|
||||
-- after withdrawing one (#304). The original derives them on demand
|
||||
-- (status_screen.asm:66-76, add_mon.asm _MoveMon); deriving once here means
|
||||
-- every later reader (menus, battle, items, SGB bar zones, the link
|
||||
-- fingerprint) sees a party-shaped mon. Runs after the level clamp above
|
||||
-- so the derived stats use a sane level. A save that already has stats is
|
||||
-- untouched.
|
||||
Stats.ensure(data.pokemon and data.pokemon[mon.species], mon)
|
||||
local moves = mon.moves
|
||||
if type(moves) ~= "table" then return end
|
||||
local hadMoves = #moves > 0
|
||||
@@ -1216,7 +1308,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 = {},
|
||||
|
||||
@@ -192,10 +192,45 @@ local function newCrySource(data, species, def)
|
||||
return newFileSource(resolved)
|
||||
end
|
||||
|
||||
-- Yellow's voiced Pikachu clips (audio/pikachu_pcm.asm
|
||||
-- PlayPikachuSoundClip): 1-bit PCM decoded to WAVs at import
|
||||
-- (data.audio.pikaCries = clip count). Returns the source, nil when the
|
||||
-- cache carries no clips (Red/Blue) or headless.
|
||||
function Sound.playPikaCry(data, n)
|
||||
if not love.audio then return nil end
|
||||
local count = data.audio and data.audio.pikaCries
|
||||
if not count then return nil end
|
||||
n = math.max(1, math.min(count, n or 1))
|
||||
local key = "pikacry:" .. n
|
||||
local src = cache[key]
|
||||
if src == false then return nil end
|
||||
if not src then
|
||||
local ok, s = pcall(love.audio.newSource,
|
||||
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n), "static")
|
||||
if not ok then
|
||||
cache[key] = false
|
||||
return nil
|
||||
end
|
||||
s:setVolume(BASE_VOLUME * volumeScale)
|
||||
cache[key] = s
|
||||
src = s
|
||||
end
|
||||
src:stop()
|
||||
src:play()
|
||||
played("cry", "PIKACHU_PCM_" .. n, "PIKACHU")
|
||||
return src
|
||||
end
|
||||
|
||||
-- returns the source (nil headless) so callers that block on the cry
|
||||
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
|
||||
function Sound.playCry(data, species)
|
||||
if not love.audio then return nil end
|
||||
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
|
||||
-- never used for the species there); clip 1 is the everyday "Pika!"
|
||||
if species == "PIKACHU" then
|
||||
local src = Sound.playPikaCry(data, 1)
|
||||
if src then return src end
|
||||
end
|
||||
local cries = data.audio and data.audio.cries
|
||||
local def = cries and cries[species]
|
||||
if not def then return nil end
|
||||
|
||||
+146
-14
@@ -32,11 +32,11 @@ local CacheFs = {}
|
||||
local SEP = package.config:sub(1, 1)
|
||||
|
||||
-- Cache-relative paths are prefixed with this before every read/write, so a
|
||||
-- Blue import lands in blue/ (see src.core.GameVersion) while a Red import
|
||||
-- keeps the historical root. The launcher sets it per import / per readiness
|
||||
-- check; it stays "" for Red. Runtime *reads* (require / newImage) do NOT go
|
||||
-- through here -- CacheFs.mountVersion overlays the active version's subtree
|
||||
-- onto the un-prefixed paths instead.
|
||||
-- Blue/Yellow import lands under its GameVersion.cachePrefix (blue/, yellow/)
|
||||
-- while a Red import keeps the historical root. The launcher sets it per
|
||||
-- import / per readiness check; it stays "" for Red. Runtime *reads*
|
||||
-- (require / newImage) do NOT go through here -- CacheFs.mountVersion overlays
|
||||
-- the active version's subtree onto the un-prefixed paths instead.
|
||||
CacheFs.prefix = ""
|
||||
|
||||
local function withPrefix(rel)
|
||||
@@ -75,6 +75,32 @@ local function resolveMkdir()
|
||||
return mkdirFn
|
||||
end
|
||||
|
||||
-- Lazily-resolved windowless rmdir, the mirror of resolveMkdir above:
|
||||
-- function(absolutePath) or false when FFI is unavailable. Both syscalls
|
||||
-- refuse a non-empty directory, so a caller has to delete the files first.
|
||||
local rmdirFn = nil
|
||||
|
||||
local function resolveRmdir()
|
||||
if rmdirFn ~= nil then return rmdirFn end
|
||||
rmdirFn = false
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not ok then return rmdirFn end
|
||||
if ffi.os == "Windows" then
|
||||
pcall(ffi.cdef, "int RemoveDirectoryA(const char *lpPathName);")
|
||||
local resolved = pcall(function() return ffi.C.RemoveDirectoryA end)
|
||||
if resolved then
|
||||
rmdirFn = function(path) pcall(ffi.C.RemoveDirectoryA, path) end
|
||||
end
|
||||
else
|
||||
pcall(ffi.cdef, "int rmdir(const char *pathname);")
|
||||
local resolved = pcall(function() return ffi.C.rmdir end)
|
||||
if resolved then
|
||||
rmdirFn = function(path) pcall(ffi.C.rmdir, path) end
|
||||
end
|
||||
end
|
||||
return rmdirFn
|
||||
end
|
||||
|
||||
-- Mount an external directory onto the physfs read path (appended, so the
|
||||
-- game's own source always wins a name clash). Returns true on success.
|
||||
--
|
||||
@@ -100,9 +126,9 @@ local function resolveMount()
|
||||
if okl and lib then
|
||||
local oks, fn = pcall(function() return lib.PHYSFS_mount end)
|
||||
if oks and fn then
|
||||
physfsMountFn = function(d, append)
|
||||
physfsMountFn = function(d, mountPoint, append)
|
||||
if append == nil then append = true end
|
||||
local okr, ret = pcall(fn, d, "", append and 1 or 0)
|
||||
local okr, ret = pcall(fn, d, mountPoint or "", append and 1 or 0)
|
||||
return okr and ret ~= 0
|
||||
end
|
||||
break
|
||||
@@ -119,7 +145,38 @@ end
|
||||
local function mountReadable(dir, append)
|
||||
local fn = resolveMount()
|
||||
if not fn then return false end
|
||||
return fn(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.
|
||||
@@ -229,6 +286,23 @@ function CacheFs.remove(rel)
|
||||
love.filesystem.remove(rel)
|
||||
end
|
||||
|
||||
-- Remove a single cache-relative directory once its files are gone. Needed
|
||||
-- because os.remove cannot delete a directory on Windows and
|
||||
-- love.filesystem.remove never reaches outside the save directory, so the
|
||||
-- portable game folder gets the same FFI-syscall treatment as its mkdir
|
||||
-- (issue #74: os.execute would flash a console window per call). Used by the
|
||||
-- mod installer so an uninstall leaves nothing behind (#330).
|
||||
function CacheFs.removeDir(rel)
|
||||
rel = withPrefix(rel)
|
||||
local root = CacheFs.root()
|
||||
if root then
|
||||
local rmdir = resolveRmdir()
|
||||
if rmdir then rmdir(realPath(root, rel)) end
|
||||
return
|
||||
end
|
||||
love.filesystem.remove(rel)
|
||||
end
|
||||
|
||||
-- Remove the game-folder copy of a cache subtree before a fresh import, so a
|
||||
-- cache-format bump does not leave orphaned files behind. No-op when the
|
||||
-- portable cache is inactive (the save-directory copy is cleared by
|
||||
@@ -256,16 +330,16 @@ end
|
||||
-- Overlay the active version's extracted cache onto the un-prefixed read
|
||||
-- paths, so require("data.generated.*") and love.graphics.newImage(
|
||||
-- "assets/generated/*") resolve to that version's files. Red lives at the
|
||||
-- cache root and needs nothing; Blue lives under blue/ and is *prepended* so
|
||||
-- it wins over any Red copy at the root and over the game source. Called
|
||||
-- once at boot, before Game:load (main.lua). Returns true when nothing was
|
||||
-- needed or the mount succeeded.
|
||||
-- cache root and needs nothing; non-Red versions (blue/, yellow/, …) are
|
||||
-- *prepended* so they win over any Red copy at the root and over the game
|
||||
-- source. Called once at boot, before Game:load (main.lua). Returns true
|
||||
-- when nothing was needed or the mount succeeded.
|
||||
function CacheFs.mountVersion(version)
|
||||
local prefix = require("src.core.GameVersion").cachePrefix(version)
|
||||
if prefix == "" then return true end -- Red: already at the root
|
||||
local sub = prefix:gsub("/+$", "") -- "blue/" -> "blue"
|
||||
local sub = prefix:gsub("/+$", "") -- "blue/" / "yellow/" -> bare dir
|
||||
-- The cache root is the portable game folder when active, else LÖVE's OS
|
||||
-- save directory (where love.filesystem wrote blue/...).
|
||||
-- save directory (where love.filesystem wrote blue/... or yellow/...).
|
||||
local base = CacheFs.root()
|
||||
if not base and love.filesystem.getSaveDirectory then
|
||||
base = love.filesystem.getSaveDirectory()
|
||||
@@ -280,4 +354,62 @@ 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/Yellow save,
|
||||
-- close it, and press Play on Red: with that version's subtree still
|
||||
-- prepended, Red's require("data.generated.*") and its generated art would
|
||||
-- silently resolve to the other game'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
|
||||
|
||||
-- Mount `dir` at `mountPoint` for the length of `fn()`, then take it back off
|
||||
-- the read path and hand back whatever fn returned.
|
||||
--
|
||||
-- Every other mount here is permanent and lands at the physfs root: this one
|
||||
-- exists to *look* at a folder the game has deliberately not mounted, which
|
||||
-- is a different job. The mods panel uses it to read a mods/ folder sitting
|
||||
-- beside the executable of a non-portable install (LauncherMods.strays).
|
||||
-- Because it unmounts again, and because a non-empty mountPoint keeps the
|
||||
-- tree in its own corner of the namespace while it is up, a folder inspected
|
||||
-- this way can never shadow a game file or change what the running game
|
||||
-- resolves -- which is what makes it safe to point at a folder whose contents
|
||||
-- nobody has validated.
|
||||
--
|
||||
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, or the
|
||||
-- mount was refused), which callers must treat as "could not look", not as
|
||||
-- "nothing there". An error inside fn still unmounts before it propagates.
|
||||
function CacheFs.withMounted(dir, mountPoint, fn)
|
||||
if not dir or dir == "" then return nil end
|
||||
local mount, unmount = resolveMount(), resolveUnmount()
|
||||
if not (mount and unmount) then return nil end
|
||||
if not mount(dir, mountPoint, true) then return nil end
|
||||
local ok, res = pcall(fn)
|
||||
unmount(dir)
|
||||
if not ok then error(res, 0) end
|
||||
return res
|
||||
end
|
||||
|
||||
return CacheFs
|
||||
|
||||
+345
-49
@@ -163,8 +163,12 @@ function RomExtractor:extractTilesets()
|
||||
for pos = offset, offset + 15 do block[#block + 1] = blocksRaw[pos] end
|
||||
blocks[#blocks + 1] = block
|
||||
end
|
||||
-- Red/Blue keep collision lists in ROM0; Yellow moved them to bank 1
|
||||
-- (pokeyellow Overworld_Coll at 01:4ac2). Pointers in $4000-$7FFF are
|
||||
-- banked; treat ROM0-range pointers as bank 0.
|
||||
local collBank = collisionPointer < 0x4000 and 0 or 1
|
||||
local walkable = sorted(self:readTerminated(
|
||||
0, collisionPointer, 0xFF))
|
||||
collBank, collisionPointer, 0xFF))
|
||||
local warpPointer = self.rom:word(
|
||||
warpPointers.bank, warpPointers.address + (index - 1) * 2)
|
||||
local warpTiles = unique(self:readTerminated(
|
||||
@@ -447,14 +451,26 @@ function RomExtractor:extractSprites()
|
||||
local pointer = self.rom:word(pointerTable.bank, address)
|
||||
local firstHalf = self.rom:byte(pointerTable.bank, address + 2)
|
||||
local bank = self.rom:byte(pointerTable.bank, address + 3)
|
||||
local byteLength = spec.imageWidth * spec.imageHeight / 4
|
||||
local frames = spec.imageHeight / 16
|
||||
local width = spec.imageWidth
|
||||
local height = spec.imageHeight
|
||||
local byteLength = width * height / 4
|
||||
local frames = height / 16
|
||||
local expected = firstHalf * (frames >= 6 and 2 or 1)
|
||||
assert(byteLength == expected, constName .. ": sprite length mismatch")
|
||||
if byteLength ~= expected then
|
||||
-- Commercial ROM sheet length wins over pret PNG atlases (Yellow nurse
|
||||
-- PNG is taller than the 12-tile SpriteSheetPointerTable entry).
|
||||
byteLength = expected
|
||||
assert(byteLength * 4 % width == 0,
|
||||
constName .. ": ROM sprite length not tile-aligned")
|
||||
height = byteLength * 4 / width
|
||||
frames = height / 16
|
||||
expected = firstHalf * (frames >= 6 and 2 or 1)
|
||||
assert(byteLength == expected, constName .. ": sprite length mismatch")
|
||||
end
|
||||
local base = spec.imageBase
|
||||
if not written[base] then
|
||||
self:write2bpp(self.rom:bytes(bank, pointer, byteLength),
|
||||
spec.imageWidth, spec.imageHeight,
|
||||
width, height,
|
||||
"sprites/" .. base .. ".png", true)
|
||||
written[base] = true
|
||||
end
|
||||
@@ -862,20 +878,24 @@ function RomExtractor:extractPalettes()
|
||||
local order = self.manifest.paletteOrder
|
||||
local paletteTable = self:symbol("SuperPalettes")
|
||||
local function scale5(value) return round(value * 255 / 31) end
|
||||
local palettes = {}
|
||||
for index, name in ipairs(order) do
|
||||
local colors = {}
|
||||
for color = 0, 3 do
|
||||
local value = self.rom:word(paletteTable.bank,
|
||||
paletteTable.address + (index - 1) * 8 + color * 2)
|
||||
colors[#colors + 1] = {
|
||||
scale5(bit.band(value, 0x1F)),
|
||||
scale5(bit.band(bit.rshift(value, 5), 0x1F)),
|
||||
scale5(bit.band(bit.rshift(value, 10), 0x1F)),
|
||||
}
|
||||
local function readTable(symbol, names)
|
||||
local out = {}
|
||||
for index, name in ipairs(names) do
|
||||
local colors = {}
|
||||
for color = 0, 3 do
|
||||
local value = self.rom:word(symbol.bank,
|
||||
symbol.address + (index - 1) * 8 + color * 2)
|
||||
colors[#colors + 1] = {
|
||||
scale5(bit.band(value, 0x1F)),
|
||||
scale5(bit.band(bit.rshift(value, 5), 0x1F)),
|
||||
scale5(bit.band(bit.rshift(value, 10), 0x1F)),
|
||||
}
|
||||
end
|
||||
out[name] = colors
|
||||
end
|
||||
palettes[name] = colors
|
||||
return out
|
||||
end
|
||||
local palettes = readTable(paletteTable, order)
|
||||
local monsterTable = self:symbol("MonsterPalettes")
|
||||
local monsterPalettes = {}
|
||||
for index, species in ipairs(self.manifest.dexOrder) do
|
||||
@@ -887,6 +907,11 @@ function RomExtractor:extractPalettes()
|
||||
source = "ROM:SuperPalettes + MonsterPalettes",
|
||||
palettes = palettes, order = order, pokemon = monsterPalettes,
|
||||
}
|
||||
-- Yellow (and GBC carts) also carry CGBBasePalettes beside SuperPalettes.
|
||||
if self.symbols["CGBBasePalettes"] then
|
||||
data.cgbBase = readTable(self:symbol("CGBBasePalettes"), order)
|
||||
data.source = data.source .. " + CGBBasePalettes"
|
||||
end
|
||||
self:write("palettes", data)
|
||||
self:tick("Color palettes", 1, 1)
|
||||
return data
|
||||
@@ -915,6 +940,10 @@ function RomExtractor:extractIcons()
|
||||
GRASS = "assets/generated/icons/plant.png",
|
||||
SNAKE = "assets/generated/icons/snake.png",
|
||||
QUADRUPED = "assets/generated/icons/quadruped.png",
|
||||
-- Yellow's ICON_PIKACHU draws from the overworld PikachuSprite sheet
|
||||
-- (data/icon_pointers.asm mon_icon_header PikachuSprite, 0/12);
|
||||
-- only referenced when the manifest's iconOrder includes it
|
||||
PIKACHU = "assets/generated/sprites/pikachu.png",
|
||||
}
|
||||
local frames = {
|
||||
{ "bug", "BugIconFrame1", "BugIconFrame2" },
|
||||
@@ -1048,7 +1077,9 @@ function RomExtractor:extractPokemon()
|
||||
local typeById = self:typesById()
|
||||
local names = self:symbol("MonsterNames")
|
||||
local baseStats = self:symbol("BaseStats")
|
||||
local mewStats = self:symbol("MewBaseStats")
|
||||
-- Red/Blue keep Mew outside BaseStats (pret pokered MewBaseStats).
|
||||
-- Yellow stores Mew as dex 151 inside BaseStats (pret/pokeyellow).
|
||||
local mewStats = self.symbols["MewBaseStats"] and self:symbol("MewBaseStats")
|
||||
local decodedNames = {}
|
||||
for index = 1, #speciesOrder do
|
||||
decodedNames[index] = self.rom:decodeText(
|
||||
@@ -1067,7 +1098,7 @@ function RomExtractor:extractPokemon()
|
||||
local dex = assert(dexBySpecies[species],
|
||||
"missing dex number for " .. species)
|
||||
local row
|
||||
if species == "MEW" then
|
||||
if species == "MEW" and mewStats then
|
||||
row = self.rom:bytes(mewStats.bank, mewStats.address, 28)
|
||||
else
|
||||
row = self.rom:bytes(
|
||||
@@ -1410,6 +1441,142 @@ function RomExtractor:extractText()
|
||||
}
|
||||
end
|
||||
|
||||
function RomExtractor:extractYellowTitleArt()
|
||||
-- pret/pokeyellow engine/movie/title_yellow.asm: the Yellow title is a
|
||||
-- tilemap composition over BOTH tile banks. LoadYellowTitleScreenGFX
|
||||
-- loads PokemonLogoGraphics into vChars2 (BG ids $00-$7F),
|
||||
-- TitlePikachuBGGraphics into vChars1 (ids $80-$EF),
|
||||
-- TitlePikachuOBGraphics at vChars1 tile $70 (ids $F0-$FC, also the eye
|
||||
-- OAM tiles), and PokemonLogoCornerGraphics at vChars1 tile $7D (ids
|
||||
-- $FD-$FF). Every tilemap mixes ids from several of those sheets, so a
|
||||
-- single-sheet lookup shows checkerboard garbage where a foreign-bank id
|
||||
-- lands (e.g. blank id $00 = logo tile 0, not Pikachu BG tile 0).
|
||||
if not self.symbols["TitlePikachuBGGraphics"] then return end
|
||||
-- raw sheets, kept for debugging / mod reference
|
||||
self:raw2bpp("TitlePikachuBGGraphics", 128, 32,
|
||||
"title/pikachu_bg.png", { transparent = true })
|
||||
self:raw2bpp("TitlePikachuOBGraphics", 96, 8,
|
||||
"title/pikachu_ob.png", { transparent = true })
|
||||
|
||||
-- Tile counts are the Graphics..GraphicsEnd symbol gaps in pokeyellow.sym.
|
||||
local function sheetTiles(label, count, transparent)
|
||||
local symbol = self:symbol(label)
|
||||
local raw = self.rom:bytes(symbol.bank, symbol.address, count * 16)
|
||||
local tiles = {}
|
||||
for offset = 1, #raw, 16 do
|
||||
local one = {}
|
||||
for i = offset, offset + 15 do one[#one + 1] = raw[i] end
|
||||
tiles[#tiles + 1] = ImageWriter.decode2bpp(one, 8, 8, transparent)
|
||||
end
|
||||
return tiles
|
||||
end
|
||||
local logo = sheetTiles("PokemonLogoGraphics", 115)
|
||||
local corner = sheetTiles("PokemonLogoCornerGraphics", 3)
|
||||
local bg = sheetTiles("TitlePikachuBGGraphics", 64)
|
||||
local ob = sheetTiles("TitlePikachuOBGraphics", 12)
|
||||
local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true)
|
||||
local function tileFor(id)
|
||||
if id < 0x80 then return logo[id + 1] end
|
||||
if id < 0xF0 then return bg[id - 0x80 + 1] end
|
||||
if id < 0xFD then return ob[id - 0xF0 + 1] end
|
||||
return corner[id - 0xFD + 1]
|
||||
end
|
||||
-- OAM-style blit: color-0 pixels stay whatever the target already holds
|
||||
-- (ImageWriter.blit copies alpha-0 pixels wholesale, which would punch
|
||||
-- holes into the face under the eye sprites).
|
||||
local function blitSprite(target, tile, tx, ty, flipX)
|
||||
for y = 0, 7 do
|
||||
for x = 0, 7 do
|
||||
local sx = flipX and 7 - x or x
|
||||
local r, g, b, a = tile:getPixel(sx, y)
|
||||
if a ~= 0 then target:setPixel(tx + x, ty + y, r, g, b, a) end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- cells = { {id, col, row}, ... }; untouched cells stay transparent
|
||||
local function compose(cols, rows, cells)
|
||||
local pose = ImageWriter.blank(cols * 8, rows * 8, 1, 1, 1, 0)
|
||||
for _, cell in ipairs(cells) do
|
||||
local tile = tileFor(cell[1])
|
||||
if tile then ImageWriter.blit(pose, tile, cell[2] * 8, cell[3] * 8) end
|
||||
end
|
||||
return pose
|
||||
end
|
||||
local function mapCells(map, cols, rows)
|
||||
local ids = self.rom:bytes(map.bank, map.address, cols * rows)
|
||||
local cells = {}
|
||||
for index, id in ipairs(ids) do
|
||||
cells[#cells + 1] =
|
||||
{ id, (index - 1) % cols, math.floor((index - 1) / cols) }
|
||||
end
|
||||
return cells
|
||||
end
|
||||
|
||||
-- TitleScreen_PlacePokemonLogo: 16x7 box at (2,1). Yellow's logo sheet
|
||||
-- is deduplicated (unlike Red's sequential rip), so the raw2bpp
|
||||
-- pokemon_logo.png from extractField is scrambled; overwrite it with the
|
||||
-- tilemap composition. Kept opaque: TitleState clears to white behind it.
|
||||
self:save(compose(16, 7,
|
||||
mapCells(self:symbol("TitleScreenPokemonLogoTilemap"), 16, 7)),
|
||||
"title/pokemon_logo.png")
|
||||
|
||||
-- TitleScreen_PlacePikaSpeechBubble: 7x4 box at (6,4) plus the two tail
|
||||
-- tiles $64/$65 the routine pokes at (9,8) -- one row below the box, over
|
||||
-- blank cells of the Pikachu row. Composed 7x5 with the tail at (3,4);
|
||||
-- matteColor0 clears the outside-the-balloon whites, the outline protects
|
||||
-- the interior.
|
||||
local bubbleCells = mapCells(
|
||||
self:symbol("TitleScreenPikaBubbleTilemap"), 7, 4)
|
||||
bubbleCells[#bubbleCells + 1] = { 0x64, 3, 4 }
|
||||
bubbleCells[#bubbleCells + 1] = { 0x65, 4, 4 }
|
||||
self:save(ImageWriter.matteColor0(compose(7, 5, bubbleCells)),
|
||||
"title/pika_bubble.png")
|
||||
|
||||
-- TitleScreen_PlacePikachu: 12x9 box at (4,8) plus the right-ear edge
|
||||
-- tiles it pokes down column 16 (rows 10-13) -- composed 13x9 with those
|
||||
-- at relative column 12, rows 2-5. The open eyes are OAM
|
||||
-- (TitleScreenPikachuEyesOAMData, copied at place time): OB tiles 0-3 at
|
||||
-- screen (56,80)/(88,80) blocks, the left eye x-flipped (attr $22); baked
|
||||
-- into the composition relative to the box origin px(32,64).
|
||||
local pikaCells = mapCells(self:symbol("TitleScreenPikachuTilemap"), 12, 9)
|
||||
pikaCells[#pikaCells + 1] = { 0x96, 12, 2 }
|
||||
pikaCells[#pikaCells + 1] = { 0x9d, 12, 3 }
|
||||
pikaCells[#pikaCells + 1] = { 0xa7, 12, 4 }
|
||||
pikaCells[#pikaCells + 1] = { 0xb1, 12, 5 }
|
||||
local pikachu = ImageWriter.matteColor0(compose(13, 9, pikaCells))
|
||||
-- DoTitleScreenFunction's blink rewrites the eye OAM tile ids with
|
||||
-- `and $f3 / or e` (e = 0 open / 4 half / 8 closed), so the OB sheet
|
||||
-- holds three 4-tile eye sets. Bake the open set into pikachu.png and
|
||||
-- save half/closed as standalone overlays for TitleState's blink.
|
||||
local EYE_LAYOUT = {
|
||||
{ 2, 24, 16, true }, { 1, 32, 16, true },
|
||||
{ 4, 24, 24, true }, { 3, 32, 24, true },
|
||||
{ 1, 56, 16 }, { 2, 64, 16 },
|
||||
{ 3, 56, 24 }, { 4, 64, 24 },
|
||||
}
|
||||
-- Blink overlays for the (24,16)-(71,31) eye band: the BG face is
|
||||
-- eyeless (the eyes are OAM), so each overlay = the blank-face crop
|
||||
-- with the half (+4) / closed (+8) tile set composited color-0
|
||||
-- transparent -- exactly what the hardware shows mid-blink.
|
||||
local overlays = {}
|
||||
for suffix, base in pairs({ eyes_half = 4, eyes_closed = 8 }) do
|
||||
local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0)
|
||||
ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16)
|
||||
for _, e in ipairs(EYE_LAYOUT) do
|
||||
blitSprite(overlay, obClear[base + e[1]], e[2] - 24, e[3] - 16, e[4])
|
||||
end
|
||||
overlays[suffix] = overlay
|
||||
end
|
||||
-- open eyes bake into pikachu.png AFTER the blank-face crops
|
||||
for _, e in ipairs(EYE_LAYOUT) do
|
||||
blitSprite(pikachu, obClear[e[1]], e[2], e[3], e[4])
|
||||
end
|
||||
self:save(pikachu, "title/pikachu.png")
|
||||
for suffix, overlay in pairs(overlays) do
|
||||
self:save(overlay, "title/" .. suffix .. ".png")
|
||||
end
|
||||
end
|
||||
|
||||
function RomExtractor:raw2bpp(label, width, height, relative, options)
|
||||
options = options or {}
|
||||
local expected = width * height / 4
|
||||
@@ -1454,6 +1621,8 @@ function RomExtractor:extractField()
|
||||
"title/copyright.png"); tick()
|
||||
self:raw2bpp("GameFreakLogoGraphics", 72, 8,
|
||||
"title/gamefreak_inc.png"); tick()
|
||||
-- Yellow fixed Pikachu title art (no-op on Red/Blue manifests).
|
||||
self:extractYellowTitleArt(); tick()
|
||||
|
||||
local fallingStar = self:raw2bpp(
|
||||
"FallingStar", 8, 8, "intro/falling_star.png",
|
||||
@@ -1502,35 +1671,71 @@ function RomExtractor:extractField()
|
||||
end
|
||||
self:save(star, "intro/big_star.png"); tick()
|
||||
|
||||
local gengar = self:symbol("FightIntroBackMon")
|
||||
local gengarRaw = self.rom:bytes(
|
||||
gengar.bank, gengar.address, 96 * 16)
|
||||
local gengarTiles = {}
|
||||
for offset = 1, #gengarRaw, 16 do
|
||||
local raw = {}
|
||||
for index = offset, offset + 15 do raw[#raw + 1] = gengarRaw[index] end
|
||||
gengarTiles[#gengarTiles + 1] = ImageWriter.decode2bpp(raw, 8, 8)
|
||||
end
|
||||
for number = 1, 3 do
|
||||
local tilemap = self:symbol("GengarIntroTiles" .. number)
|
||||
local tileIds = self.rom:bytes(tilemap.bank, tilemap.address, 49)
|
||||
local pose = ImageWriter.blank(56, 56, 0, 0, 0, 0)
|
||||
for index, tileId in ipairs(tileIds) do
|
||||
ImageWriter.blit(pose, gengarTiles[tileId + 1],
|
||||
(index - 1) % 7 * 8, math.floor((index - 1) / 7) * 8)
|
||||
-- Yellow has no FightIntro Gengar/Nidorino fight (pret/pokeyellow
|
||||
-- engine/movie/intro_yellow.asm); write blank placeholders so Title/
|
||||
-- Intro still find the expected paths. Red/Blue keep the tilemap rip.
|
||||
if self.symbols["FightIntroBackMon"] then
|
||||
local gengar = self:symbol("FightIntroBackMon")
|
||||
local gengarRaw = self.rom:bytes(
|
||||
gengar.bank, gengar.address, 96 * 16)
|
||||
local gengarTiles = {}
|
||||
for offset = 1, #gengarRaw, 16 do
|
||||
local raw = {}
|
||||
for index = offset, offset + 15 do raw[#raw + 1] = gengarRaw[index] end
|
||||
gengarTiles[#gengarTiles + 1] = ImageWriter.decode2bpp(raw, 8, 8)
|
||||
end
|
||||
for number = 1, 3 do
|
||||
local tilemap = self:symbol("GengarIntroTiles" .. number)
|
||||
local tileIds = self.rom:bytes(tilemap.bank, tilemap.address, 49)
|
||||
local pose = ImageWriter.blank(56, 56, 0, 0, 0, 0)
|
||||
for index, tileId in ipairs(tileIds) do
|
||||
ImageWriter.blit(pose, gengarTiles[tileId + 1],
|
||||
(index - 1) % 7 * 8, math.floor((index - 1) / 7) * 8)
|
||||
end
|
||||
pose = ImageWriter.matteColor0(pose)
|
||||
self:save(pose, "intro/gengar_" .. number .. ".png"); tick()
|
||||
end
|
||||
else
|
||||
for number = 1, 3 do
|
||||
self:save(ImageWriter.blank(56, 56, 0, 0, 0, 0),
|
||||
"intro/gengar_" .. number .. ".png"); tick()
|
||||
end
|
||||
pose = ImageWriter.matteColor0(pose)
|
||||
self:save(pose, "intro/gengar_" .. number .. ".png"); tick()
|
||||
end
|
||||
|
||||
for number, label in ipairs({
|
||||
"FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3",
|
||||
}) do
|
||||
self:raw2bpp(label, 48, 48,
|
||||
"intro/red_nidorino_" .. number .. ".png",
|
||||
{ transparent = true, columns = true })
|
||||
tick()
|
||||
if self.symbols["FightIntroFrontMon"] then
|
||||
for number, label in ipairs({
|
||||
"FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3",
|
||||
}) do
|
||||
self:raw2bpp(label, 48, 48,
|
||||
"intro/red_nidorino_" .. number .. ".png",
|
||||
{ transparent = true, columns = true })
|
||||
tick()
|
||||
end
|
||||
else
|
||||
for number = 1, 3 do
|
||||
self:save(ImageWriter.blank(48, 48, 1, 1, 1, 0),
|
||||
"intro/red_nidorino_" .. number .. ".png"); tick()
|
||||
end
|
||||
end
|
||||
|
||||
-- Optional Yellow-only intro atlas (pret/pokeyellow gfx/yellow_intro.asm).
|
||||
if self.symbols["YellowIntroGraphics1"] then
|
||||
self:raw2bpp("YellowIntroGraphics1", 128, 64,
|
||||
"intro/yellow_intro_1.png")
|
||||
end
|
||||
if self.symbols["YellowIntroGraphics2"] then
|
||||
-- atlas2 doubles as the intro's OBJ tile bank (vChars0); OBJ color 0
|
||||
-- is hardware-transparent, and the BG draws it over a white clear so
|
||||
-- BG cells lose nothing
|
||||
self:raw2bpp("YellowIntroGraphics2", 128, 128,
|
||||
"intro/yellow_intro_2.png", { transparent = true })
|
||||
end
|
||||
-- Yellow intro clouds (intro_yellow.asm YellowIntroCloudGFX): 8 tiles,
|
||||
-- two 4-tile animation frames -- saved 32x16, one frame per row.
|
||||
if self.symbols["YellowIntroCloudGFX"] then
|
||||
self:raw2bpp("YellowIntroCloudGFX", 32, 16, "intro/clouds.png")
|
||||
end
|
||||
|
||||
for number = 1, 2 do
|
||||
self:writeCompressedPic(
|
||||
"ShrinkPic" .. number, "intro/shrink" .. number .. ".png")
|
||||
@@ -1563,10 +1768,27 @@ function RomExtractor:extractField()
|
||||
end
|
||||
self:save(symbolSheet, "slots/symbols.png"); tick()
|
||||
|
||||
local emotes = ImageWriter.blank(48, 16, 1, 1, 1, 0)
|
||||
for index, label in ipairs({
|
||||
"ShockEmote", "QuestionEmote", "HappyEmote",
|
||||
}) do
|
||||
-- Emote sheet layout comes from manifest.field.emotionBubbles so the
|
||||
-- versions can differ: Red ships the three shared bubbles, Yellow adds
|
||||
-- the five Pikachu-only ones (emotion_bubbles.asm Skull/Heart/Bolt/
|
||||
-- Zzz/FishEmote, used by the PikachuEmotionTable reactions).
|
||||
local EMOTE_SYMBOLS = {
|
||||
EXCLAMATION_BUBBLE = "ShockEmote", QUESTION_BUBBLE = "QuestionEmote",
|
||||
SMILE_BUBBLE = "HappyEmote", SKULL_BUBBLE = "SkullEmote",
|
||||
HEART_BUBBLE = "HeartEmote", BOLT_BUBBLE = "BoltEmote",
|
||||
ZZZ_BUBBLE = "ZzzEmote", FISH_BUBBLE = "FishEmote",
|
||||
}
|
||||
local bubbleDefs = self.manifest.field.emotionBubbles
|
||||
and self.manifest.field.emotionBubbles.bubbles
|
||||
local emoteLabels = {}
|
||||
for _, b in ipairs(bubbleDefs or {}) do
|
||||
emoteLabels[#emoteLabels + 1] = EMOTE_SYMBOLS[b.name]
|
||||
end
|
||||
if #emoteLabels == 0 then
|
||||
emoteLabels = { "ShockEmote", "QuestionEmote", "HappyEmote" }
|
||||
end
|
||||
local emotes = ImageWriter.blank(#emoteLabels * 16, 16, 1, 1, 1, 0)
|
||||
for index, label in ipairs(emoteLabels) do
|
||||
local symbol = self:symbol(label)
|
||||
local image = ImageWriter.decode2bpp(
|
||||
self.rom:bytes(symbol.bank, symbol.address, 64), 16, 16, true)
|
||||
@@ -1574,6 +1796,26 @@ function RomExtractor:extractField()
|
||||
end
|
||||
self:save(emotes, "emotes.png"); tick()
|
||||
|
||||
-- Yellow-only: the Surfing Pikachu minigame sheets
|
||||
-- (gfx/surfing_pikachu.asm) at pret's canvas widths, so
|
||||
-- src/ui/SurfingMinigame.lua's quads can be read off the source pngs.
|
||||
-- 1a is the BG set (water/beach/score tiles, opaque); 1b the OAM pose
|
||||
-- sheet and 1c the intro set (both color-0 transparent).
|
||||
for _, spec in ipairs({
|
||||
{ "SurfingPikachu1Graphics1", 65, 40, false, "minigame/surf_1a.png" },
|
||||
{ "SurfingPikachu1Graphics2", 256, 128, true, "minigame/surf_1b.png" },
|
||||
{ "SurfingPikachu1Graphics3", 144, 96, true, "minigame/surf_1c.png" },
|
||||
}) do
|
||||
if self.symbols[spec[1]] then
|
||||
local symbol = self:symbol(spec[1])
|
||||
local tilesPerRow = spec[3] / 8
|
||||
local image = ImageWriter.decode2bpp(
|
||||
self.rom:bytes(symbol.bank, symbol.address, spec[2] * 16),
|
||||
spec[3], spec[2] / tilesPerRow * 8, spec[4])
|
||||
self:save(image, spec[5])
|
||||
end
|
||||
end
|
||||
|
||||
self:raw1bpp("LedgeHoppingShadow", 8, 8,
|
||||
"fx/shadow.png", true); tick()
|
||||
for _, spec in ipairs({
|
||||
@@ -1664,7 +1906,9 @@ end
|
||||
function RomExtractor:extractAudio()
|
||||
self:beginStage("Sound programs")
|
||||
local metadata = copy(self.manifest.audio)
|
||||
local bankOrder = { 2, 8, 31 }
|
||||
-- Yellow adds a fourth music bank ($20: Jessie & James, Surfing
|
||||
-- Pikachu, GB Printer); the manifest names the pack when it needs it.
|
||||
local bankOrder = metadata.programBanks or { 2, 8, 31 }
|
||||
local chunks = {}
|
||||
for index, bank in ipairs(bankOrder) do
|
||||
local first = Rom.offset(bank, 0x4000) + 1
|
||||
@@ -1683,6 +1927,7 @@ function RomExtractor:extractAudio()
|
||||
for name, header in pairs(metadata.musicHeaders) do
|
||||
songs[name] = header
|
||||
end
|
||||
metadata.pikaCries = self:extractPikachuCries()
|
||||
local cries = {}
|
||||
local cryData = metadata.cryData
|
||||
for index, species in ipairs(self.manifest.constants.speciesOrder) do
|
||||
@@ -1709,6 +1954,57 @@ function RomExtractor:extractAudio()
|
||||
return metadata
|
||||
end
|
||||
|
||||
-- Yellow's voiced Pikachu clips (audio/pikachu_cries_pointers.asm
|
||||
-- PikachuCriesPointerTable, 42 `dba` rows; each clip is `dw length` then
|
||||
-- 1-bit PCM, MSB first -- home/pikachu_cries.asm PlayPikachuPCM toggles
|
||||
-- rAUD3LEVEL per bit at roughly 190 CPU cycles a sample). Decoded to
|
||||
-- plain 8-bit mono WAVs; returns the clip count for data.audio.pikaCries,
|
||||
-- or nil when the manifest has no pointer table (Red/Blue).
|
||||
function RomExtractor:extractPikachuCries()
|
||||
if not self.symbols["PikachuCriesPointerTable"] then return nil end
|
||||
local NUM = 42 -- NUM_PIKA_CRIES
|
||||
local RATE = 22050 -- ~4.19 MHz / ~190 cycles per sample
|
||||
-- byte -> 8 samples, MSB first (LoadNextSoundClipSample: `and $80`)
|
||||
local lut = {}
|
||||
for byte = 0, 255 do
|
||||
local out = {}
|
||||
for bit = 7, 0, -1 do
|
||||
local on = math.floor(byte / 2 ^ bit) % 2 == 1
|
||||
out[#out + 1] = string.char(on and 0xE0 or 0x20)
|
||||
end
|
||||
lut[byte] = table.concat(out)
|
||||
end
|
||||
local function u16(v)
|
||||
return string.char(v % 256, math.floor(v / 256) % 256)
|
||||
end
|
||||
local function u32(v)
|
||||
return string.char(v % 256, math.floor(v / 256) % 256,
|
||||
math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256)
|
||||
end
|
||||
local CacheFs = require("src.import.CacheFs")
|
||||
local pointers = self:symbol("PikachuCriesPointerTable")
|
||||
for index = 0, NUM - 1 do
|
||||
local row = self.rom:bytes(pointers.bank, pointers.address + index * 3, 3)
|
||||
local bank, address = row[1], row[2] + row[3] * 256
|
||||
local header = self.rom:bytes(bank, address, 2)
|
||||
local length = header[1] + header[2] * 256
|
||||
local raw = self.rom:bytes(bank, address + 2, length)
|
||||
local samples = {}
|
||||
for i, byte in ipairs(raw) do samples[i] = lut[byte] end
|
||||
local pcm = table.concat(samples)
|
||||
local wav = "RIFF" .. u32(36 + #pcm) .. "WAVEfmt " .. u32(16)
|
||||
.. u16(1) .. u16(1) .. u32(RATE) .. u32(RATE) .. u16(1) .. u16(8)
|
||||
.. "data" .. u32(#pcm) .. pcm
|
||||
local ok, err = CacheFs.write(
|
||||
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(index + 1),
|
||||
wav)
|
||||
if not ok then
|
||||
error("could not write pika cry " .. (index + 1) .. ": " .. tostring(err))
|
||||
end
|
||||
end
|
||||
return NUM
|
||||
end
|
||||
|
||||
function RomExtractor:run()
|
||||
local results = {}
|
||||
results.constants = self:extractConstants()
|
||||
|
||||
+786
-139
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,17 @@ ItemEffects.BALLS = BALLS
|
||||
function ItemEffects.isBall(id) return BALLS[id] or false end
|
||||
function ItemEffects.isStone(id) return STONES[id] or false end
|
||||
|
||||
-- Does using this item take item_effects.asm's .healHP path, the one that
|
||||
-- plays SFX_HEAL_HP and lengthens the party HP bar with UpdateHPBar2 before
|
||||
-- the message (item_effects.asm .doneHealing)? The status-only cures branch
|
||||
-- to .playStatusAilmentCuringSound instead and never touch the bar. BagMenu
|
||||
-- keeps the party picker open for these so the fill has something to draw
|
||||
-- on (#252).
|
||||
function ItemEffects.healsHP(id)
|
||||
return HEAL_AMOUNT[id] ~= nil or id == "MAX_POTION" or id == "FULL_RESTORE"
|
||||
or id == "REVIVE" or id == "MAX_REVIVE"
|
||||
end
|
||||
|
||||
-- Does this item need a party-member target?
|
||||
function ItemEffects.needsTarget(id, itemDef)
|
||||
return HEAL_AMOUNT[id] or STATUS_HEAL[id] or id == "MAX_POTION"
|
||||
@@ -172,6 +183,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
local b = battle.player
|
||||
-- PIKAHAPPY_USEDXITEM (item_effects.asm ItemUseXAccuracy /
|
||||
-- GuardSpec / DireHit / XStat) on the active companion
|
||||
if itemId ~= "POKE_DOLL" then
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(save, "USEDXITEM", b and b.mon)
|
||||
end
|
||||
if itemId == "X_ACCURACY" then
|
||||
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
|
||||
-- (not an accuracy stage)
|
||||
@@ -242,6 +259,18 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
|
||||
end
|
||||
|
||||
-- PIKAHAPPY_USEDITEM (item_effects.asm ItemUseMedicine, item id up to
|
||||
-- CALCIUM): fires once a medicine has a target, before the effect
|
||||
-- resolves -- potions, status cures, revives and vitamins all count,
|
||||
-- RARE_CANDY does not (its success is a LEVELUP bump instead)
|
||||
if target and (HEAL_AMOUNT[itemId] or STATUS_HEAL[itemId]
|
||||
or itemId == "MAX_POTION" or itemId == "FULL_RESTORE"
|
||||
or itemId == "REVIVE" or itemId == "MAX_REVIVE"
|
||||
or VITAMINS[itemId]) then
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(save, "USEDITEM", target)
|
||||
end
|
||||
|
||||
local heal = HEAL_AMOUNT[itemId]
|
||||
if heal or itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
|
||||
-- a FULL RESTORE on a statused mon already at full HP acts as a
|
||||
@@ -257,6 +286,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if not target or target.hp <= 0 or target.hp >= target.stats.hp then
|
||||
return "failed", { Strings("It won't have\nany effect.") }
|
||||
end
|
||||
-- wHPBarOldHP: the bar animation starts from the HP the mon had BEFORE
|
||||
-- the item landed (item_effects.asm latches it with the party menu still
|
||||
-- up), so latch it here and hand it back as extra.healedFrom for the
|
||||
-- party-menu fill (#252)
|
||||
local before = target.hp
|
||||
if itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
|
||||
target.hp = target.stats.hp
|
||||
else
|
||||
@@ -268,7 +302,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
cureActiveToxic(battle, target)
|
||||
end
|
||||
require("src.core.Sound").play(data, "Heal_HP")
|
||||
return "consumed", msgs
|
||||
return "consumed", msgs, { healedFrom = before }
|
||||
end
|
||||
|
||||
local cures = STATUS_HEAL[itemId]
|
||||
@@ -289,7 +323,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
target.status = nil
|
||||
target.hp = itemId == "REVIVE" and math.floor(target.stats.hp / 2) or target.stats.hp
|
||||
require("src.core.Sound").play(data, "Heal_HP")
|
||||
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) }
|
||||
-- a revive takes the same .healHP -> .doneHealing route, animating up
|
||||
-- from the fainted mon's 0 HP (#252)
|
||||
return "consumed", { Strings("%s\nis revitalized!", monName(data, target)) },
|
||||
{ healedFrom = 0 }
|
||||
end
|
||||
|
||||
if itemId == "RARE_CANDY" then
|
||||
@@ -304,12 +341,29 @@ 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))
|
||||
-- PIKAHAPPY_LEVELUP on a candy level (item_effects.asm:1540)
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(save, "LEVELUP", target)
|
||||
return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) },
|
||||
{ leveledTo = target.level }
|
||||
end
|
||||
|
||||
if STONES[itemId] then
|
||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||
-- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs
|
||||
-- IsThisPartyMonStarterPikachu (OT identity match) before
|
||||
-- TryEvolvingMon and bails with the voiced cry + RefusingText.
|
||||
-- The stone is NOT consumed on the refuse path.
|
||||
if target.species == "PIKACHU"
|
||||
and require("src.core.GameVersion").isYellow()
|
||||
and target.ot == save.player.name
|
||||
and target.otId == save.player.id then
|
||||
require("src.core.Sound").playCry(data, "PIKACHU")
|
||||
local raw = data.text and data.text._RefusingText
|
||||
local line = raw and raw:gsub("{RAM:[^}]*}", monName(data, target))
|
||||
or Strings("%s\nis refusing!", monName(data, target))
|
||||
return "failed", { line }
|
||||
end
|
||||
local speciesDef = data.pokemon[target.species]
|
||||
for _, evo in ipairs(speciesDef.evolutions) do
|
||||
if evo.method == "ITEM" and evo.item == itemId then
|
||||
|
||||
+63
-4
@@ -112,6 +112,19 @@ local function scalar(v)
|
||||
return tostring(v)
|
||||
end
|
||||
|
||||
-- A volatile slot is "off" three interchangeable ways -- absent, false, or
|
||||
-- a counter sitting at 0 -- because every reader tests `if b.key then` or
|
||||
-- `key > 0`. They have to hash the same, or a match ends over a difference
|
||||
-- that does not exist. They routinely disagree: the menu-phase flinch
|
||||
-- clear (BattleState:update) and the FIGHT-branch boundTurns mirror
|
||||
-- (fightLockedAction) are written by whichever machine is sitting at ITS
|
||||
-- OWN menu, and in a link battle that is a different battler on each side,
|
||||
-- so one peer holds flinched=false / boundTurns=0 where the other still
|
||||
-- holds nil with two identical simulations underneath.
|
||||
local function off(v)
|
||||
return v == nil or v == false or v == 0
|
||||
end
|
||||
|
||||
local function stageStr(b)
|
||||
local out = {}
|
||||
for i, stat in ipairs(STAGES) do
|
||||
@@ -131,7 +144,7 @@ end
|
||||
local function volStr(b)
|
||||
local out = {}
|
||||
for _, key in ipairs(VOLATILE) do
|
||||
if b[key] ~= nil then
|
||||
if not off(b[key]) then
|
||||
out[#out + 1] = key .. "=" .. scalar(b[key])
|
||||
end
|
||||
end
|
||||
@@ -168,6 +181,17 @@ end
|
||||
|
||||
local PARTS = { "actives", "volatile", "bench" }
|
||||
|
||||
-- Which components are allowed to end a match. `actives` and `bench` carry
|
||||
-- what decides one -- species, HP, status, stat stages, PP, the rest of the
|
||||
-- party -- so a divergence there is a real split between the two
|
||||
-- simulations and stays a draw. `volatile` is per-turn bookkeeping that
|
||||
-- both sides recompute from the authoritative state every turn (see `off`
|
||||
-- above): it can disagree for a turn without either side being wrong, and
|
||||
-- when it does mean something real it lands in `actives` as damage or
|
||||
-- status within a turn or two, where it is caught. Ending a match on it
|
||||
-- alone cost players games they were winning, over nothing.
|
||||
local FATAL_PART = { actives = true, bench = true }
|
||||
|
||||
-- opts: { myParty = packed, theirParty = packed, theirName, role =
|
||||
-- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when
|
||||
-- the handshake says the two link surfaces don't match: a lockstep
|
||||
@@ -315,12 +339,23 @@ function LinkBattle.new(game, net, opts)
|
||||
Logger.warn("link: desync turn %s component=%s (%s vs %s)",
|
||||
tostring(turn), component, tostring(localH), tostring(remoteH))
|
||||
Runtime.emit("link.desync", { turn = turn, component = component,
|
||||
localHash = localH, remoteHash = remoteH })
|
||||
localHash = localH, remoteHash = remoteH,
|
||||
fatal = true })
|
||||
endAsDraw(s, Strings(
|
||||
"Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
|
||||
component))
|
||||
end
|
||||
|
||||
-- a non-fatal component split: both sides log it and carry on, so the
|
||||
-- match is decided by the battle rather than by bookkeeping
|
||||
local function noteDrift(s, turn, component, localH, remoteH)
|
||||
Logger.warn("link: %s drift on turn %s (%s vs %s) -- match continues",
|
||||
component, tostring(turn), tostring(localH), tostring(remoteH))
|
||||
Runtime.emit("link.desync", { turn = turn, component = component,
|
||||
localHash = localH, remoteHash = remoteH,
|
||||
fatal = false })
|
||||
end
|
||||
|
||||
-- a verified turn stays recorded: consuming it here left a finished
|
||||
-- battle holding 0-1 entries, so the whole-battle sweep the link suite
|
||||
-- runs over localHashes had nothing left to compare
|
||||
@@ -333,8 +368,11 @@ function LinkBattle.new(game, net, opts)
|
||||
if mine and theirs then
|
||||
for _, component in ipairs(PARTS) do
|
||||
if mine[component] ~= theirs[component] then
|
||||
reportDesync(s, turn, component, mine[component], theirs[component])
|
||||
return
|
||||
if FATAL_PART[component] then
|
||||
reportDesync(s, turn, component, mine[component], theirs[component])
|
||||
return
|
||||
end
|
||||
noteDrift(s, turn, component, mine[component], theirs[component])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -565,10 +603,19 @@ function LinkBattle.new(game, net, opts)
|
||||
if net.closed and not s.linkEnded and not s.result then
|
||||
endAsDraw(s)
|
||||
end
|
||||
-- Both early returns below skip baseUpdate, which is where the
|
||||
-- presentational clock normally advances -- so tick it here, in the same
|
||||
-- order baseUpdate would (fx, then the queue). Without this an
|
||||
-- animation caught mid-flight froze for the whole wait: a flash stopped
|
||||
-- on its inverted BGP step and repainted the UI in inverted shades, and
|
||||
-- a pic part-way through a slide or grow-in stayed off screen, which is
|
||||
-- the "the screen went inverted" / "a Pokemon just vanished" pair.
|
||||
if s.phase == "waitRemote" then
|
||||
s:tickFx()
|
||||
return -- the other side is still choosing
|
||||
end
|
||||
if s.phase == "messages" and s.afterQueue == "linkNext" then
|
||||
s:tickFx()
|
||||
if not s:updateQueue() then
|
||||
s.afterQueue = "menu"
|
||||
s.phase = "menu"
|
||||
@@ -742,6 +789,13 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
-- the two real players cleared their flinch flags when their move
|
||||
-- menu opened; a spectator has no menu, so it does it here instead,
|
||||
-- at the same point in the turn (see BattleState:clearTurnFlinches).
|
||||
-- Without this a flinch survived into the next turn and ate a move
|
||||
-- that landed in the real match, and the replay -- sharing the RNG
|
||||
-- stream -- was watching a different battle from that point on.
|
||||
s:clearTurnFlinches()
|
||||
local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run"
|
||||
and decodeWireAction(s, hostMsg, s.player) or nil
|
||||
local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run"
|
||||
@@ -837,7 +891,11 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
if net.closed and not s.linkEnded and not s.result then
|
||||
endSpectate(s)
|
||||
end
|
||||
-- same as the real-participant loop: every path that skips baseUpdate
|
||||
-- still has to advance the presentational clock, and a spectator sits in
|
||||
-- waitBoth between every single turn
|
||||
if s.phase == "messages" and s.afterQueue == "linkNext" then
|
||||
s:tickFx()
|
||||
if not s:updateQueue() then
|
||||
s.afterQueue = "waitBoth"
|
||||
s.phase = "waitBoth"
|
||||
@@ -845,6 +903,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
return
|
||||
end
|
||||
if s.phase == "menu" or s.phase == "waitBoth" then
|
||||
s:tickFx()
|
||||
return -- frozen between resolved turns; never a real decision here
|
||||
end
|
||||
baseUpdate(s, dt)
|
||||
|
||||
+46
-1
@@ -73,6 +73,9 @@ end
|
||||
function LinkState.new(game)
|
||||
local self = setmetatable({}, LinkState)
|
||||
self.game = game
|
||||
-- a link session runs at 1X on both machines whatever either player set
|
||||
-- GAME SPEED to (see Game:logicSpeed); cleared in exitWith
|
||||
game.linkSession = true
|
||||
self.stage = "menu"
|
||||
self.index = 1
|
||||
self.addr = ipDigits(Net.lanIP())
|
||||
@@ -98,6 +101,7 @@ end
|
||||
|
||||
function LinkState:exitWith(message, reason)
|
||||
DiscordPresence.setJoinCode(nil)
|
||||
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
||||
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
|
||||
if self.net then self.net:close() end
|
||||
self.game.stack:pop()
|
||||
@@ -106,6 +110,46 @@ function LinkState:exitWith(message, reason)
|
||||
end
|
||||
end
|
||||
|
||||
-- Online play meets strangers, so it requires vanilla on both ends
|
||||
-- (Handshake.onlineAllowed). Mods merge into the shared Data registries at
|
||||
-- boot and there is no unmerge, so switching them off has to go through a
|
||||
-- relaunch -- but the player should not have to go find the mod manager and
|
||||
-- work out which mods count. This turns every enabled mod off, records
|
||||
-- them so the mod manager can put them back, and relaunches. The restart
|
||||
-- is confirmed rather than silent: it drops unsaved progress.
|
||||
function LinkState:offerVanillaRestart()
|
||||
local game = self.game
|
||||
local loader = game.mods
|
||||
local mods = Handshake.mods(game)
|
||||
local names = {}
|
||||
for i, mod in ipairs(mods) do
|
||||
if i > 2 then break end
|
||||
names[#names + 1] = tostring(mod.id):upper():sub(1, 12)
|
||||
end
|
||||
local list = table.concat(names, ", ")
|
||||
if #mods > #names then list = list .. (" +%d"):format(#mods - #names) end
|
||||
local text = Strings(
|
||||
"Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?", list)
|
||||
self.game.linkSession = nil
|
||||
Runtime.emit("link.ended", { reason = "error" })
|
||||
if self.net then self.net:close() end
|
||||
game.stack:pop()
|
||||
game.stack:push(TextBox.new(game, text, nil, { choice = function(yes)
|
||||
if not yes then return end
|
||||
-- setEnabled persists the toggle itself (Loader:_saveState), so the
|
||||
-- relaunch comes up vanilla and the mod manager lists them as disabled
|
||||
-- for the player to switch back on afterwards
|
||||
for _, mod in ipairs(mods) do
|
||||
if loader and loader.setEnabled then loader:setEnabled(mod.id, false) end
|
||||
end
|
||||
if game.restartWithMods then
|
||||
game:restartWithMods()
|
||||
elseif love.event and love.event.quit then
|
||||
love.event.quit("restart")
|
||||
end
|
||||
end }))
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- handshake v2 (D8): both peers announce engine version, api version and
|
||||
-- a fingerprint of their link surface, and the verdict comes from the two
|
||||
@@ -202,7 +246,7 @@ function LinkState:update(dt)
|
||||
self.index = 1
|
||||
elseif self.index == 2 or self.index == 3 then
|
||||
if not Handshake.onlineAllowed(self.game) then
|
||||
self:exitWith(Strings("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS."))
|
||||
self:offerVanillaRestart()
|
||||
return
|
||||
end
|
||||
if self.index == 2 then
|
||||
@@ -509,6 +553,7 @@ function LinkState:updateTrade(input)
|
||||
if self.game.writeSave then self.game:writeSave() end
|
||||
local name = received.nickname or self.game.data.pokemon[received.species].name
|
||||
Runtime.emit("link.ended", { reason = "done" })
|
||||
self.game.linkSession = nil -- this path pops without exitWith
|
||||
self.net:close()
|
||||
self.game.stack:pop()
|
||||
local game = self.game
|
||||
|
||||
@@ -382,6 +382,12 @@ function TradeSession:apply(game)
|
||||
Runtime.emit("pokemon.received",
|
||||
{ mon = received, from = "link", peerName = self.peerName })
|
||||
self.party[self.myPick] = received
|
||||
-- PIKAHAPPY_TRADE (engine/link/cable_club.asm:801): trading the
|
||||
-- companion away is the biggest happiness hit and zeroes the mood
|
||||
if game and sent then
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(game.save, "TRADE", sent)
|
||||
end
|
||||
if game and game.save.pokedex then
|
||||
game.save.pokedex.seen[received.species] = true
|
||||
game.save.pokedex.owned[received.species] = true
|
||||
|
||||
@@ -91,6 +91,11 @@ function Tournament.new(game)
|
||||
self.settingsIndex = 1
|
||||
self.roster = {}
|
||||
self.spectatorRoster = {}
|
||||
-- everything from here to exitWith runs at 1X regardless of the GAME
|
||||
-- SPEED option (see Game:logicSpeed): a tournament's shot clock counts
|
||||
-- down on the logic step, so fast-forward would hand one player less
|
||||
-- real time to choose than the opponent they are racing
|
||||
game.linkSession = true
|
||||
Sound.startLoop(game.data, MUSIC)
|
||||
return self
|
||||
end
|
||||
@@ -113,6 +118,7 @@ end
|
||||
|
||||
function Tournament:exitWith(message)
|
||||
DiscordPresence.setJoinCode(nil)
|
||||
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
||||
Sound.stopLoop(MUSIC)
|
||||
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
|
||||
if self.net then self.net:close() end
|
||||
|
||||
+196
-13
@@ -3,18 +3,39 @@
|
||||
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
|
||||
-- load at boot; this reads the same options.mods enable-state the loader
|
||||
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
|
||||
-- installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree, and
|
||||
-- uninstalls a mod by removing that tree + clearing options.mods[id].
|
||||
-- installs a dropped/chosen .zip into a "mods/<id>/" tree, and uninstalls a
|
||||
-- mod by removing that tree + clearing options.mods[id].
|
||||
--
|
||||
-- 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.
|
||||
-- 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.
|
||||
--
|
||||
-- The same split decides where a mod is FOUND, and that has a sharp edge: a
|
||||
-- non-portable install never reads the game folder at all, so a mod unzipped
|
||||
-- next to the executable -- where most games would want it -- is not wrong so
|
||||
-- much as invisible, with an empty panel and no error to explain it.
|
||||
-- adoptStrays looks in those folders anyway (a scoped mount that comes down
|
||||
-- again, CacheFs.withMounted) and copies what it finds into the tree the game
|
||||
-- really reads, so the mistake costs a line of notice rather than a support
|
||||
-- thread.
|
||||
--
|
||||
-- Split in two: the pure derivation (deriveList, locateRoot, pickStrays) has
|
||||
-- no love and no filesystem, so the engine tier can table-drive it; the
|
||||
-- discovery, install, uninstall, and stray-scan 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 = {}
|
||||
|
||||
@@ -125,6 +146,28 @@ function LauncherMods.locateRoot(paths)
|
||||
return nil, "no manifest.json found in the .zip"
|
||||
end
|
||||
|
||||
-- pickStrays(found, installed) -> the rows worth adopting, pure.
|
||||
-- found is an array of { id, name, folder, path } in scan order (game folder
|
||||
-- order, then directory order); installed is the id -> true set of what the
|
||||
-- game can already see. An installed id is dropped -- the player has a
|
||||
-- working copy and the loose folder is just where they first put it -- and a
|
||||
-- duplicate id across two game folders keeps the first, the same first-wins
|
||||
-- rule discover() uses. Sorted by id so the notice reads the same every time.
|
||||
function LauncherMods.pickStrays(found, installed)
|
||||
installed = installed or {}
|
||||
local out, seen = {}, {}
|
||||
for _, row in ipairs(found or {}) do
|
||||
local id = row.id
|
||||
if id and not installed[id] and not seen[id] then
|
||||
seen[id] = true
|
||||
out[#out + 1] = { id = id, name = row.name or id,
|
||||
folder = row.folder, path = row.path }
|
||||
end
|
||||
end
|
||||
table.sort(out, function(a, b) return a.id < b.id end)
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- discovery (love.filesystem)
|
||||
|
||||
local function decodeManifest(raw, path)
|
||||
@@ -143,6 +186,13 @@ local function discover()
|
||||
local fs = love and love.filesystem
|
||||
local out = {}
|
||||
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
|
||||
-- A fused portable build keeps its mods in the game folder next to the
|
||||
-- executable; resolving the cache root is what mounts that folder onto the
|
||||
-- physfs read path, so this is what makes those mods enumerable at all
|
||||
-- (#330). A source run needs nothing (the game folder IS the source), and
|
||||
-- the launcher's readiness check has usually resolved it already; the call
|
||||
-- is cached and idempotent.
|
||||
CacheFs.root()
|
||||
if not fs.getInfo("mods") then return out end
|
||||
local seen = {}
|
||||
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
||||
@@ -233,11 +283,14 @@ local function topLevelPaths(mount)
|
||||
return paths
|
||||
end
|
||||
|
||||
-- Copy the mounted archive subtree at `src` to the install path `dst`. Reads
|
||||
-- come from love.filesystem (the .zip is mounted there); every write goes
|
||||
-- through CacheFs so it lands in the portable game folder when portable.txt is
|
||||
-- in play and in the OS save directory otherwise (#330). No explicit mkdir:
|
||||
-- CacheFs.write creates the parent chain on both paths, which also means an
|
||||
-- empty folder inside the .zip is simply not carried over (it holds nothing).
|
||||
local function copyTree(src, dst)
|
||||
local fs = love.filesystem
|
||||
if not fs.createDirectory(dst) then
|
||||
return nil, "could not create " .. dst
|
||||
end
|
||||
for _, name in ipairs(fs.getDirectoryItems(src)) do
|
||||
local s = src .. "/" .. name
|
||||
local d = dst .. "/" .. name
|
||||
@@ -248,13 +301,18 @@ local function copyTree(src, dst)
|
||||
else
|
||||
local data = fs.read(s)
|
||||
if data == nil then return nil, "could not read " .. name end
|
||||
local ok, err = fs.write(d, data)
|
||||
local ok, err = CacheFs.write(d, data)
|
||||
if not ok then return nil, "could not write " .. name .. ": " .. tostring(err) end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
|
||||
-- portable game folder is on its read path), but the deletes go through
|
||||
-- CacheFs so a portable install's real files actually go away instead of
|
||||
-- love.filesystem no-opping outside the save directory (#330). Directories
|
||||
-- are removed after their children, since rmdir refuses a non-empty one.
|
||||
local function removeTree(path)
|
||||
local fs = love.filesystem
|
||||
local info = fs.getInfo(path)
|
||||
@@ -263,10 +321,121 @@ 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
|
||||
|
||||
-- ------- strays: mods dropped beside the game that it cannot see
|
||||
|
||||
-- love.filesystem looks in two places for "mods/": the save directory, and --
|
||||
-- portable installs only -- the game folder, which CacheFs mounts. A player
|
||||
-- who unzips a mod next to the executable of an ordinary install, which is
|
||||
-- where very nearly every other game would want it, gets no error and no mod.
|
||||
-- The MODS panel simply stays empty, and there is nothing on screen to
|
||||
-- suggest the files are twenty centimetres away in the wrong folder.
|
||||
--
|
||||
-- The scan mounts each game folder at a private mount point just long enough
|
||||
-- to list mods/ inside it and drops it again (CacheFs.withMounted), so the
|
||||
-- read path the game actually runs on is never touched and a stray can never
|
||||
-- shadow a real file.
|
||||
local STRAY_MOUNT = "stray_scan"
|
||||
|
||||
-- Run fn(mountedModsRoot) for each game folder that has a readable mods/
|
||||
-- directory, one mount at a time. Folders that are already the physfs source
|
||||
-- are skipped: their mods/ is discoverable by definition, so anything there is
|
||||
-- installed already and not a stray (this is every `love <gamedir>` dev run).
|
||||
local function eachStrayRoot(fn)
|
||||
local SaveData_ = require("src.core.SaveData")
|
||||
local fs = love and love.filesystem
|
||||
if not fs then return end
|
||||
local source = fs.getSource and fs.getSource()
|
||||
local seen = {}
|
||||
for _, folder in ipairs(SaveData_.gameFolders() or {}) do
|
||||
if not seen[folder] and folder ~= source then
|
||||
seen[folder] = true
|
||||
CacheFs.withMounted(folder, STRAY_MOUNT, function()
|
||||
local root = STRAY_MOUNT .. "/mods"
|
||||
if fs.getInfo(root) then fn(root, folder) end
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Every valid mod folder sitting in a game folder's mods/, in scan order.
|
||||
-- Only reads. The rows carry the mounted path, which is live for the length
|
||||
-- of the mount and dead after it -- copying has to happen inside the same
|
||||
-- scan, which is why adoption is a flag here rather than a second pass.
|
||||
local function findStrays(fs, adopt, installed)
|
||||
local found, adopted = {}, {}
|
||||
eachStrayRoot(function(root, folder)
|
||||
local batch = {}
|
||||
for _, name in ipairs(fs.getDirectoryItems(root)) do
|
||||
local path = root .. "/" .. name
|
||||
local info = fs.getInfo(path)
|
||||
if info and info.type == "directory" then
|
||||
local raw = fs.read(path .. "/manifest.json")
|
||||
local manifest = raw and decodeManifest(raw, path)
|
||||
if manifest then
|
||||
batch[#batch + 1] = { id = manifest.id,
|
||||
name = manifest.name or manifest.id,
|
||||
folder = folder, path = path }
|
||||
end
|
||||
end
|
||||
end
|
||||
-- filtered per mount, so a copy only ever runs for a row that survived
|
||||
-- the pure rules -- and so the second game folder sees the first one's
|
||||
-- ids as taken
|
||||
for _, row in ipairs(LauncherMods.pickStrays(batch, installed)) do
|
||||
if adopt then
|
||||
-- same root pin installZip uses: the mods tree is shared by Red and
|
||||
-- Blue, never version-prefixed (#330)
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
local dest = "mods/" .. row.id
|
||||
local copied, copyErr = copyTree(row.path, dest)
|
||||
if not copied then removeTree(dest) end
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not copied then row.err = copyErr or "could not copy the files" end
|
||||
end
|
||||
installed[row.id] = true
|
||||
row.path = nil -- dead once this mount comes down
|
||||
adopted[#adopted + 1] = row
|
||||
found[#found + 1] = row
|
||||
end
|
||||
end)
|
||||
return LauncherMods.pickStrays(found, {})
|
||||
end
|
||||
|
||||
-- The strays, optionally adopted. A folder whose id the game can already see
|
||||
-- is left out: the player has a working copy, and the loose one is just where
|
||||
-- they first put it. Rows that failed to copy come back with .err set.
|
||||
local function scanStrays(adopt)
|
||||
local fs = love and love.filesystem
|
||||
if not fs then return {} end
|
||||
local installed = {}
|
||||
for _, m in ipairs(discover()) do installed[m.id] = true end
|
||||
return findStrays(fs, adopt, installed)
|
||||
end
|
||||
|
||||
-- strays() -> the rows, nothing copied.
|
||||
function LauncherMods.strays() return scanStrays(false) end
|
||||
|
||||
-- adoptStrays() -> the rows, each one copied into the mods tree the game
|
||||
-- really reads (rows carrying .err failed). Idempotent: a second call finds
|
||||
-- the ids installed and returns nothing, so the panel can run this on every
|
||||
-- open without duplicating anything or nagging twice. The loose folder is
|
||||
-- deliberately left where it is -- deleting files outside the save directory
|
||||
-- on the player's behalf is not this function's call to make.
|
||||
function LauncherMods.adoptStrays() return scanStrays(true) end
|
||||
|
||||
-- installZip(source) -> true, id | nil, errString
|
||||
-- source is an external path or a love DroppedFile. The archive is validated
|
||||
-- BEFORE anything is copied; every path unmounts and clears the staged temp
|
||||
@@ -322,10 +491,19 @@ function LauncherMods.installZip(source)
|
||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||
end
|
||||
|
||||
fs.createDirectory("mods")
|
||||
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
|
||||
-- the mods tree is shared by Red and Blue, so pin the prefix to the root for
|
||||
-- the copy and the rollback, then hand back whatever the launcher had set
|
||||
-- (an import coroutine leaves it pointed at that version -- RomImporter.lua).
|
||||
-- No fs.createDirectory("mods") here any more: CacheFs.write creates the
|
||||
-- parent chain in both homes, and doing it through love.filesystem would
|
||||
-- only ever make the directory in the save dir (#330).
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
local copied, copyErr = copyTree(root, dest)
|
||||
if not copied then removeTree(dest) end
|
||||
CacheFs.prefix = savedPrefix
|
||||
if not copied then
|
||||
removeTree(dest)
|
||||
cleanup()
|
||||
return nil, copyErr or "could not copy the mod files"
|
||||
end
|
||||
@@ -334,8 +512,9 @@ function LauncherMods.installZip(source)
|
||||
end
|
||||
|
||||
-- uninstall(id) -> true | nil, errString
|
||||
-- Removes mods/<id>/ from the save directory and clears options.mods[id] so the
|
||||
-- loader and in-game manager no longer see it. Rejects unknown / missing ids.
|
||||
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
||||
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
|
||||
-- so the loader and in-game manager no longer see it. Rejects missing ids.
|
||||
-- Does not touch other mods' enable state.
|
||||
function LauncherMods.uninstall(id)
|
||||
if type(id) ~= "string" or id == "" then
|
||||
@@ -352,7 +531,11 @@ function LauncherMods.uninstall(id)
|
||||
if not fs.getInfo(dest) then
|
||||
return nil, "mod '" .. id .. "' is not installed"
|
||||
end
|
||||
-- same root pin as installZip: the mods tree is not version-prefixed (#330)
|
||||
local savedPrefix = CacheFs.prefix
|
||||
CacheFs.prefix = ""
|
||||
removeTree(dest)
|
||||
CacheFs.prefix = savedPrefix
|
||||
-- Drop the enable flag so a reinstall of the same id starts from the
|
||||
-- loader's default (enabled) rather than a stale false.
|
||||
local options = SaveData.loadOptions()
|
||||
|
||||
+5
-1
@@ -162,7 +162,11 @@ function Loader:_loadState()
|
||||
if next(options.mods or {}) == nil and self.fs.getInfo
|
||||
and self.fs.getInfo(MOD_STATE_FILE) then
|
||||
local chunk = self.fs.load(MOD_STATE_FILE)
|
||||
local ok, state = chunk and pcall(chunk)
|
||||
-- `chunk and pcall(chunk)` truncates to one value, so state came back nil
|
||||
-- however well the chunk ran and the migration below never fired once:
|
||||
-- the guard has to be a statement for pcall's second return to survive.
|
||||
local ok, state = false, nil
|
||||
if chunk then ok, state = pcall(chunk) end
|
||||
if ok and type(state) == "table" then
|
||||
for id, disabled in pairs(state) do
|
||||
if disabled then
|
||||
|
||||
@@ -493,6 +493,12 @@ R.maps = {
|
||||
width = f.int(1), height = f.int(1),
|
||||
blocks = f.list(f.int(0, 255)),
|
||||
borderBlock = f.opt(f.int(0, 255)),
|
||||
-- A named SGB palette, which wins over the field.palettes cascade
|
||||
-- (OverworldController.lua:506 reads map.def.palette first). Deliberately
|
||||
-- a plain string rather than f.id("palettes"): the ROM-free fixture base
|
||||
-- carries no palettes at all, so an id reference would fail validation for
|
||||
-- a perfectly good mod wherever there is no imported dataset.
|
||||
palette = f.opt(f.str),
|
||||
warps = f.opt(f.list(f.rec{ x = f.int(0), y = f.int(0),
|
||||
destMap = f.str, destWarp = f.int(0) })),
|
||||
objects = f.opt(f.list(f.any)),
|
||||
|
||||
@@ -187,7 +187,7 @@ end
|
||||
|
||||
-- After-battle hook: evolve mons that leveled this battle and still
|
||||
-- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle
|
||||
-- only considers mons that gained a level during the fight — a B-cancel
|
||||
-- only considers mons that gained a level during the fight -- a B-cancel
|
||||
-- means "not this time", and the next offer waits for the next level-up
|
||||
-- (or Rare Candy / stone, which call Evolution.evolve directly).
|
||||
-- leveledUp is a set of party mon tables; nil/empty yields no evolutions.
|
||||
|
||||
@@ -31,7 +31,7 @@ end
|
||||
|
||||
-- Day Care retrieve (pokered WriteMonMoves + wLearningMovesFromDayCare):
|
||||
-- grant learnset moves with startLevel < moveLevel <= newLevel, shifting
|
||||
-- the oldest slot out when full. Silent — no LearnMove prompts.
|
||||
-- the oldest slot out when full. Silent -- no LearnMove prompts.
|
||||
function Pokemon.learnMovesFromDayCare(data, mon, speciesDef, startLevel, newLevel)
|
||||
if not (speciesDef and speciesDef.learnset and mon) then return end
|
||||
mon.moves = mon.moves or {}
|
||||
@@ -75,6 +75,10 @@ function Pokemon.new(data, species, level, rng)
|
||||
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
|
||||
stats = stats,
|
||||
hp = stats.hp,
|
||||
-- the Gen1 catch-rate byte freezes at catch time: evolution does NOT
|
||||
-- update it, so PKHeX expects a preevolution's rate on an evolved mon
|
||||
-- (#206). Evolution.apply mutates in place and never touches this.
|
||||
catchRate = def.catchRate,
|
||||
status = nil, -- "SLP"|"PSN"|"BRN"|"FRZ"|"PAR"
|
||||
moves = moves,
|
||||
}
|
||||
|
||||
@@ -42,6 +42,28 @@ function Stats.calc(speciesDef, level, dvs, statExp)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Give a mon a stat block when it has none. A real Gen 1 box_struct is a
|
||||
-- byte-for-byte PREFIX of party_struct that stops before MON_LEVEL and
|
||||
-- MON_STATS (macros/ram.asm box_struct / party_struct), so mons decoded out
|
||||
-- of an imported .sav arrive without one (src/save_convert/GenSave.lua
|
||||
-- decodeMon, isParty = false). The original derives them on demand at
|
||||
-- exactly two moments: when a box or daycare mon's status screen opens
|
||||
-- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" ->
|
||||
-- CalcStats) and when one is moved back into the party
|
||||
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is
|
||||
-- kept (box_struct does hold it) but clamped to the recalculated maximum so
|
||||
-- a tampered save cannot overfill the bar. A mon that already has stats is
|
||||
-- returned untouched, so a vanilla save round-trips. #233, #304
|
||||
function Stats.ensure(speciesDef, mon)
|
||||
if type(mon) ~= "table" or type(mon.stats) == "table" then return mon end
|
||||
if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then
|
||||
return mon
|
||||
end
|
||||
mon.stats = Stats.calc(speciesDef, mon.level or 1, mon.dvs or {}, mon.statExp)
|
||||
mon.hp = math.max(0, math.min(tonumber(mon.hp) or mon.stats.hp, mon.stats.hp))
|
||||
return mon
|
||||
end
|
||||
|
||||
-- Battle stat stage multipliers (data/battle/stat_modifiers.asm): stages
|
||||
-- -6..+6 map to N/D pairs 25/100 .. 400/100.
|
||||
local STAGE_MULT = {
|
||||
|
||||
@@ -23,6 +23,20 @@ local FLASH_STEPS = { 1 / 3, 2 / 3, 1, 2 / 3, 1 / 3, 0,
|
||||
local FLASH_HOLD = 2 -- frames per palette step
|
||||
local FLASH_CYCLES = 3
|
||||
|
||||
-- The screen stays black after the wipe lands. Shrink and Split ask for it
|
||||
-- outright (BattleTransition_BlackScreen, then `ld c, 10 / jp DelayFrames`:
|
||||
-- battle_transitions.asm:390-392 and 422-424); the other six get the same gap
|
||||
-- for free, because BattleTransition_BlackScreen has already set rBGP/rOBP0/
|
||||
-- rOBP1 to $ff (:168-174) while DoBattleTransitionAndInitBattleVariables
|
||||
-- reloads the HUD tile patterns and clears the screen (core.asm:6152-6185),
|
||||
-- InitBattleCommon decompresses the front pic (core.asm:6694-6730), and
|
||||
-- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between
|
||||
-- DisableLCD and EnableLCD (core.asm:9-49) before anything moves. This port
|
||||
-- has no load to hide behind, so the hold has to be explicit (#315). 30 is a
|
||||
-- frame budget for that work, not a number pokered states; retune this one
|
||||
-- constant against a reference recording if it reads long or short.
|
||||
local BLACK_HOLD = 30
|
||||
|
||||
local TILE = 8
|
||||
local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles
|
||||
|
||||
@@ -208,7 +222,7 @@ function BattleTransition:update(dt)
|
||||
self.t = 0
|
||||
end
|
||||
else
|
||||
if self.t >= self.wipeLen + 6 then
|
||||
if self.t >= self.wipeLen + BLACK_HOLD then
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
@@ -25,7 +25,16 @@ local shader -- false = unavailable (headless / no shader support)
|
||||
-- Mobile GPUs often compile this pass but present a black frame, and the
|
||||
-- level persists in options.lua -- soft-bricking the APK until a manual
|
||||
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
|
||||
--
|
||||
-- POKEPORT_GBCFX overrides the platform default either way ("1" force on,
|
||||
-- "0" force off), same tri-state as POKEPORT_TOUCH. Handheld packs whose
|
||||
-- GPU is in the same class as a phone's but whose getOS() says "Linux" set
|
||||
-- it to 0 in their launcher (build-rg34xxsp.sh); it also lets a desktop
|
||||
-- checkout exercise the unsupported path without stubbing love.system.
|
||||
function GBCFX.isSupported()
|
||||
local env = os.getenv("POKEPORT_GBCFX")
|
||||
if env == "1" then return true end
|
||||
if env == "0" then return false end
|
||||
if not love or not love.system or not love.system.getOS then return true end
|
||||
local osName = love.system.getOS()
|
||||
return osName ~= "Android" and osName ~= "iOS"
|
||||
|
||||
+80
-22
@@ -2,6 +2,11 @@
|
||||
-- screen: pokered overlays the $62-$7F font area with the HP bar /
|
||||
-- status sheet (font_battle_extra -> $62) and the HUD line tiles
|
||||
-- (battle_hud_1 -> $6D, battle_hud_2+3 -> $73).
|
||||
--
|
||||
-- The two screens do NOT use the same overlay: the status screen scatters
|
||||
-- hud_2 and hud_3 instead of copying them contiguously, which is what keeps
|
||||
-- its № and <ID> glyphs alive. HudTiles.tile draws the battle layout,
|
||||
-- HudTiles.statusTile the status one -- see STATUS_PAGES below. #280
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
|
||||
@@ -23,32 +28,62 @@ local PAGES = {
|
||||
image = "assets/generated/battle/battle_hud_3.png", base = 0x76 },
|
||||
}
|
||||
|
||||
local tiles
|
||||
function HudTiles.tile(code, x, y, tint)
|
||||
if not tiles then
|
||||
tiles = {}
|
||||
local registered = require("src.core.Data").font
|
||||
registered = registered and registered.pages or nil
|
||||
local function add(path, base)
|
||||
local ok, img = pcall(Assets.image, path)
|
||||
if not ok then return end
|
||||
-- The STATUS SCREEN overlays the SAME sheets differently, and the layout
|
||||
-- above would break it: engine/pokemon/status_screen.asm:86-97 copies 3
|
||||
-- tiles of hud_1 to $6D, ONE tile of hud_2 to $78 and 2 tiles of hud_3 to
|
||||
-- $76, which leaves $70/$73/$74 as font_battle_extra's <to>, <ID> and № --
|
||||
-- the glyphs the screen prints "№." and "<ID>№/" from
|
||||
-- (constants/charmap.asm:69-73). The battle overlay instead copies
|
||||
-- hud_2+hud_3 contiguously over $73-$78 (engine/battle/core.asm:6520/6532),
|
||||
-- burying № under a line tile, so the status screen needs its own table.
|
||||
-- The line glyphs land identically either way -- $76 ─, $77 ┘, $6F the
|
||||
-- halfarrow -- only the vertical bar moves ($73 in battle, $78 here). #280
|
||||
local STATUS_PAGES = {
|
||||
{ id = "font_battle_extra",
|
||||
image = "assets/generated/battle/font_battle_extra.png", base = 0x62 },
|
||||
{ id = "battle_hud_1",
|
||||
image = "assets/generated/battle/battle_hud_1.png", base = 0x6D },
|
||||
{ id = "battle_hud_3",
|
||||
image = "assets/generated/battle/battle_hud_3.png", base = 0x76, count = 2 },
|
||||
{ id = "battle_hud_2",
|
||||
image = "assets/generated/battle/battle_hud_2.png", base = 0x78, count = 1 },
|
||||
}
|
||||
|
||||
local tiles, statusTiles
|
||||
|
||||
-- Build one code -> {img, quad} map from a page list. `count` caps a page
|
||||
-- at the number of tiles the asm actually copies (the extracted sheets all
|
||||
-- carry 3 tiles; the status overlay uses fewer). A mod's registered page
|
||||
-- swaps the image in either table, but only the battle table honors its
|
||||
-- `base`: the status layout is the asm's own placement, and sliding hud_2
|
||||
-- there would bury № again.
|
||||
local function build(pages, fixedBase)
|
||||
local out = {}
|
||||
local registered = require("src.core.Data").font
|
||||
registered = registered and registered.pages or nil
|
||||
for _, page in ipairs(pages) do
|
||||
local override = registered and registered[page.id]
|
||||
local path, base = page.image, page.base
|
||||
if override and override.image then path = override.image end
|
||||
if not fixedBase and override and override.base then base = override.base end
|
||||
local ok, img = pcall(Assets.image, path)
|
||||
if ok then
|
||||
local iw, ih = img:getDimensions()
|
||||
local per = iw / 8
|
||||
for i = 0, per * (ih / 8) - 1 do
|
||||
tiles[base + i] = {
|
||||
local count = page.count or per * (ih / 8)
|
||||
for i = 0, count - 1 do
|
||||
out[base + i] = {
|
||||
img = img,
|
||||
quad = love.graphics.newQuad((i % per) * 8,
|
||||
math.floor(i / per) * 8, 8, 8, iw, ih),
|
||||
}
|
||||
end
|
||||
end
|
||||
for _, page in ipairs(PAGES) do
|
||||
local override = registered and registered[page.id]
|
||||
add(override and override.image or page.image,
|
||||
override and override.base or page.base)
|
||||
end
|
||||
end
|
||||
local t = tiles[code]
|
||||
return out
|
||||
end
|
||||
|
||||
local function put(t, x, y, tint)
|
||||
if not t then return end
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
love.graphics.setColor(tint or { 1, 1, 1, 1 })
|
||||
@@ -56,9 +91,23 @@ function HudTiles.tile(code, x, y, tint)
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
end
|
||||
|
||||
function HudTiles.tile(code, x, y, tint)
|
||||
if not tiles then tiles = build(PAGES) end
|
||||
put(tiles[code], x, y, tint)
|
||||
end
|
||||
|
||||
-- The same sheets under the status screen's overlay (STATUS_PAGES). The HP
|
||||
-- bar codes $62-$6D are identical in both layouts, so drawHPBar below keeps
|
||||
-- using the battle table. #280
|
||||
function HudTiles.statusTile(code, x, y, tint)
|
||||
if not statusTiles then statusTiles = build(STATUS_PAGES, true) end
|
||||
put(statusTiles[code], x, y, tint)
|
||||
end
|
||||
|
||||
-- lazy: the next tile() rebuilds every page from the search path
|
||||
function HudTiles.invalidate()
|
||||
tiles = nil
|
||||
statusTiles = nil
|
||||
end
|
||||
|
||||
Assets.register(HudTiles.invalidate)
|
||||
@@ -78,6 +127,11 @@ end
|
||||
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
|
||||
-- red).
|
||||
--
|
||||
-- segments: how many 8px cells the bar spans (6, the hardware width,
|
||||
-- unless a caller asks for more -- the widescreen battle layout has room
|
||||
-- for a longer bar in the same tiles). The color thresholds scale with
|
||||
-- it so a wider bar turns yellow and red at the same fractions of full.
|
||||
--
|
||||
-- grayFill (#229): when the caller will colorize this bar with an SGB
|
||||
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
|
||||
-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip
|
||||
@@ -87,18 +141,22 @@ end
|
||||
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
|
||||
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
|
||||
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
|
||||
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments)
|
||||
local x, y = tx * 8, ty * 8
|
||||
segments = math.max(1, math.floor(segments or 6))
|
||||
HudTiles.tile(0x71, x, y)
|
||||
HudTiles.tile(0x62, x + 8, y)
|
||||
local px = 0
|
||||
if mon.stats.hp > 0 and mon.hp > 0 then
|
||||
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
|
||||
px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp))
|
||||
end
|
||||
local tint
|
||||
if not grayFill then
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||
local green = math.ceil(27 * segments / 6)
|
||||
local yellow = math.ceil(10 * segments / 6)
|
||||
local name = px >= green and "GREENBAR"
|
||||
or px >= yellow and "YELLOWBAR" or "REDBAR"
|
||||
local colors = PaletteFX.pal(data, name)
|
||||
if colors then
|
||||
local c = colors[3] -- GB color 2 is the fill shade
|
||||
@@ -108,11 +166,11 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
|
||||
math.min(1, c[3] / 170), 1 }
|
||||
end
|
||||
end
|
||||
for i = 0, 5 do
|
||||
for i = 0, segments - 1 do
|
||||
local seg = math.min(8, math.max(0, px - i * 8))
|
||||
HudTiles.tile(seg >= 8 and 0x6B or 0x63 + seg, x + 16 + i * 8, y, tint)
|
||||
end
|
||||
HudTiles.tile(HudTiles.capTile(barType), x + 64, y)
|
||||
HudTiles.tile(HudTiles.capTile(barType), x + 16 + segments * 8, y)
|
||||
end
|
||||
|
||||
return HudTiles
|
||||
|
||||
+143
-33
@@ -5,10 +5,11 @@
|
||||
-- then drawn once per zone through a shader that remaps the four DMG
|
||||
-- shades to that zone's palette.
|
||||
--
|
||||
-- Port display option: COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC)
|
||||
-- transforms every zone's palette at send time via effectiveColors.
|
||||
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
|
||||
-- (data/palettes_gbc.lua), including per-species mon colors.
|
||||
-- Port display option: COLORS (OG RED / SGB / ADVANCED / OG / OG INV /
|
||||
-- SGB INV / CLASSIC) transforms every zone's palette at send time via
|
||||
-- effectiveColors. ADVANCED (the `redpp` id below) swaps the named-palette
|
||||
-- pack for pokered-gbc SuperPalettes (data/palettes_gbc.lua), including
|
||||
-- per-species mon colors.
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
@@ -18,14 +19,18 @@ local shader -- false = unavailable (headless / no shader support)
|
||||
local gbcPack -- false = missing; nil = not loaded yet
|
||||
|
||||
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
|
||||
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, RED++ =
|
||||
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, ADVANCED =
|
||||
-- pokered-gbc per-tile), then the DMG-shade novelty modes.
|
||||
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
|
||||
-- `gbc`/`gbc_inv` keep their save-value ids for back-compat; their LABELS are
|
||||
-- "SGB"/"SGB INV" because that is what the mode actually is (the old "GBC"
|
||||
-- label was a misnomer -- it never was the real Game Boy Color palette).
|
||||
-- `gbc`/`gbc_inv`/`redpp` keep their save-value ids for back-compat while
|
||||
-- their LABELS say what the mode actually is: "SGB"/"SGB INV" because the old
|
||||
-- "GBC" label was a misnomer (it never was the real Game Boy Color palette),
|
||||
-- and "ADVANCED" because `redpp` is the richest colorization of the three
|
||||
-- rather than anything Red-specific -- it reads as a misnomer outright on a
|
||||
-- Blue playthrough. Comments elsewhere still call it RED++, the name it has
|
||||
-- carried in this file since it landed.
|
||||
PaletteFX.MODE_LABELS = {
|
||||
ogred = "OG RED", gbc = "SGB", redpp = "RED++", og = "OG",
|
||||
ogred = "OG RED", gbc = "SGB", redpp = "ADVANCED", og = "OG",
|
||||
og_inv = "OG INV", gbc_inv = "SGB INV", classic = "CLASSIC",
|
||||
}
|
||||
PaletteFX.mode = "gbc"
|
||||
@@ -73,8 +78,11 @@ PaletteFX.GBC_OBJ_BLUE = {
|
||||
-- playthrough, red otherwise. White (index 1) and black (index 4) are
|
||||
-- identical across versions, so callers that only touch the endpoints
|
||||
-- (e.g. BattleState's zone white/black snap) need no version branch.
|
||||
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes) and has no extracted
|
||||
-- boot-ROM auto-palette here; keep the Red ramp -- never Blue's GBC_BG_BLUE.
|
||||
function PaletteFX.ogBg()
|
||||
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
|
||||
if GameVersion.isYellow() then return PaletteFX.GBC_BG end
|
||||
return PaletteFX.GBC_BG
|
||||
end
|
||||
|
||||
@@ -83,11 +91,32 @@ end
|
||||
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
|
||||
-- its baked-image cache by (image path, group): a shared group would collide a
|
||||
-- Red bake with a Blue one and one version would show the other's colors.
|
||||
-- Yellow: same Red OBJ green as above until Yellow-specific tables land.
|
||||
function PaletteFX.ogObj()
|
||||
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
|
||||
if GameVersion.isYellow() then return PaletteFX.GBC_OBJ, "gbcobj" end
|
||||
return PaletteFX.GBC_OBJ, "gbcobj"
|
||||
end
|
||||
|
||||
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
|
||||
-- plus its cache group (same two-value contract as ogObj). Entry 1 is never
|
||||
-- read -- SpriteRenderer.getObpImage keys OBJ color 0 to alpha, the hardware's
|
||||
-- unconditional OBJ transparency -- and entries 2..4 are OBJ colors 1..3 sent
|
||||
-- through rOBP0 = $D0 (home/fade.asm FadePal4 `dc 3,1,0,0`, the entry
|
||||
-- LoadGBPal reads while wMapPalOffset is 0): color 1 -> DMG shade 0, color 2
|
||||
-- -> shade 1, color 3 -> shade 3. Leaving the result in DMG shades is the
|
||||
-- whole point: the zone shader then colors a character out of the same map
|
||||
-- palette it colors the ground with, which is all the Super Game Boy can do to
|
||||
-- an OBJ (#301), and the OBP0 lift is what puts Red's cap on the ROUTE
|
||||
-- palette's grass green instead of its light-blue (#150).
|
||||
PaletteFX.OBP0_SHADES = {
|
||||
{ 255, 255, 255 }, { 255, 255, 255 }, { 170, 170, 170 }, { 0, 0, 0 },
|
||||
}
|
||||
|
||||
function PaletteFX.dmgObj()
|
||||
return PaletteFX.OBP0_SHADES, "obp0"
|
||||
end
|
||||
|
||||
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
||||
|
||||
function PaletteFX.shader()
|
||||
@@ -202,32 +231,45 @@ function PaletteFX.usesGbcPack(mode)
|
||||
end
|
||||
|
||||
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
||||
-- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters
|
||||
-- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red
|
||||
-- background, pink over Blue's blue background), so the player and NPCs carry a
|
||||
-- fixed object color instead of tinting with whatever region palette their
|
||||
-- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ
|
||||
-- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it
|
||||
-- overlaps -- so Red's cap must stay green in tall grass, not turn the ROUTE
|
||||
-- palette's light-blue (issue #150: SGB region-tinting sent the cap to shade-2
|
||||
-- = light-blue and the character clashed with the grass it should blend into).
|
||||
-- Terrain is unaffected -- pal() below still hands SGB its per-map BG palette;
|
||||
-- only OG RED short-circuits BG to the one global red palette. An EARLIER
|
||||
-- attempt at per-sprite SGB color baked GBC_BG (the RED background ramp) onto
|
||||
-- (the OBP bake + post-zone redraw path). OG RED alone does: the Game Boy
|
||||
-- Color boot ROM hands the game one global object palette (PaletteFX.ogObj --
|
||||
-- green over Red's red background, pink over Blue's blue background), so on
|
||||
-- that machine the player and NPCs carry a fixed object color instead of
|
||||
-- tinting with whatever region palette their feet stand over. An EARLIER
|
||||
-- attempt at per-sprite color baked GBC_BG (the RED background ramp) onto
|
||||
-- characters -- that was the "reds coloring on the player/NPCs" bug; the object
|
||||
-- palette is GBC_OBJ (green), so baking it here is the fix, not that
|
||||
-- regression. RED++ colors sprites through the usesGbcPack() path in
|
||||
-- SpriteRenderer instead.
|
||||
-- palette is GBC_OBJ (green), so baking that here is the fix, not that
|
||||
-- regression. Terrain is unaffected -- pal() below still hands SGB its per-map
|
||||
-- BG palette; only OG RED short-circuits BG to the one global red palette.
|
||||
--
|
||||
-- SGB does NOT. The Super Game Boy colorizes the composited DMG picture it is
|
||||
-- handed and cannot tell an OBJ pixel from a BG one; pokered never sends the
|
||||
-- OBJ_TRN packet that would enable SGB sprite mode (data/sgb/sgb_packets.asm
|
||||
-- defines ATTR_BLK / PAL_SET / PAL_TRN / MLT_REQ / CHR_TRN / PCT_TRN and
|
||||
-- nothing else), so a character there wears the very palette its map does.
|
||||
-- Baking GBC_OBJ over it was issue #301 ("people in SGB mode are green": the
|
||||
-- boot-ROM greens sat on top of ROUTE's own greens and blues). What issue
|
||||
-- #150 actually caught was the missing rOBP0 step, not a missing object
|
||||
-- palette: overworld OBJs run through OBP0 = $D0 (home/fade.asm FadePal4
|
||||
-- `dc 3,1,0,0`), which lifts OBJ color 1 to DMG shade 0 and color 2 to shade 1,
|
||||
-- so Red's cap lands on the ROUTE palette's shade-1 GRASS GREEN and blends into
|
||||
-- the grass exactly as #150's reference shot shows. Drawn with an identity
|
||||
-- shade map it landed on shade 2 = light-blue instead, which is the clash #150
|
||||
-- reported. SpriteRenderer bakes that OBP0 ramp (PaletteFX.dmgObj) and lets
|
||||
-- the zone shader color the result. RED++ colors sprites through the
|
||||
-- usesGbcPack() path in SpriteRenderer instead.
|
||||
function PaletteFX.usesSpriteObp(mode)
|
||||
mode = mode or PaletteFX.mode
|
||||
return mode == "ogred" or mode == "gbc"
|
||||
return mode == "ogred"
|
||||
end
|
||||
|
||||
-- ------- post-zone sprite redraw (GBC mode)
|
||||
-- ------- post-zone sprite redraw (OG RED)
|
||||
--
|
||||
-- In GBC mode the world canvas still runs through the per-map zone
|
||||
-- In OG RED the world canvas still runs through the whole-screen zone
|
||||
-- shade-remap shader, which would corrupt an OBP-baked sprite's true-color
|
||||
-- pixels. So SpriteRenderer draws the baked sprite into the canvas (its
|
||||
-- pixels. (SGB used to come through here too; it no longer bakes an object
|
||||
-- palette at all, so its characters are colorized by the zone like the ground
|
||||
-- they stand on and never queue a replay -- see usesSpriteObp, #301.) So SpriteRenderer draws the baked sprite into the canvas (its
|
||||
-- pixels come out zone-tinted there) AND records the draw here;
|
||||
-- Renderer:endFrame replays the list on top of the finished zone pass,
|
||||
-- scaled into screen space -- the GBC's OBJ-over-BG compositing, one draw
|
||||
@@ -274,6 +316,9 @@ end
|
||||
-- Red-derived pokered-gbc pack, so under RED++ a Blue playthrough must
|
||||
-- read these from the ROM-imported table or the title ribbon stays red
|
||||
-- and the Game Corner reels keep Red's pink (issue #128).
|
||||
-- Yellow is intentionally NOT in BLUE_VERSIONED: skip Blue LOGO1/SLOTS*
|
||||
-- recolors. When CGBBasePalettes were imported (palettes.cgbBase), Yellow
|
||||
-- prefers those over SGB SuperPalettes for named zones.
|
||||
local BLUE_VERSIONED = {
|
||||
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
|
||||
}
|
||||
@@ -283,6 +328,11 @@ local function romNamedPal(data, name)
|
||||
return p and p.palettes and p.palettes[name]
|
||||
end
|
||||
|
||||
local function yellowCgbNamedPal(data, name)
|
||||
local p = data and data.palettes
|
||||
return p and p.cgbBase and p.cgbBase[name]
|
||||
end
|
||||
|
||||
-- named palette from the active pack (nil on stale builds / missing name).
|
||||
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
|
||||
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
|
||||
@@ -292,10 +342,16 @@ end
|
||||
-- GBC_OBJ green), so this stays a BG-only hook.
|
||||
function PaletteFX.pal(data, name)
|
||||
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
|
||||
-- Blue-only ROM override for versioned SuperPals. Yellow (isYellow) and
|
||||
-- Red keep the active pack / Red-like path -- do not apply Blue recolors.
|
||||
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
|
||||
local fromRom = romNamedPal(data, name)
|
||||
if fromRom then return fromRom end
|
||||
end
|
||||
if GameVersion.isYellow() then
|
||||
local fromCgb = yellowCgbNamedPal(data, name)
|
||||
if fromCgb then return fromCgb end
|
||||
end
|
||||
local p = PaletteFX.pack(data)
|
||||
local c = p and p.palettes[name]
|
||||
if c then return c end
|
||||
@@ -529,6 +585,34 @@ function PaletteFX.permute(colors, map)
|
||||
colors[map[2] + 1], colors[map[3] + 1] }
|
||||
end
|
||||
|
||||
-- ------- global shade map (rBGP)
|
||||
--
|
||||
-- home/fade.asm's LoadGBPal writes ONE rBGP for the whole screen, indexing
|
||||
-- FadePal4 - wMapPalOffset. A dark cave sets wMapPalOffset = 6
|
||||
-- (home/overworld.asm's ROCK_TUNNEL_1F check; the value rides in the extracted
|
||||
-- field.darkMaps.palOffset), which lands on FadePal2 = `dc 3,3,3,2`: DMG white
|
||||
-- drops to shade 2 and every darker shade goes to shade 3. So the WHOLE
|
||||
-- screen darkens -- the original never cuts a window of light around the
|
||||
-- player (#322) -- and FLASH clears wMapPalOffset again
|
||||
-- (engine/menus/start_sub_menus.asm .flash).
|
||||
--
|
||||
-- Renderer:beginFrame clears this every frame and the state that draws a dark
|
||||
-- map re-arms it while it draws, so it can never outlive the map it belongs
|
||||
-- to: a battle or a full-screen menu draws with no map beneath it and comes
|
||||
-- out lit, exactly like init_battle_variables.asm's `ld [wMapPalOffset], a`
|
||||
-- leaves the original.
|
||||
PaletteFX.DARK_BGP = { [0] = 2, [1] = 3, [2] = 3, [3] = 3 }
|
||||
|
||||
local shadeMap = nil
|
||||
|
||||
function PaletteFX.setShadeMap(map)
|
||||
shadeMap = map
|
||||
end
|
||||
|
||||
function PaletteFX.shadeMap()
|
||||
return shadeMap
|
||||
end
|
||||
|
||||
function PaletteFX.setMode(mode)
|
||||
local prev = PaletteFX.mode
|
||||
local ok = false
|
||||
@@ -579,7 +663,10 @@ function PaletteFX.modeLabel(mode)
|
||||
mode = mode or PaletteFX.mode
|
||||
-- The GBC boot-ROM mode wears the running game's name: it is red for Red and
|
||||
-- blue for Blue (see ogBg), so a Blue playthrough shows "OG BLUE".
|
||||
-- Yellow still uses the Red boot-ROM ramp (no Yellow table yet), so keep
|
||||
-- the "OG RED" label rather than inventing an "OG YELLOW" without colors.
|
||||
if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end
|
||||
if mode == "ogred" and GameVersion.isYellow() then return "OG RED" end
|
||||
return PaletteFX.MODE_LABELS[mode] or "GBC"
|
||||
end
|
||||
|
||||
@@ -598,19 +685,42 @@ end
|
||||
-- Transform a 4-color palette for the active COLORS display mode.
|
||||
-- GBC and RED++ pass the zone colors through (RED++ already swapped the
|
||||
-- pack in pal/monPal); OG* / CLASSIC replace; GBC INV permutes shades.
|
||||
-- The "paper" shade of the active display mode: what a DMG-white background
|
||||
-- pixel ends up as once colorization has run. Every palette in the SGB pack
|
||||
-- carries the same off-white (255,239,255) as color 0, so this is well
|
||||
-- defined for the whole screen rather than per zone. Goes through
|
||||
-- PaletteFX.pal so OG RED short-circuits to the one global boot-ROM BG
|
||||
-- palette and RED++ falls back to the ROM pack, then through effectiveColors
|
||||
-- so the mono and inverted modes get their own paper (CLASSIC's pea green,
|
||||
-- and the dark paper the inverted modes should have). Returns r, g, b in
|
||||
-- 0..1, white if nothing resolves.
|
||||
function PaletteFX.paperShade(data)
|
||||
local base = PaletteFX.pal(data, "GREENBAR") or PaletteFX.GRAYS
|
||||
local colors = PaletteFX.effectiveColors(base) or base
|
||||
local c = colors and colors[1]
|
||||
if not c then return 1, 1, 1 end
|
||||
return c[1] / 255, c[2] / 255, c[3] / 255
|
||||
end
|
||||
|
||||
function PaletteFX.effectiveColors(c)
|
||||
if not c then return nil end
|
||||
local mode = PaletteFX.mode or "gbc"
|
||||
local out = c
|
||||
if mode == "og" then
|
||||
return PaletteFX.GRAYS
|
||||
out = PaletteFX.GRAYS
|
||||
elseif mode == "og_inv" then
|
||||
return PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
|
||||
out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
|
||||
elseif mode == "classic" then
|
||||
return PaletteFX.CLASSIC
|
||||
out = PaletteFX.CLASSIC
|
||||
elseif mode == "gbc_inv" then
|
||||
return PaletteFX.permute(c, INV_MAP)
|
||||
out = PaletteFX.permute(c, INV_MAP)
|
||||
end
|
||||
return c
|
||||
-- The shade map goes on LAST: rBGP is a hardware register write, so it
|
||||
-- composes on top of whatever colors the display mode settled on -- a dark
|
||||
-- cave has to read dark in CLASSIC's pea greens and in plain DMG grays too
|
||||
-- (#322). permute() is the identity (and returns `out` itself, which the
|
||||
-- mod-graphics parity check leans on) while nothing has armed one.
|
||||
return PaletteFX.permute(out, shadeMap)
|
||||
end
|
||||
|
||||
-- send a 4-color (0-255 RGB) palette to the shade-remap shader, after
|
||||
|
||||
+47
-8
@@ -15,8 +15,15 @@ local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Renderer = {}
|
||||
|
||||
-- The Game Boy surface. WIDTH/HEIGHT are the classic dimensions every
|
||||
-- screen is laid out in; uiWidth/uiHeight are the surface actually
|
||||
-- allocated this frame, which a state may widen through setUISize (the
|
||||
-- widescreen battle layout asks for 304x144). Anything drawing a normal
|
||||
-- 160x144 screen can keep reading WIDTH/HEIGHT.
|
||||
Renderer.WIDTH = 160
|
||||
Renderer.HEIGHT = 144
|
||||
Renderer.MAX_UI_WIDTH = 640
|
||||
Renderer.MAX_UI_HEIGHT = 576
|
||||
|
||||
-- Whether a value is a real Canvas we can composite. Real LOVE canvases are
|
||||
-- userdata answering typeOf("Canvas"); the headless test stub fakes them as
|
||||
@@ -83,7 +90,8 @@ function Renderer:init()
|
||||
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
|
||||
-- (#208). Every canvas below is sized in framebuffer pixels for the same
|
||||
-- reason -- worldViewSize() already works in drawable pixels.
|
||||
self.canvas = PixelCanvas.new(self.WIDTH, self.HEIGHT, "nearest")
|
||||
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
|
||||
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
|
||||
self.worldCanvas = nil
|
||||
self.worldActive = false
|
||||
-- tilt mode only: a transparent overlay canvas the size of the world
|
||||
@@ -115,7 +123,31 @@ end
|
||||
-- units via / dpiX and / dpiY when drawing.
|
||||
function Renderer:fitScale()
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
|
||||
local w, h = self:uiSize()
|
||||
return math.max(1, math.floor(math.min(pw / w, ph / h)))
|
||||
end
|
||||
|
||||
-- the native-pixel UI surface in use right now
|
||||
function Renderer:uiSize()
|
||||
return self.uiWidth or self.WIDTH, self.uiHeight or self.HEIGHT
|
||||
end
|
||||
|
||||
-- Ask for a UI surface of w x h native pixels; the canvas is reallocated
|
||||
-- only when the size actually changes, so the classic path never rebuilds
|
||||
-- it. Sizes are resolved before any state draws (Game:draw) and bounded on
|
||||
-- both ends -- never smaller than the Game Boy screen every layout assumes,
|
||||
-- never large enough for a bad request to allocate an unbounded canvas.
|
||||
function Renderer:setUISize(w, h)
|
||||
if type(w) ~= "number" or type(h) ~= "number"
|
||||
or w < self.WIDTH or h < self.HEIGHT
|
||||
or w > self.MAX_UI_WIDTH or h > self.MAX_UI_HEIGHT then
|
||||
w, h = self.WIDTH, self.HEIGHT
|
||||
end
|
||||
w, h = math.floor(w), math.floor(h)
|
||||
if w == self.uiWidth and h == self.uiHeight and self.canvas then return end
|
||||
if self.canvas and self.canvas.release then self.canvas:release() end
|
||||
self.uiWidth, self.uiHeight = w, h
|
||||
self.canvas = PixelCanvas.new(w, h, "nearest")
|
||||
end
|
||||
|
||||
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
|
||||
@@ -174,6 +206,9 @@ function Renderer:beginFrame(transparent)
|
||||
-- draws this one
|
||||
PaletteFX.clearTrueColor()
|
||||
PaletteFX.clearSpriteRedraws()
|
||||
-- rBGP is a per-frame register here: the state that draws a dark map
|
||||
-- re-arms it while it draws (#322), so nothing inherits last frame's
|
||||
PaletteFX.setShadeMap(nil)
|
||||
PaletteFX.setPass("ui")
|
||||
love.graphics.setCanvas(self.canvas)
|
||||
if transparent then
|
||||
@@ -440,10 +475,11 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
||||
local Sp = self:fitScale()
|
||||
local Sx, Sy = Sp / dpiX, Sp / dpiY
|
||||
local vpw, vph = self.WIDTH * Sx, self.HEIGHT * Sy
|
||||
local uiw, uih = self:uiSize()
|
||||
local vpw, vph = uiw * Sx, uih * Sy
|
||||
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX
|
||||
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpiY
|
||||
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
|
||||
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||
@@ -480,8 +516,11 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setCanvas(present)
|
||||
end
|
||||
-- Default letterbox is black. Battle (and any state that opts in via
|
||||
-- letterboxWhite) fills the voids white so the window matches the
|
||||
-- white battle canvas instead of showing black bars.
|
||||
-- letterboxWhite) fills the voids with the display mode's paper shade, so
|
||||
-- the bars match the canvas they frame instead of showing black. Not a
|
||||
-- literal white: the battle canvas is colorized, and in SGB mode its paper
|
||||
-- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in
|
||||
-- a visibly brighter border.
|
||||
local clearR, clearG, clearB = 0, 0, 0
|
||||
if not self.worldActive then
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
@@ -489,7 +528,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local base = stack and stack.visibleBase and stack:visibleBase()
|
||||
local state = base and stack.states and stack.states[base]
|
||||
if state and state.letterboxWhite then
|
||||
clearR, clearG, clearB = 1, 1, 1
|
||||
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||
|
||||
@@ -18,10 +18,14 @@ local function getImage(path)
|
||||
return imageCache[path]
|
||||
end
|
||||
|
||||
-- RED++ overworld sprite OBJ-palette recolor (color/sprites.asm
|
||||
-- ColorOverworldSprite), baked into an ImageData like BattleState's mon-pic
|
||||
-- palette bake (src/battle/BattleState.lua getImage): CPU-remap the 4 DMG
|
||||
-- shades to the resolved OBP colors, cached per (image path, group).
|
||||
-- Overworld sprite OBJ-palette recolor, baked into an ImageData like
|
||||
-- BattleState's mon-pic palette bake (src/battle/BattleState.lua getImage):
|
||||
-- CPU-remap the 4 DMG shades to the resolved OBP colors, cached per
|
||||
-- (image path, group). Every colour mode goes through it now (#301): RED++
|
||||
-- resolves real per-sprite colours (color/sprites.asm ColorOverworldSprite),
|
||||
-- OG RED the one boot-ROM object palette, and everything else the plain
|
||||
-- rOBP0 = $D0 shade lift (PaletteFX.dmgObj) that leaves the sprite in DMG
|
||||
-- shades for the zone shader to colour.
|
||||
--
|
||||
-- Sprite sheets carry no real alpha (every pixel, including the
|
||||
-- background, is opaque -- confirmed by sampling the extracted PNGs): the
|
||||
@@ -110,7 +114,13 @@ function SpriteRenderer:resolveImage()
|
||||
-- collide in obpCache) -- see issue #155
|
||||
return getObpImage(self.def.image, PaletteFX.ogObj())
|
||||
end
|
||||
return self.image
|
||||
-- Every other mode (SGB and the mono/inverted novelties) leaves the sprite
|
||||
-- in DMG shades so the zone shader colors it out of the map's own palette,
|
||||
-- but still bakes rOBP0 = $D0 in and keys OBJ color 0 to alpha -- the two
|
||||
-- things a raw sheet blit cannot express (#301, #150). The sheets carry no
|
||||
-- real alpha (see getObpImage), so returning self.image here would put an
|
||||
-- opaque white box behind every character a pipeline textures.
|
||||
return getObpImage(self.def.image, PaletteFX.dmgObj())
|
||||
end
|
||||
|
||||
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
||||
@@ -152,6 +162,17 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
-- top.
|
||||
image = getObpImage(self.def.image, PaletteFX.ogObj())
|
||||
redraw = true
|
||||
else
|
||||
-- SGB and the mono/inverted modes (and OG RED's tilt upright pass, which
|
||||
-- has no post-zone replay to restore a bake): the sprite stays in DMG
|
||||
-- shades -- rOBP0 = $D0 baked in, OBJ color 0 keyed to alpha -- and the
|
||||
-- whole-canvas zone shader colors it with the map's palette. That is the
|
||||
-- only thing the Super Game Boy can do to an OBJ, since pokered never
|
||||
-- sends the OBJ_TRN packet that would give sprites palettes of their own
|
||||
-- (data/sgb/sgb_packets.asm defines ATTR_BLK / PAL_SET / PAL_TRN /
|
||||
-- MLT_REQ / CHR_TRN / PCT_TRN and nothing else). No redraw is queued:
|
||||
-- being colorized by the zone IS the point (#301).
|
||||
image = getObpImage(self.def.image, PaletteFX.dmgObj())
|
||||
end
|
||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||
-- still 3-frame sprites turn to face (the nurse at her machine,
|
||||
|
||||
+20
-1
@@ -27,6 +27,11 @@ local MAX_COLS = 18
|
||||
-- WaitForSoundToFinish; nil headless), then auto.delay frames pass
|
||||
-- (default 3, Delay3) and the box pops itself + calls onDone. No
|
||||
-- blinking cursor, no Press_AB beep.
|
||||
-- opts.auto.tick, when given, runs once per frame for as long as the box
|
||||
-- is held open. It is the only per-frame hook a script has while a box is
|
||||
-- up: StateStack updates the top state only, so the overworld and its
|
||||
-- ScriptRunner are frozen underneath (the Pewter JIGGLYPUFF dance drives
|
||||
-- its spin off it, data/scripts/story5.lua, #249).
|
||||
function TextBox.new(game, text, onDone, opts)
|
||||
local self = setmetatable({}, TextBox)
|
||||
self.game = game
|
||||
@@ -179,6 +184,13 @@ function TextBox:update(dt)
|
||||
self.autoSrc = self.auto.sound and self.auto.sound() or nil
|
||||
self.autoTimer = 0
|
||||
end
|
||||
-- auto.tick: one call per frame for as long as the box is held open,
|
||||
-- run before the autoSrc gate so a tick-driven gate can clear itself
|
||||
-- on the same frame. It is the only per-frame hook a script gets
|
||||
-- while a box is up, since StateStack updates the top state only and
|
||||
-- the overworld underneath is frozen (the Pewter JIGGLYPUFF spin,
|
||||
-- #249).
|
||||
if self.auto.tick then self.auto.tick() end
|
||||
if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then
|
||||
return -- the cry is still sounding (WaitForSoundToFinish)
|
||||
end
|
||||
@@ -294,10 +306,17 @@ function TextBox:draw()
|
||||
self.scrollPx = self.scrollPx - 2
|
||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||
end
|
||||
-- Only the retained line carries the offset: it slides up from where it
|
||||
-- already sat (line2Y) to line1Y. The incoming line is drawn at its home
|
||||
-- row instead, because offsetting it too put fresh glyphs 8px low -- on the
|
||||
-- box's bottom border -- whenever the typewriter beat the 4-frame slide
|
||||
-- (#314). The sub-tile slide is ours to begin with: ScrollTextUpOneLine
|
||||
-- (home/text.asm:283) copies the rows up whole and waits 5 frames, so
|
||||
-- nothing in the original is ever drawn between two rows.
|
||||
local off = self.scrollPx or 0
|
||||
local ys = { self.line1Y, self.line2Y }
|
||||
for i, line in ipairs(self.shown) do
|
||||
local y = (ys[i] or self.line2Y) + off
|
||||
local y = (ys[i] or self.line2Y) + (i == 1 and off or 0)
|
||||
for j, code in ipairs(line) do
|
||||
Font.drawCode(code, self.textX + (j - 1) * 8, y)
|
||||
end
|
||||
|
||||
+57
-18
@@ -11,9 +11,9 @@ TileRenderer.__index = TileRenderer
|
||||
local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks)
|
||||
|
||||
-- OVERWORLD maps fill beyond-edge space from save.options.voidFill:
|
||||
-- trees (default) — solid tree wall $0F (Viridian/Cerulean/Celadon)
|
||||
-- water — solid water $43 (Cinnabar/Route 19 border block)
|
||||
-- black — solid black (no tiled metatile)
|
||||
-- trees (default) -- solid tree wall $0F (Viridian/Cerulean/Celadon)
|
||||
-- water -- solid water $43 (Cinnabar/Route 19 border block)
|
||||
-- black -- solid black (no tiled metatile)
|
||||
-- Other tilesets keep their designated border (interiors stay black/void).
|
||||
local TREE_WALL_BLOCK = 0x0F
|
||||
local WATER_BORDER_BLOCK = 0x43
|
||||
@@ -475,6 +475,13 @@ function TileRenderer.new(map, data)
|
||||
-- match the atlas's static tiles instead of showing raw grayscale
|
||||
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id,
|
||||
groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) }
|
||||
-- ...and feeds the color-0-keyed single tiles the feet overdraw needs
|
||||
-- (see getKeyedTile): same source image and palette groups, so keep the
|
||||
-- context rather than re-deriving it per draw.
|
||||
gbcCtx.imagePath = map.tileset.image
|
||||
gbcCtx.perRow = map.tileset.tilesPerRow
|
||||
self.gbcCtx = gbcCtx
|
||||
self.gbcKeyed = {}
|
||||
end
|
||||
end
|
||||
-- a full-color atlas colors everything it paints, ring and border fill
|
||||
@@ -672,17 +679,48 @@ local function getColor0KeyShader()
|
||||
return color0KeyShader or nil
|
||||
end
|
||||
|
||||
-- draw a cell's bottom tile row without touching the shader (the caller
|
||||
-- owns it). drawCellBottom wraps this with the color-0 key; tilt mode's
|
||||
-- upright pass wraps it with a color-0-keyed palette shader instead
|
||||
-- (PaletteFX.keyedShader) so the feet patch is colorized like the ground.
|
||||
-- RED++ (COLORS=ADVANCED): the color-0 key, BAKED instead of tested for.
|
||||
local function getKeyedTile(self, tile)
|
||||
local ctx = self.gbcCtx
|
||||
local cached = self.gbcKeyed[tile]
|
||||
if cached ~= nil then return cached or nil end
|
||||
local img = false
|
||||
if ctx.groupColors and love.image and love.image.newImageData then
|
||||
local group = PaletteFX.worldGroupAt(ctx.tilesetId, ctx.mapId, tile)
|
||||
local colors = group and ctx.groupColors[group + 1]
|
||||
local src = Assets.imageData(ctx.imagePath)
|
||||
local ox = (tile % ctx.perRow) * 8
|
||||
local oy = math.floor(tile / ctx.perRow) * 8
|
||||
local out = love.image.newImageData(8, 8)
|
||||
for py = 0, 7 do
|
||||
for px = 0, 7 do
|
||||
local r, g, b, a = src:getPixel(ox + px, oy + py)
|
||||
-- read shade 0 off the RAW sheet, on recolorSample's own cutoff, so
|
||||
-- the keyed pixels are exactly the ones the shader path keys
|
||||
local shade0 = r > 0.83
|
||||
r, g, b, a = recolorSample(r, g, b, a, colors)
|
||||
out:setPixel(px, py, r, g, b, shade0 and 0 or a)
|
||||
end
|
||||
end
|
||||
img = love.graphics.newImage(out)
|
||||
end
|
||||
self.gbcKeyed[tile] = img
|
||||
return img or nil
|
||||
end
|
||||
|
||||
function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY)
|
||||
local ty = cy * 2 + 1
|
||||
for i = 0, 1 do
|
||||
local tx = cx * 2 + i
|
||||
local quad = self.quads[self.map:tileAt(tx, ty)]
|
||||
if quad then
|
||||
love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY)
|
||||
local tile = self.map:tileAt(tx, ty)
|
||||
local keyed = tile and self.gbcCtx and getKeyedTile(self, tile)
|
||||
if keyed then
|
||||
love.graphics.draw(keyed, tx * 8 - camX, ty * 8 - camY)
|
||||
else
|
||||
local quad = self.quads[tile]
|
||||
if quad then
|
||||
love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -690,7 +728,9 @@ end
|
||||
-- redraw a cell's bottom tile row (tall grass hides the lower half of
|
||||
-- sprites standing in it, like the GB sprite-priority trick)
|
||||
function TileRenderer:drawCellBottom(cx, cy, camX, camY)
|
||||
local shader = getColor0KeyShader()
|
||||
-- the RED++ path is pre-keyed; the white test would be a no-op there at
|
||||
-- best, and a false hit on some other group's near-white color 0 at worst
|
||||
local shader = not self.gbcCtx and getColor0KeyShader() or nil
|
||||
if shader then love.graphics.setShader(shader) end
|
||||
self:drawCellBottomRaw(cx, cy, camX, camY)
|
||||
if shader then love.graphics.setShader() end
|
||||
@@ -712,13 +752,6 @@ function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors)
|
||||
end
|
||||
end
|
||||
|
||||
-- Window cover for the static tile layer. Refill the reusable window batch
|
||||
-- (and the per-entry animated batches) only when the camera has scrolled past
|
||||
-- what they already cover; a small margin keeps small scrolls free. Cost
|
||||
-- scales with the view, never the map -- crossing a seam or warping in builds
|
||||
-- nothing. The beyond-body area (what the old 3-block ring drew) is painted
|
||||
-- by :drawBorderFill, whose world-aligned border-block tiling is identical
|
||||
-- there, so only body tiles are gathered here.
|
||||
local WINDOW_MARGIN = 8 -- tiles of slack kept around the view between refills
|
||||
|
||||
function TileRenderer:ensureWindow(camX, camY, vw, vh)
|
||||
@@ -873,6 +906,12 @@ end
|
||||
-- atlas -- is unique to this map (gbcAtlasCache is keyed by map id).
|
||||
function TileRenderer:release()
|
||||
self:releaseBatches()
|
||||
if self.gbcKeyed then
|
||||
-- baked per instance, shared with nobody (see getKeyedTile)
|
||||
for _, img in pairs(self.gbcKeyed) do safeRelease(img) end
|
||||
self.gbcKeyed = nil
|
||||
self.gbcCtx = nil
|
||||
end
|
||||
if self.gbcAtlas and self.image then
|
||||
local key = self.map.tileset.image .. "#gbc:" .. self.map.id
|
||||
if gbcAtlasCache[key] == self.image then gbcAtlasCache[key] = nil end
|
||||
|
||||
@@ -38,6 +38,8 @@ local NAME_LENGTH = 11
|
||||
local PARTY_LENGTH = 6
|
||||
local MONS_PER_BOX = 20
|
||||
local NUM_BADGES = 8
|
||||
local NUM_CITY_MAPS = 11 -- PALLET_TOWN..SAFFRON_CITY, the bit width of
|
||||
-- wTownVisitedFlag (constants/map_constants.asm)
|
||||
local BOX_STRUCT_SIZE = 33 -- Species,HP,Level,Status,Type1,Type2,CatchRate,
|
||||
-- Moves x4,OTID,Exp x3,HPExp,AtkExp,DefExp,
|
||||
-- SpdExp,SpcExp,DVs,PP x4 (macros/ram.asm box_struct)
|
||||
@@ -65,6 +67,22 @@ O.numPcItems = O.mainData + 579 -- 1B
|
||||
O.pcItems = O.mainData + 580 -- 101B (50 x (id,qty) + $FF term)
|
||||
O.currentBoxNum = O.mainData + 681 -- 1B (bits 0-6: box 0-11, bit 7: unused here)
|
||||
O.coins = O.mainData + 685 -- 2B BCD
|
||||
-- wTownVisitedFlag (ram/wram.asm:2057): the FLY destination set, a
|
||||
-- flag_array NUM_CITY_MAPS whose bit index IS the town's map index (see the
|
||||
-- decode note). Triangulated from both neighbours, which agree exactly:
|
||||
-- backwards from the checksum-covered, independently derived O.eventFlags
|
||||
-- below by summing every wram.asm declaration between the two labels --
|
||||
-- 2 (wTownVisitedFlag) + 2 (wSafariSteps) + 1 + 1 + 2 + 1 + 1 + 1 + 1 + 1
|
||||
-- + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 8 + 1 + 1 + 1 (wBeatGymFlags) + 1 + 1 + 1
|
||||
-- (wStatusFlags3, aliased wCableClubDestinationMap) + 1 + 1 + 1 + 1 + 1 + 1
|
||||
-- + 1 + 1 + 1 (wMovementFlags) + 2 + 2 + 1 + 1 + 2 + 1 + 1 + 2 + 1 + 1 + 2
|
||||
-- = 60, so 1104 - 60 = 1044; and forwards from O.coins (mainData + 685)
|
||||
-- with 2 (wPlayerCoins) + 32 (wToggleableObjectFlags, flag_array $100) + 7
|
||||
-- + 1 (wSavedSpriteImageIndex) + 33 (wToggleableObjectList) + 1 + 200
|
||||
-- (wGameProgressFlags..End) + 56 + 14 (wObtainedHiddenItemsFlags,
|
||||
-- flag_array MAX_HIDDEN_ITEMS = 112) + 2 (wObtainedHiddenCoinsFlags) + 1
|
||||
-- (wWalkBikeSurfState) + 10 = 359, so 685 + 359 = 1044 as well.
|
||||
O.townVisited = O.mainData + 1044 -- 2B (flag_array NUM_CITY_MAPS)
|
||||
O.eventFlags = O.mainData + 1104 -- 320B (flag_array NUM_EVENTS = 2560 bits)
|
||||
-- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the
|
||||
-- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into
|
||||
@@ -232,12 +250,17 @@ local function encodeName(buf, off, len, text)
|
||||
setByte(buf, off + i, charmap.byToken[ch] or charmap.byToken["?"] or 0x50)
|
||||
i, pos = i + 1, pos + clen
|
||||
end
|
||||
-- Write exactly ONE $50 terminator and then STOP. The bytes after it are
|
||||
-- left untouched: when encoding over a template they stay as the original
|
||||
-- save's post-terminator padding (so an unchanged name round-trips
|
||||
-- byte-identical), and on a templateless export they stay zero-filled. The
|
||||
-- game reads a name only up to the first $50, so whatever follows is inert.
|
||||
-- Write exactly ONE $50 terminator and then $50-pad the rest of the
|
||||
-- field: every real save the naming screen ever wrote fills the tail
|
||||
-- with $50, and a zero tail is what PKHeX renders as garbage glyphs
|
||||
-- after the name ("JOHN{}", #206). Template bytes that are NOT zero
|
||||
-- stay untouched, so an unchanged name still round-trips
|
||||
-- byte-identical and stale template data survives.
|
||||
if i < len then setByte(buf, off + i, 0x50) end
|
||||
for j = i + 1, len - 1 do
|
||||
local cur = buf[off + j + 1]
|
||||
if cur == nil or cur:byte() == 0 then setByte(buf, off + j, 0x50) end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
@@ -375,6 +398,40 @@ function GenSave.crosswalks(data)
|
||||
}
|
||||
end
|
||||
|
||||
-- Gen1 has no "is nicknamed" bit. An un-nicknamed mon literally stores its
|
||||
-- species' standard name in the nickname slot: engine/menus/naming_screen.asm
|
||||
-- AskName's .declinedNickname copies wNameBuffer (the MonsterNames entry
|
||||
-- GetMonName just loaded) straight over the mon's nickname field. The game
|
||||
-- recovers "was it nicknamed?" by comparing the two --
|
||||
-- engine/pokemon/evos_moves.asm RenameEvolvedMon rewrites the name on
|
||||
-- evolution only while the stored one still equals the PRE-evolution
|
||||
-- species' standard name ("Renames the mon to its new, evolved form's
|
||||
-- standard name unless it had a nickname, in which case the nickname is
|
||||
-- kept"). This project models that state as mon.nickname == nil instead:
|
||||
-- every display site reads `mon.nickname or def.name` and
|
||||
-- src/pokemon/Evolution.lua deliberately never touches the field. So the
|
||||
-- two conventions must be translated at this boundary, or an imported
|
||||
-- SQUIRTLE still reads "SQUIRTLE" after it becomes a WARTORTLE and an
|
||||
-- engine-origin export writes the species CONSTANT ("NIDORAN_M", whose "_"
|
||||
-- has no charmap glyph and encodes as "?") where the cartridge keeps the
|
||||
-- display name. Both read back as a forced nickname (#257).
|
||||
--
|
||||
-- def.name is byte-for-byte what the cartridge stores: tools/extract/
|
||||
-- pokemon.py parse_names reads pokered's data/pokemon/names.asm, the very
|
||||
-- table GetMonName loads from, and all 151 names round-trip exactly through
|
||||
-- src/save_convert/data/charmap.lua, so the equality test below is exact
|
||||
-- and never mis-fires on a name the charmap mangles.
|
||||
local function speciesName(cw, species)
|
||||
local def = species and cw.speciesDefs[species]
|
||||
return (def and def.name) or species or ""
|
||||
end
|
||||
|
||||
-- stored fixed-length name -> save.lua nickname (nil when never nicknamed)
|
||||
local function importedNickname(cw, species, stored)
|
||||
if stored == speciesName(cw, species) then return nil end
|
||||
return stored
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
-- Mon struct (box_struct is a byte-for-byte prefix of party_struct;
|
||||
-- decodeMon reads the box_struct fields, then Level+Stats if isParty).
|
||||
@@ -573,7 +630,10 @@ function GenSave.decode(bytes, data, opts)
|
||||
local mon = decodeMon(bytes, O.partyMons + i * PARTY_STRUCT_SIZE, true, cw)
|
||||
if mon then
|
||||
mon.ot = decodeName(bytes, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH)
|
||||
mon.nickname = decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH)
|
||||
-- a stored name equal to the species' standard name means NOT
|
||||
-- nicknamed, which this project spells as nil (#257)
|
||||
mon.nickname = importedNickname(cw, mon.species,
|
||||
decodeName(bytes, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH))
|
||||
save.party[#save.party + 1] = mon
|
||||
end
|
||||
end
|
||||
@@ -586,7 +646,8 @@ function GenSave.decode(bytes, data, opts)
|
||||
local mon = decodeMon(bytes, base + 22 + i * BOX_STRUCT_SIZE, false, cw)
|
||||
if mon then
|
||||
mon.ot = decodeName(bytes, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH)
|
||||
mon.nickname = decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH)
|
||||
mon.nickname = importedNickname(cw, mon.species,
|
||||
decodeName(bytes, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH))
|
||||
table.insert(save.boxes[boxNum], mon)
|
||||
end
|
||||
end
|
||||
@@ -610,6 +671,28 @@ function GenSave.decode(bytes, data, opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations. wTownVisitedFlag's bit index IS the town's map index:
|
||||
-- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value
|
||||
-- into de and rotates it right one bit per iteration with b counting up
|
||||
-- from 0, storing b ("the map number of the town if it has been visited"),
|
||||
-- so bit 0 = map 0 = PALLET_TOWN, LSB first; and
|
||||
-- engine/overworld/toggleable_objects.asm
|
||||
-- MarkTownVisitedAndLoadToggleableObjects sets bit [wCurMap] on entry for
|
||||
-- any map below FIRST_ROUTE_MAP. Map indices 0-10 in
|
||||
-- data/generated/maps.lua match PALLET_TOWN..SAFFRON_CITY one for one.
|
||||
-- This project keeps the same set as save.visited[mapId]
|
||||
-- (src/ui/FlyMenu.lua, src/ui/TownMap.lua, and the only writer,
|
||||
-- src/world/OverworldController.lua's mark-on-map-entry), which an import
|
||||
-- used to leave nil: FLY then listed only the town the player happened to
|
||||
-- be standing in when the save was loaded (#263).
|
||||
save.visited = {}
|
||||
for townIdx = 0, NUM_CITY_MAPS - 1 do
|
||||
if bitGet(bytes, O.townVisited, townIdx) then
|
||||
local townId = cw.mapsByIndex[townIdx]
|
||||
if townId then save.visited[townId] = true end
|
||||
end
|
||||
end
|
||||
|
||||
-- map + position
|
||||
local mapIdx = u8(bytes, O.curMap)
|
||||
local mapId = cw.mapsByIndex[mapIdx]
|
||||
@@ -656,6 +739,19 @@ function GenSave.encode(save, data, template)
|
||||
encodeName(buf, O.playerName, NAME_LENGTH, (save.player and save.player.name) or "RED")
|
||||
encodeName(buf, O.rivalName, NAME_LENGTH, (save.player and save.player.rival) or "BLUE")
|
||||
setU16be(buf, O.playerId, (save.player and save.player.id) or 0)
|
||||
-- wOptions (engine/menus/main_menu.asm InitOptions): bit 7 = battle
|
||||
-- effects OFF, bit 6 = SET style, bits 2-0 = text speed -- the recomp's
|
||||
-- textSpeed 1/3/5 are pokered's exact FAST/MEDIUM/SLOW values
|
||||
-- (SaveData.defaultOptions). Templateless exports only: with a
|
||||
-- template the byte survives untouched (the round-trip invariant), and
|
||||
-- decode() never reads it back anyway.
|
||||
if not src then
|
||||
local opts = save.options or {}
|
||||
local ob = (tonumber(opts.textSpeed) or 3) % 8
|
||||
if opts.battleStyle == "set" then ob = bit.bor(ob, 0x40) end
|
||||
if opts.animations == false then ob = bit.bor(ob, 0x80) end
|
||||
setByte(buf, O.options, ob)
|
||||
end
|
||||
setBcd(buf, O.money, 3, math.min(save.money or 0, 999999))
|
||||
setBcd(buf, O.coins, 2, math.min(save.coins or 0, 9999))
|
||||
|
||||
@@ -700,6 +796,18 @@ function GenSave.encode(save, data, template)
|
||||
end
|
||||
end
|
||||
|
||||
-- FLY destinations back into wTownVisitedFlag (see the decode note), so a
|
||||
-- save exported from this port is flyable on hardware (#263). A save
|
||||
-- table with no `visited` key at all says nothing about the set, so leave
|
||||
-- the template's bits exactly as they are rather than blanking every town.
|
||||
if type(save.visited) == "table" then
|
||||
for townIdx = 0, NUM_CITY_MAPS - 1 do
|
||||
local townId = cw.mapsByIndex[townIdx]
|
||||
bitSet(buf, O.townVisited, townIdx,
|
||||
(townId and save.visited[townId]) and true or false)
|
||||
end
|
||||
end
|
||||
|
||||
-- party
|
||||
local party = save.party or {}
|
||||
local partyN = math.min(#party, PARTY_LENGTH)
|
||||
@@ -710,8 +818,10 @@ function GenSave.encode(save, data, template)
|
||||
setByte(buf, O.partySpecies + i, cw.pokemonIndex[mon.species] or 0)
|
||||
encodeName(buf, O.partyMonOT + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.ot or (save.player and save.player.name) or "RED")
|
||||
-- no nickname stores the species' DISPLAY name, not its ROM constant id
|
||||
-- ("NIDORAN_M" would charmap the "_" to "?") (#257)
|
||||
encodeName(buf, O.partyMonNicks + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.nickname or mon.species or "")
|
||||
mon.nickname or speciesName(cw, mon.species))
|
||||
end
|
||||
-- $FF-terminate the species index list right after the last real mon. The
|
||||
-- struct, OT-name and nickname bytes of the empty slots past partyN are left
|
||||
@@ -734,7 +844,7 @@ function GenSave.encode(save, data, template)
|
||||
encodeName(buf, base + 22 + MONS_PER_BOX * BOX_STRUCT_SIZE + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.ot or (save.player and save.player.name) or "RED")
|
||||
encodeName(buf, base + 22 + MONS_PER_BOX * (BOX_STRUCT_SIZE + NAME_LENGTH) + i * NAME_LENGTH, NAME_LENGTH,
|
||||
mon.nickname or mon.species or "")
|
||||
mon.nickname or speciesName(cw, mon.species)) -- #257, as above
|
||||
end
|
||||
-- $FF-terminate the species list after the last real mon; empty slots past
|
||||
-- n keep their template bytes (byte-identical round-trip) or zero (fresh
|
||||
|
||||
@@ -107,6 +107,7 @@ local function defaultsSave()
|
||||
modData = {},
|
||||
options = {
|
||||
textSpeed = 3, animations = true, battleStyle = "shift",
|
||||
battleLayout = "og",
|
||||
ruleset = "gen1_faithful", musicVol = 7, sfxVol = 7, musicFilter = 0,
|
||||
speed = 1, colors = "gbc", tilt = 0, gbcfx = 0,
|
||||
videoMode = "windowed", mods = {},
|
||||
|
||||
+87
-2
@@ -173,6 +173,27 @@ function Commands.check_item(ctx, itemId)
|
||||
ctx.lastCheck = (ctx.save.inventory[itemId] or 0) > 0
|
||||
end
|
||||
|
||||
-- check_dex_owned <n>: lastCheck = the player owns at least n species
|
||||
-- (the CountSetBits-over-wPokedexOwned gate in Yellow's OaksLabOak1Text)
|
||||
function Commands.check_dex_owned(ctx, n)
|
||||
local owned = 0
|
||||
for _ in pairs(ctx.save.pokedex and ctx.save.pokedex.owned or {}) do
|
||||
owned = owned + 1
|
||||
end
|
||||
ctx.lastCheck = owned >= (n or 1)
|
||||
end
|
||||
|
||||
-- dex_rating: DisplayDexRating (engine/events/pokedex_rating.asm) --
|
||||
-- Oak's seen/owned tally plus the per-decade rating line; blocks until
|
||||
-- the box closes. Headless-safe no-op without an overworld.
|
||||
function Commands.dex_rating(ctx)
|
||||
local ow = ctx.overworld
|
||||
if not ow then return end
|
||||
local runner = ctx.runner
|
||||
ow:dexRating(function() runner:resume() end)
|
||||
runner:yield()
|
||||
end
|
||||
|
||||
function Commands.jump_if_true(ctx, target)
|
||||
if ctx.lastCheck then return target end
|
||||
end
|
||||
@@ -497,6 +518,17 @@ function Commands.play_cry(ctx, species, waitForButton)
|
||||
ctx.pendingCryWait = waitForButton or nil
|
||||
end
|
||||
|
||||
-- mark_seen <species>: DisplayPokedex (pokedex.asm) records the species as
|
||||
-- seen before opening its entry. Map scripts use this for NPC-driven
|
||||
-- Pokédex previews that do not begin a battle or give the player a Pokémon.
|
||||
function Commands.mark_seen(ctx, species)
|
||||
local dex = ctx.save and ctx.save.pokedex
|
||||
if dex then
|
||||
dex.seen = dex.seen or {}
|
||||
dex.seen[species] = true
|
||||
end
|
||||
end
|
||||
|
||||
-- check_battle_result <r1> [r2 ...]: lastCheck = the last scripted
|
||||
-- battle ended with any of the given results
|
||||
-- ("win"|"lose"|"run"|"caught"), for branches like
|
||||
@@ -561,7 +593,10 @@ end
|
||||
-- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a
|
||||
-- script runner is present; mods that pre-set gift.nickname skip it.
|
||||
-- Box deposits also print SentToBoxText (give_pokemon.asm:36-37).
|
||||
function Commands.give_pokemon(ctx, species, level)
|
||||
-- skipNickname suppresses the AskName prompt: Yellow's lab Pikachu is
|
||||
-- added straight through AddPartyMon (pokeyellow scripts/OaksLab.asm
|
||||
-- OaksLabPlayerReceivedMonText) -- the starter Pikachu keeps its name.
|
||||
function Commands.give_pokemon(ctx, species, level, skipNickname)
|
||||
-- Native mods can transform a gift before the Pokémon object is created.
|
||||
-- This is intentionally an event rather than a special-case starter hook:
|
||||
-- mods can use the same seam for story gifts, fossils, or custom scripts.
|
||||
@@ -597,7 +632,7 @@ function Commands.give_pokemon(ctx, species, level)
|
||||
ctx.boxNum = boxNum
|
||||
-- AskName: both AddPartyMon and SendNewMonToBox; skip mod-set nicks
|
||||
-- and callback-style callers with no script runner to yield on.
|
||||
if not gift.nickname and ctx.runner then
|
||||
if not gift.nickname and not skipNickname and ctx.runner then
|
||||
askNickname(ctx, mon)
|
||||
end
|
||||
if boxNum then
|
||||
@@ -748,7 +783,46 @@ end
|
||||
-- player CHARMANDER -> base+0, SQUIRTLE -> base+1, BULBASAUR -> base+2.
|
||||
-- offsets (flag -> party offset) lets a modded roster remap the pick;
|
||||
-- field.starterCounterpicks is the data-side default when stamped.
|
||||
-- Yellow's rival parties key off wRivalStarter (save.rivalStarter,
|
||||
-- 1 JOLTEON / 2 FLAREON / 3 VAPOREON -- set in oaks_lab_yellow.lua), not
|
||||
-- the player's starter counterpick. Keyed by the Red call-site party so
|
||||
-- the shared story scripts need no version branches:
|
||||
-- Route 22 #1 RIVAL1 4 -> party 2 (fixed; Route22Script_50ed6), and
|
||||
-- a win upgrades FLAREON to JOLTEON (Route22Rival1AfterBattleScript)
|
||||
-- Cerulean RIVAL1 7 -> party 3 (fixed; CeruleanCity.asm:143)
|
||||
-- S.S. Anne RIVAL2 1 -> party 1 (fixed; SSAnne2F.asm:98)
|
||||
-- Tower 2F RIVAL2 4 -> 1 + starter (PokemonTower2F.asm:148)
|
||||
-- Silph 7F RIVAL2 7 -> 4 + starter (SilphCo7F.asm:185)
|
||||
-- Route 22 #2 RIVAL2 10 -> 7 + starter (Route22Script_50ee1)
|
||||
-- Champion RIVAL3 1 -> 0 + starter (ChampionsRoom.asm:69)
|
||||
local YELLOW_RIVAL_PARTIES = {
|
||||
OPP_RIVAL1 = {
|
||||
[4] = { party = 2, upgradeOnWin = { from = 2, to = 1 } },
|
||||
[7] = { party = 3 },
|
||||
},
|
||||
OPP_RIVAL2 = {
|
||||
[1] = { party = 1 }, [4] = { base = 1 },
|
||||
[7] = { base = 4 }, [10] = { base = 7 },
|
||||
},
|
||||
OPP_RIVAL3 = { [1] = { base = 0 } },
|
||||
}
|
||||
|
||||
function Commands.rival_battle(ctx, oppClass, baseParty, offsets)
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
if GameVersion.isYellow() then
|
||||
local spec = YELLOW_RIVAL_PARTIES[oppClass]
|
||||
and YELLOW_RIVAL_PARTIES[oppClass][baseParty]
|
||||
if spec then
|
||||
local starter = ctx.save.rivalStarter or 1
|
||||
local party = spec.party or (spec.base + starter)
|
||||
Commands.start_battle(ctx, "trainer", oppClass, party)
|
||||
if spec.upgradeOnWin and ctx.lastBattleResult == "win"
|
||||
and ctx.save.rivalStarter == spec.upgradeOnWin.from then
|
||||
ctx.save.rivalStarter = spec.upgradeOnWin.to
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
offsets = offsets
|
||||
or (ctx.game.data.field and ctx.game.data.field.starterCounterpicks)
|
||||
local offset = 0
|
||||
@@ -951,6 +1025,17 @@ function Commands.stop_music(ctx)
|
||||
require("src.core.Music").stop()
|
||||
end
|
||||
|
||||
-- play_default_music: PlayDefaultMusic -- resume the current map's own
|
||||
-- theme (data.audio.mapSongs) after a cutscene override, keeping the
|
||||
-- bike/surf substitution rules. Headless-safe no-op without an overworld.
|
||||
function Commands.play_default_music(ctx)
|
||||
local ow = ctx.overworld
|
||||
if not ow then return end
|
||||
require("src.core.Music").playMap(ctx.game.data, ow.map.id,
|
||||
ctx.save and ctx.save.onBike,
|
||||
ow.player and ow.player.surfing)
|
||||
end
|
||||
|
||||
-- replace_block <bx> <by> <blockId>: the Cut-tree/card-key-door idiom,
|
||||
-- on the current map
|
||||
function Commands.replace_block(ctx, bx, by, blockId)
|
||||
|
||||
@@ -191,8 +191,18 @@ function ScriptRunner:yield()
|
||||
end
|
||||
|
||||
function ScriptRunner:resume(...)
|
||||
if not self.co then return end
|
||||
local ok, err = coroutine.resume(self.co, ...)
|
||||
local co = self.co
|
||||
if not co then return end
|
||||
-- A completion callback can fire synchronously from inside the running
|
||||
-- coroutine (e.g. a battle that finishes during its own stack push when
|
||||
-- the party is already fainted). Resuming a running coroutine is an
|
||||
-- error that would kill the whole script, so land the pending yield
|
||||
-- first and continue on the next update tick instead.
|
||||
if coroutine.status(co) == "running" then
|
||||
self.waitingFrames = 1
|
||||
return
|
||||
end
|
||||
local ok, err = coroutine.resume(co, ...)
|
||||
if not ok then
|
||||
local source = self.ctx and self.ctx.source
|
||||
local where = source
|
||||
@@ -209,8 +219,10 @@ function ScriptRunner:resume(...)
|
||||
self.co = nil
|
||||
self.waitingFrames = nil
|
||||
self.waitingCheck = nil
|
||||
elseif coroutine.status(self.co) == "dead" then
|
||||
self.co = nil
|
||||
-- status via the captured co: a nested resume during the call above may
|
||||
-- already have torn self.co down, and status(nil) would throw
|
||||
elseif coroutine.status(co) == "dead" then
|
||||
if self.co == co then self.co = nil end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+37
-6
@@ -41,10 +41,17 @@ local function showMessages(game, msgs, onDone)
|
||||
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone))
|
||||
end
|
||||
|
||||
-- run the use-flow for an item on a chosen target
|
||||
local function useOn(game, battle, id, target, list, moveIndex)
|
||||
-- run the use-flow for an item on a chosen target. `picker` is the party
|
||||
-- menu when it was opened with keepOpen (HP medicine only): it is still on
|
||||
-- the stack, so every exit that prints has to close it afterwards. For
|
||||
-- every other item the picker popped itself first and closePicker's identity
|
||||
-- check makes it a no-op (#252).
|
||||
local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
local result, payload, extra = ItemEffects.use(game.data, game.save, id, target,
|
||||
battle, moveIndex, game.overworld)
|
||||
local function closePicker()
|
||||
if picker then picker:close() end
|
||||
end
|
||||
|
||||
-- field POKé FLUTE: play the tune, then the no-effect text
|
||||
if result == "flute_field" then
|
||||
@@ -151,15 +158,22 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
local moveId = payload
|
||||
local mdef = game.data.moves[moveId]
|
||||
local function teach()
|
||||
-- PIKAHAPPY_USEDTMHM on a successful teach (item_effects.asm:2500)
|
||||
local function taught()
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(game.save, "USEDTMHM", target)
|
||||
end
|
||||
if #target.moves < 4 then
|
||||
table.insert(target.moves, { id = moveId, pp = mdef.pp })
|
||||
showMessages(game, { Strings("%s learned\n%s!", target.nickname or
|
||||
game.data.pokemon[target.species].name, mdef.name) })
|
||||
if result == "learn" then consume(game, id) end
|
||||
taught()
|
||||
else
|
||||
require("src.ui.Screens").push(game, "MoveLearnMenu", target, moveId,
|
||||
function(learned)
|
||||
if learned and result == "learn" then consume(game, id) end
|
||||
if learned then taught() end
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -288,16 +302,29 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
end
|
||||
end
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
-- HP medicine: fill the bar in the still-open picker first, then print
|
||||
-- and close, the order item_effects.asm .doneHealing runs in
|
||||
-- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message).
|
||||
-- picker is nil for every other item and for in-battle use, which keeps
|
||||
-- the pop-then-print path below. #252
|
||||
if picker and extra and extra.healedFrom and target then
|
||||
picker:animateTo(target, extra.healedFrom, function()
|
||||
showMessages(game, payload, closePicker)
|
||||
end)
|
||||
return
|
||||
end
|
||||
if battle then
|
||||
list:close()
|
||||
showMessages(game, payload, function() battle:itemUsed({}) end)
|
||||
else
|
||||
showMessages(game, payload)
|
||||
showMessages(game, payload, closePicker)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
showMessages(game, payload) -- failed
|
||||
-- .healingItemNoEffect prints over the still-drawn party menu too, so the
|
||||
-- refusal closes the picker the same way (#252)
|
||||
showMessages(game, payload, closePicker) -- failed
|
||||
end
|
||||
|
||||
local function pickTargetAndUse(game, battle, id, list)
|
||||
@@ -308,9 +335,13 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
local def = game.data.items[id]
|
||||
local opts = {
|
||||
pickOnly = true,
|
||||
onSwitch = function(mon)
|
||||
-- HP medicine animates its bar with the picker still up (#252). Only
|
||||
-- out of battle: the in-battle tail closes the bag list underneath
|
||||
-- first, which needs the picker already gone.
|
||||
keepOpen = (not battle) and ItemEffects.healsHP(id),
|
||||
onSwitch = function(mon, picker)
|
||||
if not wantsMove then
|
||||
useOn(game, battle, id, mon, list)
|
||||
useOn(game, battle, id, mon, list, nil, picker)
|
||||
return
|
||||
end
|
||||
local rows = {}
|
||||
|
||||
+54
-3
@@ -7,6 +7,7 @@ local Font = require("src.render.Font")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
@@ -73,6 +74,14 @@ local function withdraw(game)
|
||||
list.footer = "The party is full!"
|
||||
return
|
||||
end
|
||||
-- add_mon.asm _MoveMon's tail ("returning mon to party, compute
|
||||
-- level and stats"): a box mon carries no stat block, because
|
||||
-- box_struct stops before MON_LEVEL/MON_STATS, so the party copy
|
||||
-- runs CalcStats. Without it a mon decoded out of an imported .sav
|
||||
-- reaches the party menu with mon.stats nil and the HP bar draw
|
||||
-- nil-indexes it (#304, same family as #233). Already-shaped mons
|
||||
-- (everything the engine itself put in a box) pass through.
|
||||
Stats.ensure(game.data.pokemon[mon.species], mon)
|
||||
table.remove(box, item.value)
|
||||
table.insert(game.save.party, mon)
|
||||
local name = monName(game, mon)
|
||||
@@ -118,6 +127,9 @@ local function deposit(game)
|
||||
end
|
||||
table.remove(game.save.party, item.value)
|
||||
table.insert(active, mon)
|
||||
-- PIKAHAPPY_DEPOSITED (engine/pokemon/bills_pc.asm:247)
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(game.save, "DEPOSITED", mon)
|
||||
local name = monName(game, mon)
|
||||
game.stringBuffer = name
|
||||
game.boxNumString = tostring(game.save.currentBox)
|
||||
@@ -211,13 +223,43 @@ local function drawChrome(game)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- PrintPCBox (engine/printer/printer.asm): Yellow's box-list print job,
|
||||
-- box number plus each stored mon's name, level and dex number; the PNG
|
||||
-- under prints/ stands in for the printer paper.
|
||||
local function printBox(game)
|
||||
local box = Boxes.active(game.save)
|
||||
local Printer = require("src.core.Printer")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local h = 32 + math.max(1, #box) * 10
|
||||
local saved, err = Printer.save("box_" .. (game.save.currentBox or 1),
|
||||
160, h, function()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, h)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("BOX No.%d", game.save.currentBox or 1), 8, 8)
|
||||
if #box == 0 then Font.draw(Strings("Empty."), 8, 24) end
|
||||
for i, mon in ipairs(box) do
|
||||
local def = game.data.pokemon[mon.species]
|
||||
Font.draw(mon.nickname or (def and def.name) or tostring(mon.species),
|
||||
8, 14 + i * 10)
|
||||
Font.draw(Strings(":L%d No.%03d", mon.level or 0,
|
||||
def and def.dex or 0), 88, 14 + i * 10)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end)
|
||||
game.stack:push(TextBox.new(game, saved
|
||||
and Strings("Printed BOX %d!\fSaved as\n%s\vin the save\nfolder.",
|
||||
game.save.currentBox or 1, saved)
|
||||
or Strings("Printer error!\n%s", tostring(err))))
|
||||
end
|
||||
|
||||
function BoxMenu.new(game)
|
||||
Boxes.ensure(game.save)
|
||||
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
|
||||
-- 12x10 → total 14x12. "CHANGE BOX" / "WITHDRAW <PK><MN>" need the
|
||||
-- full interior (cursor col + label). keepOpen so WITHDRAW/DEPOSIT/
|
||||
-- RELEASE/CHANGE BOX leave this menu underneath (jp BillsPCMenu).
|
||||
local menu = Menu.new(game, {
|
||||
local items = {
|
||||
{ label = Strings("WITHDRAW <PK><MN>"), keepOpen = true,
|
||||
onSelect = function() withdraw(game) end },
|
||||
{ label = Strings("DEPOSIT <PK><MN>"), keepOpen = true,
|
||||
@@ -226,10 +268,19 @@ function BoxMenu.new(game)
|
||||
onSelect = function() release(game) end },
|
||||
{ label = Strings("CHANGE BOX"), keepOpen = true,
|
||||
onSelect = function() changeBox(game) end },
|
||||
{ label = Strings("SEE YA!") },
|
||||
}
|
||||
-- Yellow's PRINT BOX item (bills_pc.asm _YELLOW -> PrintPCBox): the
|
||||
-- Game Boy Printer box list becomes a PNG under prints/, like the
|
||||
-- Pokédex PRNT stand-in
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
items[#items + 1] = { label = Strings("PRINT BOX"), keepOpen = true,
|
||||
onSelect = function() printBox(game) end }
|
||||
end
|
||||
items[#items + 1] = { label = Strings("SEE YA!") }
|
||||
local menu = Menu.new(game, items,
|
||||
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
|
||||
-- engine/menus/pokemon_pc.asm)
|
||||
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
|
||||
{ tx = 0, ty = 0, tw = 14, th = #items * 2 + 2, noSound = true })
|
||||
local baseDraw = menu.draw
|
||||
function menu:draw()
|
||||
baseDraw(self)
|
||||
|
||||
+18
-8
@@ -37,7 +37,11 @@ function DexEntryMenu.new(game, speciesOrOpts)
|
||||
self.def = game.data.pokemon[species]
|
||||
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
|
||||
{ kind = "dex" })
|
||||
local ok, img = path and pcall(love.graphics.newImage, path)
|
||||
-- `path and pcall(...)` truncates to one value, so img was always nil and
|
||||
-- every dex page drew without its pic (#307); the guard has to be a
|
||||
-- statement for pcall's second return to survive.
|
||||
local ok, img = false, nil
|
||||
if path then ok, img = pcall(love.graphics.newImage, path) end
|
||||
self.sprite = ok and img or nil
|
||||
require("src.core.Sound").playCry(game.data, species)
|
||||
return self
|
||||
@@ -51,11 +55,17 @@ function DexEntryMenu:update(dt)
|
||||
end
|
||||
|
||||
function DexEntryMenu:draw()
|
||||
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned)
|
||||
end
|
||||
|
||||
-- Static entry-page renderer, shared with the printer stand-in
|
||||
-- (src/core/Printer.lua renders the same page into a PNG the way
|
||||
-- PrintPokedexEntry rendered it to the Game Boy Printer).
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local def = self.def
|
||||
if self.sprite then
|
||||
love.graphics.draw(self.sprite, 8, math.max(0, 60 - self.sprite:getHeight()))
|
||||
if sprite then
|
||||
love.graphics.draw(sprite, 8, math.max(0, 60 - sprite:getHeight()))
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(def.name, 72, 8)
|
||||
@@ -66,10 +76,10 @@ function DexEntryMenu:draw()
|
||||
Font.draw(e.kind or "?", 72, 20)
|
||||
-- same number width as the list (constants.dexDigits), so a dex past 999
|
||||
-- prints the extra digit everywhere at once
|
||||
local digits = (self.game.data.constants or {}).dexDigits or 3
|
||||
local digits = (game.data.constants or {}).dexDigits or 3
|
||||
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
|
||||
local owned = self.forceOwned
|
||||
or (self.game.save.pokedex and self.game.save.pokedex.owned[def.id])
|
||||
local owned = forceOwned
|
||||
or (game.save.pokedex and game.save.pokedex.owned[def.id])
|
||||
-- height/weight print only once owned, like the description
|
||||
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
|
||||
-- height, weight, or description")
|
||||
@@ -80,7 +90,7 @@ function DexEntryMenu:draw()
|
||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
||||
end
|
||||
local text = owned and e.text and self.game.data.text[e.text] or nil
|
||||
local text = owned and e.text and game.data.text[e.text] or nil
|
||||
local y = 72
|
||||
if text then
|
||||
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- The dex-completion diploma (engine/events/diploma.asm DisplayDiploma /
|
||||
-- diploma2.asm DisplayDiplomaTop): a bordered certificate page with the
|
||||
-- player's name, shown by the Celadon Mansion 3F game designer once 150
|
||||
-- species are owned. Diploma.render also backs the Yellow-only printed
|
||||
-- copy (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Diploma = {}
|
||||
Diploma.__index = Diploma
|
||||
Diploma.isOpaque = true
|
||||
|
||||
function Diploma.new(game, onDone)
|
||||
return setmetatable({ game = game, onDone = onDone }, Diploma)
|
||||
end
|
||||
|
||||
function Diploma:update()
|
||||
local input = self.game.input
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
end
|
||||
|
||||
-- the DisplayDiplomaTop layout, hlcoord tiles kept as x*8 / y*8 pixels
|
||||
function Diploma.render(game)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("line", 2.5, 2.5, 155, 139)
|
||||
Font.draw(Strings("<Diploma>"), 40, 16) -- hlcoord 5,2
|
||||
Font.draw(Strings("Player"), 24, 32) -- hlcoord 3,4
|
||||
Font.draw(game.save.player.name or "RED", 80, 32) -- hlcoord 10,4
|
||||
local congrats = { -- hlcoord 2,6
|
||||
"Congrats! This", "diploma certifies", "that you have",
|
||||
"completed your", "POKéDEX.",
|
||||
}
|
||||
for i, line in ipairs(congrats) do
|
||||
Font.draw(Strings(line), 16, 48 + (i - 1) * 10)
|
||||
end
|
||||
Font.draw(Strings("GAME FREAK"), 72, 128) -- hlcoord 9,16
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function Diploma:draw()
|
||||
Diploma.render(self.game)
|
||||
end
|
||||
|
||||
return Diploma
|
||||
@@ -20,6 +20,22 @@ EvolutionState.isOpaque = true
|
||||
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
||||
function EvolutionState:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
-- engine/movie/evolution.asm EvolveMon runs the back-and-forth flash with
|
||||
-- the whole screen on PAL_BLACK -- `ld c, 1 ; set PAL_BLACK instead of mon
|
||||
-- palette` right before .animLoop, then `ld c, 0` again at .done once the
|
||||
-- loop is over -- so both forms read as silhouettes while they trade places
|
||||
-- and only the settled form wears a mon palette (#279). PAL_BLACK is not
|
||||
-- four blacks: data/sgb/sgb_palettes.asm gives it `RGB 31,29,31, 07,07,07,
|
||||
-- 02,03,03, 03,02,02`, the usual paper white with the three darker shades
|
||||
-- crushed, which is why a hardware capture shows a dark mon on an unchanged
|
||||
-- background rather than an all-black screen. Going through P.pal keeps
|
||||
-- every COLORS mode honest for free: OG RED short-circuits every name to the
|
||||
-- one global boot-ROM palette (a Game Boy Color ignores the SGB packets, so
|
||||
-- it never blacks out) and the mono modes replace it in effectiveColors.
|
||||
if not self.done then
|
||||
local black = P.pal(game.data, "BLACK")
|
||||
if black then return { P.whole(black) } end
|
||||
end
|
||||
-- a cancelled evolution keeps the old species (never applied), so only
|
||||
-- colorize with the new form once it has actually evolved
|
||||
local species = (self.done and not self.canceled) and self.newSpecies
|
||||
|
||||
+42
-54
@@ -9,6 +9,7 @@
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Font = require("src.render.Font")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
@@ -64,17 +65,6 @@ local function dexRatingKey(owned)
|
||||
return ("_DexRatingText_Own%dTo%d"):format(lo, lo + 9)
|
||||
end
|
||||
|
||||
-- \n/\v/\f-marked extracted text, one Font.draw line at a time (same
|
||||
-- technique as DexEntryMenu.lua's dex-description block)
|
||||
local function drawTextBlock(text, x, y, maxY)
|
||||
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
|
||||
if maxY and y > maxY then break end
|
||||
Font.draw(line, x, y)
|
||||
y = y + 8
|
||||
end
|
||||
return y
|
||||
end
|
||||
|
||||
function HallOfFame.new(game, onDone)
|
||||
local self = setmetatable({}, HallOfFame)
|
||||
self.game = game
|
||||
@@ -182,26 +172,50 @@ function HallOfFame:update(dt)
|
||||
self.phase = "player_stats"
|
||||
self.timer = DEX_HOLD
|
||||
elseif self.phase == "player_stats" then
|
||||
-- name / play time / money boxes are up; then DexSeenOwnedText
|
||||
-- name / play time / money boxes are up; then the dex texts
|
||||
self.timer = self.timer - 1
|
||||
if skip or self.timer <= 0 then
|
||||
self.phase = "player_dex"
|
||||
self.timer = DEX_HOLD
|
||||
end
|
||||
elseif self.phase == "player_dex" then
|
||||
self.timer = self.timer - 1
|
||||
if skip or self.timer <= 0 then
|
||||
self.phase = "player_rating"
|
||||
self.timer = DEX_HOLD
|
||||
end
|
||||
elseif self.phase == "player_rating" then
|
||||
self.timer = self.timer - 1
|
||||
if skip or self.timer <= 0 then
|
||||
-- HoFFadeOutScreenAndMusic -> Credits lead-in (no A wait here)
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
self:showDexTexts()
|
||||
end
|
||||
end
|
||||
-- player_dex / player_rating are driven by the TextBox chain that
|
||||
-- showDexTexts pushes; nothing is timed here
|
||||
end
|
||||
|
||||
-- HoFDisplayPlayerStats' three HoFPrintTextAndDelay calls: seen/owned,
|
||||
-- "POKéDEX Rating:", then the tier text DisplayDexRating copied into
|
||||
-- wDexRatingText -- each through PrintText (the standard two-row box, so
|
||||
-- the tier text's \v rows scroll inside it) followed by 120 DelayFrames
|
||||
-- with no button wait. Hand-drawing these flattened the \v scrolls and
|
||||
-- painted the third row over the box's bottom border (#314).
|
||||
function HallOfFame:showDexTexts()
|
||||
local game = self.game
|
||||
local text = game.data.text or {}
|
||||
local seen, owned = self:dexSeenOwned()
|
||||
local seenOwned = (text._DexSeenOwnedText
|
||||
or Strings("POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"))
|
||||
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
||||
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
||||
local header = (text._DexRatingText or Strings("POKéDEX Rating{COLON}"))
|
||||
:gsub("{COLON}", ":")
|
||||
local rating = text[dexRatingKey(owned)] or Strings("Keep it up!")
|
||||
local function finish()
|
||||
-- HoFFadeOutScreenAndMusic -> Credits lead-in (no A wait here)
|
||||
game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
local function showRating()
|
||||
game.stack:push(TextBox.new(game, rating, finish,
|
||||
{ auto = { delay = DEX_HOLD } }))
|
||||
end
|
||||
local function showHeader()
|
||||
self.phase = "player_rating"
|
||||
game.stack:push(TextBox.new(game, header, showRating,
|
||||
{ auto = { delay = DEX_HOLD } }))
|
||||
end
|
||||
game.stack:push(TextBox.new(game, seenOwned, showHeader,
|
||||
{ auto = { delay = DEX_HOLD } }))
|
||||
end
|
||||
|
||||
-- HoFDisplayMonInfo: TextBoxBorder (0,2) b=9,c=10 + LEVEL/TYPE labels
|
||||
@@ -263,28 +277,6 @@ function HallOfFame:drawPlayerStats()
|
||||
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
|
||||
end
|
||||
|
||||
function HallOfFame:drawDexBox(kind)
|
||||
local save = self.game.save
|
||||
local text = self.game.data.text or {}
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if kind == "seen" then
|
||||
local seen, owned = self:dexSeenOwned()
|
||||
local seenOwned = text._DexSeenOwnedText
|
||||
or Strings("POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}")
|
||||
seenOwned = seenOwned
|
||||
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
||||
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
||||
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
|
||||
else
|
||||
local _, owned = self:dexSeenOwned()
|
||||
local ratingHeader = (text._DexRatingText or Strings("POKéDEX Rating{COLON}")):gsub("{COLON}", ":")
|
||||
Font.draw(ratingHeader, 1 * 8, 14 * 8)
|
||||
local rating = text[dexRatingKey(owned)] or Strings("Keep it up!")
|
||||
drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
|
||||
end
|
||||
end
|
||||
|
||||
function HallOfFame:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
@@ -309,14 +301,10 @@ function HallOfFame:draw()
|
||||
elseif self.phase == "player_stats" then
|
||||
self:drawPic(self.playerPic)
|
||||
self:drawPlayerStats()
|
||||
elseif self.phase == "player_dex" then
|
||||
elseif self.phase == "player_dex" or self.phase == "player_rating" then
|
||||
-- the TextBox chain draws the dex texts over the stat boxes
|
||||
self:drawPic(self.playerPic)
|
||||
self:drawPlayerStats()
|
||||
self:drawDexBox("seen")
|
||||
elseif self.phase == "player_rating" then
|
||||
self:drawPic(self.playerPic)
|
||||
self:drawPlayerStats()
|
||||
self:drawDexBox("rating")
|
||||
end
|
||||
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ function ListMenu.new(game, title, items, opts)
|
||||
self.dialogue = opts.dialogue
|
||||
-- PC item lists (players_pc.asm): PrintListMenuEntries shows 4 names
|
||||
-- and PrintText footers ("How many?", stored/withdrew) use the standard
|
||||
-- bottom text box — same row budget as the mart, without the money box.
|
||||
-- bottom text box -- same row budget as the mart, without the money box.
|
||||
self.messageBox = opts.messageBox
|
||||
self.money = opts.money -- () -> current money for the box
|
||||
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
|
||||
|
||||
@@ -40,12 +40,12 @@ function OakSpeech:sgbPalettes(game)
|
||||
end
|
||||
|
||||
local FALLBACKS = {
|
||||
_OakSpeechText1 = "Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!",
|
||||
_OakSpeechText2A = "This world is\ninhabited by\vcreatures called\vPOKéMON!",
|
||||
_OakSpeechText2B = "\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession.",
|
||||
_OakSpeechText3 = "{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!",
|
||||
_IntroducePlayerText = "First, what is\nyour name?",
|
||||
_IntroduceRivalText = "This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?",
|
||||
_OakSpeechText1 = Strings.source("Hello there!\nWelcome to the\vworld of POKéMON!\fMy name is OAK!\nPeople call me\vthe POKéMON PROF!"),
|
||||
_OakSpeechText2A = Strings.source("This world is\ninhabited by\vcreatures called\vPOKéMON!"),
|
||||
_OakSpeechText2B = Strings.source("\fFor some people,\nPOKéMON are\vpets. Others use\vthem for fights.\fMyself...\fI study POKéMON\nas a profession."),
|
||||
_OakSpeechText3 = Strings.source("{PLAYER}!\fYour very own\nPOKéMON legend is\vabout to unfold!\fA world of dreams\nand adventures\vwith POKéMON\vawaits! Let's go!"),
|
||||
_IntroducePlayerText = Strings.source("First, what is\nyour name?"),
|
||||
_IntroduceRivalText = Strings.source("This is my grand-\nson. He's been\vyour rival since\vyou were a baby.\f...Erm, what is\nhis name again?"),
|
||||
}
|
||||
|
||||
local function textOr(game, key)
|
||||
|
||||
@@ -142,6 +142,17 @@ local function buildRows(game)
|
||||
o.battleStyle = o.battleStyle == "set" and "shift" or "set"
|
||||
return true
|
||||
end },
|
||||
-- OG is the classic 160x144 battle screen; WIDE is the 304x144
|
||||
-- widescreen composition (src/battle/WideBattle.lua)
|
||||
{ id = "battleLayout", label = Strings("BATTLE LAYOUT"),
|
||||
value = function(g)
|
||||
return g.save.options.battleLayout == "wide" and "WIDE" or "OG"
|
||||
end,
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
|
||||
return true
|
||||
end },
|
||||
{ id = "ruleset", label = Strings("RULESET"),
|
||||
value = function(g) return rulesetName(g) end,
|
||||
step = function(g, dir)
|
||||
|
||||
+204
-18
@@ -23,9 +23,51 @@ local PartyMenu = {}
|
||||
PartyMenu.__index = PartyMenu
|
||||
PartyMenu.isOpaque = true
|
||||
|
||||
-- SGB: generic whole-screen palette (SET_PAL_GENERIC)
|
||||
-- SGB (SetPal_PartyMenu, engine/gfx/palettes.asm:90): the party screen is
|
||||
-- NOT a one-palette screen. data/sgb/sgb_packets.asm BlkPacket_PartyMenu
|
||||
-- splits it into MEWMON over the mon-icon column with GREENBAR everywhere
|
||||
-- else, plus one block per HP bar row whose palette
|
||||
-- UpdatePartyMenuBlkPacket (engine/gfx/palettes.asm:299-325) sets from that
|
||||
-- mon's GetHealthBarColor -- pal 1 GREENBAR / 2 YELLOWBAR / 3 REDBAR
|
||||
-- (PalPacket_PartyMenu, sgb_packets.asm:219). Handing the whole screen
|
||||
-- MEWMON instead painted every bar with MEWMON's shades, which is why a
|
||||
-- full bar came out black and a low one purple (#274, absorbing #272).
|
||||
--
|
||||
-- Two rects differ from the packet's, both because this port draws pixels
|
||||
-- where the hardware drew OAM over BG:
|
||||
-- * the icon block is rows 0-11, not the packet's 0-12 -- row 12 is the
|
||||
-- message box's top edge, which on hardware was BG under an OBJ-free
|
||||
-- part of the block; here it would take MEWMON instead of the base.
|
||||
-- * the bar blocks sit one tile right of the packet's 05-11 because this
|
||||
-- port's bar starts at tile 5 where party_menu.asm:71-76 starts it at
|
||||
-- 4; the span is the same "left cap + six fill tiles".
|
||||
function PartyMenu:sgbPalettes(game)
|
||||
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
|
||||
local P = require("src.render.PaletteFX")
|
||||
local base = P.pal(game.data, "GREENBAR")
|
||||
if not base then return nil end
|
||||
local zones = { P.whole(base) }
|
||||
local mew = P.pal(game.data, "MEWMON")
|
||||
if mew then zones[#zones + 1] = P.zone(mew, 1, 0, 2, 11) end
|
||||
-- the TM/HM list prints ABLE / NOT ABLE where the bar would be, so those
|
||||
-- rows have no bar to color (party_menu.asm .teachMoveMenu; #210)
|
||||
if not self.tmhm then
|
||||
local party = self.party or (game.save and game.save.party) or {}
|
||||
for i, mon in ipairs(party) do
|
||||
-- While a medicine's bar fill runs the block palette is STALE, not
|
||||
-- recomputed: SetPartyMenuHPBarColor (party_menu.asm:80/295) is only
|
||||
-- reached from the party-menu redraw loop, never from hp_bar.asm, so
|
||||
-- UpdateHPBar2 lengthens the bar under the PRE-heal color and
|
||||
-- RedrawPartyMenu snaps it green when the message prints. Hold the
|
||||
-- starting HP here for exactly that window (#252).
|
||||
local hp = mon.hp
|
||||
if self.heal and self.heal.mon == mon then hp = self.heal.from end
|
||||
local bar = P.pal(game.data, P.barPalName(hp, mon.stats.hp))
|
||||
if bar then
|
||||
zones[#zones + 1] = P.zone(bar, 6, i * 2 - 1, 12, i * 2 - 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
return zones
|
||||
end
|
||||
|
||||
local function sameItems(_, items) return items end
|
||||
@@ -50,7 +92,9 @@ local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
|
||||
-- Frame1; SNAKE/QUADRUPED are the reverse. Sprite-reused icons draw
|
||||
-- from 16x16x6 overworld sheets where index 3 is walk-down (tile 12):
|
||||
-- MON/FAIRY/BIRD rest on the walk frame and animate to standing
|
||||
-- (tile 0); WATER (Seel) is the reverse.
|
||||
-- (tile 0); WATER (Seel) is the reverse. Only the frame's LEFT half
|
||||
-- ever reaches the screen -- see PartyMenu.mirrorsIcon (#276) -- which
|
||||
-- is why a walk frame does not look like a walk frame here.
|
||||
PartyMenu.iconFrames = {
|
||||
BUG = { rest = 1, alt = 0 }, -- BugIconFrame2 <-> BugIconFrame1
|
||||
GRASS = { rest = 1, alt = 0 }, -- PlantIconFrame2 <-> PlantIconFrame1
|
||||
@@ -60,6 +104,7 @@ PartyMenu.iconFrames = {
|
||||
FAIRY = { rest = 3, alt = 0 }, -- FairySprite tile 12 <-> tile 0
|
||||
BIRD = { rest = 3, alt = 0 }, -- BirdSprite tile 12 <-> tile 0
|
||||
WATER = { rest = 0, alt = 3 }, -- SeelSprite tile 0 <-> tile 12
|
||||
PIKACHU = { rest = 0, alt = 3 }, -- Yellow: PikachuSprite tile 0 <-> 12
|
||||
}
|
||||
|
||||
-- Which 16x16 frame of `name`'s sheet to draw; `ih` (sheet pixel
|
||||
@@ -71,7 +116,45 @@ function PartyMenu.frameFor(name, alt, ih)
|
||||
return alt and ((ih or 0) >= 64 and 3 or 1) or 0
|
||||
end
|
||||
|
||||
-- HELIX is the one icon WriteMonPartySpriteOAM sends down the asymmetric
|
||||
-- path (engine/gfx/mon_icons.asm:246 `cp ICON_HELIX << 2 / jr z, .helix`);
|
||||
-- every other built-in icon is drawn as a mirrored left half (see
|
||||
-- drawIcon). A mod that supplies its own image instead of a built-in icon
|
||||
-- name has no vanilla counterpart, so its art draws whole. #276
|
||||
function PartyMenu.mirrorsIcon(name)
|
||||
return name ~= nil and name ~= "HELIX"
|
||||
end
|
||||
|
||||
local iconImages = {}
|
||||
|
||||
-- Party icons are OBJs (engine/gfx/mon_icons.asm WriteMonPartySpriteOAM
|
||||
-- writes OAM blocks), so they render through OBP0, and GBPalNormal
|
||||
-- (home/palettes.asm:20-26 `ld a, %11010000 ; 3100 / ldh [rOBP0], a`)
|
||||
-- holds OBP0 at "3100": OBJ color 1 shows as shade 0, color 2 as shade 1,
|
||||
-- color 3 as shade 3. An object never displays shade 2. This canvas has
|
||||
-- no OBJ layer, so bake that map into the icon art once per path (the same
|
||||
-- CPU-remap trick as SpriteRenderer.getObpImage, and the same "#obp" cache
|
||||
-- key convention) and let the screen's SGB zone color the result. Without
|
||||
-- it every color-2 pixel took the zone palette's shade-2 color -- the
|
||||
-- ADVANCED pack's MEWMON purple {115,33,165}, i.e. the "weirdly colored"
|
||||
-- party sprites of #274.
|
||||
local function obpIcon(path)
|
||||
if not (love.image and love.image.newImageData) then
|
||||
return love.graphics.newImage(Assets.resolve(path)) -- headless stub
|
||||
end
|
||||
local id = Assets.imageData(path)
|
||||
id:mapPixel(function(_, _, r, _, _, a)
|
||||
-- the extracted art is the four DMG grays, keyed off the red channel
|
||||
-- exactly the way PaletteFX's shade-remap shader keys them
|
||||
local v = 0
|
||||
if r > 0.5 then v = 1 -- OBJ colors 0 and 1 -> shade 0
|
||||
elseif r > 0.17 then v = 170 / 255 -- OBJ color 2 -> shade 1
|
||||
end -- OBJ color 3 -> shade 3
|
||||
return v, v, v, a
|
||||
end)
|
||||
return love.graphics.newImage(id)
|
||||
end
|
||||
|
||||
local function drawIcon(game, mon, x, y, selected, counter)
|
||||
local icons = game.data.icons
|
||||
if not icons then return end
|
||||
@@ -98,14 +181,26 @@ local function drawIcon(game, mon, x, y, selected, counter)
|
||||
end
|
||||
path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name })
|
||||
if not path then return end
|
||||
if iconImages[path] == nil then
|
||||
-- Built-in icon classes are DMG 2bpp OBJ art and get the OBP0 bake; a
|
||||
-- mod's own image (an entry table rather than an icon name) is authored
|
||||
-- art with no hardware counterpart, so it loads untouched -- the same
|
||||
-- split PartyMenu.mirrorsIcon makes for the OAM mirror. Both live in one
|
||||
-- cache under different keys, so a mod pointing a table entry at a
|
||||
-- built-in path still gets its unbaked copy. #274
|
||||
local key = name and (path .. "#obp") or path
|
||||
if iconImages[key] == nil then
|
||||
-- resolve through Assets so an overrides/ or transform-derived icon
|
||||
-- (e.g. a per-species image at assets/generated/icons/<name>.png) is
|
||||
-- picked up the same way battle sprites are
|
||||
local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||
iconImages[path] = ok and img or false
|
||||
local ok, img
|
||||
if name then
|
||||
ok, img = pcall(obpIcon, path)
|
||||
else
|
||||
ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
|
||||
end
|
||||
iconImages[key] = ok and img or false
|
||||
end
|
||||
local img = iconImages[path]
|
||||
local img = iconImages[key]
|
||||
if not img then return end
|
||||
local alt = false
|
||||
if selected then
|
||||
@@ -118,10 +213,28 @@ local function drawIcon(game, mon, x, y, selected, counter)
|
||||
alt = false
|
||||
end
|
||||
local iw, ih = img:getDimensions()
|
||||
if ih > 16 then
|
||||
local frame = PartyMenu.frameFor(name, alt, ih)
|
||||
-- a 16x16 sheet (BALL, HELIX) is its own only frame
|
||||
local frame = ih > 16 and PartyMenu.frameFor(name, alt, ih) or 0
|
||||
if PartyMenu.mirrorsIcon(name) then
|
||||
-- WriteSymmetricMonPartySpriteOAM (engine/items/town_map.asm:494-534)
|
||||
-- lays each icon out as 2x2 OAM blocks that use only the frame's LEFT
|
||||
-- column of tiles (base+0, base+2): the inner loop writes the same
|
||||
-- wOAMBaseTile twice with the attributes alternating 0 / OAM_XFLIP and
|
||||
-- only then bumps the tile by 2, because "all the sprites other than
|
||||
-- the helix one have a vertical line of symmetry". MON / FAIRY / BIRD
|
||||
-- reuse overworld sheets whose walk-down frame is NOT symmetric, so
|
||||
-- drawing the raw 16x16 showed a tucked-back foot the hardware never
|
||||
-- displays (#276, absorbing #238).
|
||||
local half = love.graphics.newQuad(0, frame * 16, 8, 16, iw, ih)
|
||||
love.graphics.draw(img, half, x, y)
|
||||
-- sx = -1 about the block's right edge, so the flipped copy lands on
|
||||
-- x+8..x+16: the OAM_XFLIP half
|
||||
love.graphics.draw(img, half, x + 16, y, 0, -1, 1)
|
||||
elseif ih > 16 then
|
||||
love.graphics.draw(img, love.graphics.newQuad(0, frame * 16, 16, 16, iw, ih), x, y)
|
||||
else
|
||||
-- HELIX and any mod art that is a single frame: drawn whole, at
|
||||
-- whatever size the file is (unchanged path)
|
||||
love.graphics.draw(img, x, y)
|
||||
end
|
||||
end
|
||||
@@ -134,6 +247,11 @@ function PartyMenu.new(game, opts)
|
||||
self.onSwitch = opts.onSwitch
|
||||
self.onCancel = opts.onCancel
|
||||
self.pickOnly = opts.pickOnly
|
||||
-- Medicine keeps the picker on screen: item_effects.asm .doneHealing
|
||||
-- animates the party HP bar and then prints the message through
|
||||
-- RedrawPartyMenu with the menu STILL up, so BagMenu asks for keepOpen and
|
||||
-- calls :close() itself once the message is done (#252).
|
||||
self.keepOpen = opts.keepOpen
|
||||
-- TM/HM teaching: opts.tmhm = { move, kind } switches the list to Gen 1's
|
||||
-- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the
|
||||
-- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210
|
||||
@@ -148,9 +266,47 @@ function PartyMenu.new(game, opts)
|
||||
return self
|
||||
end
|
||||
|
||||
-- UpdateHPBar2 (engine/gfx/hp_bar.asm, predef'd from item_effects.asm's
|
||||
-- .doneHealing): UpdateHPBar_AnimateHPBar is documented "for (a) ticks (two
|
||||
-- waiting frames each)" over a 48-pixel bar, so the shown HP walks
|
||||
-- maxHP/96 per frame -- the same rate the battle HUD drains at
|
||||
-- (BattleState:stepHPDrain). onDone fires on the frame it lands, which is
|
||||
-- when the caller prints its message. #252
|
||||
function PartyMenu:animateTo(mon, fromHP, onDone)
|
||||
if not (mon and mon.stats) then
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
local from = math.max(0, fromHP or mon.hp)
|
||||
-- `from` outlives `shown`: sgbPalettes above needs the pre-heal HP for the
|
||||
-- whole fill, because the SGB bar color does not move until the redraw.
|
||||
self.heal = { mon = mon, from = from, shown = from, onDone = onDone }
|
||||
end
|
||||
|
||||
-- Close a picker the caller kept open (see self.keepOpen). A TextBox pops
|
||||
-- itself BEFORE it fires onDone (src/render/TextBox.lua), so this menu is
|
||||
-- the top state by then; the identity check makes it a no-op for the pickers
|
||||
-- that already popped themselves, and stops a double close eating the bag
|
||||
-- underneath. #252
|
||||
function PartyMenu:close()
|
||||
if self.game.stack:top() == self then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
function PartyMenu:update(dt)
|
||||
-- icon animation counter; 320 = a whole cycle at every HP speed
|
||||
self.blink = ((self.blink or 0) + 1) % 320
|
||||
-- The bar fill owns the menu while it runs: UpdateHPBar2 is a blocking
|
||||
-- predef in item_effects.asm, so no button is read until it lands (#252).
|
||||
local heal = self.heal
|
||||
if heal then
|
||||
heal.shown = math.min(heal.mon.hp,
|
||||
heal.shown + math.max(1, heal.mon.stats.hp) / 96)
|
||||
if heal.shown >= heal.mon.hp then
|
||||
self.heal = nil
|
||||
if heal.onDone then heal.onDone() end
|
||||
end
|
||||
return
|
||||
end
|
||||
local input = self.game.input
|
||||
local party = self.party or self.game.save.party
|
||||
|
||||
@@ -304,10 +460,12 @@ function PartyMenu:update(dt)
|
||||
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
|
||||
local t2 = (self.game.data.text._CanMoveBouldersText
|
||||
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
|
||||
-- like surf (#320): the blink belongs UNDER the texts, not as a
|
||||
-- flashbang on the empty map after them; the stack only updates
|
||||
-- the top state, so the flash holds until the texts close
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
self.game.stack:push(TextBox.new(self.game, t1, function()
|
||||
self.game.stack:push(TextBox.new(self.game, t2, function()
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
self.game.stack:push(TextBox.new(self.game, t2))
|
||||
end, { auto = { sound = function()
|
||||
return require("src.core.Sound").playCry(self.game.data, mon.species)
|
||||
end } }))
|
||||
@@ -367,8 +525,12 @@ function PartyMenu:update(dt)
|
||||
end
|
||||
self.swapFrom = nil
|
||||
elseif self.onSwitch and (self.forceSwitch or self.pickOnly or not self.battle) then
|
||||
self.game.stack:pop()
|
||||
self.onSwitch(mon)
|
||||
-- keepOpen callers (HP medicine) need the menu still drawn while the
|
||||
-- bar fills and the message prints, and close it themselves; everyone
|
||||
-- else keeps the old pop-then-call order. Popping first is what made
|
||||
-- a POTION snap the picker shut before the item had even run (#252).
|
||||
if not self.keepOpen then self.game.stack:pop() end
|
||||
self.onSwitch(mon, self)
|
||||
else
|
||||
self.submenu = true
|
||||
self.subIndex = 1
|
||||
@@ -389,7 +551,7 @@ function PartyMenu:update(dt)
|
||||
-- Battle still excludes this list via `not self.battle`. Softboiled
|
||||
-- can appear for a fainted user; its heal transfer then no-ops.
|
||||
if not self.battle and ow then
|
||||
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
|
||||
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU --
|
||||
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
|
||||
local outside = Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets"))
|
||||
@@ -484,6 +646,16 @@ function PartyMenu:draw()
|
||||
Font.draw(Strings("No POKéMON!"), 16, 64)
|
||||
end
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
-- Each bar row carries its own GREENBAR / YELLOWBAR / REDBAR zone (see
|
||||
-- sgbPalettes), so the fill must stay the raw DMG shade-2 gray and let
|
||||
-- the zone color it -- but only when a zone pass will actually run.
|
||||
-- Renderer's blit takes the shader path exactly when the zone list is
|
||||
-- non-empty AND PaletteFX.shader() resolves, which is the same pair of
|
||||
-- conditions tested here; with no shader the canvas blits unshaded and
|
||||
-- drawHPBar's per-pixel tint is the only color the bar can get. #274
|
||||
local barZoned = PaletteFX.shader() ~= nil
|
||||
and PaletteFX.pal(self.game.data, "GREENBAR") ~= nil
|
||||
for i, mon in ipairs(party) do
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local y = PartyMenu.entryY(i)
|
||||
@@ -523,11 +695,25 @@ function PartyMenu:draw()
|
||||
elseif mon.status then
|
||||
Font.draw(mon.status, 136, y)
|
||||
end
|
||||
-- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor)
|
||||
-- the tile HP bar (DrawHP2 + SetPartyMenuHPBarColor). grayFill:
|
||||
-- tinting the fill AND running it through the row's zone
|
||||
-- double-applies -- a green fill has red channel 0, so the tint
|
||||
-- zeroes the bar's red and the zone's red-keyed shade shader then
|
||||
-- maps every pixel to color 3, i.e. black. That is the #229 hazard
|
||||
-- HudTiles documents; #274 (with #272) is this screen's instance.
|
||||
--
|
||||
-- While a medicine's UpdateHPBar2 fill runs, this row draws the HP the
|
||||
-- animation has reached rather than the final value; drawHPBar reads
|
||||
-- only .hp and .stats, so a shim table is enough and the real mon is
|
||||
-- never mutated for display (#252).
|
||||
local shown = mon
|
||||
if self.heal and self.heal.mon == mon then
|
||||
shown = { hp = math.floor(self.heal.shown), stats = mon.stats }
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon)
|
||||
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, shown, nil, barZoned)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
|
||||
Font.draw(("%3d/%3d"):format(shown.hp, mon.stats.hp), 104, y + 8)
|
||||
end
|
||||
-- home/pokemon.asm PartyMenuInit seeds wTopMenuItemY/X with 1/0, so the
|
||||
-- cursor sits on the entry's *second* tile row (the level/HP line),
|
||||
|
||||
+29
-3
@@ -57,7 +57,7 @@ function PokedexMenu.new(game, opts)
|
||||
-- original, QUIT returns to the list
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
game.stack:push(Menu.new(game, {
|
||||
local entries = {
|
||||
{ label = Strings("DATA"), onSelect = function()
|
||||
Screens.push(game, "DexEntryMenu", item.value)
|
||||
end },
|
||||
@@ -67,8 +67,34 @@ function PokedexMenu.new(game, opts)
|
||||
{ label = Strings("AREA"), onSelect = function()
|
||||
Screens.push(game, "TownMap", { nestSpecies = item.value })
|
||||
end },
|
||||
{ label = Strings("QUIT") },
|
||||
}, { tx = 12, ty = 8, tw = 8, th = 10 }))
|
||||
}
|
||||
-- Yellow's PRNT item (engine/menus/pokedex.asm PokedexMenuItemsText
|
||||
-- _YELLOW branch -> PrintPokedexEntry): the Game Boy Printer job is
|
||||
-- stood in for by a PNG of the entry page saved under prints/.
|
||||
if require("src.core.GameVersion").isYellow() then
|
||||
entries[#entries + 1] = { label = Strings("PRNT"), onSelect = function()
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
local Printer = require("src.core.Printer")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local def = game.data.pokemon[item.value]
|
||||
local path = require("src.pokemon.Sprites").path(
|
||||
game.data, item.value, "front", { kind = "dex" })
|
||||
local ok, sprite = false, nil
|
||||
if path then ok, sprite = pcall(love.graphics.newImage, path) end
|
||||
local saved, err = Printer.save("dex_" .. item.value, 160, 144,
|
||||
function()
|
||||
DexEntryMenu.render(game, def, ok and sprite or nil, false)
|
||||
end)
|
||||
game.stack:push(TextBox.new(game, saved
|
||||
and Strings("Printed %s's\ndata!\fSaved as\n%s\vin the save\nfolder.",
|
||||
def.name, saved)
|
||||
or Strings("Printer error!\n%s", tostring(err))))
|
||||
end }
|
||||
end
|
||||
entries[#entries + 1] = { label = Strings("QUIT") }
|
||||
game.stack:push(Menu.new(game, entries,
|
||||
{ tx = 12, ty = 8, tw = 8,
|
||||
th = #entries * 2 + 2 }))
|
||||
end,
|
||||
})
|
||||
list.sgbPalettes = PokedexMenu.sgbPalettes
|
||||
|
||||
+71
-18
@@ -13,6 +13,7 @@ local Font = require("src.render.Font")
|
||||
-- battle move-type box already do (#214).
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
|
||||
local SummaryMenu = {}
|
||||
SummaryMenu.__index = SummaryMenu
|
||||
@@ -30,6 +31,16 @@ function SummaryMenu:sgbPalettes(game)
|
||||
end
|
||||
|
||||
function SummaryMenu.new(game, mon)
|
||||
-- status_screen.asm:66-76: StatusScreen recalculates the stat block before
|
||||
-- it draws anything when the mon came from a box or the daycare ("mon is
|
||||
-- in a box or daycare" -> CalcStats), because box_struct carries none.
|
||||
-- Bill's PC hands us that mon table directly (src/ui/BoxMenu.lua's STATS
|
||||
-- submenu entry), and for a .sav imported through
|
||||
-- src/save_convert/GenSave.lua it really does arrive with mon.stats nil,
|
||||
-- which crashed the HP bar draw below (#233). Redundant once
|
||||
-- SaveData.validate has run over a loaded save, but this is the site the
|
||||
-- original recomputes at, and it also covers a mon handed in by a mod.
|
||||
Stats.ensure(game.data.pokemon[mon.species], mon)
|
||||
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local path = Sprites.path(game.data, mon.species, "front",
|
||||
@@ -59,10 +70,31 @@ end
|
||||
-- drawn from the same HUD tiles the original loads
|
||||
local function drawLineBox(tx, ty, b, c)
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
for i = 0, b - 1 do HudTiles.tile(0x73, tx * 8, (ty + i) * 8) end
|
||||
HudTiles.tile(0x77, tx * 8, (ty + b) * 8)
|
||||
for i = 1, c do HudTiles.tile(0x76, (tx - i) * 8, (ty + b) * 8) end
|
||||
HudTiles.tile(0x6F, (tx - c - 1) * 8, (ty + b) * 8)
|
||||
-- Under the status screen's overlay the vertical is $78 -- DrawLineBox
|
||||
-- writes `ld [hl], $78` (status_screen.asm:222), and :90-93 is what puts
|
||||
-- hud_2's single bar tile there. $73 is the <ID> glyph on this screen,
|
||||
-- not a line, so the whole box has to come off statusTile (#280). The
|
||||
-- drawn shapes are unchanged: hud_2 tile 0 is the same bar the battle
|
||||
-- layout parks at $73.
|
||||
for i = 0, b - 1 do HudTiles.statusTile(0x78, tx * 8, (ty + i) * 8) end
|
||||
HudTiles.statusTile(0x77, tx * 8, (ty + b) * 8)
|
||||
for i = 1, c do HudTiles.statusTile(0x76, (tx - i) * 8, (ty + b) * 8) end
|
||||
HudTiles.statusTile(0x6F, (tx - c - 1) * 8, (ty + b) * 8)
|
||||
end
|
||||
|
||||
-- home/pokemon.asm:335-345 PrintLevel: the "<LV>" (":L") tile at (tx,ty)
|
||||
-- then the level LEFT_ALIGNed after it; at level 100 hl is decremented so
|
||||
-- the third digit is written back OVER the ":L" tile. Both status pages
|
||||
-- print a level this way, and src/ui/PartyMenu.lua models the same rule for
|
||||
-- its rows. #280
|
||||
local function printLevel(tx, ty, level)
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
local x = tx * 8
|
||||
if level < 100 then
|
||||
HudTiles.statusTile(0x6E, x, ty * 8)
|
||||
x = x + 8
|
||||
end
|
||||
Font.draw(tostring(level), x, ty * 8)
|
||||
end
|
||||
|
||||
function SummaryMenu:draw()
|
||||
@@ -73,21 +105,33 @@ function SummaryMenu:draw()
|
||||
local data = game.data
|
||||
local def = data.pokemon[mon.species]
|
||||
|
||||
-- shared header: pic (1,0), name (9,1), <LV> (14,2), No. (1,7)
|
||||
-- shared header: pic (1,0), name (9,1), № + dex number (1,7). The pic is
|
||||
-- MIRRORED -- status_screen.asm:170 draws it through
|
||||
-- LoadFlippedFrontSpriteByMonIndex (home/pokemon.asm sets wSpriteFlipped),
|
||||
-- the same routine the intro's NIDORINO show-off uses (OakSpeech picFlip:
|
||||
-- negative x scale anchored at the pic's right edge). #280
|
||||
if self.sprite then
|
||||
love.graphics.draw(self.sprite, 8,
|
||||
math.max(0, 56 - self.sprite:getHeight()))
|
||||
love.graphics.draw(self.sprite, 8 + self.sprite:getWidth(),
|
||||
math.max(0, 56 - self.sprite:getHeight()), 0, -1, 1)
|
||||
end
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(mon.nickname or def.name, 72, 8)
|
||||
HudTiles.tile(0x6E, 112, 16) -- <LV>
|
||||
Font.draw(tostring(mon.level), 120, 16)
|
||||
Font.draw(("No.%03d"):format(def.dex or 0), 8, 56)
|
||||
-- status_screen.asm:109-113 backs hl up from DrawLineBox's end to write
|
||||
-- the single-tile '№' at (1,7) and '<DOT>' at (2,7); :143-146 then
|
||||
-- PrintNumbers the dex number (LEADING_ZEROES, 3 digits) at (3,7).
|
||||
-- Spelling "No." out of three letter tiles pushed every digit a column
|
||||
-- right of the original. #280
|
||||
HudTiles.statusTile(0x74, 8, 56) -- №
|
||||
Font.drawCode(0xF2, 16, 56) -- <DOT> (charmap.asm:182)
|
||||
Font.draw(("%03d"):format(def.dex or 0), 24, 56)
|
||||
|
||||
if self.page == 1 then
|
||||
-- HP bar (11,3) + numbers row 4, STATUS/ (9,6), the DrawLineBox
|
||||
-- bracket around the name/HP block
|
||||
-- bracket around the name/HP block, and PrintLevel at (14,2). The
|
||||
-- level belongs to page 1 ONLY: StatusScreen2 opens with ClearScreenArea
|
||||
-- over (9,2) 5x10 (status_screen.asm:303-305), which wipes it. #280
|
||||
printLevel(14, 2, mon.level)
|
||||
drawLineBox(19, 1, 6, 10)
|
||||
HudTiles.drawHPBar(data, 11, 3, mon, 1) -- wHPBarType 1
|
||||
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32)
|
||||
@@ -114,7 +158,12 @@ function SummaryMenu:draw()
|
||||
Font.draw(Strings("TYPE2/"), 80, 88)
|
||||
Font.draw(TypeChart.displayName(def.types[2]), 88, 96)
|
||||
end
|
||||
Font.draw(Strings("IDNo/"), 80, 104)
|
||||
-- TypesIDNoOTText's third row is "<ID>№/" (status_screen.asm:205-210):
|
||||
-- two single-tile glyphs and a slash, three columns wide, not the five
|
||||
-- letter tiles "IDNo/" this used to spell out. #280
|
||||
HudTiles.statusTile(0x73, 80, 104) -- <ID>
|
||||
HudTiles.statusTile(0x74, 88, 104) -- №
|
||||
Font.draw("/", 96, 104)
|
||||
-- the trainer ID is rolled at new game (SaveData.newGame) and
|
||||
-- backfilled on load for old saves
|
||||
Font.draw(("%05d"):format(mon.otId or game.save.player.id or 0), 96, 112)
|
||||
@@ -124,17 +173,21 @@ function SummaryMenu:draw()
|
||||
-- page 2: EXP + the moves with PP (StatusScreen2)
|
||||
drawLineBox(19, 1, 6, 10)
|
||||
Font.draw(Strings("EXP POINTS"), 72, 24)
|
||||
Font.draw(("%d"):format(mon.exp), 96, 32)
|
||||
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols
|
||||
-- at (7,6); space at (14,6); PrintLevel at (16,6). The old
|
||||
-- "%d to L%d" string at x=88 overflowed the DrawLineBox edge.
|
||||
-- PrintNumber at (12,4) with 7 columns: the exp is RIGHT-aligned into
|
||||
-- cols 12-18 (status_screen.asm:400-403), not left-aligned from col 12.
|
||||
-- #280
|
||||
Font.draw(("%7d"):format(mon.exp), 96, 32)
|
||||
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols at
|
||||
-- (7,6); the narrow '<to>' tile at (14,6); PrintLevel at (16,6)
|
||||
-- (status_screen.asm:393-403). The old "%d to L%d" string at x=88
|
||||
-- overflowed the DrawLineBox edge.
|
||||
Font.draw(Strings("LEVEL UP"), 72, 40)
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local nextExp = mon.level < 100
|
||||
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
|
||||
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
|
||||
HudTiles.tile(0x6E, 128, 48) -- <LV>
|
||||
Font.draw(tostring(math.min(100, mon.level + 1)), 136, 48)
|
||||
HudTiles.statusTile(0x70, 112, 48) -- '<to>' at (14,6), was missing (#280)
|
||||
printLevel(16, 6, math.min(100, mon.level + 1))
|
||||
Font.drawBox(0, 8, 20, 10)
|
||||
for i = 1, 4 do
|
||||
local mv = mon.moves[i]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user