mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-14 23:31:27 +02:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfba1f7bb7 | |||
| 8278b209b4 | |||
| 1c4515eaaa | |||
| c04b93ce1c | |||
| 2c9970a643 | |||
| 352a9a0eb8 | |||
| 5533785364 | |||
| 11f4f5570e | |||
| 6239387643 | |||
| 4a50cb0728 | |||
| 78aa1f3c0a | |||
| 5128b840b1 | |||
| da14b7ee3f | |||
| 2dbe21babf | |||
| 7a85862602 | |||
| 09b12a7cf0 | |||
| e2f2b432ff | |||
| 8c274d969e | |||
| 4c7633b56e | |||
| 943ae23e73 | |||
| 839cf19088 | |||
| 5019ba2caf | |||
| 0b267fc536 | |||
| 290bde1c39 | |||
| 7e92e54669 | |||
| b89b895962 | |||
| 4311955c43 | |||
| 747d9acad1 | |||
| 5041125751 | |||
| 15029d811f | |||
| 2c2a5a4220 | |||
| f64666c6ce | |||
| 3f4aaccbf5 | |||
| 20ad4e6c41 | |||
| 60b3cebdce | |||
| 9468ffccdf | |||
| 948ae8abbd | |||
| cb22684c2a | |||
| 94be169e35 | |||
| 36188ef18a | |||
| 7a1b4e5c45 | |||
| eba6e8d752 | |||
| ec5a72b365 | |||
| 9325d56046 |
@@ -0,0 +1,101 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Something in the game is broken, wrong, or not behaving like the original.
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
A screenshot is worth more than any description. If you can grab one, grab one.
|
||||||
|
If you genuinely can't, that's fine, but then the details below need to be thorough
|
||||||
|
enough that someone can find the bug without ever seeing your screen.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: One line summary
|
||||||
|
description: What's broken, in a sentence.
|
||||||
|
placeholder: Warp at the south end of the Route 5-8 underground path drops you in the wrong spot
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: os
|
||||||
|
attributes:
|
||||||
|
label: Which build are you running
|
||||||
|
options:
|
||||||
|
- macOS
|
||||||
|
- Windows
|
||||||
|
- Both
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: The release you downloaded. It's in the release title, like 0.1.4.
|
||||||
|
placeholder: 0.1.4
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: location
|
||||||
|
attributes:
|
||||||
|
label: Where in the game
|
||||||
|
description: >
|
||||||
|
Be specific. Route, town, building, floor, menu, battle. If it's a map
|
||||||
|
transition, say which side you came in from.
|
||||||
|
placeholder: Underground path between Route 5 and Route 8, south entrance
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: screenshot
|
||||||
|
attributes:
|
||||||
|
label: Screenshot, or a detailed description if you couldn't take one
|
||||||
|
description: >
|
||||||
|
Drag the image straight into this box. If you couldn't get a screenshot,
|
||||||
|
write "no screenshot" and then describe exactly what was on screen: where
|
||||||
|
the player sprite was, what the tiles around it looked like, any text on
|
||||||
|
screen, whether anything was frozen or flickering. The more concrete, the better.
|
||||||
|
placeholder: |
|
||||||
|
Drop the image here.
|
||||||
|
|
||||||
|
Or, with no screenshot:
|
||||||
|
Screen faded out like a normal warp, then faded back in with the player
|
||||||
|
standing on the grass tile north of the Route 6 gate house instead of
|
||||||
|
inside the tunnel. Player was facing down. No text box. Music kept playing
|
||||||
|
the underground path track instead of switching to the Route 6 track.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: How to make it happen
|
||||||
|
description: Numbered steps, starting from a point someone else can get to.
|
||||||
|
placeholder: |
|
||||||
|
1. Start a new game, get to Cerulean
|
||||||
|
2. Go south to Route 5, enter the underground path building
|
||||||
|
3. Walk to the south end of the tunnel
|
||||||
|
4. Step onto the stairs
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: What should have happened
|
||||||
|
placeholder: Should have come out inside the Route 8 side entrance building, facing up.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Anything else
|
||||||
|
description: >
|
||||||
|
Does it happen every time or only sometimes. Did it start after a specific
|
||||||
|
release. Anything you were doing right before it. Leave blank if nothing comes to mind.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Ask for something new, or a change to how something already works.
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
|
||||||
|
and how you picture it working. A request with real detail is one someone can
|
||||||
|
actually build without guessing at what you meant.
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: One line summary
|
||||||
|
description: What you want, in a sentence.
|
||||||
|
placeholder: Add a run toggle so you can move at bike speed without the bike
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What do you want
|
||||||
|
description: >
|
||||||
|
Describe it properly. What is it, where does it live in the game, what does the
|
||||||
|
player see or do. If it changes something that already exists, say what it does today
|
||||||
|
and what it should do instead.
|
||||||
|
placeholder: |
|
||||||
|
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
|
||||||
|
foot and the player moves at bike speed. Release and you go back to walking.
|
||||||
|
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: why
|
||||||
|
attributes:
|
||||||
|
label: Why is this worth doing
|
||||||
|
description: >
|
||||||
|
What's annoying or missing right now. What does this fix. If it's just because you
|
||||||
|
think it would be fun, say that, it's a real answer.
|
||||||
|
placeholder: |
|
||||||
|
Backtracking across routes you've already cleared is slow, and the bike is a menu
|
||||||
|
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
|
||||||
|
without making the game easier.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: how
|
||||||
|
attributes:
|
||||||
|
label: How should it work
|
||||||
|
description: >
|
||||||
|
The specifics. Which button, which menu, what happens in the edge cases. If you don't
|
||||||
|
know, say what you'd expect as a player and leave the rest open.
|
||||||
|
placeholder: |
|
||||||
|
- Hold B on the overworld to run
|
||||||
|
- Same speed as the bike
|
||||||
|
- Disabled anywhere the bike is disabled
|
||||||
|
- Sprite should probably need a running animation, not sure what that takes
|
||||||
|
- No effect on encounter rates
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: scope
|
||||||
|
attributes:
|
||||||
|
label: Does this change how the original game plays
|
||||||
|
description: >
|
||||||
|
Some requests are quality of life, some change the actual game. Both are fine,
|
||||||
|
it just helps to know which one you're asking for.
|
||||||
|
options:
|
||||||
|
- Quality of life, original game is untouched
|
||||||
|
- Changes how the game plays
|
||||||
|
- Not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Anything else
|
||||||
|
description: >
|
||||||
|
Reference screenshots, how another game does it, related issues. Leave blank
|
||||||
|
if nothing comes to mind.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Builds the macOS and Windows desktop apps on the self-hosted Mac runner
|
# Builds the macOS, Windows, and Linux desktop apps plus an Android APK
|
||||||
# and publishes them as a GitHub Release.
|
# on the self-hosted Mac runner, and publishes them as a GitHub Release.
|
||||||
#
|
#
|
||||||
# Versioning:
|
# Versioning:
|
||||||
# - First ever release is 0.1.0.
|
# - First ever release is 0.1.0.
|
||||||
@@ -14,6 +14,12 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
# CI/workflow and docs-only changes don't ship anything to users, so they
|
||||||
|
# don't earn a release. A push touching these *and* real source still
|
||||||
|
# releases; only pushes confined entirely to these paths are skipped.
|
||||||
|
paths-ignore:
|
||||||
|
- '.github/**'
|
||||||
|
- '**.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
@@ -23,6 +29,8 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
issues: read
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release
|
group: release
|
||||||
@@ -144,14 +152,20 @@ jobs:
|
|||||||
echo "Identities available to codesign:"
|
echo "Identities available to codesign:"
|
||||||
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
||||||
|
|
||||||
- name: Build macOS + Windows
|
- name: Build macOS + Windows + Linux
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Sign in-build (identity auto-detected from the temp keychain);
|
# Sign in-build (identity auto-detected from the temp keychain);
|
||||||
# notarize separately below so it uses secret credentials, not a
|
# notarize separately below so it uses secret credentials, not a
|
||||||
# login-keychain profile.
|
# login-keychain profile. "all" also builds the Linux AppImage,
|
||||||
|
# which needs no signing/notarization.
|
||||||
scripts/build.sh all --version "${{ steps.ver.outputs.version }}" --no-notarize
|
scripts/build.sh all --version "${{ steps.ver.outputs.version }}" --no-notarize
|
||||||
|
|
||||||
|
- name: Build Android
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||||
|
|
||||||
- name: Notarize & staple macOS app
|
- name: Notarize & staple macOS app
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -163,8 +177,8 @@ jobs:
|
|||||||
set -a; . "$ci_dir/notary.env"; set +a
|
set -a; . "$ci_dir/notary.env"; set +a
|
||||||
echo "::add-mask::$APPLE_APP_PASSWORD"
|
echo "::add-mask::$APPLE_APP_PASSWORD"
|
||||||
|
|
||||||
app=".bazinga/work/PokemonRed.app"
|
app=".bazinga/work/gen1recomp.app"
|
||||||
zip="dist/mac/PokemonRed-macos.zip"
|
zip="dist/mac/gen1recomp-macos.zip"
|
||||||
[ -d "$app" ] || { echo "::error::signed app not found at $app"; exit 1; }
|
[ -d "$app" ] || { echo "::error::signed app not found at $app"; exit 1; }
|
||||||
if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_APP_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then
|
if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_APP_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then
|
||||||
echo "::error::notary.env is missing APPLE_ID / APPLE_APP_PASSWORD / APPLE_TEAM_ID."
|
echo "::error::notary.env is missing APPLE_ID / APPLE_APP_PASSWORD / APPLE_TEAM_ID."
|
||||||
@@ -194,8 +208,12 @@ jobs:
|
|||||||
outdir="dist/release"
|
outdir="dist/release"
|
||||||
rm -rf "$outdir"
|
rm -rf "$outdir"
|
||||||
mkdir -p "$outdir"
|
mkdir -p "$outdir"
|
||||||
cp "dist/mac/PokemonRed-macos.zip" "$outdir/PokemonRed-${v}-macos.zip"
|
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
|
||||||
cp "dist/win/PokemonRed-win64.zip" "$outdir/PokemonRed-${v}-windows.zip"
|
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
|
||||||
|
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
|
||||||
|
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||||
|
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||||
|
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||||
ls -lh "$outdir"
|
ls -lh "$outdir"
|
||||||
|
|
||||||
- name: Publish GitHub Release
|
- name: Publish GitHub Release
|
||||||
@@ -206,14 +224,49 @@ jobs:
|
|||||||
v="${{ steps.ver.outputs.version }}"
|
v="${{ steps.ver.outputs.version }}"
|
||||||
tag="${{ steps.ver.outputs.tag }}"
|
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.
|
||||||
|
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 \
|
||||||
|
-f owner="${GITHUB_REPOSITORY%/*}" \
|
||||||
|
-f name="${GITHUB_REPOSITORY#*/}" \
|
||||||
|
-F pr="$pr" \
|
||||||
|
-f query='
|
||||||
|
query($owner:String!, $name:String!, $pr:Int!) {
|
||||||
|
repository(owner:$owner, name:$name) {
|
||||||
|
pullRequest(number:$pr) {
|
||||||
|
closingIssuesReferences(first:50) { nodes { number title } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}' \
|
||||||
|
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[]
|
||||||
|
| "- #\(.number) \(.title)"' 2>/dev/null || true)"$'\n'
|
||||||
|
done
|
||||||
|
closed="$(printf '%s' "$closed" | grep . | sort -t'#' -k2 -n || true)"
|
||||||
|
|
||||||
notes="Download the correct version for your computer below."
|
notes="Download the correct version for your computer below."
|
||||||
|
if [ -n "$closed" ]; then
|
||||||
|
notes+=$'\n\n## Issues closed\n\n'"$closed"
|
||||||
|
fi
|
||||||
|
printf 'Release notes:\n%s\n' "$notes"
|
||||||
|
|
||||||
gh release create "$tag" \
|
gh release create "$tag" \
|
||||||
--target "$GITHUB_SHA" \
|
--target "$GITHUB_SHA" \
|
||||||
--title "$v" \
|
--title "$v" \
|
||||||
--notes "$notes" \
|
--notes "$notes" \
|
||||||
"dist/release/PokemonRed-${v}-macos.zip" \
|
"dist/release/gen1recomp-${v}-macos.zip" \
|
||||||
"dist/release/PokemonRed-${v}-windows.zip"
|
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||||
|
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||||
|
"dist/release/gen1recomp-${v}-android.apk"
|
||||||
|
|
||||||
echo "Published release $tag"
|
echo "Published release $tag"
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
# Pokemon Gen 1 Recompilation Project
|
# Pokemon Gen 1 Recompilation Project
|
||||||
|
|
||||||
A native LÖVE2D recreation of Pokemon Red. The engine and map behavior are
|
A native LÖVE2D recreation of Pokemon Red and Blue. The engine and map
|
||||||
hand-written Lua; game data and graphics are decoded from a ROM supplied by
|
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||||
the player.
|
supplied by the player.
|
||||||
|
|
||||||
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
|
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
|
||||||
|
|
||||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||||
or download a disassembly. A canonical US Pokemon Red ROM is the only game
|
or download a disassembly. A canonical US Pokemon Red or Blue ROM is the only
|
||||||
content input.
|
game content input.
|
||||||
|
|
||||||
The ROM is verified, used during import, and then released from memory. It is
|
The ROM is verified, used during import, and then released from memory. It is
|
||||||
not copied into the cache. Later launches load the private generated cache and
|
not copied into the cache. Later launches load the private generated cache and
|
||||||
do not ask for the ROM again.
|
do not ask for the ROM again. Red and Blue can both be imported and played
|
||||||
|
side by side.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -20,8 +21,12 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` file
|
|||||||
or drop it onto the window. Import takes a few seconds and the game starts
|
or drop it onto the window. Import takes a few seconds and the game starts
|
||||||
automatically.
|
automatically.
|
||||||
|
|
||||||
Only the canonical 1 MiB US Red ROM is accepted. The importer verifies SHA-1
|
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
|
||||||
`ea9bcae617fdf159b045185467ae58b2e4a48b9a` before creating any game data.
|
verifies SHA-1 before creating any game data:
|
||||||
|
|
||||||
|
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||||
|
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||||
|
|
||||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||||
sound effects, and cries are synthesized while the game runs from compact
|
sound effects, and cries are synthesized while the game runs from compact
|
||||||
audio channel programs copied out of the verified ROM.
|
audio channel programs copied out of the verified ROM.
|
||||||
@@ -33,11 +38,11 @@ B; Escape opens START. F1 saves and F2 loads. Controllers are supported.
|
|||||||
|
|
||||||
## Running From Source
|
## Running From Source
|
||||||
|
|
||||||
Requires LÖVE 11.x. Place the ROM in the project folder and double-click
|
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
|
||||||
`Play-Mac.command` or `Play-Windows.bat`, or run:
|
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/setup.sh --rom "/path/to/Pokemon Red.gb"
|
scripts/setup.sh --rom "/path/to/Pokemon Red.gb" # or Pokemon Blue.gb
|
||||||
scripts/run.sh
|
scripts/run.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -45,6 +50,29 @@ then `love .` for later launches. Windows PowerShell scripts, the optional
|
|||||||
developer data build, test suites, and cache management are covered in
|
developer data build, test suites, and cache management are covered in
|
||||||
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
|
[Developer Setup](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Developer-Setup).
|
||||||
|
|
||||||
|
## Portable Mode
|
||||||
|
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## Modding
|
## Modding
|
||||||
|
|
||||||
The game ships a native mod platform: content registries, events and hooks,
|
The game ships a native mod platform: content registries, events and hooks,
|
||||||
@@ -55,6 +83,21 @@ reference — lives on the
|
|||||||
|
|
||||||
Shipped example mods, one per kind of author, live in [`mods/`](mods/).
|
Shipped example mods, one per kind of author, live in [`mods/`](mods/).
|
||||||
|
|
||||||
|
## Bugs and Ideas
|
||||||
|
|
||||||
|
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
|
||||||
|
that looks wrong, text in the wrong box, anything that does not match the
|
||||||
|
original game.
|
||||||
|
[Open a bug report](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=bug_report.yml).
|
||||||
|
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).
|
||||||
|
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
|
## More
|
||||||
|
|
||||||
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play)
|
- [Link play](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki/Guide-Link-Play)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 753 KiB |
@@ -1,13 +1,16 @@
|
|||||||
function love.conf(t)
|
function love.conf(t)
|
||||||
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
||||||
|
local developer = os.getenv("POKEPORT_DEV") == "1"
|
||||||
if arg then
|
if arg then
|
||||||
for _, a in ipairs(arg) do
|
for _, a in ipairs(arg) do
|
||||||
if a == "--editor" then editor = true end
|
if a == "--editor" then editor = true end
|
||||||
|
if a == "--developer" then developer = true end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- main.lua runs in the same Lua state right after conf.lua; stash the
|
-- main.lua runs in the same Lua state right after conf.lua; stash the
|
||||||
-- decision in a global so it doesn't need to reparse `arg`.
|
-- decision in a global so it doesn't need to reparse `arg`.
|
||||||
_G.POKEPORT_EDITOR_MODE = editor
|
_G.POKEPORT_EDITOR_MODE = editor
|
||||||
|
_G.POKEPORT_DEV_MODE = developer
|
||||||
|
|
||||||
if editor then
|
if editor then
|
||||||
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
|
t.identity = os.getenv("POKEPORT_IDENTITY") or "pokemon-love2d-editor"
|
||||||
@@ -20,9 +23,13 @@ function love.conf(t)
|
|||||||
-- back to the plain title if the source is not mounted yet
|
-- back to the plain title if the source is not mounted yet
|
||||||
local ok, Version = pcall(require, "src.core.Version")
|
local ok, Version = pcall(require, "src.core.Version")
|
||||||
t.window.title = ok and Version.title()
|
t.window.title = ok and Version.title()
|
||||||
or "Pokemon Red (Gen 1 Recompilation Project)"
|
or "gen1recomp"
|
||||||
t.window.width = 160 * 4
|
-- Open at the launcher's design size (the split-screen ROM selector is
|
||||||
t.window.height = 144 * 4
|
-- laid out for 1024x768). The window is resizable and the 160x144 game
|
||||||
|
-- canvas letterboxes into whatever size it ends up, so this only sets the
|
||||||
|
-- starting size, not the game's resolution.
|
||||||
|
t.window.width = 1024
|
||||||
|
t.window.height = 768
|
||||||
end
|
end
|
||||||
t.version = "11.5"
|
t.version = "11.5"
|
||||||
t.window.vsync = 1
|
t.window.vsync = 1
|
||||||
@@ -34,14 +41,29 @@ function love.conf(t)
|
|||||||
local osName = love._os
|
local osName = love._os
|
||||||
local mobile = osName == "Android" or osName == "iOS"
|
local mobile = osName == "Android" or osName == "iOS"
|
||||||
if mobile then
|
if mobile then
|
||||||
-- On Android/iOS, width/height aspect picks portrait vs landscape
|
-- resizable is what unlocks orientation. SDL's Android backend, given no
|
||||||
-- (fullscreen alone is not enough). Use a tall portrait size; the
|
-- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
|
||||||
-- OS then resizes to the real display. highdpi is required for
|
-- at window creation -- FULL_SENSOR when the window is resizable (rotates
|
||||||
-- Retina iOS (Android always behaves as highdpi).
|
-- freely to portrait or landscape), otherwise locked to the window's w/h
|
||||||
|
-- aspect. So a non-resizable tall window forced portrait; resizable lets
|
||||||
|
-- the game follow the device. The renderer letterboxes the 160x144
|
||||||
|
-- viewport into whatever size results, and touch input is gesture-based,
|
||||||
|
-- so both orientations just work. iOS follows the Info.plist orientations
|
||||||
|
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
|
||||||
|
t.window.resizable = true
|
||||||
|
-- Starting size is a tall portrait hint; the OS resizes to the real
|
||||||
|
-- display and rotations resize again. highdpi is required for Retina iOS
|
||||||
|
-- (Android always behaves as highdpi).
|
||||||
t.window.width = 1080
|
t.window.width = 1080
|
||||||
t.window.height = 1920
|
t.window.height = 1920
|
||||||
t.window.fullscreen = true
|
t.window.fullscreen = true
|
||||||
t.window.highdpi = true
|
t.window.highdpi = true
|
||||||
|
-- Android only (irrelevant on iOS): puts the save directory under the
|
||||||
|
-- app's external-files folder, which is readable/writable via USB or a
|
||||||
|
-- file manager with no runtime permission, so RomImporter can ask the
|
||||||
|
-- player to copy their ROM there instead of needing a native file
|
||||||
|
-- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua).
|
||||||
|
t.externalstorage = osName == "Android"
|
||||||
else
|
else
|
||||||
t.window.resizable = true
|
t.window.resizable = true
|
||||||
end
|
end
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
-- Celadon Chief House (scripts/CeladonChiefHouse.asm). The CHIEF is a
|
||||||
|
-- talk-only NPC in the original ROM, and ChiefData (data/trainers/
|
||||||
|
-- parties.asm) is an empty, unreferenced trainer entry. After the HALL
|
||||||
|
-- OF FAME (EVENT_BEAT_CHAMPION_RIVAL) the CHIEF puts up a fight, using a
|
||||||
|
-- reconstructed party for the otherwise-unused OPP_CHIEF class.
|
||||||
|
|
||||||
|
return {
|
||||||
|
talk = {
|
||||||
|
TEXT_CELADONCHIEFHOUSE_CHIEF = {
|
||||||
|
{ "face_player" }, -- 1
|
||||||
|
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 2
|
||||||
|
{ "jump_if_false", 12 }, -- 3
|
||||||
|
{ "check_flag", "EVENT_BEAT_CELADON_CHIEF" }, -- 4
|
||||||
|
{ "jump_if_true", 12 }, -- 5
|
||||||
|
{ "show_text", "So you've come to\nshut down my\noperation?\f"
|
||||||
|
.. "TEAM ROCKET's\nCHIEF won't go\ndown so easy!" }, -- 6
|
||||||
|
{ "start_battle", "trainer", "OPP_CHIEF", 1 }, -- 7
|
||||||
|
{ "jump_if_false", "end" }, -- 8
|
||||||
|
{ "set_flag", "EVENT_BEAT_CELADON_CHIEF" }, -- 9
|
||||||
|
{ "show_text", "Gah! Even the\nCHIEF is no match\nfor you!\f"
|
||||||
|
.. "TEAM ROCKET is\nfinished for\ngood!" }, -- 10
|
||||||
|
{ "jump", "end" }, -- 11
|
||||||
|
{ "show_text", "_CeladonChiefHouseChiefText" }, -- 12
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm).
|
-- Pewter City flavor dialogue (pokered/scripts/PewterCity.asm).
|
||||||
-- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum
|
-- PewterCity_TextPointers text_asm bodies for the SUPER_NERD1 museum
|
||||||
-- guide, SUPER_NERD2 garden nerd, and the leaving-east YOUNGSTER.
|
-- guide and SUPER_NERD2 garden nerd.
|
||||||
--
|
--
|
||||||
-- The escort choreography (SUPER_NERD1 walking the player to the
|
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
|
||||||
-- museum, YOUNGSTER walking the player to the gym) is scripted NPC
|
-- story5.lua so the lockstep RLE walk is not overwritten by this
|
||||||
-- movement + a wPewterCityCurScript state machine that steers the
|
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
|
||||||
-- player off-map; that part is already covered on this map by
|
-- YES/NO-branched flavor text is here.
|
||||||
-- story5.lua's onStep gate (walks the player back a step at the
|
|
||||||
-- east exit before EVENT_BEAT_BROCK). Here we only port the real
|
|
||||||
-- YES/NO-branched flavor text these NPCs speak when talked to.
|
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -60,15 +57,6 @@ M.PEWTER_CITY = {
|
|||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
|
|
||||||
-- PewterCityYoungsterText (scripts/PewterCity.asm): the "follow
|
|
||||||
-- me" line the youngster says when the player is stopped from
|
|
||||||
-- leaving Pewter east before beating Brock; the actual gate/step
|
|
||||||
-- block is handled by story5.lua's onStep for this map.
|
|
||||||
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
|
|
||||||
local t = text(game)
|
|
||||||
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
|
|
||||||
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!", done)
|
|
||||||
end,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Red's House 2F (pokered data/events/hidden_events.asm +
|
||||||
|
-- engine/events/hidden_objects PrintRedSNESText).
|
||||||
|
--
|
||||||
|
-- The bedroom SNES is a hidden_event at (3, 5) with ANY_FACING that
|
||||||
|
-- prints _RedBedroomSNESText. field.py extracts OpenRedsPC on this map
|
||||||
|
-- but skips PrintRedSNESText, so interaction was a no-op (#135).
|
||||||
|
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
|
return {
|
||||||
|
REDS_HOUSE_2F = {
|
||||||
|
-- hidden_events.asm:
|
||||||
|
-- hidden_event 3, 5, PrintRedSNESText, ANY_FACING
|
||||||
|
onInteract = function(game, ow, fx, fy)
|
||||||
|
if fx ~= 3 or fy ~= 5 then return false end
|
||||||
|
local text = game.data.text._RedBedroomSNESText
|
||||||
|
or "{PLAYER} is\nplaying the SNES!"
|
||||||
|
game.stack:push(TextBox.new(game, text))
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -5,19 +5,23 @@ return {
|
|||||||
SILPH_CO_9F = {
|
SILPH_CO_9F = {
|
||||||
talk = {
|
talk = {
|
||||||
-- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm):
|
-- SilphCo9FNurseText (pokered/scripts/SilphCo9F.asm):
|
||||||
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party and shows
|
-- before EVENT_BEAT_SILPH_CO_GIOVANNI: heals the party, white fade
|
||||||
-- "You look tired..." then "Don't give up!"; after the event, just
|
-- (Delay3 between out/in; no Music_PkmnHealed), then "Don't give
|
||||||
-- says thanks. Nurse texts are not in data/generated/text.lua, so
|
-- up!"; after the event, just says thanks. Nurse texts are not in
|
||||||
-- the exact pokered/text/SilphCo9F.asm strings are used as literals.
|
-- data/generated/text.lua, so the exact pokered/text/SilphCo9F.asm
|
||||||
|
-- strings are used as literals.
|
||||||
TEXT_SILPHCO9F_NURSE = {
|
TEXT_SILPHCO9F_NURSE = {
|
||||||
{ "face_player" },
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
|
||||||
{ "jump_if_true", 8 },
|
{ "jump_if_true", 11 }, -- 3
|
||||||
{ "show_text", "You look tired!\nYou should take a\nquick nap!" },
|
{ "show_text", "You look tired!\nYou should take a\nquick nap!" }, -- 4
|
||||||
{ "heal_party" },
|
{ "heal_party" }, -- 5
|
||||||
{ "show_text", "Don't give up!" },
|
{ "fade", "out", "white" }, -- 6
|
||||||
{ "jump", 9 },
|
{ "wait", 3 }, -- 7 Delay3
|
||||||
{ "show_text", "Thank you so\nmuch!" },
|
{ "fade", "in", "white" }, -- 8
|
||||||
|
{ "show_text", "Don't give up!" }, -- 9
|
||||||
|
{ "jump", "end" }, -- 10
|
||||||
|
{ "show_text", "Thank you so\nmuch!" }, -- 11
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
-- Viridian City flavor dialogue (pokered/scripts/ViridianCity.asm).
|
-- Viridian City flavor dialogue (pokered/scripts/ViridianCity.asm).
|
||||||
-- Ports the text_asm bodies for GAMBLER1, YOUNGSTER2, GIRL and OLD_MAN.
|
-- Ports the text_asm bodies for GAMBLER1, YOUNGSTER2 and GIRL.
|
||||||
--
|
--
|
||||||
-- Not ported here (already handled elsewhere / not talk-reachable):
|
-- Not ported here (already handled elsewhere / not talk-reachable):
|
||||||
-- * TEXT_VIRIDIANCITY_FISHER (TM42 gift) -- already ported as a
|
-- * TEXT_VIRIDIANCITY_FISHER (TM42 gift) -- already ported as a
|
||||||
-- `gift()` entry in data/scripts/story5.lua's M.VIRIDIAN_CITY.talk.
|
-- `gift()` entry in data/scripts/story5.lua's M.VIRIDIAN_CITY.talk.
|
||||||
-- * TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY / TEXT_VIRIDIANCITY_GYM_LOCKED --
|
-- * TEXT_VIRIDIANCITY_OLD_MAN (the walking man at (17,5)) and
|
||||||
-- these are step-triggered blocking texts (ViridianCityCheckGotPokedexScript /
|
-- TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY (the sleeper at (18,9)) -- both
|
||||||
-- ViridianCityCheckGymOpenScript), not npc talk text_asm bodies; the
|
-- live in data/scripts/story.lua, which owns this map's onStep gate
|
||||||
-- gates themselves are already implemented via story5.lua's onStep
|
-- and can reach the `old_man_demo` command for the real catch
|
||||||
-- chain (viridianOldManStep / viridianGymLock) for this map.
|
-- tutorial. Keep them there: story.lua loads BEFORE this file, so a
|
||||||
-- * The old man's catch-training minigame trigger (SCRIPT_VIRIDIANCITY_
|
-- duplicate here would silently win the merge.
|
||||||
-- OLD_MAN_START_CATCH_TRAINING / battle vs. WEEDLE) is a full
|
-- * TEXT_VIRIDIANCITY_GYM_LOCKED -- a step-triggered blocking text
|
||||||
-- scripted-battle cutscene outside this task's Commands vocabulary
|
-- (ViridianCityCheckGymOpenScript), implemented by story5.lua's
|
||||||
-- (no static_battle-style "battle a scripted old-man WEEDLE" command
|
-- onStep chain (viridianGymLock -> viridianOldManStep) for this map.
|
||||||
-- exists); we port the real YES/NO branch text he speaks but the
|
|
||||||
-- "yes" branch here just shows the "I'll show you how" line rather
|
|
||||||
-- than actually starting the minigame, since that machinery isn't
|
|
||||||
-- ported to this map yet.
|
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -91,24 +87,6 @@ M.VIRIDIAN_CITY = {
|
|||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
|
|
||||||
-- ViridianCityOldManText (scripts/ViridianCity.asm): once he's had
|
|
||||||
-- his coffee, he asks (YES/NO) whether you want to learn how to
|
|
||||||
-- catch Pokemon. YES leads into the catch-training minigame
|
|
||||||
-- (SCRIPT_VIRIDIANCITY_OLD_MAN_START_CATCH_TRAINING, not ported --
|
|
||||||
-- see file header); NO just brushes you off ("Time is money...").
|
|
||||||
TEXT_VIRIDIANCITY_OLD_MAN = function(game, ow, npc, done)
|
|
||||||
local t = text(game)
|
|
||||||
ask(game, t._ViridianCityOldManHadMyCoffeeNowText
|
|
||||||
or "Ahh, I've had my\ncoffee now and I\nfeel great!\nSure you can go\nthrough!\nAre you in a\nhurry?", function(yes)
|
|
||||||
if yes then
|
|
||||||
push(game, t._ViridianCityOldManKnowHowToCatchPokemonText
|
|
||||||
or "I see you're using\na POKéDEX.\nWhen you catch a\nPOKéMON, POKéDEX\nis automatically\nupdated.\nWhat? Don't you\nknow how to catch\nPOKéMON?\nI'll show you\nhow to then.", done)
|
|
||||||
else
|
|
||||||
push(game, t._ViridianCityOldManTimeIsMoneyText
|
|
||||||
or "Time is money...\nGo along then.", done)
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ local files = {
|
|||||||
"data.scripts.flavor.pokemon_fan_club",
|
"data.scripts.flavor.pokemon_fan_club",
|
||||||
"data.scripts.flavor.power_plant",
|
"data.scripts.flavor.power_plant",
|
||||||
"data.scripts.flavor.reds_house_1f",
|
"data.scripts.flavor.reds_house_1f",
|
||||||
|
"data.scripts.flavor.reds_house_2f",
|
||||||
"data.scripts.flavor.route11_gate_2f",
|
"data.scripts.flavor.route11_gate_2f",
|
||||||
"data.scripts.flavor.route18_gate_2f",
|
"data.scripts.flavor.route18_gate_2f",
|
||||||
"data.scripts.flavor.route_12_gate_2f",
|
"data.scripts.flavor.route_12_gate_2f",
|
||||||
|
|||||||
+94
-56
@@ -10,8 +10,8 @@
|
|||||||
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
|
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
|
||||||
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
|
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
|
||||||
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
|
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
|
||||||
-- afterwards he gloats or sulks and marches out of the lab
|
-- afterwards HealParty + flag always, then he gloats or sulks and
|
||||||
-- (OaksLabRivalBattleEndScript).
|
-- marches out (OaksLabRivalEndBattleScript). A loss does not black out.
|
||||||
|
|
||||||
-- ball objects: CHARMANDER (6,3), SQUIRTLE (7,3), BULBASAUR (8,3);
|
-- ball objects: CHARMANDER (6,3), SQUIRTLE (7,3), BULBASAUR (8,3);
|
||||||
-- rival = object 1 at (4,3). rivalBallX is the counter-pick's column.
|
-- rival = object 1 at (4,3). rivalBallX is the counter-pick's column.
|
||||||
@@ -19,32 +19,41 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
|||||||
rivalBallX, rivalBall)
|
rivalBallX, rivalBall)
|
||||||
return {
|
return {
|
||||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1
|
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1
|
||||||
{ "jump_if_true", 19 }, -- 2
|
{ "jump_if_true", 20 }, -- 2
|
||||||
-- no picking until Oak has walked you in (OaksLabScript gating)
|
-- no picking until Oak has walked you in (OaksLabScript gating)
|
||||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
||||||
{ "jump_if_false", 19 }, -- 4
|
{ "jump_if_false", 20 }, -- 4
|
||||||
{ "ask", askText }, -- 5
|
-- the Pokédex "new species" entry shows before the ask (predef
|
||||||
{ "jump_if_false", 20 }, -- 6
|
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
|
||||||
{ "give_pokemon", species, 5 }, -- 7
|
-- sets the owned bits so ShowPokedexData prints height/weight/text;
|
||||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 8
|
-- forceOwned is that bypass without mutating save.pokedex.owned.
|
||||||
{ "set_flag", choseFlag }, -- 9
|
{ "push_screen", "DexEntryMenu",
|
||||||
|
{ species = species, forceOwned = true } }, -- 5
|
||||||
|
{ "ask", askText }, -- 6
|
||||||
|
{ "jump_if_false", 21 }, -- 7
|
||||||
|
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
|
||||||
|
-- inside give_pokemon). Show the received text first so the
|
||||||
|
-- nickname prompt follows "you got X", matching Gen1.
|
||||||
|
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
|
||||||
|
{ "give_pokemon", species, 5 }, -- 9
|
||||||
|
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
|
||||||
|
{ "set_flag", choseFlag }, -- 11
|
||||||
-- POKé BALLs are not handed out here in the original -- Oak gives
|
-- POKé BALLs are not handed out here in the original -- Oak gives
|
||||||
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
|
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
|
||||||
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
|
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
|
||||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 10
|
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
|
||||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 11
|
|
||||||
-- the rival walks to the countering ball (around the furniture)
|
-- the rival walks to the countering ball (around the furniture)
|
||||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 12
|
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
|
||||||
{ "face_object", 1, "up" }, -- 13
|
{ "face_object", 1, "up" }, -- 14
|
||||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 14
|
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
|
||||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 15
|
{ "hide_object", "OAKS_LAB", rivalBall }, -- 16
|
||||||
{ "show_text", "_OaksLabRivalReceivedMonText",
|
{ "show_text", "_OaksLabRivalReceivedMonText",
|
||||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||||
or "BULBASAUR" } }, -- 16
|
or "BULBASAUR" } }, -- 17
|
||||||
{ "jump", 20 }, -- 17
|
{ "jump", 21 }, -- 18
|
||||||
{ "jump", 20 }, -- 18 (spacer)
|
{ "jump", 21 }, -- 19 (spacer)
|
||||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 19
|
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -58,33 +67,44 @@ return {
|
|||||||
TEXT_OAKSLAB_OAK1 = {
|
TEXT_OAKSLAB_OAK1 = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
|
{ "check_flag", "EVENT_GOT_OAKS_PARCEL" }, -- 2
|
||||||
{ "jump_if_false", 12 }, -- 3
|
{ "jump_if_false", 16 }, -- 3
|
||||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
|
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 4
|
||||||
{ "jump_if_true", 12 }, -- 5
|
{ "jump_if_true", 16 }, -- 5
|
||||||
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
|
{ "show_text", "_OaksLabOak1DeliverParcelText" }, -- 6
|
||||||
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
|
{ "take_item", "OAKS_PARCEL", 1 }, -- 7
|
||||||
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
|
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, -- 8
|
||||||
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
|
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" }, -- 9
|
||||||
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
|
{ "set_flag", "EVENT_GOT_POKEDEX" }, -- 10
|
||||||
{ "jump", 30 }, -- 11
|
-- OaksLab.asm OakGivesPokedex: HideObject TOGGLE_POKEDEX_1/2
|
||||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 12
|
-- so the table sprites leave with the gift (#106).
|
||||||
{ "jump_if_false", 27 }, -- 13
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, -- 11
|
||||||
{ "check_item", "POKE_BALL" }, -- 14
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, -- 12
|
||||||
{ "jump_if_true", 25 }, -- 15
|
-- the Pokédex swaps Viridian's two old men (OaksLab.asm:602-606:
|
||||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 16
|
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN).
|
||||||
{ "jump_if_false", 29 }, -- 17
|
-- Until this ran, the walking man at (17,5) -- who owns the coffee
|
||||||
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 18
|
-- ask and the catch tutorial -- stayed OFF for the whole game
|
||||||
{ "jump_if_true", 25 }, -- 19
|
-- (toggleable_objects.asm seeds him OFF, the sleeper ON).
|
||||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 20
|
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
|
||||||
{ "give_item", "POKE_BALL", 5, false }, -- 21
|
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, -- 14
|
||||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 22
|
{ "jump", "end" }, -- 15
|
||||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 23
|
{ "check_flag", "EVENT_GOT_STARTER" }, -- 16
|
||||||
{ "jump", 30 }, -- 24
|
{ "jump_if_false", 31 }, -- 17
|
||||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 25
|
{ "check_item", "POKE_BALL" }, -- 18
|
||||||
{ "jump", 30 }, -- 26
|
{ "jump_if_true", 29 }, -- 19
|
||||||
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 27
|
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, -- 20
|
||||||
{ "jump", 30 }, -- 28
|
{ "jump_if_false", 33 }, -- 21
|
||||||
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 29 (30 = end)
|
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 22
|
||||||
|
{ "jump_if_true", 29 }, -- 23
|
||||||
|
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, -- 24
|
||||||
|
{ "give_item", "POKE_BALL", 5, false }, -- 25
|
||||||
|
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, -- 26
|
||||||
|
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, -- 27
|
||||||
|
{ "jump", "end" }, -- 28
|
||||||
|
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, -- 29
|
||||||
|
{ "jump", "end" }, -- 30
|
||||||
|
{ "show_text", "_OaksLabOak1WhichPokemonDoYouWantText" }, -- 31
|
||||||
|
{ "jump", "end" }, -- 32
|
||||||
|
{ "show_text", "_OaksLabOak1RaiseYourYoungPokemonText" }, -- 33
|
||||||
},
|
},
|
||||||
|
|
||||||
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
|
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
|
||||||
@@ -100,9 +120,9 @@ return {
|
|||||||
TEXT_OAKSLAB_RIVAL = {
|
TEXT_OAKSLAB_RIVAL = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
|
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
|
||||||
{ "jump_if_false", 20 }, -- 3
|
{ "jump_if_false", 21 }, -- 3
|
||||||
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
|
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
|
||||||
{ "jump_if_true", 18 }, -- 5
|
{ "jump_if_true", 19 }, -- 5
|
||||||
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
|
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
|
||||||
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
|
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
|
||||||
{ "jump_if_false", 11 }, -- 8
|
{ "jump_if_false", 11 }, -- 8
|
||||||
@@ -113,21 +133,36 @@ return {
|
|||||||
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
|
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
|
||||||
{ "jump", 16 }, -- 14
|
{ "jump", 16 }, -- 14
|
||||||
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
|
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
|
||||||
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 16
|
-- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way
|
||||||
{ "jump", 21 }, -- 17
|
{ "heal_party" }, -- 16
|
||||||
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 18
|
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17
|
||||||
{ "jump", 25 }, -- 19
|
{ "jump", 23 }, -- 18
|
||||||
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 20
|
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19
|
||||||
-- battle aftermath: on a win the rival sulks and marches out
|
{ "jump", "end" }, -- 20
|
||||||
-- (OaksLabRivalBattleEnd); on a loss the blackout already warped
|
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21
|
||||||
-- us away, so the script just ends
|
{ "jump", "end" }, -- 22
|
||||||
{ "jump_if_false", 25 }, -- 21
|
-- win: sulk text then exit; loss: Rival1WinText already played in
|
||||||
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 22
|
-- battle (HandlePlayerBlackOut), so skip straight to the walk-out
|
||||||
{ "move_npc_to", 1, 4, 11 }, -- 23
|
{ "jump_if_false", 25 }, -- 23
|
||||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 24 (25 = end)
|
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24
|
||||||
|
{ "move_npc_to", 1, 4, 11 }, -- 25
|
||||||
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
-- Saves that got the Pokédex before #106 never wrote objectToggles for
|
||||||
|
-- the table sprites; re-entering the lab applies the same HideObject
|
||||||
|
-- the gift script now does (OaksLab.asm OakGivesPokedex).
|
||||||
|
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,
|
||||||
|
|
||||||
-- Oak stops you leaving without a starter; the rival stops you on
|
-- Oak stops you leaving without a starter; the rival stops you on
|
||||||
-- the way out for the first battle (scripts/OaksLab.asm
|
-- the way out for the first battle (scripts/OaksLab.asm
|
||||||
-- OaksLabScript8 / OaksLabRivalChallenge)
|
-- OaksLabScript8 / OaksLabRivalChallenge)
|
||||||
@@ -171,9 +206,12 @@ return {
|
|||||||
local party = flags.EVENT_CHOSE_BULBASAUR and 3
|
local party = flags.EVENT_CHOSE_BULBASAUR and 3
|
||||||
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
|
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
|
||||||
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
|
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
|
||||||
|
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
|
||||||
|
table.insert(rows, { "heal_party" })
|
||||||
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
||||||
-- a loss blacks out to another map: end the script there
|
-- win: sulk text then exit; loss jumps to the walk-out (taunt was
|
||||||
table.insert(rows, { "jump_if_false", base + 7 })
|
-- already shown in-battle via Rival1WinText)
|
||||||
|
table.insert(rows, { "jump_if_false", base + 6 })
|
||||||
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||||
|
|||||||
@@ -10,15 +10,39 @@
|
|||||||
-- The girl, fisher and the four signs resolve automatically through the
|
-- The girl, fisher and the four signs resolve automatically through the
|
||||||
-- extracted text pointers (no script needed).
|
-- extracted text pointers (no script needed).
|
||||||
|
|
||||||
|
-- Once the HALL OF FAME has been reached (EVENT_BEAT_CHAMPION_RIVAL, set
|
||||||
|
-- alongside Commands.record_hall_of_fame) Oak offers the ProfOakData
|
||||||
|
-- battle (data/trainers/parties.asm) -- three teams picked by the
|
||||||
|
-- player's starter, mirroring the rival's type-advantage lineup, that go
|
||||||
|
-- unused in the original ROM.
|
||||||
return {
|
return {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_PALLETTOWN_OAK = {
|
TEXT_PALLETTOWN_OAK = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
|
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 2
|
||||||
{ "jump_if_true", 6 }, -- 3
|
{ "jump_if_false", 20 }, -- 3
|
||||||
{ "show_text", "_PalletTownOakHeyWaitDontGoOutText" },-- 4
|
{ "check_flag", "EVENT_BEAT_PROF_OAK" }, -- 4
|
||||||
{ "jump", 7 }, -- 5
|
{ "jump_if_true", 20 }, -- 5
|
||||||
{ "show_text", "_PalletTownOakItsUnsafeText" }, -- 6
|
{ "show_text", "OAK: So you want\nto test your\nskills on me?\f"
|
||||||
|
.. "Very well! Let\nme show you what\na real trainer\ncan do!" }, -- 6
|
||||||
|
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
|
||||||
|
{ "jump_if_false", 11 }, -- 8
|
||||||
|
{ "start_battle", "trainer", "OPP_PROF_OAK", 3 }, -- 9 CHARIZARD
|
||||||
|
{ "jump", 16 }, -- 10
|
||||||
|
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11
|
||||||
|
{ "jump_if_false", 15 }, -- 12
|
||||||
|
{ "start_battle", "trainer", "OPP_PROF_OAK", 2 }, -- 13 VENUSAUR
|
||||||
|
{ "jump", 16 }, -- 14
|
||||||
|
{ "start_battle", "trainer", "OPP_PROF_OAK", 1 }, -- 15 BLASTOISE
|
||||||
|
{ "jump_if_false", "end" }, -- 16
|
||||||
|
{ "set_flag", "EVENT_BEAT_PROF_OAK" }, -- 17
|
||||||
|
{ "show_text", "OAK: Impressive!\nYou truly are a\nPOKéMON MASTER!" }, -- 18
|
||||||
|
{ "jump", "end" }, -- 19
|
||||||
|
{ "check_flag", "EVENT_GOT_STARTER" }, -- 20
|
||||||
|
{ "jump_if_true", 24 }, -- 21
|
||||||
|
{ "show_text", "_PalletTownOakHeyWaitDontGoOutText" }, -- 22
|
||||||
|
{ "jump", "end" }, -- 23
|
||||||
|
{ "show_text", "_PalletTownOakItsUnsafeText" }, -- 24
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm.
|
-- Hand-ported from pret/pokered scripts/RedsHouse1F.asm.
|
||||||
-- Mom (RedsHouse1FMomText, text_asm) heals the party and shows the
|
-- Mom (RedsHouse1FMomText): pre-starter shows the wake-up / Oak tip;
|
||||||
-- "you should rest" / "looking great" dialogue. The intro "wake up"
|
-- after EVENT_GOT_STARTER, RedsHouse1FMomHealScript fades to white,
|
||||||
-- branch is tied to the unported intro cutscene, so the heal path is used.
|
-- heals, plays MUSIC_PKMN_HEALED, fades back, then "looking great".
|
||||||
|
|
||||||
return {
|
return {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_REDSHOUSE1F_MOM = {
|
TEXT_REDSHOUSE1F_MOM = {
|
||||||
{ "face_player" },
|
{ "face_player" }, -- 1
|
||||||
{ "show_text", "_RedsHouse1FMomYouShouldRestText" },
|
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
|
||||||
{ "heal_party" },
|
{ "jump_if_true", 6 }, -- 3
|
||||||
{ "show_text", "_RedsHouse1FMomLookingGreatText" },
|
{ "show_text", "_RedsHouse1FMomWakeUpText" }, -- 4
|
||||||
|
{ "jump", "end" }, -- 5
|
||||||
|
-- RedsHouse1FMomHealScript
|
||||||
|
{ "show_text", "_RedsHouse1FMomYouShouldRestText" }, -- 6
|
||||||
|
{ "fade", "out", "white" }, -- 7 GBFadeOutToWhite
|
||||||
|
{ "heal_party" }, -- 8
|
||||||
|
{ "play_once", "Music_PkmnHealed" }, -- 9 wait + restore map
|
||||||
|
{ "fade", "in", "white" }, -- 10 GBFadeInFromWhite
|
||||||
|
{ "show_text", "_RedsHouse1FMomLookingGreatText" }, -- 11
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+494
-110
@@ -10,6 +10,31 @@ local M = {}
|
|||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
M.VIRIDIAN_MART = {
|
M.VIRIDIAN_MART = {
|
||||||
|
-- scripts/ViridianMart.asm: the parcel hand-off is the map's DEFAULT
|
||||||
|
-- script, not a talk. Entering with a starter and no parcel runs
|
||||||
|
-- ViridianMartDefaultScript -- the clerk calls out, then
|
||||||
|
-- StartSimulatingJoypadStates walks the player to the counter
|
||||||
|
-- (.PlayerMovement: PAD_LEFT 1, PAD_UP 2, door (3,7) -> counter (2,5))
|
||||||
|
-- and ViridianMartOaksParcelScript hands the parcel over. The player
|
||||||
|
-- never presses A. This matters beyond convenience: the parcel gates
|
||||||
|
-- Oak's Pokedex and the old man clearing Route 2, so vanilla guarantees
|
||||||
|
-- it on entry rather than letting you walk out without it.
|
||||||
|
--
|
||||||
|
-- The talk branch below is kept as the fallback for a save that reaches
|
||||||
|
-- the counter without this having fired.
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
local f = game.save.flags
|
||||||
|
if f.EVENT_OAK_GOT_PARCEL or f.EVENT_GOT_OAKS_PARCEL then return end
|
||||||
|
if not f.EVENT_GOT_STARTER then return end
|
||||||
|
ow:queueScript({
|
||||||
|
{ "show_text", "_ViridianMartClerkYouCameFromPalletTownText" },
|
||||||
|
{ "move_player", "left", 1 },
|
||||||
|
{ "move_player", "up", 2 },
|
||||||
|
-- the quest text's last page is "{PLAYER} got\nOAK's PARCEL!"
|
||||||
|
{ "give_item", "OAKS_PARCEL", 1, "_ViridianMartClerkParcelQuestText" },
|
||||||
|
{ "set_flag", "EVENT_GOT_OAKS_PARCEL" },
|
||||||
|
})
|
||||||
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_VIRIDIANMART_CLERK = {
|
TEXT_VIRIDIANMART_CLERK = {
|
||||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
|
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
|
||||||
@@ -33,42 +58,48 @@ M.VIRIDIAN_MART = {
|
|||||||
|
|
||||||
M.VIRIDIAN_CITY = {
|
M.VIRIDIAN_CITY = {
|
||||||
talk = {
|
talk = {
|
||||||
-- the old man napping on the north path (scripts/ViridianCity.asm)
|
-- The GAMBLER_ASLEEP at (18,9) (ViridianCityOldManSleepyText): he
|
||||||
-- after his coffee, the old man offers the catch tutorial: not in a
|
-- only ever grumbles and shoves you back down -- he never wakes,
|
||||||
-- hurry -> he demos catching a wild mon (BATTLE_TYPE_OLD_MAN)
|
-- moves or hides. The coffee ask and the catch tutorial belong to
|
||||||
|
-- the *other* old man, the walking SPRITE_GAMBLER at (17,5)
|
||||||
|
-- (TEXT_VIRIDIANCITY_OLD_MAN, below). The two are swapped by the
|
||||||
|
-- Pokédex in data/scripts/oaks_lab.lua, not by talking to either.
|
||||||
TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY = {
|
TEXT_VIRIDIANCITY_OLD_MAN_SLEEPY = {
|
||||||
{ "check_flag", "EVENT_OAK_GOT_PARCEL" }, -- 1
|
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 1
|
||||||
{ "jump_if_true", 5 }, -- 2
|
{ "move_player", "down", 1 }, -- 2
|
||||||
{ "show_text", "_ViridianCityOldManSleepyPrivatePropertyText" }, -- 3
|
},
|
||||||
{ "jump", 14 }, -- 4
|
|
||||||
{ "face_player" }, -- 5
|
-- The walking old man at (17,5), shown once the Pokédex swaps him in
|
||||||
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 6
|
-- (ViridianCityOldManText). "Are you in a hurry?" -- YES brushes you
|
||||||
{ "jump_if_true", 12 }, -- 7 (in a hurry)
|
-- off, NO leads into the catch tutorial: he explains, demos a catch
|
||||||
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 8
|
-- on a wild WEEDLE (BATTLE_TYPE_OLD_MAN), then comments afterwards.
|
||||||
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" }, -- 9
|
-- pokered prints YouNeedToWeakenTheTarget *after* the demo battle
|
||||||
{ "old_man_demo" }, -- 10
|
-- (ViridianCityOldManEndCatchTrainingScript), not before it.
|
||||||
{ "jump", 13 }, -- 11
|
TEXT_VIRIDIANCITY_OLD_MAN = {
|
||||||
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 12
|
{ "face_player" }, -- 1
|
||||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, -- 13
|
{ "ask", "_ViridianCityOldManHadMyCoffeeNowText" }, -- 2
|
||||||
|
{ "jump_if_true", 8 }, -- 3 (yes = in a hurry)
|
||||||
|
{ "show_text", "_ViridianCityOldManKnowHowToCatchPokemonText" }, -- 4
|
||||||
|
{ "old_man_demo" }, -- 5
|
||||||
|
{ "show_text", "_ViridianCityOldManYouNeedToWeakenTheTargetText" },-- 6
|
||||||
|
{ "jump", 9 }, -- 7
|
||||||
|
{ "show_text", "_ViridianCityOldManTimeIsMoneyText" }, -- 8 (9 = end)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
-- the sleeping old man lies across the Route 22 path (18,9): until
|
-- ViridianCityCheckGotPokedexScript: the north corridor is gated on
|
||||||
-- he moves you can't slip past on either side (scripts/ViridianCity
|
-- EVENT_GOT_POKEDEX, NOT on the sleeper being hidden, and it triggers
|
||||||
-- blocks the whole corridor, not just his tile)
|
-- on exactly one cell -- (19,9), the gap east of the sleeper (18,9)
|
||||||
|
-- and the girl (17,9). With the Pokédex the check returns immediately
|
||||||
|
-- and you simply walk past at x=19.
|
||||||
onStep = function(game, ow, x, y)
|
onStep = function(game, ow, x, y)
|
||||||
local gone = game.save.objectToggles and game.save.objectToggles.VIRIDIAN_CITY
|
if game.save.flags and game.save.flags.EVENT_GOT_POKEDEX then return false end
|
||||||
and game.save.objectToggles.VIRIDIAN_CITY.VIRIDIANCITY_OLD_MAN_SLEEPY == false
|
if x ~= 19 or y ~= 9 then return false end
|
||||||
if gone then return false end
|
local TextBox = require("src.render.TextBox")
|
||||||
-- crossing north of his row through the 3-wide gap (x 17-19, y<=8)
|
game.stack:push(TextBox.new(game,
|
||||||
if y <= 8 and x >= 17 and x <= 19 then
|
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
||||||
local TextBox = require("src.render.TextBox")
|
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
||||||
game.stack:push(TextBox.new(game,
|
function() ow:scriptMove(ow.player, "down", 1) end))
|
||||||
game.data.text._ViridianCityOldManSleepyPrivatePropertyText
|
return true
|
||||||
or "You can't go\nthrough here!\fThis is private\nproperty!",
|
|
||||||
function() ow:scriptMove(ow.player, "down", 1) end))
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
return false
|
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,26 +133,145 @@ M.BLUES_HOUSE = {
|
|||||||
|
|
||||||
M.BILLS_HOUSE = {
|
M.BILLS_HOUSE = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_BILLSHOUSE_BILL_POKEMON = {
|
-- BillsHouseBillPokemonText: "I'm not a POKéMON!", a YES/NO choice
|
||||||
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 1
|
-- (NO only adds "No, you gotta help!" before rejoining the YES
|
||||||
{ "jump_if_true", 11 }, -- 2
|
-- path), then the "get in the TELEPORTER" line and the monster
|
||||||
{ "show_text", "_BillsHouseBillImNotAPokemonText" }, -- 3
|
-- walking into the cell-separator machine
|
||||||
{ "show_text", "_BillsHouseBillNoYouGottaHelpText" }, -- 4
|
-- (BillsHousePokemonWalkToMachineScript: up 3, or around the player
|
||||||
-- the cell-separator PC throws its switch
|
-- when they stand in the way facing down), where it is hidden and
|
||||||
-- (bills_house_pc.asm BillsHouseInitiatedText: SFX_SWITCH)
|
-- EVENT_BILL_SAID_USE_CELL_SEPARATOR arms the PC at (1,4) -- see
|
||||||
{ "play_sound", "Switch" }, -- 5
|
-- OverworldState.billsHousePC for the separator itself.
|
||||||
{ "show_text", "_BillsHouseBillThankYouText" }, -- 6
|
TEXT_BILLSHOUSE_BILL_POKEMON = function(game, ow, npc, done)
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local t = game.data.text
|
||||||
|
local function toMachine()
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._BillsHouseBillUseSeparationSystemText
|
||||||
|
or "When I'm in the\nTELEPORTER, run\nthe Cell\nSeparation System!", function()
|
||||||
|
local function entered()
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
Commands.hide_object({ game = game, save = game.save,
|
||||||
|
overworld = ow },
|
||||||
|
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||||
|
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||||
|
done()
|
||||||
|
end
|
||||||
|
if ow.player.facing == "down" then
|
||||||
|
-- the player is standing on his straight path: walk around
|
||||||
|
-- (.PokemonWalkAroundPlayerMovement)
|
||||||
|
ow:scriptMove(npc, "right", 1, function()
|
||||||
|
ow:scriptMove(npc, "up", 2, function()
|
||||||
|
ow:scriptMove(npc, "left", 1, function()
|
||||||
|
ow:scriptMove(npc, "up", 1, entered)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
ow:scriptMove(npc, "up", 3, entered)
|
||||||
|
end
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._BillsHouseBillImNotAPokemonText or "Hey! I'm not a\nPOKéMON!",
|
||||||
|
nil, { choice = function(yes)
|
||||||
|
if yes then
|
||||||
|
toMachine()
|
||||||
|
else
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._BillsHouseBillNoYouGottaHelpText
|
||||||
|
or "No! You gotta\nhelp me!", toMachine))
|
||||||
|
end
|
||||||
|
end }))
|
||||||
|
end,
|
||||||
|
|
||||||
|
-- BillsHouseBillSSTicketText (human Bill after the separation)
|
||||||
|
TEXT_BILLSHOUSE_BILL_SS_TICKET = {
|
||||||
|
{ "face_player" }, -- 1
|
||||||
|
{ "check_flag", "EVENT_GOT_SS_TICKET" }, -- 2
|
||||||
|
{ "jump_if_true", 12 }, -- 3
|
||||||
|
{ "show_text", "_BillsHouseBillThankYouText" }, -- 4
|
||||||
-- pokered gives first (GiveItem fills wStringBuffer), then prints
|
-- pokered gives first (GiveItem fills wStringBuffer), then prints
|
||||||
-- the received text that reads it (scripts/BillsHouse.asm; the
|
-- the received text that reads it (scripts/BillsHouse.asm; the
|
||||||
-- item id is S_S_TICKET in generated items.lua -- keyItem, so the
|
-- item id is S_S_TICKET in generated items.lua -- keyItem, so the
|
||||||
-- sound_get_key_item jingle plays like BillsHouse.asm:196)
|
-- sound_get_key_item jingle plays like BillsHouse.asm:196)
|
||||||
{ "give_item", "S_S_TICKET", 1, false }, -- 7
|
{ "give_item", "S_S_TICKET", 1, false }, -- 5
|
||||||
{ "show_text", "_SSTicketReceivedText" }, -- 8
|
{ "show_text", "_SSTicketReceivedText" }, -- 6
|
||||||
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 9
|
{ "set_flag", "EVENT_GOT_SS_TICKET" }, -- 7
|
||||||
{ "jump", 12 }, -- 10
|
-- The two Cerulean guards are a SWAP PAIR, not scenery
|
||||||
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 11
|
-- (BillsHouse.asm:174-178): handing over the ticket shows GUARD1 at
|
||||||
|
-- (28,12) and hides GUARD2 at (27,12). This matters far more than it
|
||||||
|
-- looks: (27,12) is the ONLY walkable neighbour of the trashed
|
||||||
|
-- house's south door at (27,11), and that house is one of the two
|
||||||
|
-- ways through the fence that splits Cerulean in half (the badge
|
||||||
|
-- house is the other). Leaving GUARD2 up forever severs the city --
|
||||||
|
-- the gym/mart half can never reach the Route 5 exit.
|
||||||
|
-- Same swap fires after the TM28 Rocket (CeruleanCity_2.asm
|
||||||
|
-- CeruleanHideRocket), so either route opens the path.
|
||||||
|
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 8
|
||||||
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 9
|
||||||
|
{ "show_text", "_BillsHouseBillWhyDontYouGoInsteadOfMeText" }, -- 10
|
||||||
|
{ "jump", 13 }, -- 11
|
||||||
|
{ "show_text", "_BillsHouseBillWhyDontYouGoInsteadOfMeText" }, -- 12
|
||||||
|
},
|
||||||
|
|
||||||
|
TEXT_BILLSHOUSE_BILL_CHECK_OUT_MY_RARE_POKEMON = {
|
||||||
|
{ "face_player" }, -- 1
|
||||||
|
{ "show_text", "_BillsHouseBillCheckOutMyRarePokemonText" }, -- 2
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
-- repair saves that already got the ticket under the old collapsed
|
||||||
|
-- script (the monster never hidden, human Bill never shown)
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
local flags = game.save.flags
|
||||||
|
if flags.EVENT_GOT_SS_TICKET
|
||||||
|
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
|
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||||
|
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||||
|
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||||
|
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
|
||||||
|
flags.EVENT_MET_BILL = true
|
||||||
|
flags.EVENT_MET_BILL_2 = true
|
||||||
|
end
|
||||||
|
-- After Route25ToggleBillsScript, BILL2 is the visible human Bill
|
||||||
|
-- ("check out my rare POKéMON"). Re-apply on enter so a mid-house
|
||||||
|
-- load still matches the toggles if the pool was rebuilt.
|
||||||
|
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
|
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||||
|
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||||
|
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
-- Route 25 outside Bill's house (scripts/Route25.asm
|
||||||
|
-- Route25ToggleBillsScript): leaving after the SS Ticket arms the
|
||||||
|
-- Eevee PC list and swaps human Bill to his post-help dialogue NPC.
|
||||||
|
-- Leaving mid-quest (Bill still in the machine) puts the monster back.
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
M.ROUTE_25 = {
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
local flags = game.save.flags
|
||||||
|
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
|
if not flags.EVENT_MET_BILL_2 then
|
||||||
|
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
|
||||||
|
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not flags.EVENT_GOT_SS_TICKET then return end
|
||||||
|
flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true
|
||||||
|
-- TOGGLE_NUGGET_BRIDGE_GUY (ROUTE24_COOLTRAINER_M1)
|
||||||
|
Commands.hide_object(ctx, "ROUTE_24", "ROUTE24_COOLTRAINER_M1")
|
||||||
|
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||||
|
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
@@ -142,17 +292,63 @@ M.VERMILION_CITY = {
|
|||||||
game.save.trashPuzzle = puz
|
game.save.trashPuzzle = puz
|
||||||
puz.first = love.math.random(0, 7) * 2
|
puz.first = love.math.random(0, 7) * 2
|
||||||
end,
|
end,
|
||||||
|
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
|
||||||
|
-- the unguarded cell (18,30) just west of the sailor leads straight
|
||||||
|
-- onto the dock warp. Stepping onto it facing down always runs the
|
||||||
|
-- sailor dialog (DisplayTextID TEXT_VERMILIONCITY_SAILOR1); only a
|
||||||
|
-- ticket while the ship is still docked lets the player continue --
|
||||||
|
-- otherwise they are walked back up. The sailor himself never hides.
|
||||||
|
onStep = function(game, ow, x, y)
|
||||||
|
if x ~= 18 or y ~= 30 then return false end
|
||||||
|
if ow.player.facing ~= "down" then return false end
|
||||||
|
local Flags = require("src.script.Flags")
|
||||||
|
local t = game.data.text
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local shipLeft = Flags.get(game.save, "EVENT_SS_ANNE_LEFT")
|
||||||
|
local hasTicket = (game.save.inventory.S_S_TICKET or 0) > 0
|
||||||
|
if shipLeft then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||||
|
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
||||||
|
-- VermilionCitySailor1Text always takes .greet_player_and_check_ticket:
|
||||||
|
-- DoYouHaveATicket, then FlashedTicket (allow through) or YouNeedATicket
|
||||||
|
-- (walk back). Returning true while the box is up also blocks the
|
||||||
|
-- dock warp on this step.
|
||||||
|
local ask = t._VermilionCitySailor1DoYouHaveATicketText
|
||||||
|
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
|
||||||
|
if hasTicket then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
ask .. "\f"
|
||||||
|
.. (t._VermilionCitySailor1FlashedTicketText
|
||||||
|
or "{PLAYER} flashed\nthe S.S.TICKET!")))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
ask .. "\f"
|
||||||
|
.. (t._VermilionCitySailor1YouNeedATicketText
|
||||||
|
or "You need a ticket\nto get aboard."),
|
||||||
|
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||||
|
return true
|
||||||
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
-- the sailor guarding the dock gangway
|
-- the sailor guarding the dock gangway (VermilionCitySailor1Text):
|
||||||
|
-- flashing the ticket just lets you through -- he never hides, and
|
||||||
|
-- once the ship has sailed he only reports it gone
|
||||||
TEXT_VERMILIONCITY_SAILOR1 = {
|
TEXT_VERMILIONCITY_SAILOR1 = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 2
|
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
|
||||||
{ "check_item", "S_S_TICKET" }, -- 3
|
{ "jump_if_true", 11 }, -- 3
|
||||||
{ "jump_if_false", 8 }, -- 4
|
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
|
||||||
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 5
|
{ "check_item", "S_S_TICKET" }, -- 5
|
||||||
{ "hide_object", "VERMILION_CITY", "VERMILIONCITY_SAILOR1" }, -- 6
|
{ "jump_if_false", 9 }, -- 6
|
||||||
{ "jump", 9 }, -- 7
|
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
|
||||||
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 8
|
{ "jump", 12 }, -- 8
|
||||||
|
{ "show_text", "_VermilionCitySailor1YouNeedATicketText" }, -- 9
|
||||||
|
{ "jump", 12 }, -- 10
|
||||||
|
{ "show_text", "_VermilionCitySailor1ShipSetSailText" }, -- 11
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -175,18 +371,21 @@ M.SS_ANNE_2F = {
|
|||||||
|
|
||||||
M.SS_ANNE_CAPTAINS_ROOM = {
|
M.SS_ANNE_CAPTAINS_ROOM = {
|
||||||
talk = {
|
talk = {
|
||||||
|
-- SSAnneCaptainsRoomCaptainText: after the rub line's text_asm tail,
|
||||||
|
-- pokered plays MUSIC_PKMN_HEALED (scripts/SSAnneCaptainsRoom.asm).
|
||||||
TEXT_SSANNECAPTAINSROOM_CAPTAIN = {
|
TEXT_SSANNECAPTAINSROOM_CAPTAIN = {
|
||||||
{ "check_flag", "EVENT_GOT_HM01" }, -- 1
|
{ "check_flag", "EVENT_GOT_HM01" }, -- 1
|
||||||
{ "jump_if_true", 9 }, -- 2
|
{ "jump_if_true", 10 }, -- 2
|
||||||
{ "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3
|
{ "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3
|
||||||
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 4
|
{ "play_once", "Music_PkmnHealed" }, -- 4
|
||||||
|
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 5
|
||||||
-- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem
|
-- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem
|
||||||
-- fills wStringBuffer; the received text reads it)
|
-- fills wStringBuffer; the received text reads it)
|
||||||
{ "give_item", "HM_CUT", 1, false }, -- 5
|
{ "give_item", "HM_CUT", 1, false }, -- 6
|
||||||
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 6
|
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 7
|
||||||
{ "set_flag", "EVENT_GOT_HM01" }, -- 7
|
{ "set_flag", "EVENT_GOT_HM01" }, -- 8
|
||||||
{ "jump", 10 }, -- 8
|
{ "jump", 11 }, -- 9
|
||||||
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 9
|
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 10
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -208,7 +407,17 @@ M.POKEMON_TOWER_7F = {
|
|||||||
{ "show_object", "MR_FUJIS_HOUSE", "MRFUJISHOUSE_MR_FUJI" }, -- 5
|
{ "show_object", "MR_FUJIS_HOUSE", "MRFUJISHOUSE_MR_FUJI" }, -- 5
|
||||||
{ "hide_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET8" }, -- 6
|
{ "hide_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET8" }, -- 6
|
||||||
{ "show_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET9" }, -- 7
|
{ "show_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET9" }, -- 7
|
||||||
{ "warp", "MR_FUJIS_HOUSE", 3, 3, "down" }, -- 8
|
-- pokered warps to wDestinationWarpID $1 (0-based) -- the house's
|
||||||
|
-- SECOND warp, the door mat at (3,7) -- facing UP
|
||||||
|
-- (PokemonTower7FWarpToMrFujiHouseScript: SPRITE_FACING_UP +
|
||||||
|
-- hWarpDestinationMap MR_FUJIS_HOUSE). Landing (3,3) instead put
|
||||||
|
-- the player at the Pokédex table, and the route's first waypoint
|
||||||
|
-- (3,7) then stepped onto a LIVE door mat and exited the house
|
||||||
|
-- before ever talking to Fuji -- so the POKE_FLUTE was never
|
||||||
|
-- collected and the Route 16 SNORLAX sealed the map. The arrival
|
||||||
|
-- mat itself is inert until stepped off (warpEntryCell), which is
|
||||||
|
-- what makes the vanilla coordinates safe.
|
||||||
|
{ "warp", "MR_FUJIS_HOUSE", 3, 7, "up" }, -- 8
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -343,6 +552,50 @@ M.WARDENS_HOUSE = {
|
|||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
M.SILPH_CO_11F = {
|
M.SILPH_CO_11F = {
|
||||||
|
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
|
||||||
|
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
|
||||||
|
-- .PlayerCoordsArray -- (6,13) and (7,12) -- every frame while
|
||||||
|
-- EVENT_BEAT_SILPH_CO_GIOVANNI is unset: standing there shows his text,
|
||||||
|
-- walks him three tiles down (.GiovanniMovement), and starts the fight.
|
||||||
|
-- He also has no trainer-header entry, so sight engagement never fires
|
||||||
|
-- either. Without this hook he was a talk-only statue four tiles away
|
||||||
|
-- from anything the route (or a vanilla-faithful player walking the same
|
||||||
|
-- line) would touch, and the whole Silph ending -- the flag, the Master
|
||||||
|
-- Ball, the Saffron streets clearing -- silently never happened.
|
||||||
|
--
|
||||||
|
-- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and,
|
||||||
|
-- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss
|
||||||
|
-- sets nothing, so the trigger re-arms exactly as vanilla does.
|
||||||
|
onStep = function(game, ow, x, y)
|
||||||
|
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end
|
||||||
|
if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end
|
||||||
|
local gio
|
||||||
|
for _, npc in ipairs(ow.npcs) do
|
||||||
|
if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
|
||||||
|
end
|
||||||
|
if not gio or ow:trainerDefeated(gio) then return false end
|
||||||
|
ow:scriptMove(gio, "down", 3, function()
|
||||||
|
gio:facePlayer(ow.player)
|
||||||
|
ow:engageTrainer(gio, function()
|
||||||
|
-- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor
|
||||||
|
-- after the loss (the street rockets are handled by
|
||||||
|
-- M.SAFFRON_CITY.onEnter in story4.lua).
|
||||||
|
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
|
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
|
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
||||||
|
end
|
||||||
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
|
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
@@ -363,7 +616,8 @@ M.SILPH_CO_11F = {
|
|||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
|
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
|
||||||
-- a boulder resting on a switch removes a barrier block; the 3F hole
|
-- a boulder resting on a switch removes a barrier block; the 3F hole
|
||||||
-- drops a boulder down to the 2F switch.
|
-- drops a boulder down to the 2F switch, and also dungeon-warps the
|
||||||
|
-- player (IsPlayerOnDungeonWarp + DungeonWarpList/Data -> 2F at 22,16).
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
local function boulderAt(ow, x, y)
|
local function boulderAt(ow, x, y)
|
||||||
@@ -429,6 +683,18 @@ M.VICTORY_ROAD_3F = {
|
|||||||
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
|
Commands.show_object(ctx, "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER")
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
|
-- scripts/VictoryRoad3F.asm VictoryRoad3FDefaultScript: the same hole
|
||||||
|
-- is a dungeon warp for the player (wDungeonWarpDestinationMap =
|
||||||
|
-- VICTORY_ROAD_2F, wWhichDungeonWarp = 2 -> DungeonWarpData 22,16).
|
||||||
|
-- Mirrors POKEMON_MANSION_3F.onStep; CAVERN $22 is walkable so the
|
||||||
|
-- fall is onStep, not a collision block.
|
||||||
|
onStep = function(game, ow, x, y)
|
||||||
|
if x == 23 and y == 15 then
|
||||||
|
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
@@ -441,6 +707,16 @@ M.VICTORY_ROAD_3F = {
|
|||||||
-- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches,
|
-- IndigoPlateauLobby.asm) so the champion is re-fightable on rematches,
|
||||||
-- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set
|
-- like pokered's re-entry cutscene. EVENT_BEAT_CHAMPION_RIVAL stays set
|
||||||
-- forever (postgame gates like the Cerulean cave guard read it).
|
-- forever (postgame gates like the Cerulean cave guard read it).
|
||||||
|
--
|
||||||
|
-- pokered forces the fight on map entry: Agatha's victory arms
|
||||||
|
-- SCRIPT_CHAMPIONSROOM_PLAYER_ENTERS (scripts/AgathasRoom.asm), and
|
||||||
|
-- ChampionsRoomPlayerEntersScript then runs RivalEntrance_RLEMovement
|
||||||
|
-- (up 1, right 1, up 3) before ChampionsRoomRivalReadyToBattleScript.
|
||||||
|
-- The rival object has no trainer header / sight range, so without that
|
||||||
|
-- entrance script the player can walk past (issue #99). We arm on the
|
||||||
|
-- run flag instead of Agatha's victory bit: same observable effect for
|
||||||
|
-- first clear and Indigo rematches, without a cross-map script pointer.
|
||||||
|
--
|
||||||
-- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript ->
|
-- scripts/ChampionsRoom.asm ChampionsRoomRivalDefeatedScript ->
|
||||||
-- OakArrivesScript -> OakCongratulatesPlayerScript ->
|
-- OakArrivesScript -> OakCongratulatesPlayerScript ->
|
||||||
-- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript,
|
-- OakDisappointedWithRivalScript -> OakComeWithMeScript -> OakExitsScript,
|
||||||
@@ -449,43 +725,69 @@ M.VICTORY_ROAD_3F = {
|
|||||||
-- itself is NOT run here: it belongs to the HALL_OF_FAME room script
|
-- itself is NOT run here: it belongs to the HALL_OF_FAME room script
|
||||||
-- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room
|
-- (scripts/HallOfFame.asm), so we set a one-shot marker and warp; the room
|
||||||
-- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record.
|
-- onEnter (M.HALL_OF_FAME below) drives the HoF Oak speech + record.
|
||||||
|
local championsRoomRivalScript = {
|
||||||
|
{ "face_player" }, -- 1
|
||||||
|
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
|
||||||
|
{ "jump_if_true", 25 }, -- 3 past end
|
||||||
|
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
|
||||||
|
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
|
||||||
|
{ "jump_if_false", 25 }, -- 6 past end
|
||||||
|
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
|
||||||
|
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
|
||||||
|
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
|
||||||
|
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
|
||||||
|
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
|
||||||
|
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
|
||||||
|
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
|
||||||
|
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
|
||||||
|
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
|
||||||
|
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
|
||||||
|
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
|
||||||
|
{ "show_text", "_ChampionsRoomOakText" }, -- 11
|
||||||
|
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
|
||||||
|
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
|
||||||
|
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||||
|
{ "face_object", 1, "left" }, -- 14
|
||||||
|
{ "face_object", 2, "down" }, -- 15
|
||||||
|
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
|
||||||
|
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||||
|
{ "face_object", 2, "right" }, -- 17
|
||||||
|
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
|
||||||
|
-- OakComeWithMeScript: Oak faces down again, then exits up
|
||||||
|
{ "face_object", 2, "down" }, -- 19
|
||||||
|
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
|
||||||
|
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
|
||||||
|
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
|
||||||
|
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
||||||
|
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||||
|
{ "set_field", "pendingHallOfFame", true }, -- 23
|
||||||
|
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
|
||||||
|
}
|
||||||
|
|
||||||
M.CHAMPIONS_ROOM = {
|
M.CHAMPIONS_ROOM = {
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
if game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN then return end
|
||||||
|
-- Lance entrance warps land at y=7; HoF return warps land at y=0.
|
||||||
|
-- Only the south entry should run ChampionsRoomPlayerEntersScript.
|
||||||
|
if ow.player.cellY < 7 then return end
|
||||||
|
local rival
|
||||||
|
for _, npc in ipairs(ow.npcs) do
|
||||||
|
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then
|
||||||
|
rival = npc
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- RivalEntrance_RLEMovement, then the battle/Oak script (queued
|
||||||
|
-- separately so talk-script jump indices stay 1-based as written).
|
||||||
|
ow:queueScript({
|
||||||
|
{ "move_player", "up", 1 },
|
||||||
|
{ "move_player", "right", 1 },
|
||||||
|
{ "move_player", "up", 3 },
|
||||||
|
})
|
||||||
|
ow:queueScript(championsRoomRivalScript, { npc = rival })
|
||||||
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_CHAMPIONSROOM_RIVAL = {
|
TEXT_CHAMPIONSROOM_RIVAL = championsRoomRivalScript,
|
||||||
{ "face_player" }, -- 1
|
|
||||||
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
|
|
||||||
{ "jump_if_true", 24 }, -- 3
|
|
||||||
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
|
|
||||||
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
|
|
||||||
{ "jump_if_false", 24 }, -- 6
|
|
||||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
|
|
||||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
|
|
||||||
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
|
|
||||||
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
|
|
||||||
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
|
|
||||||
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
|
|
||||||
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
|
|
||||||
-- ChampionsRoomOakArrivesScript: Oak's "{PLAYER}!" then reveal + walk in
|
|
||||||
{ "show_text", "_ChampionsRoomOakText" }, -- 10
|
|
||||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 11
|
|
||||||
{ "move_npc", 2, "up", 5 }, -- 12 OakEntranceAfterVictoryMovement (3,7)->(3,2)
|
|
||||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
|
||||||
{ "face_object", 1, "left" }, -- 13
|
|
||||||
{ "face_object", 2, "down" }, -- 14
|
|
||||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 15
|
|
||||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
|
||||||
{ "face_object", 2, "right" }, -- 16
|
|
||||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 17
|
|
||||||
-- OakComeWithMeScript: Oak faces down again, then exits up
|
|
||||||
{ "face_object", 2, "down" }, -- 18
|
|
||||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 19
|
|
||||||
{ "move_npc", 2, "up", 2 }, -- 20 OakExitChampionsRoomMovement (3,2)->(3,0)
|
|
||||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 21
|
|
||||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
|
||||||
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
|
||||||
{ "set_field", "pendingHallOfFame", true }, -- 22
|
|
||||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 23
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +817,29 @@ M.HALL_OF_FAME = {
|
|||||||
local toggles = game.save.objectToggles and game.save.objectToggles.HALL_OF_FAME
|
local toggles = game.save.objectToggles and game.save.objectToggles.HALL_OF_FAME
|
||||||
if toggles then toggles.HALLOFFAME_OAK = nil end
|
if toggles then toggles.HALLOFFAME_OAK = nil end
|
||||||
|
|
||||||
|
-- Make the Hall of Fame recording machine interactable. The console
|
||||||
|
-- juts from the north wall as the two solid cells (4,1) and (5,1); a
|
||||||
|
-- sign on each lets the player face the machine (from below, or from
|
||||||
|
-- either side) and press A to run the TEXT_HALLOFFAME_PC talk script.
|
||||||
|
-- maps.lua is generated from the ROM and gitignored, so the sign is
|
||||||
|
-- injected here (a tracked script) rather than baked into map data.
|
||||||
|
local def = ow.map.def
|
||||||
|
def.signs = def.signs or {}
|
||||||
|
local present = false
|
||||||
|
for _, s in ipairs(def.signs) do
|
||||||
|
if s.text == "TEXT_HALLOFFAME_PC" then present = true break end
|
||||||
|
end
|
||||||
|
if not present then
|
||||||
|
table.insert(def.signs, { text = "TEXT_HALLOFFAME_PC", x = 4, y = 1 })
|
||||||
|
table.insert(def.signs, { text = "TEXT_HALLOFFAME_PC", x = 5, y = 1 })
|
||||||
|
end
|
||||||
|
-- the live Map instance built its signAt lookup from def.signs before
|
||||||
|
-- this hook ran, so rebuild it (idempotent) to pick up the injection
|
||||||
|
ow.map.signAt = {}
|
||||||
|
for _, s in ipairs(def.signs) do
|
||||||
|
ow.map.signAt[s.y * ow.map.widthCells + s.x] = s
|
||||||
|
end
|
||||||
|
|
||||||
if not game.save.pendingHallOfFame then return end
|
if not game.save.pendingHallOfFame then return end
|
||||||
game.save.pendingHallOfFame = false
|
game.save.pendingHallOfFame = false
|
||||||
ow:queueScript({
|
ow:queueScript({
|
||||||
@@ -528,6 +853,21 @@ M.HALL_OF_FAME = {
|
|||||||
{ "record_hall_of_fame" }, -- predef HallOfFamePC: induction + credits
|
{ "record_hall_of_fame" }, -- predef HallOfFamePC: induction + credits
|
||||||
})
|
})
|
||||||
end,
|
end,
|
||||||
|
talk = {
|
||||||
|
-- The recording machine doubles as a "warp home" PC: a YES/NO prompt
|
||||||
|
-- that teleports back to the new-game bedroom spawn (special_warps.asm
|
||||||
|
-- NewGameWarp: REDS_HOUSE_2F, 3, 6, facing down). Fabricated
|
||||||
|
-- convenience -- there is no such prompt in the original ROM.
|
||||||
|
-- Heal + remember_outdoor so house LAST_MAP mats land in Pallet, not
|
||||||
|
-- Indigo Plateau (issue #103 escape path for already-stuck saves).
|
||||||
|
TEXT_HALLOFFAME_PC = {
|
||||||
|
{ "ask", "Return to\nPALLET TOWN?" }, -- 1 YES/NO -> lastCheck
|
||||||
|
{ "jump_if_false", "end" }, -- 2 NO: back away
|
||||||
|
{ "heal_party" }, -- 3
|
||||||
|
{ "remember_outdoor", "PALLET_TOWN", 5, 6 }, -- 4 LAST_MAP -> Pallet door
|
||||||
|
{ "warp", "REDS_HOUSE_2F", 3, 6, "down" }, -- 5 YES: home to your room
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
@@ -552,21 +892,65 @@ M.CERULEAN_CITY = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- PokemonTower2F.asm: after the battle, PokemonTower2FDefeatedRivalScript
|
||||||
|
-- walks him out (RightThenDown vs DownThenRight from EVENT_POKEMON_TOWER_
|
||||||
|
-- RIVAL_ON_LEFT) then HideObject TOGGLE_POKEMON_TOWER_2F_RIVAL. Player
|
||||||
|
-- at (15,5) is the ON_LEFT case.
|
||||||
|
local TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN =
|
||||||
|
{ "right", "down", "down", "right", "down", "down", "right", "right" }
|
||||||
|
local TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT =
|
||||||
|
{ "down", "down", "right", "right", "right", "right", "down", "down" }
|
||||||
|
|
||||||
|
local function pokemonTower2FRivalScript(playerX)
|
||||||
|
local exitDirs = (playerX == 15)
|
||||||
|
and TOWER_RIVAL_EXIT_DOWN_THEN_RIGHT
|
||||||
|
or TOWER_RIVAL_EXIT_RIGHT_THEN_DOWN
|
||||||
|
return {
|
||||||
|
{ "face_player" }, -- 1
|
||||||
|
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
|
||||||
|
{ "jump_if_true", 12 }, -- 3
|
||||||
|
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
|
||||||
|
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
|
||||||
|
{ "jump_if_false", "end" }, -- 6 loss: stay
|
||||||
|
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
||||||
|
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
||||||
|
{ "walk_npc", 1, exitDirs }, -- 9
|
||||||
|
{ "hide_object", "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL" }, -- 10
|
||||||
|
{ "jump", "end" }, -- 11
|
||||||
|
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 12
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
M.POKEMON_TOWER_2F = {
|
M.POKEMON_TOWER_2F = {
|
||||||
|
rivalScript = pokemonTower2FRivalScript,
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_POKEMONTOWER2F_RIVAL = {
|
TEXT_POKEMONTOWER2F_RIVAL = function(game, ow, npc, done)
|
||||||
{ "face_player" }, -- 1
|
ow.runner:run(pokemonTower2FRivalScript(ow.player.cellX),
|
||||||
{ "check_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 2
|
{ npc = npc, onDone = done })
|
||||||
{ "jump_if_true", 10 }, -- 3
|
end,
|
||||||
{ "show_text", "_PokemonTower2FRivalWhatBringsYouHereText" }, -- 4
|
|
||||||
{ "rival_battle", "OPP_RIVAL2", 4 }, -- 5
|
|
||||||
{ "jump_if_false", 11 }, -- 6
|
|
||||||
{ "set_flag", "EVENT_BEAT_POKEMON_TOWER_RIVAL" }, -- 7
|
|
||||||
{ "show_text", "_PokemonTower2FRivalDefeatedText" }, -- 8
|
|
||||||
{ "jump", 11 }, -- 9
|
|
||||||
{ "show_text", "_PokemonTower2FRivalHowsYourDexText" }, -- 10
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
-- Saves that beat him before the exit walk was ported still have the
|
||||||
|
-- flag but a visible rival; hide on enter like BillsHouse repairs.
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
if not game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return end
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
Commands.hide_object({ game = game, save = game.save, overworld = ow },
|
||||||
|
"POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL")
|
||||||
|
end,
|
||||||
|
-- PokemonTower2FDefaultScript: walking past the rival's tile forces
|
||||||
|
-- the encounter (ArePlayerCoordsInArray on (15,5)/(14,6)) -- he never
|
||||||
|
-- waits to be talked to
|
||||||
|
onStep = function(game, ow, x, y)
|
||||||
|
if game.save.flags.EVENT_BEAT_POKEMON_TOWER_RIVAL then return false end
|
||||||
|
if not ((x == 15 and y == 5) or (x == 14 and y == 6)) then return false end
|
||||||
|
if ow.runner:isRunning() then return false end
|
||||||
|
local rival = ow:npcByIndex(1)
|
||||||
|
if not rival then return false end
|
||||||
|
ow.player.facing = (x == 15) and "left" or "up"
|
||||||
|
require("src.core.Music").play(game.data, "Music_MeetRival")
|
||||||
|
ow.runner:run(pokemonTower2FRivalScript(x), { npc = rival })
|
||||||
|
return true
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|||||||
+229
-59
@@ -278,6 +278,23 @@ M.PALLET_TOWN = {
|
|||||||
|
|
||||||
local DRINKS = { "FRESH_WATER", "SODA_POP", "LEMONADE" }
|
local DRINKS = { "FRESH_WATER", "SODA_POP", "LEMONADE" }
|
||||||
|
|
||||||
|
-- Hand over the first drink in the bag, if any. Mirrors RemoveGuardDrink
|
||||||
|
-- (engine/items/inventory.asm), which walks the same three item ids and
|
||||||
|
-- removes ONE, and the caller's BIT_GAVE_SAFFRON_GUARDS_DRINK.
|
||||||
|
local function takeGuardDrink(game)
|
||||||
|
for _, drink in ipairs(DRINKS) do
|
||||||
|
if (game.save.inventory[drink] or 0) > 0 then
|
||||||
|
game.save.inventory[drink] = game.save.inventory[drink] - 1
|
||||||
|
if game.save.inventory[drink] == 0 then
|
||||||
|
game.save.inventory[drink] = nil
|
||||||
|
end
|
||||||
|
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
local function saffronGate(guardText, triggers, horizontal)
|
local function saffronGate(guardText, triggers, horizontal)
|
||||||
return {
|
return {
|
||||||
talk = {
|
talk = {
|
||||||
@@ -289,18 +306,15 @@ local function saffronGate(guardText, triggers, horizontal)
|
|||||||
t._SaffronGateGuardThanksForTheDrinkText or "Gee, that was\ntasty!", done))
|
t._SaffronGateGuardThanksForTheDrinkText or "Gee, that was\ntasty!", done))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
for _, drink in ipairs(DRINKS) do
|
if takeGuardDrink(game) then
|
||||||
if (game.save.inventory[drink] or 0) > 0 then
|
game.stack:push(TextBox.new(game,
|
||||||
game.save.inventory[drink] = game.save.inventory[drink] - 1
|
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
|
||||||
if game.save.inventory[drink] == 0 then
|
function()
|
||||||
game.save.inventory[drink] = nil
|
game.stack:push(TextBox.new(game,
|
||||||
end
|
(t._SaffronGateGuardYouCanGoOnThroughText or
|
||||||
game.save.flags.EVENT_GAVE_GUARDS_DRINK = true
|
"You can go on\nthrough!"), done))
|
||||||
game.stack:push(TextBox.new(game,
|
end))
|
||||||
(t._SaffronGateGuardYouCanGoOnThroughText or
|
return
|
||||||
"Thanks! You can\ngo on through!"), done))
|
|
||||||
return
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!", done))
|
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!", done))
|
||||||
@@ -317,6 +331,31 @@ local function saffronGate(guardText, triggers, horizontal)
|
|||||||
if game.save.flags.EVENT_GAVE_GUARDS_DRINK then return false end
|
if game.save.flags.EVENT_GAVE_GUARDS_DRINK then return false end
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
|
-- Stepping on the trigger WITH a drink hands it over right here.
|
||||||
|
--
|
||||||
|
-- Route5GateDefaultScript (scripts/Route5Gate.asm) runs
|
||||||
|
-- `farcall RemoveGuardDrink` before it decides anything: the coord
|
||||||
|
-- trigger itself takes the drink and sets
|
||||||
|
-- BIT_GAVE_SAFFRON_GUARDS_DRINK, and only a player carrying nothing
|
||||||
|
-- gets the thirsty line and the walk-back. We had the removal on the
|
||||||
|
-- guard's TALK handler only, so walking up with a FRESH_WATER in the
|
||||||
|
-- bag was turned away and the four gates stayed shut unless you
|
||||||
|
-- happened to talk to him -- which vanilla never requires.
|
||||||
|
--
|
||||||
|
-- Saffron is the middle of the map, so this sealed it: every route
|
||||||
|
-- through the city (Celadon <-> Lavender, Vermilion <-> Cerulean the
|
||||||
|
-- short way) was unreachable, and the bot could not get to Lavender
|
||||||
|
-- for the POKE_FLUTE at all.
|
||||||
|
if takeGuardDrink(game) then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._SaffronGateGuardImParchedText or "Whoa, boy!\nI'm parched!",
|
||||||
|
function()
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
(t._SaffronGateGuardYouCanGoOnThroughText or
|
||||||
|
"You can go on\nthrough!")))
|
||||||
|
end))
|
||||||
|
return true
|
||||||
|
end
|
||||||
local back
|
local back
|
||||||
if horizontal then
|
if horizontal then
|
||||||
back = ow.player.facing == "left" and "right" or "left"
|
back = ow.player.facing == "left" and "right" or "left"
|
||||||
@@ -324,7 +363,7 @@ local function saffronGate(guardText, triggers, horizontal)
|
|||||||
back = ow.player.facing == "up" and "down" or "up"
|
back = ow.player.facing == "up" and "down" or "up"
|
||||||
end
|
end
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._SaffronGateGuardImParchedText or "I'm parched...\nNo entry until\nI get a drink!",
|
t._SaffronGateGuardGeeImThirstyText or "Gee, I'm thirsty\nthough!\nThe road's closed.",
|
||||||
function()
|
function()
|
||||||
ow:scriptMove(ow.player, back, 1)
|
ow:scriptMove(ow.player, back, 1)
|
||||||
end))
|
end))
|
||||||
@@ -386,6 +425,26 @@ M.BIKE_SHOP = {
|
|||||||
-- the Cinnabar lab (the wait is skipped).
|
-- the Cinnabar lab (the wait is skipped).
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- The Super Nerd (object index 1) claims both fossils and blocks the way
|
||||||
|
-- to them. In scripts/MtMoonB2F.asm he isn't a sight-line trainer: his
|
||||||
|
-- header carries no range, so MtMoonB2FDefaultScript force-triggers the
|
||||||
|
-- battle the instant the player steps onto (13,8) -- the chokepoint tile
|
||||||
|
-- to his right -- and reaching for a fossil intercepts you too.
|
||||||
|
local function superNerdBeaten(ow)
|
||||||
|
local nerd = ow:npcByIndex(1)
|
||||||
|
return not nerd or ow:trainerDefeated(nerd)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function engageSuperNerd(game, ow, onDone)
|
||||||
|
local nerd = ow:npcByIndex(1)
|
||||||
|
if not nerd or ow:trainerDefeated(nerd) then
|
||||||
|
if onDone then onDone() end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
nerd:facePlayer(ow.player)
|
||||||
|
ow:engageTrainer(nerd, onDone)
|
||||||
|
end
|
||||||
|
|
||||||
local function mtMoonFossil(itemId, otherName)
|
local function mtMoonFossil(itemId, otherName)
|
||||||
return function(game, ow, npc, done)
|
return function(game, ow, npc, done)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
@@ -394,7 +453,13 @@ local function mtMoonFossil(itemId, otherName)
|
|||||||
game.stack:push(TextBox.new(game, "You already took\na fossil.", done))
|
game.stack:push(TextBox.new(game, "You already took\na fossil.", done))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
game.stack:push(TextBox.new(game, "You found a\nfossil! Take it?", function()
|
-- can't grab a fossil until the Super Nerd is beaten -- he intercepts
|
||||||
|
if not superNerdBeaten(ow) then
|
||||||
|
engageSuperNerd(game, ow, done)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local name = game.data.items[itemId].name
|
||||||
|
game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function()
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
if not yes then done() return end
|
if not yes then done() return end
|
||||||
game.save.inventory[itemId] = 1
|
game.save.inventory[itemId] = 1
|
||||||
@@ -403,7 +468,6 @@ local function mtMoonFossil(itemId, otherName)
|
|||||||
local ctx = { save = game.save, overworld = ow, game = game }
|
local ctx = { save = game.save, overworld = ow, game = game }
|
||||||
Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name)
|
Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name)
|
||||||
Commands.hide_object(ctx, "MT_MOON_B2F", otherName)
|
Commands.hide_object(ctx, "MT_MOON_B2F", otherName)
|
||||||
local name = game.data.items[itemId].name
|
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
("%s got the\n%s!"):format(game.save.player.name, name), done))
|
("%s got the\n%s!"):format(game.save.player.name, name), done))
|
||||||
end))
|
end))
|
||||||
@@ -412,6 +476,15 @@ local function mtMoonFossil(itemId, otherName)
|
|||||||
end
|
end
|
||||||
|
|
||||||
M.MT_MOON_B2F = {
|
M.MT_MOON_B2F = {
|
||||||
|
-- MtMoonB2FDefaultScript forces the Super Nerd battle when the player
|
||||||
|
-- steps onto (13,8), the tile beside him guarding the fossils.
|
||||||
|
onStep = function(game, ow, x, y)
|
||||||
|
if x == 13 and y == 8 and not superNerdBeaten(ow) then
|
||||||
|
engageSuperNerd(game, ow, nil)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"),
|
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"),
|
||||||
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"),
|
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"),
|
||||||
@@ -646,73 +719,170 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
|
|||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
-- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per
|
-- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per
|
||||||
-- step; the fee is ¥100 plus ¥100 per level gained.
|
-- step; the fee is ¥100 plus ¥100 per level gained.
|
||||||
|
--
|
||||||
|
-- #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 —
|
||||||
|
-- TextBox.TOKENS.RAM only knows wStringBuffer.
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function fillDaycareText(s, subs)
|
||||||
|
s = s:gsub("{PLAYER}", subs.player or "")
|
||||||
|
s = s:gsub("{RAM:([^}]*)}", function(name) return subs[name] or "" end)
|
||||||
|
-- extractor NUM spans may include flags after a comma; keep the name
|
||||||
|
s = s:gsub("{NUM:([%w_]+)[^}]*}", function(name)
|
||||||
|
return tostring(subs[name] or "0")
|
||||||
|
end)
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
|
||||||
M.DAYCARE = {
|
M.DAYCARE = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done)
|
TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
local dc = game.save.daycare
|
local dc = game.save.daycare
|
||||||
|
local playerName = game.save.player and game.save.player.name or "RED"
|
||||||
|
|
||||||
|
local function monName(mon)
|
||||||
|
local def = game.data.pokemon[mon.species]
|
||||||
|
return mon.nickname or (def and def.name) or mon.species
|
||||||
|
end
|
||||||
|
|
||||||
if dc and dc.mon then
|
if dc and dc.mon then
|
||||||
local Growth = require("src.pokemon.Growth")
|
local Growth = require("src.pokemon.Growth")
|
||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
|
local Party = require("src.pokemon.Party")
|
||||||
local mon = dc.mon
|
local mon = dc.mon
|
||||||
local def = game.data.pokemon[mon.species]
|
local def = game.data.pokemon[mon.species]
|
||||||
mon.exp = mon.exp + (dc.steps or 0)
|
-- Apply deferred step-exp once (OverworldController only bumps
|
||||||
local newLevel = math.min(100, Growth.levelForExp(def.growthRate, mon.exp))
|
-- daycare.steps). Clearing prevents double-count on re-talk.
|
||||||
local fee = 100 + (newLevel - mon.level) * 100
|
mon.exp = (mon.exp or 0) + (dc.steps or 0)
|
||||||
local grew = newLevel > mon.level
|
dc.steps = 0
|
||||||
mon.level = newLevel
|
-- depositLevel mirrors wDayCareMonBoxLevel: fee baseline that
|
||||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
-- must survive a declined retrieve. Fall back to mon.level for
|
||||||
mon.hp = mon.stats.hp
|
-- older saves that predate the field.
|
||||||
local msg = grew and (t._DaycareGentlemanMonHasGrownText or "It's grown a lot!")
|
if dc.depositLevel == nil then dc.depositLevel = mon.level end
|
||||||
or "Back already?"
|
local startLevel = dc.depositLevel
|
||||||
game.stack:push(TextBox.new(game,
|
local newLevel = Growth.levelForExp(def and def.growthRate, mon.exp)
|
||||||
msg .. ("\fThe fee is ¥%d.\nGet it back?"):format(fee), function()
|
if newLevel >= 100 then
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
newLevel = 100
|
||||||
if yes and game.save.money >= fee then
|
if def then
|
||||||
|
mon.exp = Growth.expForLevel(def.growthRate, 100)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local levelsGrown = math.max(0, newLevel - startLevel)
|
||||||
|
local fee = 100 + levelsGrown * 100
|
||||||
|
local name = monName(mon)
|
||||||
|
local subs = {
|
||||||
|
player = playerName,
|
||||||
|
wNameBuffer = name,
|
||||||
|
wDayCareMonName = name,
|
||||||
|
wDayCareNumLevelsGrown = levelsGrown,
|
||||||
|
wDayCareTotalCost = fee,
|
||||||
|
}
|
||||||
|
local statusText = levelsGrown > 0
|
||||||
|
and (t._DaycareGentlemanMonHasGrownText
|
||||||
|
or "Your {RAM:wNameBuffer}\nhas grown a lot!\fBy level, it's\ngrown by {NUM:wDayCareNumLevelsGrown, 1, 3}!\fAren't I great?")
|
||||||
|
or (t._DaycareGentlemanMonNeedsMoreTimeText
|
||||||
|
or "Back already?\nYour {RAM:wNameBuffer}\nneeds some more\ntime with me.")
|
||||||
|
|
||||||
|
game.stack:push(TextBox.new(game, fillDaycareText(statusText, subs), function()
|
||||||
|
if #game.save.party >= Party.MAX then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._DaycareGentlemanNoRoomForMonText
|
||||||
|
or "You have no room\nfor this POKéMON!", done))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
fillDaycareText(
|
||||||
|
t._DaycareGentlemanOweMoneyText
|
||||||
|
or "You owe me ¥{NUM:wDayCareTotalCost, 2 | LEADING_ZEROES | LEFT_ALIGN}\nfor the return\nof this POKéMON.",
|
||||||
|
subs),
|
||||||
|
nil, { choice = function(yes)
|
||||||
|
if not yes then
|
||||||
|
-- .leaveMonInDayCare: revert any transient level bump
|
||||||
|
mon.level = startLevel
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
(t._DaycareGentlemanAllRightThenText or "All right then,\n")
|
||||||
|
.. (t._DaycareGentlemanComeAgainText or "come again."),
|
||||||
|
done))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if (game.save.money or 0) < fee then
|
||||||
|
mon.level = startLevel
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._DaycareGentlemanNotEnoughMoneyText
|
||||||
|
or "Hey, you don't\nhave enough ¥!", done))
|
||||||
|
return
|
||||||
|
end
|
||||||
game.save.money = game.save.money - fee
|
game.save.money = game.save.money - fee
|
||||||
|
mon.level = newLevel
|
||||||
|
if def then
|
||||||
|
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
||||||
|
mon.hp = mon.stats.hp
|
||||||
|
end
|
||||||
table.insert(game.save.party, mon)
|
table.insert(game.save.party, mon)
|
||||||
game.save.daycare = nil
|
game.save.daycare = nil
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._DaycareGentlemanGotMonBackText or "Here you go!", done))
|
t._DaycareGentlemanHeresYourMonText
|
||||||
else
|
or "Thank you! Here's\nyour POKéMON!", function()
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
yes and (t._DaycareGentlemanOweMoneyText or "You owe me money!")
|
fillDaycareText(
|
||||||
or "Come again!", done))
|
t._DaycareGentlemanGotMonBackText
|
||||||
end
|
or "{PLAYER} got\n{RAM:wDayCareMonName} back!",
|
||||||
end))
|
subs), done))
|
||||||
|
end))
|
||||||
|
end }))
|
||||||
end))
|
end))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if #game.save.party < 2 then
|
|
||||||
game.stack:push(TextBox.new(game,
|
|
||||||
"You only have one\nPOKéMON with you!", done))
|
|
||||||
return
|
|
||||||
end
|
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._DaycareGentlemanIntroText or "I can raise a\nPOKéMON for you.", function()
|
t._DaycareGentlemanIntroText
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
or "I run a DAYCARE.\nWould you like me\nto raise one of\nyour POKéMON?",
|
||||||
if not yes then done() return end
|
nil, { choice = function(yes)
|
||||||
local PartyMenu = require("src.ui.PartyMenu")
|
if not yes then
|
||||||
game.stack:push(PartyMenu.new(game, {
|
game.stack:push(TextBox.new(game,
|
||||||
pickOnly = true,
|
t._DaycareGentlemanComeAgainText or "come again.", done))
|
||||||
onSwitch = function(mon)
|
return
|
||||||
for i, m in ipairs(game.save.party) do
|
end
|
||||||
if m == mon then table.remove(game.save.party, i) break end
|
if #game.save.party < 2 then
|
||||||
end
|
game.stack:push(TextBox.new(game,
|
||||||
game.save.daycare = { mon = mon, steps = 0 }
|
t._DaycareGentlemanOnlyHaveOneMonText
|
||||||
game.stack:push(TextBox.new(game,
|
or "You only have one\nPOKéMON with you.", done))
|
||||||
t._DaycareGentlemanWillLookAfterMonText or
|
return
|
||||||
"Fine, I'll look\nafter it a while!", done))
|
end
|
||||||
end,
|
game.stack:push(TextBox.new(game,
|
||||||
}))
|
t._DaycareGentlemanWhichMonText or "Which POKéMON\nshould I raise?",
|
||||||
end))
|
function()
|
||||||
end))
|
local PartyMenu = require("src.ui.PartyMenu")
|
||||||
|
game.stack:push(PartyMenu.new(game, {
|
||||||
|
pickOnly = true,
|
||||||
|
onSwitch = function(mon)
|
||||||
|
for i, m in ipairs(game.save.party) do
|
||||||
|
if m == mon then table.remove(game.save.party, i) break end
|
||||||
|
end
|
||||||
|
local name = monName(mon)
|
||||||
|
-- depositLevel = wDayCareMonBoxLevel at deposit time
|
||||||
|
game.save.daycare = {
|
||||||
|
mon = mon, steps = 0, depositLevel = mon.level,
|
||||||
|
}
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
fillDaycareText(
|
||||||
|
t._DaycareGentlemanWillLookAfterMonText
|
||||||
|
or "Fine, I'll look\nafter {RAM:wNameBuffer}\nfor a while.",
|
||||||
|
{ player = playerName, wNameBuffer = name }),
|
||||||
|
function()
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._DaycareGentlemanComeSeeMeInAWhileText
|
||||||
|
or "Come see me in\na while.", done))
|
||||||
|
end))
|
||||||
|
end,
|
||||||
|
}))
|
||||||
|
end))
|
||||||
|
end }))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+307
-62
@@ -63,7 +63,15 @@ M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
|
|||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
|
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
|
||||||
-- (10,16) until identified with the Silph Scope and defeated.
|
-- (10,16) until defeated.
|
||||||
|
--
|
||||||
|
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
|
||||||
|
-- Silph Scope check at the trigger -- the scope only decides whether the
|
||||||
|
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
|
||||||
|
-- balls dodged). An earlier version of this port turned the player back
|
||||||
|
-- without the scope and never opened the battle, which made 6F
|
||||||
|
-- impassable on any route that skips Rocket Hideout; vanilla lets the
|
||||||
|
-- battle open and a POKE_DOLL end it (see wBattleResult below).
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
M.POKEMON_TOWER_6F = {
|
M.POKEMON_TOWER_6F = {
|
||||||
@@ -71,26 +79,32 @@ M.POKEMON_TOWER_6F = {
|
|||||||
if game.save.flags.EVENT_BEAT_GHOST_MAROWAK then return false end
|
if game.save.flags.EVENT_BEAT_GHOST_MAROWAK then return false end
|
||||||
if x ~= 10 or y ~= 16 then return false end
|
if x ~= 10 or y ~= 16 then return false end
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
if not game.save.inventory.SILPH_SCOPE then
|
local t = game.data.text
|
||||||
game.stack:push(TextBox.new(game,
|
|
||||||
"A GHOST blocks\nthe way...\fDarn! You can't\nidentify it!",
|
|
||||||
function()
|
|
||||||
local back = ow.player.facing == "up" and "down" or "up"
|
|
||||||
ow:scriptMove(ow.player, back, 1)
|
|
||||||
end))
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
"The GHOST was\nMAROWAK!\fThe restless soul\nattacks!", function()
|
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
|
||||||
local BattleState = require("src.battle.BattleState")
|
local BattleState = require("src.battle.BattleState")
|
||||||
local battle = BattleState.newWild(game, "MAROWAK", 30)
|
local battle = BattleState.newWild(game, "MAROWAK", 30)
|
||||||
|
if not game.save.inventory.SILPH_SCOPE then
|
||||||
|
battle:makeGhost()
|
||||||
|
end
|
||||||
battle.onFinish = function(result)
|
battle.onFinish = function(result)
|
||||||
if result == "win" then
|
-- wBattleResult parity (PokemonTower6FMarowakBattleScript's
|
||||||
|
-- "and a / jr nz"): losing writes $1 and running writes $2, but
|
||||||
|
-- ItemUsePokeDoll ends the battle WITHOUT touching it, so the
|
||||||
|
-- script reads 0 -- defeated. That is the famous Poke Doll
|
||||||
|
-- trick, and the speedrun route this bot follows depends on it.
|
||||||
|
if result == "win" or battle.pokeDollEscape then
|
||||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
|
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
"The restless soul\ncalmed down and\ndeparted!"))
|
t._PokemonTower6FSoulWasCalmedText
|
||||||
|
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!"))
|
||||||
|
elseif result ~= "lose" then
|
||||||
|
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||||
|
-- so fleeing does not leave you standing on a cell that
|
||||||
|
-- immediately re-fires.
|
||||||
|
ow:scriptMove(ow.player, "right", 1)
|
||||||
end
|
end
|
||||||
ow:afterBattle(result)
|
ow:afterBattle(result, battle)
|
||||||
end
|
end
|
||||||
game.stack:push(battle)
|
game.stack:push(battle)
|
||||||
end))
|
end))
|
||||||
@@ -136,18 +150,20 @@ local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right =
|
|||||||
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
|
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
|
||||||
local WALK_ORDER = { "up", "down", "left", "right" }
|
local WALK_ORDER = { "up", "down", "left", "right" }
|
||||||
|
|
||||||
local function elevatorWalkOut(ow, floor)
|
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
|
||||||
local m, p = ow.map, ow.player
|
-- door (warp id, map id). Shared generated map data, but the car's
|
||||||
-- .UpdateWarp is run twice, so BOTH car warp entries get the same
|
-- warps are only read from inside the car; rides rewrite them again.
|
||||||
-- (warp id, map id): point every exit warp at the picked floor's
|
local function elevatorSetExit(ow, floor)
|
||||||
-- elevator-door warp (the reciprocal warp found while building the
|
if not floor then return end
|
||||||
-- menu). The car map's def is shared generated data, but its own
|
for _, w in ipairs(ow.map.def.warps) do
|
||||||
-- warps are only ever read from inside the car, and this rewrite runs
|
|
||||||
-- on every ride before the walk-out fires, so it is self-correcting.
|
|
||||||
for _, w in ipairs(m.def.warps) do
|
|
||||||
w.destMap = floor.map
|
w.destMap = floor.map
|
||||||
w.destWarp = floor.warpIdx
|
w.destWarp = floor.warpIdx
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function elevatorWalkOut(ow, floor)
|
||||||
|
local m, p = ow.map, ow.player
|
||||||
|
elevatorSetExit(ow, floor)
|
||||||
-- leave by the exit tile under the player (they warped in onto one),
|
-- leave by the exit tile under the player (they warped in onto one),
|
||||||
-- else the nearest
|
-- else the nearest
|
||||||
local door
|
local door
|
||||||
@@ -185,37 +201,68 @@ local function elevatorWalkOut(ow, floor)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function elevatorFloors(elevatorMapId, game)
|
||||||
|
local floors = {}
|
||||||
|
for mapId, def in pairs(game.data.maps) do
|
||||||
|
for i, w in ipairs(def.warps) do
|
||||||
|
if w.destMap == elevatorMapId then
|
||||||
|
-- short floor token pokered actually prints, e.g.
|
||||||
|
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
|
||||||
|
local token = mapId:match("_([^_]+)$") or mapId
|
||||||
|
-- warpIdx: this floor's warp back into the elevator IS the
|
||||||
|
-- warp the car's rewritten exit lands on (the reciprocal
|
||||||
|
-- pair), matching wElevatorWarpMaps' (warp id, map id)
|
||||||
|
table.insert(floors,
|
||||||
|
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
|
||||||
|
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
|
||||||
|
table.sort(floors, function(a, b)
|
||||||
|
return (tonumber(a.token:match("%d+")) or 0) <
|
||||||
|
(tonumber(b.token:match("%d+")) or 0)
|
||||||
|
end)
|
||||||
|
return floors
|
||||||
|
end
|
||||||
|
|
||||||
|
local function elevatorSeedExit(ow, floors, fromMapId)
|
||||||
|
-- Seed a walk-out destination before the menu (or key-gate text):
|
||||||
|
-- entry floor when known, else the first listed floor (1F). Choosing
|
||||||
|
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
|
||||||
|
-- keeps this seed so walking out of the car cannot hit a missing ROM
|
||||||
|
-- placeholder (#123) or the car's static default floor (#90: Rocket
|
||||||
|
-- Hideout defaults to B1F even when entered from B2F/B4F).
|
||||||
|
local exitFloor = floors[1]
|
||||||
|
if fromMapId then
|
||||||
|
for _, f in ipairs(floors) do
|
||||||
|
if f.map == fromMapId then exitFloor = f break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elevatorSetExit(ow, exitFloor)
|
||||||
|
return exitFloor
|
||||||
|
end
|
||||||
|
|
||||||
local function elevator(elevatorMapId, keyGate, preFrames)
|
local function elevator(elevatorMapId, keyGate, preFrames)
|
||||||
return {
|
return {
|
||||||
onEnter = function(game, ow)
|
-- fromMapId: the floor the player just left (setMap passes it), so a
|
||||||
|
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
|
||||||
|
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
|
||||||
|
-- asserted and hard-crashed (#123).
|
||||||
|
onEnter = function(game, ow, fromMapId)
|
||||||
|
local floors = elevatorFloors(elevatorMapId, game)
|
||||||
|
elevatorSeedExit(ow, floors, fromMapId)
|
||||||
|
-- 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.
|
||||||
if keyGate and not game.save.inventory[keyGate.item] then
|
if keyGate and not game.save.inventory[keyGate.item] then
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text[keyGate.text] or "It appears to\nneed a key."))
|
game.data.text[keyGate.text] or "It appears to\nneed a key."))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local floors = {}
|
|
||||||
for mapId, def in pairs(game.data.maps) do
|
|
||||||
for i, w in ipairs(def.warps) do
|
|
||||||
if w.destMap == elevatorMapId then
|
|
||||||
-- short floor token pokered actually prints, e.g.
|
|
||||||
-- SILPH_CO_10F -> "10F", ROCKET_HIDEOUT_B2F -> "B2F"
|
|
||||||
local token = mapId:match("_([^_]+)$") or mapId
|
|
||||||
-- warpIdx: this floor's warp back into the elevator IS the
|
|
||||||
-- warp the car's rewritten exit lands on (the reciprocal
|
|
||||||
-- pair), matching wElevatorWarpMaps' (warp id, map id)
|
|
||||||
table.insert(floors,
|
|
||||||
{ map = mapId, x = w.x, y = w.y, token = token, warpIdx = i })
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
-- numeric floor order (SilphCoElevatorFloors' FLOOR_1F..FLOOR_11F),
|
|
||||||
-- not lexicographic -- otherwise 10F/11F sort before 2F..9F
|
|
||||||
table.sort(floors, function(a, b)
|
|
||||||
return (tonumber(a.token:match("%d+")) or 0) <
|
|
||||||
(tonumber(b.token:match("%d+")) or 0)
|
|
||||||
end)
|
|
||||||
local items = {}
|
local items = {}
|
||||||
for _, f in ipairs(floors) do
|
for _, f in ipairs(floors) do
|
||||||
table.insert(items, { label = f.token, value = f })
|
table.insert(items, { label = f.token, value = f })
|
||||||
@@ -248,7 +295,8 @@ local function elevator(elevatorMapId, keyGate, preFrames)
|
|||||||
end,
|
end,
|
||||||
onCancel = function()
|
onCancel = function()
|
||||||
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
||||||
-- happens, the player just stays in the car
|
-- happens, the player just stays in the car (exit warps were
|
||||||
|
-- already seeded to the entry floor above)
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
end,
|
end,
|
||||||
@@ -260,6 +308,98 @@ M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
|
|||||||
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
|
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
|
||||||
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
|
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
-- Rocket Hideout B4F (scripts/RocketHideoutB4F.asm):
|
||||||
|
-- Rocket3's after-battle text_asm drops the LIFT KEY item ball
|
||||||
|
-- (CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
|
||||||
|
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5). Both start hidden in the map
|
||||||
|
-- objects; without this talk side-effect the key never appears (#90,
|
||||||
|
-- #105).
|
||||||
|
-- Giovanni's post-battle script likewise ShowObject's the Silph Scope
|
||||||
|
-- after the hope-we-meet-again line (TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_4).
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
M.ROCKET_HIDEOUT_B4F = {
|
||||||
|
talk = {
|
||||||
|
TEXT_ROCKETHIDEOUTB4F_ROCKET3 = function(game, ow, npc, done)
|
||||||
|
if not ow:trainerDefeated(npc) then
|
||||||
|
ow:engageTrainer(npc, done)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local t = game.data.text
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._RocketHideoutB4FRocket3AfterBattleText
|
||||||
|
or "Oh no! I dropped\nthe LIFT KEY!",
|
||||||
|
function()
|
||||||
|
-- CheckAndSetEvent EVENT_ROCKET_DROPPED_LIFT_KEY: first talk
|
||||||
|
-- after the win reveals the ball; later talks only reprint.
|
||||||
|
if not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
|
||||||
|
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
Commands.show_object(
|
||||||
|
{ game = game, save = game.save, overworld = ow },
|
||||||
|
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
|
||||||
|
end
|
||||||
|
done()
|
||||||
|
end))
|
||||||
|
end,
|
||||||
|
|
||||||
|
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
|
||||||
|
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
|
||||||
|
-- owns both the engage and the BeatGiovanniScript aftermath.
|
||||||
|
if ow:trainerDefeated(npc)
|
||||||
|
or game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI then
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
game.data.text._RocketHideoutB4FGiovanniHopeWeMeetAgainText
|
||||||
|
or "I hope we meet\nagain...", done))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local t = game.data.text
|
||||||
|
local impressed = t._RocketHideoutB4FGiovanniImpressedYouGotHereText
|
||||||
|
or "So! I must say, I\nam impressed you\ngot here!"
|
||||||
|
local cannotBe = t._RocketHideoutB4FGiovanniWhatCannotBeText
|
||||||
|
or "WHAT!\nThis cannot be!"
|
||||||
|
local hope = t._RocketHideoutB4FGiovanniHopeWeMeetAgainText
|
||||||
|
or "I hope we meet\nagain..."
|
||||||
|
game.stack:push(TextBox.new(game, impressed, function()
|
||||||
|
local battle = BattleState.newTrainer(game, "OPP_GIOVANNI", 1)
|
||||||
|
battle.onFinish = function(result)
|
||||||
|
if result ~= "win" then
|
||||||
|
ow:afterBattle(result, battle)
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
game.save.defeatedTrainers[npc.id] = true
|
||||||
|
game.save.flags.EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = true
|
||||||
|
-- End-battle "WHAT!" then BeatGiovanniScript's hope text,
|
||||||
|
-- fade, HideObject Giovanni, ShowObject Silph Scope.
|
||||||
|
game.stack:push(TextBox.new(game, cannotBe, function()
|
||||||
|
game.stack:push(TextBox.new(game, hope, function()
|
||||||
|
local Transition = require("src.render.Transition")
|
||||||
|
game.stack:push(Transition.new(game, function()
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
|
Commands.hide_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||||
|
"ROCKETHIDEOUTB4F_GIOVANNI")
|
||||||
|
Commands.show_object(ctx, "ROCKET_HIDEOUT_B4F",
|
||||||
|
"ROCKETHIDEOUTB4F_SILPH_SCOPE")
|
||||||
|
end, function()
|
||||||
|
ow:afterBattle(result, battle)
|
||||||
|
done()
|
||||||
|
end))
|
||||||
|
end))
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
ow:pushBattle(battle)
|
||||||
|
end))
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
-- Game Corner coins, prizes, and the rocket-poster switch that reveals
|
-- Game Corner coins, prizes, and the rocket-poster switch that reveals
|
||||||
-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm +
|
-- the hideout stairs (scripts/GameCorner.asm, data/events/prizes.asm +
|
||||||
@@ -272,14 +412,23 @@ M.GAME_CORNER = {
|
|||||||
-- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after)
|
-- EVENT_FOUND_ROCKET_HIDEOUT is unset, $43 after)
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
local poster = game.data.field.gameCornerPoster
|
local poster = game.data.field.gameCornerPoster
|
||||||
if not poster then return end
|
if poster then
|
||||||
local block = game.save.flags[poster.event] and poster.openBlock
|
local block = game.save.flags[poster.event] and poster.openBlock
|
||||||
or poster.closedBlock
|
or poster.closedBlock
|
||||||
ow:replaceBlock(poster.x, poster.y, block)
|
ow:replaceBlock(poster.x, poster.y, block)
|
||||||
|
end
|
||||||
-- pick this visit's lucky slot machine
|
-- pick this visit's lucky slot machine
|
||||||
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm)
|
-- (wLuckySlotHiddenEventIndex, engine/slots/game_corner_slots2.asm)
|
||||||
local seats = game.data.field.slotMachines.GAME_CORNER
|
local seats = game.data.field.slotMachines.GAME_CORNER
|
||||||
ow.luckySlot = love.math.random(1, #seats)
|
ow.luckySlot = love.math.random(1, #seats)
|
||||||
|
-- #131: pre-#50 saves beat the poster grunt (defeatedTrainers) but
|
||||||
|
-- never hid him; clear the tile if he is already marked defeated
|
||||||
|
local rocketId = "GAME_CORNER_obj_11"
|
||||||
|
if game.save.defeatedTrainers and game.save.defeatedTrainers[rocketId] then
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
Commands.hide_object({ game = game, save = game.save, overworld = ow },
|
||||||
|
"GAME_CORNER", "GAMECORNER_ROCKET")
|
||||||
|
end
|
||||||
end,
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
-- the poster bg event: pressing A reveals the hidden switch
|
-- the poster bg event: pressing A reveals the hidden switch
|
||||||
@@ -303,6 +452,39 @@ M.GAME_CORNER = {
|
|||||||
done()
|
done()
|
||||||
end))
|
end))
|
||||||
end,
|
end,
|
||||||
|
-- the grunt guarding the poster (GameCornerRocketText /
|
||||||
|
-- GameCornerRocketBattleScript / GameCornerRocketExitScript): after
|
||||||
|
-- losing he warns the BOSS and leaves the floor for good, freeing
|
||||||
|
-- the tile in front of the hideout switch
|
||||||
|
TEXT_GAMECORNER_ROCKET = function(game, ow, npc, done)
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local function hideRocket()
|
||||||
|
Commands.hide_object({ game = game, save = game.save,
|
||||||
|
overworld = ow },
|
||||||
|
"GAME_CORNER", "GAMECORNER_ROCKET")
|
||||||
|
end
|
||||||
|
-- already beaten: hide anyway so pre-#50 saves that only have
|
||||||
|
-- defeatedTrainers (no objectToggles hide) clear the poster tile
|
||||||
|
if ow:trainerDefeated(npc) then
|
||||||
|
hideRocket()
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
ow:engageTrainer(npc, function()
|
||||||
|
if not ow:trainerDefeated(npc) then
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
game.data.text._GameCornerRocketAfterBattleText
|
||||||
|
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
|
||||||
|
function()
|
||||||
|
hideRocket()
|
||||||
|
done()
|
||||||
|
end))
|
||||||
|
end)
|
||||||
|
end,
|
||||||
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
|
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
@@ -349,24 +531,42 @@ M.GAME_CORNER = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Red-version prize lists (data/events/prizes.asm, prize_mon_levels.asm)
|
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
|
||||||
local PRIZES = {
|
-- The six mon prizes differ between Red and Blue; the three TM prizes are
|
||||||
|
-- identical, so they are shared and appended to each version's mon list.
|
||||||
|
local PRIZE_TMS = {
|
||||||
|
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
|
||||||
|
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
|
||||||
|
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
|
||||||
|
}
|
||||||
|
local RED_PRIZES = {
|
||||||
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
|
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
|
||||||
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
|
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
|
||||||
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
|
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
|
||||||
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
|
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
|
||||||
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
|
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
|
||||||
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
||||||
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
|
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
|
||||||
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
|
|
||||||
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
|
|
||||||
}
|
}
|
||||||
|
local BLUE_PRIZES = {
|
||||||
|
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
|
||||||
|
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
|
||||||
|
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
|
||||||
|
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
|
||||||
|
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
|
||||||
|
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
|
||||||
|
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
|
||||||
|
}
|
||||||
|
|
||||||
|
local function activePrizes()
|
||||||
|
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
|
||||||
|
end
|
||||||
|
|
||||||
local function prizeCounter(game, ow, npc, done)
|
local function prizeCounter(game, ow, npc, done)
|
||||||
local ListMenu = require("src.ui.ListMenu")
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local items = {}
|
local items = {}
|
||||||
for _, p in ipairs(PRIZES) do
|
for _, p in ipairs(activePrizes()) do
|
||||||
local label
|
local label
|
||||||
if p.kind == "mon" then
|
if p.kind == "mon" then
|
||||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||||
@@ -411,18 +611,63 @@ M.GAME_CORNER_PRIZE_ROOM = {
|
|||||||
-- and the player steps off the dock, the ship sets sail.
|
-- and the player steps off the dock, the ship sets sail.
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- the ship's hull/deck blocks (block cols 5-8, rows 1-2) and the water
|
||||||
|
-- that replaces them once she sails (the surrounding blocks of each row)
|
||||||
|
local DOCK_SHIP_BLOCKS = {
|
||||||
|
{ bx = 5, by = 1, water = 1 }, { bx = 6, by = 1, water = 1 },
|
||||||
|
{ bx = 7, by = 1, water = 1 }, { bx = 8, by = 1, water = 1 },
|
||||||
|
{ bx = 5, by = 2, water = 13 }, { bx = 6, by = 2, water = 13 },
|
||||||
|
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
|
||||||
|
}
|
||||||
|
|
||||||
M.VERMILION_DOCK = {
|
M.VERMILION_DOCK = {
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
if game.save.flags.EVENT_SS_ANNE_LEFT then
|
local Flags = require("src.script.Flags")
|
||||||
|
local f = game.save.flags
|
||||||
|
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||||
|
-- the ship is long gone: erase her right away, and anyone who
|
||||||
|
-- still lands here is sent back out past the guard
|
||||||
|
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||||
|
ow.map:setBlock(b.bx, b.by, b.water)
|
||||||
|
end
|
||||||
|
ow.map.renderer:rebuild()
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
game.data.text._VermilionCitySailor1ShipSetSailText
|
game.data.text._VermilionCitySailor1ShipSetSailText
|
||||||
or "The ship set sail.", function()
|
or "The ship set sail.", function()
|
||||||
ow:startWarpTo("VERMILION_CITY", 19, 30, "up")
|
ow:startWarpTo("VERMILION_CITY", 18, 29, "up")
|
||||||
end))
|
end))
|
||||||
elseif game.save.flags.EVENT_GOT_HM01 then
|
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
||||||
game.save.flags.EVENT_SS_ANNE_LEFT = true
|
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
||||||
require("src.core.Sound").play(game.data, "SS_Anne_Horn")
|
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
|
||||||
|
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
|
||||||
|
-- drift off the funnel, the horn blows, the ship is erased to
|
||||||
|
-- open water, and the player is walked off the dock into the
|
||||||
|
-- city past the guard (VermilionCity's
|
||||||
|
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
|
||||||
|
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
|
||||||
|
local Music = require("src.core.Music")
|
||||||
|
Music.stop()
|
||||||
|
Music.play(game.data, "Music_Surfing")
|
||||||
|
local function puff(n, cx)
|
||||||
|
if n <= 0 then return end
|
||||||
|
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||||
|
end
|
||||||
|
puff(3, 15)
|
||||||
|
local rows = {}
|
||||||
|
rows[#rows + 1] = { "wait", 100 }
|
||||||
|
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||||
|
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||||
|
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
|
||||||
|
end
|
||||||
|
rows[#rows + 1] = { "wait", 30 }
|
||||||
|
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||||
|
-- keep Music_Surfing across the city warp (pokered stays on it
|
||||||
|
-- through the exit-ship walk)
|
||||||
|
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
|
||||||
|
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
||||||
|
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||||
|
ow:queueScript(rows)
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,6 +457,25 @@ M.ROUTE_24 = {
|
|||||||
battleOrDone()
|
battleOrDone()
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
-- Route24DefaultScript forces TEXT_ROUTE24_COOLTRAINER_M1 whenever the
|
||||||
|
-- player stands on (10,15) in front of the recruiter while
|
||||||
|
-- EVENT_GOT_NUGGET is unset (dbmapcoord 10,15), independent of facing
|
||||||
|
-- or input -- he has no trainer header, so sight never engages him.
|
||||||
|
onStep = function(game, ow, x, y)
|
||||||
|
if game.save.flags.EVENT_GOT_NUGGET then return false end
|
||||||
|
if not (x == 10 and y == 15) then return false end
|
||||||
|
if ow.runner:isRunning() or ow.engaging then return false end
|
||||||
|
local recruiter
|
||||||
|
for _, npc in ipairs(ow.npcs) do
|
||||||
|
if npc.def and npc.def.name == "ROUTE24_COOLTRAINER_M1" then
|
||||||
|
recruiter = npc
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not recruiter then return false end
|
||||||
|
ow:showMapText("TEXT_ROUTE24_COOLTRAINER_M1", recruiter)
|
||||||
|
return true
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
@@ -626,11 +645,153 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Lorelei/Bruno/Agatha EndBattleScript (pokered): EndTrainerBattle then
|
||||||
|
-- DisplayTextID -> TalkToTrainer, which prints the AfterBattle text on a
|
||||||
|
-- win. engageTrainer only shows the won line, so wrap talk like Lance /
|
||||||
|
-- Game Corner Rocket and push header.after immediately after a win.
|
||||||
|
local function e4LeaderTalk(afterLabel)
|
||||||
|
return function(game, ow, npc, done)
|
||||||
|
done = done or function() end
|
||||||
|
if ow:trainerDefeated(npc) then
|
||||||
|
local after = text(game)[afterLabel]
|
||||||
|
if after then push(game, after, done) else done() end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
ow:engageTrainer(npc, function()
|
||||||
|
if not ow:trainerDefeated(npc) then
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local after = text(game)[afterLabel]
|
||||||
|
if after then push(game, after, done) else done() end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
|
M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
|
||||||
"_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM")
|
"_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM")
|
||||||
|
M.LORELEIS_ROOM.talk = {
|
||||||
|
TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText"),
|
||||||
|
}
|
||||||
M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
|
M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
|
||||||
"_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM")
|
"_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM")
|
||||||
|
M.BRUNOS_ROOM.talk = {
|
||||||
|
TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText"),
|
||||||
|
}
|
||||||
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
|
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
|
||||||
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
|
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
|
||||||
|
M.AGATHAS_ROOM.talk = {
|
||||||
|
TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText"),
|
||||||
|
}
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
|
||||||
|
-- rooms this one gates its ENTRANCE, not its exit: the .blk ships with
|
||||||
|
-- the arena doorway CLOSED (blocks $72/$73 at block (2,6)/(3,6), cells
|
||||||
|
-- (4-7,12-13)), and LanceShowOrHideEntranceBlocks OPENS it ($31/$32)
|
||||||
|
-- on every map load while EVENT_LANCES_ROOM_LOCK_DOOR is unset.
|
||||||
|
-- Without this script the doorway never opened, so the whole arena --
|
||||||
|
-- Lance AND both CHAMPIONS_ROOM warps at (5,0)/(6,0) -- was sealed off
|
||||||
|
-- from the entrance hall and the league dead-ended here ("goto (6,11)
|
||||||
|
-- unreachable on LANCES_ROOM").
|
||||||
|
--
|
||||||
|
-- LancesRoomDefaultScript's coordinate triggers, all inert once
|
||||||
|
-- EVENT_BEAT_LANCE is set:
|
||||||
|
-- (5,1)/(6,2) beside Lance -> his battle starts (a coordinate
|
||||||
|
-- trigger, not a talk; victories.lua OPP_LANCE#1 sets
|
||||||
|
-- EVENT_BEAT_LANCE on the win, so a loss re-arms)
|
||||||
|
-- (5,11)/(6,11) the doorway -> CheckAndSetEvent
|
||||||
|
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
|
||||||
|
-- the door behind the player with SFX_GO_INSIDE
|
||||||
|
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
|
||||||
|
-- landing on (6,11). Vanilla WalkToLance_RLEList is
|
||||||
|
-- up 12 / left 12 / down 7 / left 6 and skips collision
|
||||||
|
-- through void tiles; our camera follows that literally
|
||||||
|
-- and briefly shows the empty upper chamber (reads as
|
||||||
|
-- "Gary's room"). We keep the same landing cell but
|
||||||
|
-- route along the open-door floor corridor instead.
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Floor corridor (24,16) -> (6,11) with EVENT_LANCES_ROOM_LOCK_DOOR
|
||||||
|
-- unset (entrance blocks $31/$32). Exposed for parity tests.
|
||||||
|
local LANCE_WALK_IN = {
|
||||||
|
{ "down", 2 }, { "left", 6 }, { "down", 5 }, { "left", 10 },
|
||||||
|
{ "up", 9 }, { "left", 2 }, { "up", 3 },
|
||||||
|
}
|
||||||
|
|
||||||
|
local function lanceEntranceBlocks(game, ow)
|
||||||
|
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
|
||||||
|
ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
|
||||||
|
ow:replaceBlock(3, 6, locked and 0x73 or 0x32)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function lanceLockDoor(game, ow)
|
||||||
|
if game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR then return end
|
||||||
|
game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR = true
|
||||||
|
require("src.core.Sound").play(game.data, "Go_Inside")
|
||||||
|
lanceEntranceBlocks(game, ow)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function lanceWalkIn(game, ow)
|
||||||
|
local i = 0
|
||||||
|
local function step()
|
||||||
|
i = i + 1
|
||||||
|
local seg = LANCE_WALK_IN[i]
|
||||||
|
if not seg then
|
||||||
|
-- lands on (6,11); vanilla's per-frame coord poll then locks the
|
||||||
|
-- door at once. scriptMove landings do not fire onStep, so lock
|
||||||
|
-- here.
|
||||||
|
lanceLockDoor(game, ow)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
ow:scriptMove(ow.player, seg[1], seg[2], step)
|
||||||
|
end
|
||||||
|
step()
|
||||||
|
end
|
||||||
|
|
||||||
|
M.LANCES_ROOM = {
|
||||||
|
walkInRoute = LANCE_WALK_IN,
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
lanceEntranceBlocks(game, ow)
|
||||||
|
-- the warp arrival lands ON the staircase trigger, and onStep only
|
||||||
|
-- fires for completed steps -- start the walk-in here, the same
|
||||||
|
-- way Lorelei's auto walk-in runs from its onEnter
|
||||||
|
if not game.save.flags.EVENT_BEAT_LANCE
|
||||||
|
and ow.player.cellX == 24 and ow.player.cellY == 16 then
|
||||||
|
lanceWalkIn(game, ow)
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
onStep = function(game, ow, x, y)
|
||||||
|
if game.save.flags.EVENT_BEAT_LANCE then return false end
|
||||||
|
if (x == 5 and y == 1) or (x == 6 and y == 2) then
|
||||||
|
local lance
|
||||||
|
for _, npc in ipairs(ow.npcs) do
|
||||||
|
if npc.def and npc.def.name == "LANCESROOM_LANCE" then lance = npc break end
|
||||||
|
end
|
||||||
|
if not lance or ow:trainerDefeated(lance) then return false end
|
||||||
|
lance:facePlayer(ow.player)
|
||||||
|
-- LancesRoomLanceEndBattleScript DisplayTextID -> TalkToTrainer
|
||||||
|
-- after-battle text (rival became champion first). engageTrainer
|
||||||
|
-- only shows the won line, so push the after text on a win.
|
||||||
|
ow:engageTrainer(lance, function()
|
||||||
|
if not ow:trainerDefeated(lance) then return end
|
||||||
|
local after = text(game)._LancesRoomLanceAfterBattleText
|
||||||
|
if after then push(game, after) end
|
||||||
|
end)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if (x == 5 or x == 6) and y == 11 then
|
||||||
|
lanceLockDoor(game, ow)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if x == 24 and y == 16 then
|
||||||
|
lanceWalkIn(game, ow)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
M.CELADON_CHIEF_HOUSE = require("data.scripts.celadon_chief_house")
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|||||||
+194
-13
@@ -259,23 +259,183 @@ M.CINNABAR_ISLAND = {
|
|||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Pewter's youngster stops you leaving east before Brock is beaten
|
-- Pewter's youngster stops you leaving east before Brock is beaten and
|
||||||
-- (scripts/PewterCity.asm PewterCityCheckPlayerLeavingEastScript; the
|
-- escorts you to the gym (scripts/PewterCity.asm
|
||||||
-- original escorts you to the gym, we walk you back a step)
|
-- PewterCityCheckPlayerLeavingEastScript /
|
||||||
|
-- PewterCityYoungsterShowsPlayerGymScript, engine/overworld/auto_movement.asm
|
||||||
|
-- PewterGymGuyMovementScriptPointerTable, engine/events/pewter_guys.asm
|
||||||
|
-- PewterGymGuyCoords). Same lockstep style as Oak's lab escort: the
|
||||||
|
-- youngster walks RLEList_PewterGymGuy while the player plays the
|
||||||
|
-- reverse of RLEList_PewterGymPlayer with a PewterGuys positioning
|
||||||
|
-- preamble. Ends at (11,18) / (12,18) by the gym, not phasing through
|
||||||
|
-- the building.
|
||||||
|
local pewterEscort = {}
|
||||||
|
|
||||||
|
-- RLEList_PewterGymGuy (NPC directions play forward)
|
||||||
|
pewterEscort.guySteps = {
|
||||||
|
"down", "down",
|
||||||
|
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||||
|
"left", "left", "left", "left", "left", "left", "left",
|
||||||
|
"up", "up", "up", "up", "up",
|
||||||
|
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||||
|
"left", "left", "left",
|
||||||
|
"down", "down", "down", "down", "down",
|
||||||
|
"right", "right", "right",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Walk home: reverse of guySteps with opposite facings (gym → spawn).
|
||||||
|
do
|
||||||
|
local opp = { up = "down", down = "up", left = "right", right = "left" }
|
||||||
|
local ret = {}
|
||||||
|
for i = #pewterEscort.guySteps, 1, -1 do
|
||||||
|
ret[#ret + 1] = opp[pewterEscort.guySteps[i]]
|
||||||
|
end
|
||||||
|
pewterEscort.guyReturnSteps = ret
|
||||||
|
end
|
||||||
|
|
||||||
|
-- RLEList_PewterGymPlayer before reverse / PewterGuys (NO_INPUT, RIGHT×2,
|
||||||
|
-- DOWN×5, LEFT×11, UP×5, LEFT×15)
|
||||||
|
pewterEscort.playerRle = {
|
||||||
|
"NO",
|
||||||
|
"right", "right",
|
||||||
|
"down", "down", "down", "down", "down",
|
||||||
|
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||||
|
"left", "left", "left",
|
||||||
|
"up", "up", "up", "up", "up",
|
||||||
|
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||||
|
"left", "left", "left", "left", "left", "left", "left",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- PewterGymGuyCoords: (x, y) -> positioning moves written after the RLE
|
||||||
|
-- (played in reverse; $00 pauses are one overworld frame ≈ 1/8 tile for
|
||||||
|
-- the NPC, so eight of them ≈ one guy head-start step)
|
||||||
|
pewterEscort.preambles = {
|
||||||
|
["34,16"] = { "left", "down", "down", "right" },
|
||||||
|
["35,17"] = { "left", "down", "right", "left" },
|
||||||
|
["37,18"] = { "left", "left", "left",
|
||||||
|
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
|
||||||
|
["37,19"] = { "left", "left", "up", "left" },
|
||||||
|
["36,17"] = { "left", "down", "left",
|
||||||
|
"NO", "NO", "NO", "NO", "NO", "NO", "NO", "NO" },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Realized player path for a trigger tile: PewterGuys overwrites the
|
||||||
|
-- last RLE byte and appends the preamble, then simulated joypad plays
|
||||||
|
-- high→low (reverse). Leading NO×8 collapses to guyHeadStart=1; the
|
||||||
|
-- trailing end-of-list NO is dropped (one-frame pause).
|
||||||
|
function pewterEscort.playerPlan(x, y)
|
||||||
|
local pre = pewterEscort.preambles[x .. "," .. y]
|
||||||
|
if not pre then return nil end
|
||||||
|
local buf = {}
|
||||||
|
for i, d in ipairs(pewterEscort.playerRle) do buf[i] = d end
|
||||||
|
buf[#buf] = pre[1]
|
||||||
|
for i = 2, #pre do buf[#buf + 1] = pre[i] end
|
||||||
|
local path = {}
|
||||||
|
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
|
||||||
|
local head = 0
|
||||||
|
while path[head + 1] == "NO" do head = head + 1 end
|
||||||
|
local tail = #path
|
||||||
|
while tail > head and path[tail] == "NO" do tail = tail - 1 end
|
||||||
|
local steps = {}
|
||||||
|
for i = head + 1, tail do steps[#steps + 1] = path[i] end
|
||||||
|
return { steps = steps, guyHeadStart = math.floor(head / 8) }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function pewterGymEscort(game, ow)
|
||||||
|
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return end
|
||||||
|
local x, y = ow.player.cellX, ow.player.cellY
|
||||||
|
local plan = pewterEscort.playerPlan(x, y)
|
||||||
|
local Music = require("src.core.Music")
|
||||||
|
local t = text(game)
|
||||||
|
local follow = t._PewterCityYoungsterYoureATrainerFollowMeText
|
||||||
|
or "You're a trainer\nright? BROCK's\nlooking for new\nchallengers!\nFollow me!"
|
||||||
|
-- PewterGuys only has entries for five tiles; an unmatched talk tile
|
||||||
|
-- (e.g. (36,16) east of him) just gets the follow-me line, same as a
|
||||||
|
-- failed coords lookup would refuse to arm the walk.
|
||||||
|
if not plan then
|
||||||
|
push(game, follow)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local guy = ow:npcByIndex(5) -- PEWTERCITY_YOUNGSTER
|
||||||
|
local guySteps = pewterEscort.guySteps
|
||||||
|
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.)
|
||||||
|
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
|
||||||
|
guy.facing = "down"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
ow:scriptMove(guy, ret[i], 1, tick)
|
||||||
|
end
|
||||||
|
tick()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function afterWalk()
|
||||||
|
if guy then guy.facing = "left" end
|
||||||
|
Music.playMap(game.data, "PEWTER_CITY")
|
||||||
|
push(game, t._PewterCityYoungsterGoTakeOnBrockText
|
||||||
|
or "Go take on BROCK\nat the GYM first!", walkHome)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function lockstep()
|
||||||
|
local i = 0
|
||||||
|
local function tick()
|
||||||
|
i = i + 1
|
||||||
|
local ps = plan.steps[i]
|
||||||
|
if not ps then
|
||||||
|
afterWalk()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local gs = guySteps[head + i]
|
||||||
|
if guy and gs then ow:scriptMove(guy, gs, 1) end
|
||||||
|
ow:scriptMove(ow.player, ps, 1, tick)
|
||||||
|
end
|
||||||
|
tick()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function beginWalk()
|
||||||
|
Music.play(game.data, "Music_MuseumGuy")
|
||||||
|
if guy and head > 0 then
|
||||||
|
local h = 0
|
||||||
|
local function headTick()
|
||||||
|
h = h + 1
|
||||||
|
if h > head then lockstep(); return end
|
||||||
|
ow:scriptMove(guy, guySteps[h], 1, headTick)
|
||||||
|
end
|
||||||
|
headTick()
|
||||||
|
else
|
||||||
|
lockstep()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
push(game, follow, beginWalk)
|
||||||
|
end
|
||||||
|
|
||||||
M.PEWTER_CITY = {
|
M.PEWTER_CITY = {
|
||||||
|
escort = pewterEscort,
|
||||||
|
-- PewterCityYoungsterText: talking also arms the gym escort script
|
||||||
|
talk = {
|
||||||
|
TEXT_PEWTERCITY_YOUNGSTER = function(game, ow, npc, done)
|
||||||
|
pewterGymEscort(game, ow)
|
||||||
|
if done then done() end
|
||||||
|
end,
|
||||||
|
},
|
||||||
onStep = function(game, ow, x, y)
|
onStep = function(game, ow, x, y)
|
||||||
if game.save.flags.EVENT_BEAT_BROCK then return false end
|
if game.save.flags.EVENT_BEAT_BROCK then return false end
|
||||||
|
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
|
||||||
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
|
if not inCoords({ { 35, 17 }, { 36, 17 }, { 37, 18 }, { 37, 19 } }, x, y) then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local t = text(game)
|
pewterGymEscort(game, ow)
|
||||||
push(game, t._PewterCityYoungsterYoureATrainerFollowMeText
|
|
||||||
or "Hey! You're a\ntrainer, right?", function()
|
|
||||||
push(game, t._PewterCityYoungsterGoTakeOnBrockText
|
|
||||||
or "Go take on BROCK\nat the GYM first!", function()
|
|
||||||
ow:scriptMove(ow.player, "left", 1)
|
|
||||||
end)
|
|
||||||
end)
|
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
@@ -360,7 +520,7 @@ local rocketRows = {
|
|||||||
{ "jump_if_true", 9 }, -- 5
|
{ "jump_if_true", 9 }, -- 5
|
||||||
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
||||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
||||||
{ "jump_if_false", 16 }, -- 8
|
{ "jump_if_false", 18 }, -- 8
|
||||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
||||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
||||||
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
||||||
@@ -368,6 +528,15 @@ local rocketRows = {
|
|||||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
||||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
||||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 15
|
||||||
|
-- CeruleanHideRocket (scripts/CeruleanCity_2.asm) does all three behind
|
||||||
|
-- one fade: the ROCKET goes, GUARD1 (28,12) appears and GUARD2 (27,12)
|
||||||
|
-- disappears. GUARD2 stands on the only walkable neighbour of the
|
||||||
|
-- trashed house's south door, which is one of the two ways through the
|
||||||
|
-- fence dividing Cerulean, so this swap is what reconnects the city.
|
||||||
|
-- Bill's ticket performs the same swap (data/scripts/story.lua), and
|
||||||
|
-- both are idempotent, so whichever the player reaches first opens it.
|
||||||
|
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||||
|
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||||
}
|
}
|
||||||
|
|
||||||
M.CERULEAN_CITY = {
|
M.CERULEAN_CITY = {
|
||||||
@@ -444,10 +613,22 @@ local function bikeGateGuard(coords, stopText, explainText)
|
|||||||
return function(game, ow, x, y)
|
return function(game, ow, x, y)
|
||||||
if game.save.inventory.BICYCLE then return false end
|
if game.save.inventory.BICYCLE then return false end
|
||||||
if not inCoords(coords, x, y) then return false end
|
if not inCoords(coords, x, y) then return false end
|
||||||
|
-- walk the player up to the tile beside the counter, no further:
|
||||||
|
-- (matchedY - closestY) tiles, 0 when already next to it
|
||||||
|
-- (Route16Gate1FDefaultScript's wCoordIndex-1). Forcing a fixed one
|
||||||
|
-- tile up from the counter-adjacent row shoved the player onto the
|
||||||
|
-- impassable desk and boxed them in.
|
||||||
|
local closestY = coords[1][2]
|
||||||
|
for _, c in ipairs(coords) do
|
||||||
|
if c[2] < closestY then closestY = c[2] end
|
||||||
|
end
|
||||||
|
local dist = y - closestY
|
||||||
local t = text(game)
|
local t = text(game)
|
||||||
push(game, t[stopText] or "Hey! Wait up!", function()
|
push(game, t[stopText] or "Hey! Wait up!", function()
|
||||||
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
|
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
|
||||||
ow:scriptMove(ow.player, "up", 1)
|
if dist > 0 then
|
||||||
|
ow:scriptMove(ow.player, "up", dist)
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
return true
|
return true
|
||||||
|
|||||||
+42
-11
@@ -2,27 +2,57 @@
|
|||||||
-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported
|
-- "OPP_CLASS#partyIndex" (the object_event trainer args). Hand-ported
|
||||||
-- from the leaders'/bosses' text_asm victory scripts:
|
-- from the leaders'/bosses' text_asm victory scripts:
|
||||||
-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm
|
-- gym badges: scripts/PewterGym.asm ... ViridianGym.asm
|
||||||
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
|
-- Rocket Hideout Giovanni: Silph Scope is a hidden item ball revealed by
|
||||||
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
|
-- ShowObject in RocketHideoutB4FBeatGiovanniScript (ported as the
|
||||||
|
-- TEXT_ROCKETHIDEOUTB4F_GIOVANNI talk handler in story3.lua).
|
||||||
-- The TM each gym leader hands out afterwards is also ported.
|
-- The TM each gym leader hands out afterwards is also ported.
|
||||||
|
--
|
||||||
|
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
|
||||||
|
-- sets to retire unfought non-leader trainers (PewterGym.asm
|
||||||
|
-- "; deactivate gym trainers" / SetEventRange in the other gyms, and
|
||||||
|
-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER ..
|
||||||
|
-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3).
|
||||||
|
|
||||||
|
local function range(prefix, first, last)
|
||||||
|
local t = {}
|
||||||
|
for i = first, last do
|
||||||
|
t[#t + 1] = prefix .. i
|
||||||
|
end
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
-- PewterGym.asm .gymVictory also HideObject TOGGLE_GYM_GUY
|
||||||
|
-- (PEWTERCITY_YOUNGSTER) and TOGGLE_ROUTE_22_RIVAL_1 so the east-exit
|
||||||
|
-- escort NPC and the first Route 22 rival stay gone after the badge.
|
||||||
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
|
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
|
||||||
item = "TM_BIDE" },
|
item = "TM_BIDE",
|
||||||
|
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
|
||||||
|
hide = {
|
||||||
|
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||||
|
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||||
|
} },
|
||||||
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
||||||
item = "TM_BUBBLEBEAM" },
|
item = "TM_BUBBLEBEAM",
|
||||||
|
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
|
||||||
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
|
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
|
||||||
item = "TM_THUNDERBOLT" },
|
item = "TM_THUNDERBOLT",
|
||||||
|
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2) },
|
||||||
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
|
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
|
||||||
item = "TM_MEGA_DRAIN" },
|
item = "TM_MEGA_DRAIN",
|
||||||
|
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6) },
|
||||||
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
|
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
|
||||||
item = "TM_TOXIC" },
|
item = "TM_TOXIC",
|
||||||
|
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) },
|
||||||
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
|
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
|
||||||
item = "TM_PSYWAVE" },
|
item = "TM_PSYWAVE",
|
||||||
|
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) },
|
||||||
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
|
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
|
||||||
item = "TM_FIRE_BLAST" },
|
item = "TM_FIRE_BLAST",
|
||||||
|
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6) },
|
||||||
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
|
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
|
||||||
item = "TM_FISSURE" },
|
item = "TM_FISSURE",
|
||||||
|
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) },
|
||||||
|
|
||||||
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
|
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
|
||||||
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||||
@@ -31,7 +61,8 @@ return {
|
|||||||
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
|
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
|
||||||
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
|
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
|
||||||
-- only him (data/maps/objects/FightingDojo.asm).
|
-- only him (data/maps/objects/FightingDojo.asm).
|
||||||
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER" },
|
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
|
||||||
|
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) },
|
||||||
|
|
||||||
-- Elite Four progress flags (their rooms' door logic isn't ported, but
|
-- Elite Four progress flags (their rooms' door logic isn't ported, but
|
||||||
-- the flags make the Hall of Fame checkable)
|
-- the flags make the Hall of Fame checkable)
|
||||||
|
|||||||
@@ -6,4 +6,37 @@ work for true parity. Faithfully-ported behavior is documented in
|
|||||||
docs/behavior-porting-notes.md; deliberate additions beyond the original
|
docs/behavior-porting-notes.md; deliberate additions beyond the original
|
||||||
are in docs/new-features.md.
|
are in docs/new-features.md.
|
||||||
|
|
||||||
None currently.
|
## Reimplemented unused Prof. Oak and Rocket Chief battles
|
||||||
|
|
||||||
|
The original ROM defines trainer data for `PROF_OAK` and `CHIEF`
|
||||||
|
(`data/trainers/parties.asm`) but never attaches either to an NPC, so
|
||||||
|
both battles are unreachable in the real game. This project makes them
|
||||||
|
fightable after the Hall of Fame:
|
||||||
|
|
||||||
|
- Prof. Oak battles you in Pallet Town once `EVENT_BEAT_CHAMPION_RIVAL`
|
||||||
|
is set, using `ProfOakData`'s three starter-matched teams (the team is
|
||||||
|
picked by the type that counters your starter, mirroring the rival).
|
||||||
|
- The Celadon Game Corner Chief battles you in his house post-game.
|
||||||
|
`ChiefData` is empty in the ROM, so `OPP_CHIEF` is given a
|
||||||
|
reconstructed party.
|
||||||
|
|
||||||
|
This is an intentional divergence: neither battle can be triggered in the
|
||||||
|
original game.
|
||||||
|
|
||||||
|
## Reimplemented unused Silph Co. card-key doors
|
||||||
|
|
||||||
|
`engine/events/card_key.asm` and the unused `CardKeyTable1/2/3` coordinate
|
||||||
|
lists (`data/events/card_key_coords.asm`) describe locked doors for Silph
|
||||||
|
Co. floors 2F-11F, but no retail `.blk` map layout ever places the closed
|
||||||
|
door block at those coordinates, so the card key check is dead code in
|
||||||
|
the original game. This project stamps the closed door block (`$54`/`$5f`
|
||||||
|
on floors 2F-10F, `$20` on 11F) over each of the 20 door coordinates on
|
||||||
|
map load, and swaps it for the open block once that door's
|
||||||
|
`EVENT_SILPH_CO_n_UNLOCKED_DOORn` flag is set (using the key from a Team
|
||||||
|
Rocket grunt, as in the original's unused design).
|
||||||
|
|
||||||
|
This is an intentional divergence: the doors are not visible or
|
||||||
|
functional in the original game. The door layout lives in
|
||||||
|
`tools/rom_manifest.json` (`field.cardKeyDoors.closedDoors`), hand-ported
|
||||||
|
since no retail ROM data encodes it; `src/import/RomExtractor.lua` copies
|
||||||
|
it straight through on ROM import.
|
||||||
|
|||||||
@@ -17,3 +17,33 @@ Regenerate the reference straight into a wiki checkout:
|
|||||||
```sh
|
```sh
|
||||||
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
|
luajit tools/gen_registry_docs.lua ../pokemon-gen1-recomp-project.wiki
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Developer console
|
||||||
|
|
||||||
|
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||||
|
hotkeys. Either set `POKEPORT_DEV=1` in the environment or pass
|
||||||
|
`--developer` on the command line:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
love . --developer
|
||||||
|
```
|
||||||
|
|
||||||
|
While developer mode is active:
|
||||||
|
|
||||||
|
- `` ` `` (backtick) opens the console overlay — a Lua REPL with `game`,
|
||||||
|
`data` and `mods` in scope. Press `` ` `` again to close it.
|
||||||
|
- `F5` hot-reloads mods and asset caches without restarting.
|
||||||
|
|
||||||
|
The console understands these verbs (anything else is evaluated as Lua):
|
||||||
|
|
||||||
|
- `warp MAP [x y]` — teleport to a map (default cell 5,5).
|
||||||
|
- `give ID [n|level]` — add an item (count) or a Pokémon (level).
|
||||||
|
- `flag NAME [on|off]` — read or set an event flag.
|
||||||
|
- `party` — dump the current party.
|
||||||
|
- `mods` — list loaded mods and their state.
|
||||||
|
- `reload` — hot-reload mods (same as `F5`).
|
||||||
|
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
|
||||||
|
- `help` — list the verbs.
|
||||||
|
|
||||||
|
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||||
|
that reach outside their permission set.
|
||||||
|
|||||||
+28
-10
@@ -42,8 +42,10 @@ Game Boy equivalent:
|
|||||||
rows above the player recede and rows below come toward the viewer. Only
|
rows above the player recede and rows below come toward the viewer. Only
|
||||||
things that actually *stand* on the ground draw as upright billboards,
|
things that actually *stand* on the ground draw as upright billboards,
|
||||||
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
|
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
|
||||||
and the screen-anchored FX attached to them (heal machine glow, emote
|
and the standing FX attached to them (emote bubbles, the fishing rod,
|
||||||
bubbles, the fishing rod, the FLY bird). An earlier revision tried
|
the FLY bird). The Poké Center heal-machine overlay stays on the ground
|
||||||
|
plane with the machine tiles (it is OAM glued to a BG graphic, not a
|
||||||
|
standing sprite). An earlier revision tried
|
||||||
billboarding buildings/trees/signs too (cutting them out of the ground
|
billboarding buildings/trees/signs too (cutting them out of the ground
|
||||||
per hand-curated per-tileset tables); that chased an endless tail of
|
per hand-curated per-tileset tables); that chased an endless tail of
|
||||||
special cases, dense tree canopy, fences fused into grass, building
|
special cases, dense tree canopy, fences fused into grass, building
|
||||||
@@ -68,19 +70,31 @@ Game Boy equivalent:
|
|||||||
|
|
||||||
## Colors mode
|
## Colors mode
|
||||||
|
|
||||||
The `2` key (and the Options menu COLORS row) cycles the global shade-remap
|
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||||
display mode through **GBC → OG → OG INV → GBC INV → CLASSIC → GBC**:
|
through **OG RED → SGB → RED++ → OG → OG INV → SGB INV → CLASSIC → OG RED**.
|
||||||
|
The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||||
|
|
||||||
- **GBC** (default): current SGB / GBC zone palettes.
|
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
|
||||||
|
red BG palette + one green OBJ palette, every map, no per-map variation
|
||||||
|
(Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally).
|
||||||
|
The player/NPCs stay green over the red terrain via the OBP bake +
|
||||||
|
post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`).
|
||||||
|
- **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`).
|
||||||
- **OG**: force the four DMG grays (colorization off).
|
- **OG**: force the four DMG grays (colorization off).
|
||||||
- **OG INV**: inverted DMG grays.
|
- **OG INV**: inverted DMG grays.
|
||||||
- **GBC INV**: each SGB zone palette with shade order reversed.
|
- **SGB INV**: each SGB zone palette with shade order reversed.
|
||||||
- **CLASSIC**: original Game Boy pea-soup greens
|
- **CLASSIC**: original Game Boy pea-soup greens
|
||||||
(`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`).
|
(`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`).
|
||||||
|
|
||||||
The transform is applied centrally in `PaletteFX.sendColors`, so it covers
|
The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
|
||||||
overworld, menus, battles, and tilt upright billboards. Persisted as
|
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
|
||||||
`save.options.colors`.
|
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 FX
|
## GBC FX
|
||||||
|
|
||||||
@@ -132,6 +146,10 @@ migrated once into `options.lua` on load.
|
|||||||
- Music / SFX volume
|
- Music / SFX volume
|
||||||
- Music Filter
|
- Music Filter
|
||||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||||
- COLORS (GBC / OG / OG INV / GBC INV / CLASSIC), also hotkey `2`
|
- COLORS (OG RED / SGB / RED++ / OG / OG INV / SGB INV / CLASSIC), also
|
||||||
|
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
|
||||||
|
SuperPalettes + per-species mon colors)
|
||||||
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||||
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
||||||
|
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
|
||||||
|
a hard render frame-rate cap (`save.options.fpsCap`).
|
||||||
@@ -13,7 +13,32 @@ local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
|||||||
-- coroutine that receives `Game` and yields once per
|
-- coroutine that receives `Game` and yields once per
|
||||||
-- frame; used headless (xvfb) for scripted screenshots
|
-- frame; used headless (xvfb) for scripted screenshots
|
||||||
|
|
||||||
local function bootGame()
|
-- --speed N / POKEPORT_SPEED=N: run the logic clock N times faster without
|
||||||
|
-- touching audio (src/core/GameSpeed.lua). Overrides the saved option so a
|
||||||
|
-- bot or screenshot run is not at the mercy of the player's last choice.
|
||||||
|
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
|
||||||
|
|
||||||
|
-- How many times to run a scripted act+step loop per rendered frame. Only
|
||||||
|
-- scripted runs use this; interactive play fast-forwards through
|
||||||
|
-- Game.speedOverride / the GAME SPEED option instead.
|
||||||
|
local function scriptedIterations()
|
||||||
|
if not (autopilot or driverCo) then return 1 end
|
||||||
|
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
|
||||||
|
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.
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
|
||||||
|
require("src.import.CacheFs").mountVersion(GameVersion.get())
|
||||||
|
if love.window and love.window.setTitle then
|
||||||
|
local Version = require("src.core.Version")
|
||||||
|
love.window.setTitle(Version.title(
|
||||||
|
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
|
||||||
|
end
|
||||||
Game = require("src.core.Game")
|
Game = require("src.core.Game")
|
||||||
Game:load()
|
Game:load()
|
||||||
if os.getenv("POKEPORT_AUTOPILOT") then
|
if os.getenv("POKEPORT_AUTOPILOT") then
|
||||||
@@ -24,6 +49,10 @@ local function bootGame()
|
|||||||
local fn = assert(loadfile(driverPath))()
|
local fn = assert(loadfile(driverPath))()
|
||||||
driverCo = coroutine.create(fn)
|
driverCo = coroutine.create(fn)
|
||||||
end
|
end
|
||||||
|
-- After the two above are known: a scripted run drives the multiplier
|
||||||
|
-- from love.update's loop, so the in-engine one must stay at 1 or the
|
||||||
|
-- two would compound (10x10 = 100 steps per observation).
|
||||||
|
Game.speedOverride = (autopilot or driverCo) and 1 or speedOverride
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.load(args)
|
function love.load(args)
|
||||||
@@ -31,8 +60,12 @@ function love.load(args)
|
|||||||
for i, a in ipairs(args or {}) do
|
for i, a in ipairs(args or {}) do
|
||||||
if a == "--editor" then
|
if a == "--editor" then
|
||||||
editorMode = true
|
editorMode = true
|
||||||
|
elseif a == "--developer" then
|
||||||
|
_G.POKEPORT_DEV_MODE = true
|
||||||
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
|
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
|
||||||
savePath = args[i + 1]
|
savePath = args[i + 1]
|
||||||
|
elseif a == "--speed" and tonumber(args[i + 1]) then
|
||||||
|
speedOverride = tonumber(args[i + 1])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
love.graphics.setDefaultFilter("nearest", "nearest")
|
love.graphics.setDefaultFilter("nearest", "nearest")
|
||||||
@@ -47,43 +80,82 @@ function love.load(args)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local RomImporter = require("src.import.RomImporter")
|
local RomImporter = require("src.import.RomImporter")
|
||||||
if os.getenv("POKEPORT_FORCE_IMPORT") == "1" or not RomImporter.isReady() then
|
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
||||||
Importer = RomImporter.new(function()
|
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
|
||||||
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
|
-- Scripted / headless runs pick their game from POKEPORT_VERSION (default
|
||||||
love.event.quit()
|
-- Red); the launcher's per-column choice does not apply to them.
|
||||||
return
|
local scriptedVersion = os.getenv("POKEPORT_VERSION") or "red"
|
||||||
end
|
local ready = RomImporter.isReady(scriptedVersion)
|
||||||
Importer = nil
|
-- Scripted / headless runs have to reach the game with no human pressing
|
||||||
bootGame()
|
-- Play: an autopilot, a frame driver, an import-only build step, or an
|
||||||
end)
|
-- explicit ROM path all bypass the interactive launcher and keep today's
|
||||||
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
|
-- import-then-boot (or boot-straight-in) behavior.
|
||||||
if importPath then Importer:startPath(importPath) end
|
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
|
||||||
|
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or importPath ~= nil
|
||||||
|
|
||||||
|
if scripted then
|
||||||
|
if forceImport or not ready then
|
||||||
|
-- The importer detects the dropped/loaded ROM's version by SHA-1 and
|
||||||
|
-- passes it to onComplete; boot that version.
|
||||||
|
Importer = RomImporter.new(function(version)
|
||||||
|
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
|
||||||
|
love.event.quit()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
Importer = nil
|
||||||
|
bootGame(version or scriptedVersion)
|
||||||
|
end)
|
||||||
|
if importPath then Importer:startPath(importPath) end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
bootGame(scriptedVersion)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
bootGame()
|
|
||||||
|
-- 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.
|
||||||
|
Importer = RomImporter.new(function(version)
|
||||||
|
Importer = nil
|
||||||
|
bootGame(version)
|
||||||
|
end, { launcher = true, forceImport = forceImport })
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.update(dt)
|
function love.update(dt)
|
||||||
if editorMode then return EditorApp.update(dt) end
|
if editorMode then return EditorApp.update(dt) end
|
||||||
if Importer then return Importer:update(dt) end
|
if Importer then return Importer:update(dt) end
|
||||||
|
|
||||||
|
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
|
||||||
|
-- once per Game:update, so they must keep a 1:1 relationship with the
|
||||||
|
-- logic step. Fast-forwarding them by scaling the step inside
|
||||||
|
-- Game:update would run N steps per observation: a held direction walks
|
||||||
|
-- through all N, the player slides past the waypoint, and the script
|
||||||
|
-- re-plans from an overshot cell. So iterate the whole act+step loop
|
||||||
|
-- instead -- same script, just more of it per rendered frame.
|
||||||
|
local iterations = scriptedIterations()
|
||||||
|
|
||||||
if autopilot then
|
if autopilot then
|
||||||
autopilot.update()
|
for _ = 1, iterations do
|
||||||
Game:update(1 / 60) -- deterministic stepping for the autopilot
|
autopilot.update()
|
||||||
|
Game:update(1 / 60) -- deterministic stepping for the autopilot
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if driverCo then
|
if driverCo then
|
||||||
local ok, err = coroutine.resume(driverCo, Game)
|
for _ = 1, iterations do
|
||||||
if not ok then
|
local ok, err = coroutine.resume(driverCo, Game)
|
||||||
print("driver error: " .. tostring(err))
|
if not ok then
|
||||||
love.event.quit(1)
|
print("driver error: " .. tostring(err))
|
||||||
return
|
love.event.quit(1)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if coroutine.status(driverCo) == "dead" then
|
||||||
|
love.event.quit()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
Game:update(1 / 60)
|
||||||
end
|
end
|
||||||
if coroutine.status(driverCo) == "dead" then
|
|
||||||
love.event.quit()
|
|
||||||
return
|
|
||||||
end
|
|
||||||
Game:update(1 / 60)
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
Game:update(dt)
|
Game:update(dt)
|
||||||
@@ -139,6 +211,31 @@ function love.gamepadaxis(joystick, axis, value)
|
|||||||
Game:gamepadaxis(joystick, axis, value)
|
Game:gamepadaxis(joystick, axis, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function love.joystickremoved(joystick)
|
||||||
|
if editorMode then return end
|
||||||
|
if Importer then return end
|
||||||
|
Game:joystickremoved(joystick)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- f is true on focus gained, false on focus lost (e.g. alt-tab). A held
|
||||||
|
-- direction's key-up can be delivered to the OS instead of the game while
|
||||||
|
-- unfocused, so reset input on either transition rather than trust it.
|
||||||
|
function love.focus(f)
|
||||||
|
if editorMode then return end
|
||||||
|
if Importer then
|
||||||
|
if Importer.focus then Importer:focus(f) end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
Game:focus(f)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- v is true when the window becomes visible again, false on minimize.
|
||||||
|
function love.visible(v)
|
||||||
|
if editorMode then return end
|
||||||
|
if Importer then return end
|
||||||
|
Game:visible(v)
|
||||||
|
end
|
||||||
|
|
||||||
function love.touchpressed(id, x, y, dx, dy, pressure)
|
function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||||
if editorMode then return end
|
if editorMode then return end
|
||||||
if Importer then return Importer:mousepressed(x, y, 1) end
|
if Importer then return Importer:mousepressed(x, y, 1) end
|
||||||
@@ -191,6 +288,9 @@ function love.quit()
|
|||||||
if editorMode and EditorApp.quit then
|
if editorMode and EditorApp.quit then
|
||||||
return EditorApp.quit() -- return true to abort quit
|
return EditorApp.quit() -- return true to abort quit
|
||||||
end
|
end
|
||||||
|
pcall(function()
|
||||||
|
require("src.core.DiscordPresence").shutdown()
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.filedropped(file)
|
function love.filedropped(file)
|
||||||
@@ -199,3 +299,78 @@ function love.filedropped(file)
|
|||||||
end
|
end
|
||||||
if Importer then Importer:filedropped(file) end
|
if Importer then Importer:filedropped(file) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function pacingEnabled()
|
||||||
|
if os.getenv("POKEPORT_AUTOPILOT") then return false end
|
||||||
|
if os.getenv("POKEPORT_DRIVER") then return false end
|
||||||
|
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function love.run()
|
||||||
|
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
|
||||||
|
|
||||||
|
-- don't let love.load's cost land in the first frame's dt
|
||||||
|
if love.timer then love.timer.step() end
|
||||||
|
|
||||||
|
local FrameCap = require("src.core.FrameCap")
|
||||||
|
local paced = pacingEnabled()
|
||||||
|
-- The deadline the next present() should not beat. Carried forward one
|
||||||
|
-- budget per frame so pacing stays even instead of drifting with the
|
||||||
|
-- per-frame sleep-granularity jitter.
|
||||||
|
local nextFrame = love.timer and love.timer.getTime() or 0
|
||||||
|
local dt = 0
|
||||||
|
|
||||||
|
return function()
|
||||||
|
-- process events
|
||||||
|
if love.event then
|
||||||
|
love.event.pump()
|
||||||
|
for name, a, b, c, d, e, f in love.event.poll() do
|
||||||
|
if name == "quit" then
|
||||||
|
if not love.quit or not love.quit() then
|
||||||
|
return a or 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
love.handlers[name](a, b, c, d, e, f)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- update dt
|
||||||
|
if love.timer then dt = love.timer.step() end
|
||||||
|
|
||||||
|
-- call update and draw
|
||||||
|
if love.update then love.update(dt) end
|
||||||
|
|
||||||
|
if love.graphics and love.graphics.isActive() then
|
||||||
|
love.graphics.origin()
|
||||||
|
love.graphics.clear(love.graphics.getBackgroundColor())
|
||||||
|
if love.draw then love.draw() end
|
||||||
|
love.graphics.present()
|
||||||
|
end
|
||||||
|
|
||||||
|
if love.timer then
|
||||||
|
if paced then
|
||||||
|
-- Sleep out the remainder of the frame budget, measured from the
|
||||||
|
-- carried deadline, in small chunks so the OS timer stays
|
||||||
|
-- responsive. vsync is untouched: when it already paces slower
|
||||||
|
-- than the cap the remainder is <= 0 and this rounds to a no-op.
|
||||||
|
local budget = 1 / FrameCap.current
|
||||||
|
nextFrame = nextFrame + budget
|
||||||
|
local now = love.timer.getTime()
|
||||||
|
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
|
||||||
|
-- deadline more than a full budget in the past; re-anchor to now so
|
||||||
|
-- we pace the next frame rather than burst uncapped to catch up.
|
||||||
|
if now - nextFrame > budget then
|
||||||
|
nextFrame = now
|
||||||
|
end
|
||||||
|
while true do
|
||||||
|
local remaining = nextFrame - love.timer.getTime()
|
||||||
|
if remaining <= 0 then break end
|
||||||
|
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
love.timer.sleep(0.001)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
+15
-19
@@ -27,17 +27,17 @@ refresh is safe, just rebuild.
|
|||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Debug APK (default Android debug keystore)
|
# Build the APK
|
||||||
scripts/build_android.sh
|
scripts/build_android.sh
|
||||||
|
|
||||||
# Release APK (signing is manual, see below)
|
# Build the APK, setting app.version_name/app.version_code to match a release
|
||||||
scripts/build_android.sh --release
|
scripts/build_android.sh --version 0.2.5
|
||||||
|
|
||||||
# Zip game.love + branding only (no Android SDK required)
|
# Zip game.love + branding only (no Android SDK required)
|
||||||
scripts/build_android.sh --package-only
|
scripts/build_android.sh --package-only
|
||||||
```
|
```
|
||||||
|
|
||||||
Or via `scripts/build.sh android`.
|
Or via `scripts/build.sh android [--version X.Y.Z]`.
|
||||||
|
|
||||||
The embedded `game.love` deliberately excludes `data/generated/`,
|
The embedded `game.love` deliberately excludes `data/generated/`,
|
||||||
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
|
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
|
||||||
@@ -59,12 +59,10 @@ Set `ANDROID_SDK_ROOT` (or `ANDROID_HOME`), or let the script write
|
|||||||
`local.properties` when it finds `~/Library/Android/sdk`.
|
`local.properties` when it finds `~/Library/Android/sdk`.
|
||||||
|
|
||||||
Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone).
|
Gradle flavor used: **`embedNoRecord`** (game fused into the APK, no microphone).
|
||||||
|
Build task: `assembleEmbedNoRecordDebug`.
|
||||||
|
|
||||||
- Debug task: `assembleEmbedNoRecordDebug`
|
The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
|
||||||
- Release task: `assembleEmbedNoRecordRelease`
|
`scripts/build_android.sh` also copies it to `dist/android/debug/`.
|
||||||
|
|
||||||
APKs land under `app/build/outputs/apk/embedNoRecord/{debug,release}/`.
|
|
||||||
`scripts/build_android.sh` also copies the built APK(s) to `dist/android/{debug,release}/`.
|
|
||||||
|
|
||||||
### Payload path
|
### Payload path
|
||||||
|
|
||||||
@@ -79,17 +77,15 @@ scripts, tests, and mobile build sources are excluded.
|
|||||||
| `app.application_id` | `com.theboisclub.pokemonred` |
|
| `app.application_id` | `com.theboisclub.pokemonred` |
|
||||||
| `app.name` | Pokemon Red |
|
| `app.name` | Pokemon Red |
|
||||||
| `app.orientation` | `portrait` |
|
| `app.orientation` | `portrait` |
|
||||||
|
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
|
||||||
| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept |
|
| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept |
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
`.github/workflows/release.yml` builds the APK with `--version` set to the
|
||||||
|
release version and publishes it alongside the macOS/Windows/Linux builds as
|
||||||
|
`PokemonRed-<version>-android.apk`.
|
||||||
|
|
||||||
## Signing
|
## Signing
|
||||||
|
|
||||||
- **Debug**: default Android debug keystore (no setup).
|
Signed with the default Android keystore (no setup required).
|
||||||
- **Release**: out-of-band. Create a keystore yourself and wire it into
|
|
||||||
`app/build.gradle`, **do not commit keystores or passwords**.
|
|
||||||
|
|
||||||
Example (placeholder only):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
keytool -genkey -v -keystore /path/to/pokemonred-release.jks \
|
|
||||||
-alias pokemonred -keyalg RSA -keysize 2048 -validity 10000
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
#app.name=LÖVE for Android
|
#app.name=LÖVE for Android
|
||||||
|
|
||||||
app.application_id=com.theboisclub.pokemonred
|
app.application_id=com.theboisclub.pokemonred
|
||||||
app.orientation=portrait
|
# fullUser: allow every orientation the player's device permits (portrait and
|
||||||
|
# landscape), honouring their auto-rotate lock. Was "portrait" (locked).
|
||||||
|
app.orientation=fullUser
|
||||||
app.version_code=32
|
app.version_code=32
|
||||||
app.version_name=11.5a
|
app.version_name=11.5a
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
*.[oa]
|
*.[oa]
|
||||||
|
# Prebuilt vendored static libs Android.mk links against directly (not
|
||||||
|
# built by the Gradle/NDK build itself) -- must stay tracked or a fresh
|
||||||
|
# checkout fails ndk-build with "LOCAL_SRC_FILES points to a missing file".
|
||||||
|
!android/*/libluajit.a
|
||||||
*.so
|
*.so
|
||||||
*.obj
|
*.obj
|
||||||
*.lib
|
*.lib
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -36,6 +36,7 @@ demos
|
|||||||
*~
|
*~
|
||||||
Makefile*
|
Makefile*
|
||||||
config*
|
config*
|
||||||
|
!src/common/config.h
|
||||||
libtool
|
libtool
|
||||||
/platform/unix/ar-lib
|
/platform/unix/ar-lib
|
||||||
/platform/unix/compile
|
/platform/unix/compile
|
||||||
|
|||||||
@@ -183,6 +183,18 @@ void vibrate(double seconds)
|
|||||||
env->DeleteLocalRef(activity);
|
env->DeleteLocalRef(activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool showFilePicker()
|
||||||
|
{
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z");
|
||||||
|
jboolean result = env->CallStaticBooleanMethod(activity, method);
|
||||||
|
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Helper functions for the filesystem module
|
* Helper functions for the filesystem module
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ bool openURL(const std::string &url);
|
|||||||
|
|
||||||
void vibrate(double seconds);
|
void vibrate(double seconds);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the system's "pick a document" UI (Storage Access Framework).
|
||||||
|
* Returns true if the picker was launched; the picked file (if any) is
|
||||||
|
* copied asynchronously by GameActivity.onActivityResult into the app's
|
||||||
|
* external save directory, not returned here -- see src/import/RomImporter.lua.
|
||||||
|
**/
|
||||||
|
bool showFilePicker();
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Helper functions for the filesystem module
|
* Helper functions for the filesystem module
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2006-2023 LOVE Development Team
|
||||||
|
*
|
||||||
|
* This software is provided 'as-is', without any express or implied
|
||||||
|
* warranty. In no event will the authors be held liable for any damages
|
||||||
|
* arising from the use of this software.
|
||||||
|
*
|
||||||
|
* Permission is granted to anyone to use this software for any purpose,
|
||||||
|
* including commercial applications, and to alter it and redistribute it
|
||||||
|
* freely, subject to the following restrictions:
|
||||||
|
*
|
||||||
|
* 1. The origin of this software must not be misrepresented; you must not
|
||||||
|
* claim that you wrote the original software. If you use this software
|
||||||
|
* in a product, an acknowledgment in the product documentation would be
|
||||||
|
* appreciated but is not required.
|
||||||
|
* 2. Altered source versions must be plainly marked as such, and must not be
|
||||||
|
* misrepresented as being the original software.
|
||||||
|
* 3. This notice may not be removed or altered from any source distribution.
|
||||||
|
**/
|
||||||
|
|
||||||
|
#ifndef LOVE_CONFIG_H
|
||||||
|
#define LOVE_CONFIG_H
|
||||||
|
|
||||||
|
// Platform stuff.
|
||||||
|
#if defined(WIN32) || defined(_WIN32)
|
||||||
|
# define LOVE_WINDOWS 1
|
||||||
|
// If _USING_V110_SDK71_ is defined it means we are using the xp toolset.
|
||||||
|
# if defined(_MSC_VER) && (_MSC_VER >= 1700) && !_USING_V110_SDK71_
|
||||||
|
# include <winapifamily.h>
|
||||||
|
# if WINAPI_FAMILY_PARTITION(WINAPI_PARTITION_APP) && !WINAPI_FAMILY_PARTITION(WINAPI_PARTITION_DESKTOP)
|
||||||
|
# define LOVE_WINDOWS_UWP 1
|
||||||
|
# define LOVE_NO_MODPLUG 1
|
||||||
|
# define LOVE_NOMPG123 1
|
||||||
|
# endif
|
||||||
|
# endif
|
||||||
|
#endif
|
||||||
|
#if defined(linux) || defined(__linux) || defined(__linux__)
|
||||||
|
# define LOVE_LINUX 1
|
||||||
|
#endif
|
||||||
|
#if defined(__ANDROID__)
|
||||||
|
# define LOVE_ANDROID 1
|
||||||
|
#endif
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
# include <TargetConditionals.h>
|
||||||
|
# if TARGET_OS_IPHONE
|
||||||
|
# define LOVE_IOS 1
|
||||||
|
# elif TARGET_OS_MAC
|
||||||
|
# define LOVE_MACOSX 1
|
||||||
|
# endif
|
||||||
|
#endif
|
||||||
|
#if defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__)
|
||||||
|
// I know it's not linux, but it seems most "linux-only" code is bsd-compatible
|
||||||
|
# define LOVE_LINUX 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Endianness.
|
||||||
|
#if defined(__ppc__) || defined(__ppc) || defined(__powerpc__) || defined(__powerpc)
|
||||||
|
# define LOVE_BIG_ENDIAN 1
|
||||||
|
#else
|
||||||
|
# define LOVE_LITTLE_ENDIAN 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// SSE instructions.
|
||||||
|
#if defined(__SSE__)
|
||||||
|
# define LOVE_SIMD_SSE
|
||||||
|
#elif defined(_MSC_VER)
|
||||||
|
# if defined(_M_AMD64) || defined(_M_X64)
|
||||||
|
# define LOVE_SIMD_SSE
|
||||||
|
# elif _M_IX86_FP
|
||||||
|
# define LOVE_SIMD_SSE
|
||||||
|
# endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// NEON instructions.
|
||||||
|
#if defined(__ARM_NEON)
|
||||||
|
# define LOVE_SIMD_NEON
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Warnings.
|
||||||
|
#ifndef _CRT_SECURE_NO_WARNINGS
|
||||||
|
# define _CRT_SECURE_NO_WARNINGS
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Preferably, and ironically, this macro should go unused.
|
||||||
|
#ifndef LOVE_UNUSED
|
||||||
|
# define LOVE_UNUSED(x) (void)sizeof(x)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
// Warn on unused return values
|
||||||
|
#ifdef __GNUC__
|
||||||
|
# define LOVE_WARN_UNUSED __attribute__((warn_unused_result))
|
||||||
|
#elif _MSC_VER
|
||||||
|
# define LOVE_WARN_UNUSED _Check_return_
|
||||||
|
#else
|
||||||
|
# define LOVE_WARN_UNUSED
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef LOVE_BUILD
|
||||||
|
# define LOVE_BUILD
|
||||||
|
# define LOVE_BUILD_STANDALONE
|
||||||
|
# define LOVE_BUILD_EXE
|
||||||
|
//# define LOVE_BUILD_DLL
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// DLL-stuff.
|
||||||
|
#if defined(_MSC_VER) || defined(__MINGW32__)
|
||||||
|
# define LOVE_EXPORT __declspec(dllexport)
|
||||||
|
#elif defined(__GNUC__) || defined(__clang__)
|
||||||
|
# define LOVE_EXPORT __attribute__((visibility("default")))
|
||||||
|
#else
|
||||||
|
# define LOVE_EXPORT
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(LOVE_WINDOWS)
|
||||||
|
#ifndef LOVE_WINDOWS_UWP
|
||||||
|
# define LOVE_LEGENDARY_CONSOLE_IO_HACK
|
||||||
|
#endif // LOVE_WINDOWS_UWP
|
||||||
|
#ifndef __MINGW32__
|
||||||
|
# define NOMINMAX
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(LOVE_MACOSX) || defined(LOVE_IOS)
|
||||||
|
# define LOVE_LEGENDARY_APP_ARGV_HACK
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(LOVE_ANDROID) || defined(LOVE_IOS)
|
||||||
|
# define LOVE_LEGENDARY_ACCELEROMETER_AS_JOYSTICK_HACK
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Autotools config.h
|
||||||
|
#ifdef HAVE_CONFIG_H
|
||||||
|
# include <../config.h>
|
||||||
|
# undef VERSION
|
||||||
|
# ifdef WORDS_BIGENDIAN
|
||||||
|
# undef LOVE_LITTLE_ENDIAN
|
||||||
|
# define LOVE_BIG_ENDIAN 1
|
||||||
|
# else
|
||||||
|
# undef LOVE_BIG_ENDIAN
|
||||||
|
# define LOVE_LITTLE_ENDIAN 1
|
||||||
|
# endif
|
||||||
|
#else
|
||||||
|
# define LOVE_ENABLE_LOVE
|
||||||
|
# define LOVE_ENABLE_AUDIO
|
||||||
|
# define LOVE_ENABLE_DATA
|
||||||
|
# define LOVE_ENABLE_EVENT
|
||||||
|
# define LOVE_ENABLE_FILESYSTEM
|
||||||
|
# define LOVE_ENABLE_FONT
|
||||||
|
# define LOVE_ENABLE_GRAPHICS
|
||||||
|
# define LOVE_ENABLE_IMAGE
|
||||||
|
# define LOVE_ENABLE_JOYSTICK
|
||||||
|
# define LOVE_ENABLE_KEYBOARD
|
||||||
|
# define LOVE_ENABLE_MATH
|
||||||
|
# define LOVE_ENABLE_MOUSE
|
||||||
|
# define LOVE_ENABLE_PHYSICS
|
||||||
|
# define LOVE_ENABLE_SOUND
|
||||||
|
# define LOVE_ENABLE_SYSTEM
|
||||||
|
# define LOVE_ENABLE_THREAD
|
||||||
|
# define LOVE_ENABLE_TIMER
|
||||||
|
# define LOVE_ENABLE_TOUCH
|
||||||
|
# define LOVE_ENABLE_VIDEO
|
||||||
|
# define LOVE_ENABLE_WINDOW
|
||||||
|
|
||||||
|
# define LOVE_ENABLE_ENET
|
||||||
|
# define LOVE_ENABLE_LUASOCKET
|
||||||
|
# define LOVE_ENABLE_LUA53
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Check we have a sane configuration
|
||||||
|
#if !defined(LOVE_WINDOWS) && !defined(LOVE_LINUX) && !defined(LOVE_IOS) && !defined(LOVE_MACOSX) && !defined(LOVE_ANDROID)
|
||||||
|
# error Could not detect target platform
|
||||||
|
#endif
|
||||||
|
#if !defined(LOVE_LITTLE_ENDIAN) && !defined(LOVE_BIG_ENDIAN)
|
||||||
|
# error Could not detect endianness
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif // LOVE_CONFIG_H
|
||||||
@@ -186,6 +186,18 @@ bool Filesystem::setIdentity(const char *ident, bool appendToPath)
|
|||||||
|
|
||||||
save_path_full = storage_path + std::string("/save/") + save_identity;
|
save_path_full = storage_path + std::string("/save/") + save_identity;
|
||||||
|
|
||||||
|
// love::android::mkdir is a single mkdir(), not mkdir -p: on a genuinely
|
||||||
|
// first-ever launch (nothing has touched this app's external-files dir
|
||||||
|
// before) save_directory doesn't exist yet either, so creating
|
||||||
|
// save_path_full in one step fails with ENOENT and PHYSFS_mount below
|
||||||
|
// silently never mounts anything for the rest of this process -- not
|
||||||
|
// just the save dir, but everything routed through it (save.lua/
|
||||||
|
// options.lua, the ROM-derived asset cache, RomImporter's Android
|
||||||
|
// folder scan). Ensure each level exists in order instead.
|
||||||
|
if (!love::android::directoryExists(save_directory.c_str()) &&
|
||||||
|
!love::android::mkdir(save_directory.c_str()))
|
||||||
|
SDL_Log("Error: Could not create save directory %s!", save_directory.c_str());
|
||||||
|
|
||||||
if (!love::android::directoryExists(save_path_full.c_str()) &&
|
if (!love::android::directoryExists(save_path_full.c_str()) &&
|
||||||
!love::android::mkdir(save_path_full.c_str()))
|
!love::android::mkdir(save_path_full.c_str()))
|
||||||
SDL_Log("Error: Could not create save directory %s!", save_path_full.c_str());
|
SDL_Log("Error: Could not create save directory %s!", save_path_full.c_str());
|
||||||
@@ -338,6 +350,26 @@ bool Filesystem::setupWriteDirectory()
|
|||||||
std::string temp_writedir = getDriveRoot(save_path_full);
|
std::string temp_writedir = getDriveRoot(save_path_full);
|
||||||
std::string temp_createdir = skipDriveRoot(save_path_full);
|
std::string temp_createdir = skipDriveRoot(save_path_full);
|
||||||
|
|
||||||
|
#ifdef LOVE_ANDROID
|
||||||
|
// getUserDirectory() falls back to $HOME/getpwuid() (physfs_platform_posix.c),
|
||||||
|
// which is meaningless on Android and unrelated to save_path_full (an
|
||||||
|
// SDL_AndroidGet*StoragePath() subdirectory -- see setIdentity above), so
|
||||||
|
// the generic check below never matches and falls through to setting the
|
||||||
|
// write dir to the drive root ("/"), which no Android app can write to.
|
||||||
|
// Anchor to the real Android storage root instead.
|
||||||
|
std::string androidStorageRoot = isAndroidSaveExternal()
|
||||||
|
? SDL_AndroidGetExternalStoragePath() : SDL_AndroidGetInternalStoragePath();
|
||||||
|
if (save_path_full.find(androidStorageRoot) == 0)
|
||||||
|
{
|
||||||
|
temp_writedir = androidStorageRoot;
|
||||||
|
temp_createdir = save_path_full.substr(androidStorageRoot.length());
|
||||||
|
|
||||||
|
size_t startpos = temp_createdir.find_first_not_of('/');
|
||||||
|
if (startpos != std::string::npos)
|
||||||
|
temp_createdir = temp_createdir.substr(startpos);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
#endif
|
||||||
// On some sandboxed platforms, physfs will break when its write directory
|
// On some sandboxed platforms, physfs will break when its write directory
|
||||||
// is the root of the drive and it tries to create a folder (even if the
|
// is the root of the drive and it tries to create a folder (even if the
|
||||||
// folder's path is in a writable location.) If the user's home folder is
|
// folder's path is in a writable location.) If the user's home folder is
|
||||||
|
|||||||
@@ -180,6 +180,15 @@ void System::vibrate(double seconds) const
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool System::pickFile() const
|
||||||
|
{
|
||||||
|
#ifdef LOVE_ANDROID
|
||||||
|
return love::android::showFilePicker();
|
||||||
|
#else
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
bool System::hasBackgroundMusic() const
|
bool System::hasBackgroundMusic() const
|
||||||
{
|
{
|
||||||
#if defined(LOVE_ANDROID)
|
#if defined(LOVE_ANDROID)
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ public:
|
|||||||
*/
|
*/
|
||||||
virtual void vibrate(double seconds) const;
|
virtual void vibrate(double seconds) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the platform's native "pick a file" UI, if one is available.
|
||||||
|
* Android only for now; the result (if any) is not returned here -- see
|
||||||
|
* love::android::showFilePicker and src/import/RomImporter.lua.
|
||||||
|
*
|
||||||
|
* @return Whether the picker was shown.
|
||||||
|
**/
|
||||||
|
virtual bool pickFile() const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets if the user is playing music on background.
|
* Gets if the user is playing music on background.
|
||||||
* Throws an exception on unsupported platforms.
|
* Throws an exception on unsupported platforms.
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ int w_vibrate(lua_State *L)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int w_pickFile(lua_State *L)
|
||||||
|
{
|
||||||
|
luax_pushboolean(L, instance()->pickFile());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
int w_hasBackgroundMusic(lua_State *L)
|
int w_hasBackgroundMusic(lua_State *L)
|
||||||
{
|
{
|
||||||
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
||||||
@@ -110,6 +116,7 @@ static const luaL_Reg functions[] =
|
|||||||
{ "getPowerInfo", w_getPowerInfo },
|
{ "getPowerInfo", w_getPowerInfo },
|
||||||
{ "openURL", w_openURL },
|
{ "openURL", w_openURL },
|
||||||
{ "vibrate", w_vibrate },
|
{ "vibrate", w_vibrate },
|
||||||
|
{ "pickFile", w_pickFile },
|
||||||
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
||||||
{ 0, 0 }
|
{ 0, 0 }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -61,6 +61,13 @@ public class GameActivity extends SDLActivity {
|
|||||||
protected final int[] recordAudioRequestDummy = new int[1];
|
protected final int[] recordAudioRequestDummy = new int[1];
|
||||||
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
|
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
|
||||||
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
|
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
|
||||||
|
public static final int ROM_PICKER_REQUEST_CODE = 4;
|
||||||
|
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM
|
||||||
|
// is dropped so RomImporter's existing folder scan finds it -- see
|
||||||
|
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
|
||||||
|
// save directory to getExternalFilesDir()/save/<identity>).
|
||||||
|
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
|
||||||
|
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
|
||||||
private static boolean immersiveActive = false;
|
private static boolean immersiveActive = false;
|
||||||
private static boolean needToCopyGameInArchive = false;
|
private static boolean needToCopyGameInArchive = false;
|
||||||
private boolean storagePermissionUnnecessary = false;
|
private boolean storagePermissionUnnecessary = false;
|
||||||
@@ -332,6 +339,64 @@ public class GameActivity extends SDLActivity {
|
|||||||
return openURL(url) == 0;
|
return openURL(url) == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the system document picker (Storage Access Framework) so the
|
||||||
|
* player can pick their ROM from anywhere (Downloads, Drive, etc.)
|
||||||
|
* without needing to know where the app's external files folder is.
|
||||||
|
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
|
||||||
|
* arrives later in onActivityResult, not synchronously here.
|
||||||
|
*/
|
||||||
|
@Keep
|
||||||
|
public static boolean showRomFilePicker() {
|
||||||
|
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||||
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
|
if (self == null) return false;
|
||||||
|
|
||||||
|
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||||
|
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||||
|
intent.setType("*/*");
|
||||||
|
try {
|
||||||
|
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data);
|
||||||
|
if (requestCode != ROM_PICKER_REQUEST_CODE) return;
|
||||||
|
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
|
||||||
|
Log.d("GameActivity", "ROM picker returned no file (cancelled?)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri uri = data.getData();
|
||||||
|
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||||
|
if (!destDir.exists() && !destDir.mkdirs()) {
|
||||||
|
Log.d("GameActivity", "could not create " + destDir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
File destFile = new File(destDir, PICKED_ROM_FILENAME);
|
||||||
|
|
||||||
|
InputStream source;
|
||||||
|
try {
|
||||||
|
source = getContentResolver().openInputStream(uri);
|
||||||
|
} catch (FileNotFoundException e) {
|
||||||
|
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (source == null) {
|
||||||
|
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!copyAssetFile(source, destFile.getPath())) {
|
||||||
|
Log.d("GameActivity", "could not copy picked ROM to " + destFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copies a given file from the assets folder to the destination.
|
* Copies a given file from the assets folder to the destination.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -62,10 +62,15 @@
|
|||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UTExportedTypeDeclarations</key>
|
<key>UTExportedTypeDeclarations</key>
|
||||||
<array>
|
<array>
|
||||||
|
|||||||
+101
-19
@@ -1,17 +1,19 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Packages the LÖVE2D Pokémon Red port into distributable macOS and
|
# Packages the LÖVE2D Pokémon Red port into distributable macOS, Windows,
|
||||||
# Windows builds. Runs entirely on macOS (no cross-compiling needed,
|
# and Linux builds. Runs entirely on macOS (no cross-compiling needed,
|
||||||
# the Windows build reuses LÖVE's prebuilt win64 binaries).
|
# the Windows and Linux builds reuse LÖVE's prebuilt win64 / AppImage
|
||||||
|
# binaries, fusing our game.love onto them the same way love.exe does).
|
||||||
#
|
#
|
||||||
# Usage: scripts/build.sh [mac|win|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
|
# Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
|
||||||
# [--notary-profile NAME] [--no-notarize]
|
# [--notary-profile NAME] [--no-notarize]
|
||||||
# [--release] # android/ios: release config instead of debug
|
# [--release] # ios only: release config instead of debug
|
||||||
#
|
#
|
||||||
# Output: dist/mac/PokemonRed-macos.zip
|
# Output: dist/mac/gen1recomp-macos.zip
|
||||||
# dist/win/PokemonRed-win64.zip
|
# dist/win/gen1recomp-win64.zip
|
||||||
# dist/android/{debug,release}/*.apk (full gradle output stays under
|
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
|
||||||
|
# dist/android/debug/*.apk (full gradle output stays under
|
||||||
# mobile/android/app/build/outputs/apk/embedNoRecord/)
|
# mobile/android/app/build/outputs/apk/embedNoRecord/)
|
||||||
# dist/ios/<Config>-<sdk>/PokemonRed.app (full xcodebuild output stays
|
# dist/ios/<Config>-<sdk>/gen1recomp.app (full xcodebuild output stays
|
||||||
# under mobile/ios/build/Build/Products/)
|
# under mobile/ios/build/Build/Products/)
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -23,15 +25,15 @@ WORK="$HERE/work"
|
|||||||
DIST="$ROOT/dist"
|
DIST="$ROOT/dist"
|
||||||
ENTITLEMENTS="$ROOT/scripts/macos-entitlements.plist"
|
ENTITLEMENTS="$ROOT/scripts/macos-entitlements.plist"
|
||||||
|
|
||||||
APP_NAME="PokemonRed"
|
APP_NAME="gen1recomp"
|
||||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||||
LOVE_VERSION="11.5"
|
LOVE_VERSION="11.5"
|
||||||
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)"
|
||||||
|
VERSION_EXPLICIT=false
|
||||||
IDENTITY=""
|
IDENTITY=""
|
||||||
TARGET="all"
|
TARGET="all"
|
||||||
NOTARY_PROFILE="notary-profile"
|
NOTARY_PROFILE="notary-profile"
|
||||||
NOTARIZE=true
|
NOTARIZE=true
|
||||||
ANDROID_RELEASE=false
|
|
||||||
IOS_RELEASE=false
|
IOS_RELEASE=false
|
||||||
|
|
||||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
@@ -40,25 +42,25 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
|||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
mac|win|android|ios|all) TARGET="$1" ;;
|
mac|win|linux|android|ios|all) TARGET="$1" ;;
|
||||||
--version) VERSION="$2"; shift ;;
|
--version) VERSION="$2"; VERSION_EXPLICIT=true; shift ;;
|
||||||
--identity) IDENTITY="$2"; shift ;;
|
--identity) IDENTITY="$2"; shift ;;
|
||||||
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
||||||
--no-notarize) NOTARIZE=false ;;
|
--no-notarize) NOTARIZE=false ;;
|
||||||
--release) ANDROID_RELEASE=true; IOS_RELEASE=true ;;
|
--release) IOS_RELEASE=true ;;
|
||||||
*) fail "unknown argument: $1" ;;
|
*) fail "unknown argument: $1" ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
|
||||||
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win"
|
mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
|
||||||
|
|
||||||
# --------------------------------------------------------------- game.love
|
# --------------------------------------------------------------- game.love
|
||||||
say "packing game.love"
|
say "packing game.love"
|
||||||
LOVE_FILE="$WORK/game.love"
|
LOVE_FILE="$WORK/game.love"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||||
main.lua conf.lua src data assets tools/rom_manifest.json \
|
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
if unzip -Z1 "$LOVE_FILE" \
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
@@ -139,11 +141,20 @@ build_win() {
|
|||||||
say "building Windows (win64) app"
|
say "building Windows (win64) app"
|
||||||
local zip_name="love-$LOVE_VERSION-win64.zip"
|
local zip_name="love-$LOVE_VERSION-win64.zip"
|
||||||
local love_zip="$CACHE/$zip_name"
|
local love_zip="$CACHE/$zip_name"
|
||||||
|
# A cache hit only checks existence, not validity -- a prior run truncated
|
||||||
|
# by a network drop mid-download (curl still leaves the partial file if
|
||||||
|
# the exit code slips through) would otherwise be reused forever.
|
||||||
|
if [ -f "$love_zip" ] && ! unzip -tqq "$love_zip" >/dev/null 2>&1; then
|
||||||
|
warn "cached $zip_name is not a valid zip, removing and re-downloading"
|
||||||
|
rm -f "$love_zip"
|
||||||
|
fi
|
||||||
if [ ! -f "$love_zip" ]; then
|
if [ ! -f "$love_zip" ]; then
|
||||||
say "downloading LÖVE $LOVE_VERSION win64 binaries"
|
say "downloading LÖVE $LOVE_VERSION win64 binaries"
|
||||||
curl -fL --progress-bar \
|
curl -fL --progress-bar \
|
||||||
"https://github.com/love2d/love/releases/download/$LOVE_VERSION/$zip_name" \
|
"https://github.com/love2d/love/releases/download/$LOVE_VERSION/$zip_name" \
|
||||||
-o "$love_zip" || fail "download failed, check LOVE_VERSION or your network"
|
-o "$love_zip" || fail "download failed, check LOVE_VERSION or your network"
|
||||||
|
unzip -tqq "$love_zip" >/dev/null 2>&1 \
|
||||||
|
|| fail "downloaded $zip_name is not a valid zip (truncated download?)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local extract_dir="$WORK/love-win64"
|
local extract_dir="$WORK/love-win64"
|
||||||
@@ -167,12 +178,82 @@ build_win() {
|
|||||||
say "Windows build: $zip_out"
|
say "Windows build: $zip_out"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- Linux
|
||||||
|
build_linux() {
|
||||||
|
say "building Linux (x86_64 AppImage) app"
|
||||||
|
local appimage_name="love-$LOVE_VERSION-x86_64.AppImage"
|
||||||
|
local love_appimage="$CACHE/$appimage_name"
|
||||||
|
# Same cache-validity gap as the win64 zip above: an AppImage is just an
|
||||||
|
# ELF, so check the magic bytes before trusting a cached copy is complete.
|
||||||
|
if [ -f "$love_appimage" ] && [ "$(head -c 4 "$love_appimage" | od -An -tx1 | tr -d ' \n')" != "7f454c46" ]; then
|
||||||
|
warn "cached $appimage_name is not a valid ELF binary, removing and re-downloading"
|
||||||
|
rm -f "$love_appimage"
|
||||||
|
fi
|
||||||
|
if [ ! -f "$love_appimage" ]; then
|
||||||
|
say "downloading LÖVE $LOVE_VERSION Linux AppImage"
|
||||||
|
curl -fL --progress-bar \
|
||||||
|
"https://github.com/love2d/love/releases/download/$LOVE_VERSION/$appimage_name" \
|
||||||
|
-o "$love_appimage" || fail "download failed, check LOVE_VERSION or your network"
|
||||||
|
[ "$(head -c 4 "$love_appimage" | od -An -tx1 | tr -d ' \n')" = "7f454c46" ] \
|
||||||
|
|| fail "downloaded $appimage_name is not a valid ELF binary (truncated download?)"
|
||||||
|
fi
|
||||||
|
chmod +x "$love_appimage"
|
||||||
|
|
||||||
|
# The Windows-style `cat love.exe game.love` fusion does NOT work here:
|
||||||
|
# an AppImage is a small runtime ELF with a squashfs appended, and at
|
||||||
|
# launch the runtime mounts the squashfs and executes bin/love from
|
||||||
|
# *inside* it -- bytes appended to the outer file are never read, so
|
||||||
|
# users would just get vanilla LÖVE's no-game screen. Instead, unpack
|
||||||
|
# the squashfs, drop game.love in, point AppRun's FUSE_PATH hook at it
|
||||||
|
# (the hook ships commented-out in LÖVE's official AppImage), and glue
|
||||||
|
# runtime + repacked squashfs back together.
|
||||||
|
command -v unsquashfs >/dev/null && command -v mksquashfs >/dev/null \
|
||||||
|
|| fail "squashfs tools not found; install with: brew install squashfs"
|
||||||
|
|
||||||
|
# The squashfs starts right where the ELF ends:
|
||||||
|
# e_shoff + e_shnum * e_shentsize (all little-endian in the ELF64 header).
|
||||||
|
local e_shoff e_shentsize e_shnum sfs_offset
|
||||||
|
e_shoff=$(od -An -j40 -N8 -tu8 "$love_appimage" | tr -d ' ')
|
||||||
|
e_shentsize=$(od -An -j58 -N2 -tu2 "$love_appimage" | tr -d ' ')
|
||||||
|
e_shnum=$(od -An -j60 -N2 -tu2 "$love_appimage" | tr -d ' ')
|
||||||
|
sfs_offset=$((e_shoff + e_shentsize * e_shnum))
|
||||||
|
[ "$(dd if="$love_appimage" bs=1 skip="$sfs_offset" count=4 2>/dev/null)" = "hsqs" ] \
|
||||||
|
|| fail "no squashfs superblock at computed offset $sfs_offset (unexpected AppImage layout)"
|
||||||
|
|
||||||
|
local appdir="$WORK/linux-appdir"
|
||||||
|
rm -rf "$appdir"
|
||||||
|
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
|
||||||
|
|
||||||
|
cp "$LOVE_FILE" "$appdir/game.love"
|
||||||
|
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
|
||||||
|
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
||||||
|
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
|
||||||
|
|
||||||
|
# Match the upstream image's compression (gzip, 128K blocks) so the
|
||||||
|
# bundled runtime can read it.
|
||||||
|
local sfs_out="$WORK/game.squashfs"
|
||||||
|
rm -f "$sfs_out"
|
||||||
|
mksquashfs "$appdir" "$sfs_out" \
|
||||||
|
-comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null
|
||||||
|
|
||||||
|
local out_bin="$WORK/$APP_NAME-x86_64.AppImage"
|
||||||
|
rm -f "$out_bin"
|
||||||
|
head -c "$sfs_offset" "$love_appimage" > "$out_bin"
|
||||||
|
cat "$sfs_out" >> "$out_bin"
|
||||||
|
chmod +x "$out_bin"
|
||||||
|
|
||||||
|
local zip_out="$DIST/linux/$APP_NAME-linux.zip"
|
||||||
|
rm -f "$zip_out"
|
||||||
|
(cd "$WORK" && zip -q -9 -j "$zip_out" "$(basename "$out_bin")")
|
||||||
|
say "Linux build: $zip_out"
|
||||||
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- Android
|
# --------------------------------------------------------------- Android
|
||||||
build_android() {
|
build_android() {
|
||||||
say "building Android (delegating to scripts/build_android.sh)"
|
say "building Android (delegating to scripts/build_android.sh)"
|
||||||
local args=()
|
local args=()
|
||||||
if [ "$ANDROID_RELEASE" = true ]; then
|
if [ "$VERSION_EXPLICIT" = true ]; then
|
||||||
args+=(--release)
|
args+=(--version "$VERSION")
|
||||||
fi
|
fi
|
||||||
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
|
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
|
||||||
}
|
}
|
||||||
@@ -190,9 +271,10 @@ build_ios() {
|
|||||||
case "$TARGET" in
|
case "$TARGET" in
|
||||||
mac) build_mac ;;
|
mac) build_mac ;;
|
||||||
win) build_win ;;
|
win) build_win ;;
|
||||||
|
linux) build_linux ;;
|
||||||
android) build_android ;;
|
android) build_android ;;
|
||||||
ios) build_ios ;;
|
ios) build_ios ;;
|
||||||
all) build_mac; build_win ;;
|
all) build_mac; build_win; build_linux ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
case "$TARGET" in
|
case "$TARGET" in
|
||||||
|
|||||||
+31
-33
@@ -1,11 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
# Packages the LÖVE2D Pokémon Red port into an Android APK via love-android 11.5a.
|
||||||
#
|
#
|
||||||
# Usage: scripts/build_android.sh [--release] [--package-only]
|
# Usage: scripts/build_android.sh [--version X.Y.Z] [--package-only]
|
||||||
#
|
#
|
||||||
# (default) assembleEmbedNoRecordDebug (debug keystore)
|
# --version X.Y.Z set app.version_name / app.version_code (else left as-is)
|
||||||
# --release assembleEmbedNoRecordRelease (requires out-of-band signing)
|
# --package-only zip game.love + apply branding; skip gradle
|
||||||
# --package-only zip game.love + apply branding; skip gradle
|
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
|
# - mobile/android vendored love-android tree at tag 11.5a (in-repo; see mobile/ANDROID.md)
|
||||||
@@ -13,9 +12,8 @@
|
|||||||
# - JDK 17
|
# - JDK 17
|
||||||
#
|
#
|
||||||
# Output (after gradle):
|
# Output (after gradle):
|
||||||
# dist/android/{debug,release}/*.apk (convenience copy)
|
# dist/android/debug/*.apk (convenience copy)
|
||||||
# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk
|
# mobile/android/app/build/outputs/apk/embedNoRecord/debug/*.apk
|
||||||
# mobile/android/app/build/outputs/apk/embedNoRecord/release/*.apk
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -24,12 +22,12 @@ ANDROID_DIR="$ROOT/mobile/android"
|
|||||||
EMBED_ASSETS="$ANDROID_DIR/app/src/embed/assets"
|
EMBED_ASSETS="$ANDROID_DIR/app/src/embed/assets"
|
||||||
LOVE_FILE="$EMBED_ASSETS/game.love"
|
LOVE_FILE="$EMBED_ASSETS/game.love"
|
||||||
DIST="$ROOT/dist/android"
|
DIST="$ROOT/dist/android"
|
||||||
APP_NAME="Pokemon Red"
|
APP_NAME="gen1recomp"
|
||||||
APPLICATION_ID="com.theboisclub.pokemonred"
|
APPLICATION_ID="com.theboisclub.pokemonred"
|
||||||
LOVE_ANDROID_VERSION="11.5a"
|
LOVE_ANDROID_VERSION="11.5a"
|
||||||
NDK_VERSION="25.2.9519653"
|
NDK_VERSION="25.2.9519653"
|
||||||
|
|
||||||
RELEASE=false
|
VERSION=""
|
||||||
PACKAGE_ONLY=false
|
PACKAGE_ONLY=false
|
||||||
|
|
||||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
@@ -38,17 +36,29 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
|||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--release) RELEASE=true ;;
|
--version) VERSION="$2"; shift ;;
|
||||||
--package-only) PACKAGE_ONLY=true ;;
|
--package-only) PACKAGE_ONLY=true ;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
sed -n '2,20p' "$0"
|
sed -n '2,20p' "$0"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*) fail "unknown argument: $1 (try --release or --package-only)" ;;
|
*) fail "unknown argument: $1 (try --version X.Y.Z or --package-only)" ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
|
||||||
|
VERSION_CODE=""
|
||||||
|
if [ -n "$VERSION" ]; then
|
||||||
|
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
fail "invalid --version '$VERSION' (expected X.Y.Z)"
|
||||||
|
fi
|
||||||
|
major="${VERSION%%.*}"
|
||||||
|
rest="${VERSION#*.}"
|
||||||
|
minor="${rest%%.*}"
|
||||||
|
patch="${rest##*.}"
|
||||||
|
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
|
||||||
|
fi
|
||||||
|
|
||||||
# --------------------------------------------------------------- preconditions
|
# --------------------------------------------------------------- preconditions
|
||||||
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
|
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
|
||||||
fail "love-android not found at mobile/android/.
|
fail "love-android not found at mobile/android/.
|
||||||
@@ -74,10 +84,10 @@ apply_android_branding() {
|
|||||||
|
|
||||||
say "applying Android branding (gradle.properties + permission trim)"
|
say "applying Android branding (gradle.properties + permission trim)"
|
||||||
|
|
||||||
python3 - "$props" "$APPLICATION_ID" "$APP_NAME" <<'PY'
|
python3 - "$props" "$APPLICATION_ID" "$APP_NAME" "$VERSION" "$VERSION_CODE" <<'PY'
|
||||||
import pathlib, re, sys
|
import pathlib, re, sys
|
||||||
path = pathlib.Path(sys.argv[1])
|
path = pathlib.Path(sys.argv[1])
|
||||||
app_id, name = sys.argv[2], sys.argv[3]
|
app_id, name, version, version_code = sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5]
|
||||||
text = path.read_text()
|
text = path.read_text()
|
||||||
|
|
||||||
def set_prop(text, key, value):
|
def set_prop(text, key, value):
|
||||||
@@ -91,7 +101,10 @@ def set_prop(text, key, value):
|
|||||||
text = re.sub(r"(?m)^app\.name_byte_array=.*\n?", "", text)
|
text = re.sub(r"(?m)^app\.name_byte_array=.*\n?", "", text)
|
||||||
text = set_prop(text, "app.name", name)
|
text = set_prop(text, "app.name", name)
|
||||||
text = set_prop(text, "app.application_id", app_id)
|
text = set_prop(text, "app.application_id", app_id)
|
||||||
text = set_prop(text, "app.orientation", "portrait")
|
text = set_prop(text, "app.orientation", "fullUser")
|
||||||
|
if version:
|
||||||
|
text = set_prop(text, "app.version_name", version)
|
||||||
|
text = set_prop(text, "app.version_code", version_code)
|
||||||
path.write_text(text)
|
path.write_text(text)
|
||||||
PY
|
PY
|
||||||
|
|
||||||
@@ -124,7 +137,7 @@ pack_game_love() {
|
|||||||
mkdir -p "$EMBED_ASSETS"
|
mkdir -p "$EMBED_ASSETS"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||||
main.lua conf.lua src data assets tools/rom_manifest.json \
|
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||||
-x 'data/generated/*' -x 'assets/generated/*')
|
-x 'data/generated/*' -x 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
if unzip -Z1 "$LOVE_FILE" \
|
||||||
@@ -178,16 +191,8 @@ require_android_sdk() {
|
|||||||
|
|
||||||
# --------------------------------------------------------------- gradle
|
# --------------------------------------------------------------- gradle
|
||||||
run_gradle() {
|
run_gradle() {
|
||||||
local task
|
local task="assembleEmbedNoRecordDebug"
|
||||||
if $RELEASE; then
|
say "building APK ($task)"
|
||||||
task="assembleEmbedNoRecordRelease"
|
|
||||||
say "building release APK ($task)"
|
|
||||||
warn "release signing is out-of-band, see mobile/ANDROID.md (Signing)."
|
|
||||||
warn "without a signingConfig, assembleRelease may produce an unsigned APK or fail."
|
|
||||||
else
|
|
||||||
task="assembleEmbedNoRecordDebug"
|
|
||||||
say "building debug APK ($task), uses the default Android debug keystore"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! (
|
if ! (
|
||||||
cd "$ANDROID_DIR"
|
cd "$ANDROID_DIR"
|
||||||
@@ -199,19 +204,12 @@ run_gradle() {
|
|||||||
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
|
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local out_dir
|
local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
|
||||||
if $RELEASE; then
|
|
||||||
out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/release"
|
|
||||||
else
|
|
||||||
out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
|
|
||||||
fi
|
|
||||||
if [ -d "$out_dir" ]; then
|
if [ -d "$out_dir" ]; then
|
||||||
say "APK output:"
|
say "APK output:"
|
||||||
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
||||||
|
|
||||||
local flavor="debug"
|
local dist_dir="$DIST/debug"
|
||||||
$RELEASE && flavor="release"
|
|
||||||
local dist_dir="$DIST/$flavor"
|
|
||||||
rm -rf "$dist_dir"
|
rm -rf "$dist_dir"
|
||||||
mkdir -p "$dist_dir"
|
mkdir -p "$dist_dir"
|
||||||
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
|
find "$out_dir" -name '*.apk' -exec cp {} "$dist_dir/" \;
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
|
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
|
||||||
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
|
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
|
||||||
#
|
#
|
||||||
# Output: dist/ios/<Config>-<sdk>/PokemonRed.app (convenience copy)
|
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
|
||||||
# mobile/ios/build/Build/Products/<Config>-<sdk>/PokemonRed.app
|
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -33,8 +33,8 @@ RESOURCES_DIR="$XCODE_DIR/ios/resources"
|
|||||||
LOVE_FILE="$RESOURCES_DIR/game.love"
|
LOVE_FILE="$RESOURCES_DIR/game.love"
|
||||||
LIBS_DIR="$XCODE_DIR/ios/libraries"
|
LIBS_DIR="$XCODE_DIR/ios/libraries"
|
||||||
|
|
||||||
APP_NAME="PokemonRed"
|
APP_NAME="gen1recomp"
|
||||||
DISPLAY_NAME="Pokemon Red"
|
DISPLAY_NAME="gen1recomp"
|
||||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||||
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
|
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
|
||||||
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
|
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
|
||||||
@@ -155,7 +155,7 @@ require_ios_libraries
|
|||||||
apply_ios_branding() {
|
apply_ios_branding() {
|
||||||
[ -f "$OVERLAY_PLIST" ] || fail "missing overlay plist: $OVERLAY_PLIST"
|
[ -f "$OVERLAY_PLIST" ] || fail "missing overlay plist: $OVERLAY_PLIST"
|
||||||
local dest="$XCODE_DIR/ios/love-ios.plist"
|
local dest="$XCODE_DIR/ios/love-ios.plist"
|
||||||
say "applying iOS branding (portrait-only Info.plist, display name)"
|
say "applying iOS branding (portrait + landscape Info.plist, display name)"
|
||||||
cp "$OVERLAY_PLIST" "$dest"
|
cp "$OVERLAY_PLIST" "$dest"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ pack_game_love() {
|
|||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
# Same payload as scripts/build.sh / build_android.sh: game sources only.
|
# Same payload as scripts/build.sh / build_android.sh: game sources only.
|
||||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||||
main.lua conf.lua src data assets tools/rom_manifest.json \
|
main.lua conf.lua src data assets tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||||
-x 'data/generated/*' -x 'assets/generated/*')
|
-x 'data/generated/*' -x 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
if unzip -Z1 "$LOVE_FILE" \
|
||||||
|
|||||||
+11
-18
@@ -78,23 +78,14 @@ run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
|||||||
# ------- content tier: only meaningful with an imported ROM
|
# ------- content tier: only meaningful with an imported ROM
|
||||||
|
|
||||||
|
|
||||||
# tests/run_tests.lua carries two pre-existing failures that are stale
|
# tests/run_tests.lua is expected to be clean. It used to carry two stale
|
||||||
# about the chip-audio architecture rather than real defects:
|
# chip-audio assertions on the allowlist below (Pikachu cry WAV exists /
|
||||||
#
|
# low-health alarm sfx extracted); both have since been fixed, so the
|
||||||
# Pikachu cry WAV exists nothing writes .wav any more -- cries are
|
# baseline is zero and any failure fails the tier. Keep the allowlist
|
||||||
# synthesized at play time from
|
# mechanism rather than ignoring the exit code -- that would hide every
|
||||||
# Data.audio.cries + programs.bin
|
# future content regression.
|
||||||
# low-health alarm sfx extracted the importer deliberately does not
|
KNOWN_CONTENT_FAILURES=0
|
||||||
# extract it; Sound.startLoop falls back to
|
KNOWN_CONTENT_LINES=""
|
||||||
# ChipAudio.newLowHealthAlarm (Sound.lua:268)
|
|
||||||
#
|
|
||||||
# They are left in place (fixing them is a separate, reviewed change), so
|
|
||||||
# the tier passes on exactly this baseline and fails the moment a third
|
|
||||||
# failure appears or one of these two changes identity. Ignoring the exit
|
|
||||||
# code outright would hide every future content regression.
|
|
||||||
KNOWN_CONTENT_FAILURES=2
|
|
||||||
KNOWN_CONTENT_LINES="FAIL Pikachu cry WAV exists
|
|
||||||
FAIL low-health alarm sfx extracted"
|
|
||||||
|
|
||||||
run_content_behavior() {
|
run_content_behavior() {
|
||||||
local out
|
local out
|
||||||
@@ -107,7 +98,9 @@ run_content_behavior() {
|
|||||||
if [ "$count" -eq "$KNOWN_CONTENT_FAILURES" ] \
|
if [ "$count" -eq "$KNOWN_CONTENT_FAILURES" ] \
|
||||||
&& [ "$lines" = "$(printf '%s\n' "$KNOWN_CONTENT_LINES" | sort)" ]; then
|
&& [ "$lines" = "$(printf '%s\n' "$KNOWN_CONTENT_LINES" | sort)" ]; then
|
||||||
printf '%s\n' "$out" | tail -3
|
printf '%s\n' "$out" | tail -3
|
||||||
echo "(the $KNOWN_CONTENT_FAILURES known stale audio assertions, unchanged)"
|
if [ "$KNOWN_CONTENT_FAILURES" -gt 0 ]; then
|
||||||
|
echo "(the $KNOWN_CONTENT_FAILURES known stale assertions, unchanged)"
|
||||||
|
fi
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,25 @@ function AnimPlayer.new(data)
|
|||||||
}, AnimPlayer)
|
}, AnimPlayer)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Release the tilesheet images and quads this player built. They live in
|
||||||
|
-- per-instance caches (a fresh AnimPlayer is made per battle), so unlike a
|
||||||
|
-- shared module cache they are dead the moment the battle ends -- freeing
|
||||||
|
-- them here instead of waiting on a GC finalizer keeps grinding battles
|
||||||
|
-- from piling orphaned VRAM up faster than the (Lua-heap-triggered) GC
|
||||||
|
-- reclaims it.
|
||||||
|
function AnimPlayer:release()
|
||||||
|
for _, img in pairs(self.images) do
|
||||||
|
if img and img.release then pcall(img.release, img) end
|
||||||
|
end
|
||||||
|
self.images = {}
|
||||||
|
for _, byTile in pairs(self.quads) do
|
||||||
|
for _, q in pairs(byTile) do
|
||||||
|
if q and q.release then pcall(q.release, q) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self.quads = {}
|
||||||
|
end
|
||||||
|
|
||||||
function AnimPlayer:warnOnce(key, fmt, ...)
|
function AnimPlayer:warnOnce(key, fmt, ...)
|
||||||
if not self.warned[key] then
|
if not self.warned[key] then
|
||||||
self.warned[key] = true
|
self.warned[key] = true
|
||||||
|
|||||||
+456
-82
@@ -52,6 +52,10 @@ local imageCache = {}
|
|||||||
-- fully transparent rows below a pic's content (the extracted 32x32 back
|
-- 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
|
-- pics carry baked-in padding); used to sit the pic flush on the text box
|
||||||
local imagePadBottom = {}
|
local imagePadBottom = {}
|
||||||
|
-- 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 = {}
|
||||||
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
|
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
|
||||||
-- rebuilt for any battle pic, whatever code loaded it
|
-- rebuilt for any battle pic, whatever code loaded it
|
||||||
local imageMeta = {}
|
local imageMeta = {}
|
||||||
@@ -63,7 +67,7 @@ local function getImage(path, pal, trueColor)
|
|||||||
if trueColor then pal = nil end
|
if trueColor then pal = nil end
|
||||||
local key = pal and (path .. "#" .. pal.name) or path
|
local key = pal and (path .. "#" .. pal.name) or path
|
||||||
if not imageCache[key] then
|
if not imageCache[key] then
|
||||||
local img, pad = nil, 0
|
local img, pad, padL = nil, 0, 0
|
||||||
if love.image and love.image.newImageData then
|
if love.image and love.image.newImageData then
|
||||||
local id = Assets.imageData(path)
|
local id = Assets.imageData(path)
|
||||||
if pal then
|
if pal then
|
||||||
@@ -86,13 +90,25 @@ local function getImage(path, pal, trueColor)
|
|||||||
if opaque then break end
|
if opaque then break end
|
||||||
bottom = bottom - 1
|
bottom = bottom - 1
|
||||||
end
|
end
|
||||||
|
local left = 0
|
||||||
|
while left < w do
|
||||||
|
local opaque = false
|
||||||
|
for y = 0, h - 1 do
|
||||||
|
local _, _, _, a = id:getPixel(left, y)
|
||||||
|
if a > 0 then opaque = true break end
|
||||||
|
end
|
||||||
|
if opaque then break end
|
||||||
|
left = left + 1
|
||||||
|
end
|
||||||
img = love.graphics.newImage(id)
|
img = love.graphics.newImage(id)
|
||||||
pad = h - 1 - bottom
|
pad = h - 1 - bottom
|
||||||
|
padL = left
|
||||||
else
|
else
|
||||||
img = Assets.image(path) -- headless stub: no pixel access
|
img = Assets.image(path) -- headless stub: no pixel access
|
||||||
end
|
end
|
||||||
imageCache[key] = img
|
imageCache[key] = img
|
||||||
imagePadBottom[img] = pad
|
imagePadBottom[img] = pad
|
||||||
|
imagePadLeft[img] = padL
|
||||||
imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil }
|
imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil }
|
||||||
end
|
end
|
||||||
return imageCache[key]
|
return imageCache[key]
|
||||||
@@ -101,24 +117,30 @@ end
|
|||||||
-- hot reload: the next getImage re-resolves every pic through the asset
|
-- hot reload: the next getImage re-resolves every pic through the asset
|
||||||
-- search path and re-measures its ground padding
|
-- search path and re-measures its ground padding
|
||||||
function BattleState.invalidate()
|
function BattleState.invalidate()
|
||||||
imageCache, imagePadBottom, imageMeta = {}, {}, {}
|
imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {}
|
||||||
end
|
end
|
||||||
|
|
||||||
Assets.register(BattleState.invalidate)
|
Assets.register(BattleState.invalidate)
|
||||||
|
|
||||||
-- the species' SGB palette (data/pokemon/palettes.asm), or nil
|
-- the species' SGB palette (active COLORS pack), or nil
|
||||||
local function monPalette(data, species)
|
local function monPalette(data, species)
|
||||||
local p = data.palettes
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
local name = p and p.pokemon[species]
|
local colors = PaletteFX.monPal(data, species)
|
||||||
local colors = name and p.palettes[name]
|
if not colors then return nil end
|
||||||
return colors and { name = name, colors = colors } or nil
|
local name = PaletteFX.monPalName(data, species)
|
||||||
|
-- prefix so GBC vs RED++ cache keys don't collide on shared names
|
||||||
|
if PaletteFX.usesGbcPack() then name = "redpp:" .. name end
|
||||||
|
return { name = name, colors = colors }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- a named palette from data/generated/palettes.lua as a getImage pal
|
-- a named palette from the active COLORS pack as a getImage pal
|
||||||
local function namedPalette(data, name)
|
local function namedPalette(data, name)
|
||||||
local p = data.palettes
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
local colors = p and p.palettes[name]
|
local colors = PaletteFX.pal(data, name)
|
||||||
return colors and { name = name, colors = colors } or nil
|
if not colors then return nil end
|
||||||
|
local key = name
|
||||||
|
if PaletteFX.usesGbcPack() then key = "redpp:" .. name end
|
||||||
|
return { name = key, colors = colors }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
|
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
|
||||||
@@ -252,6 +274,10 @@ local function makeBattler(data, mon, isPlayer, save)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- LinkBattle builds clamped copies with save=nil (no badge boosts); wild
|
||||||
|
-- and trainer constructors pass the live save for the player side.
|
||||||
|
BattleState.makeBattler = makeBattler
|
||||||
|
|
||||||
-- The battle pic for `species` on the given side (back pic for the
|
-- The battle pic for `species` on the given side (back pic for the
|
||||||
-- player side, front pic for the enemy side), tinted PAL_GRAYMON --
|
-- player side, front pic for the enemy side), tinted PAL_GRAYMON --
|
||||||
-- the same path makeBattler uses, but forced gray -- since this is only
|
-- the same path makeBattler uses, but forced gray -- since this is only
|
||||||
@@ -264,8 +290,10 @@ function BattleState:speciesSprite(species, isPlayerSide)
|
|||||||
if not def then return nil end
|
if not def then return nil end
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
local colors = PaletteFX.monPal(self.data, species, true)
|
local colors = PaletteFX.monPal(self.data, species, true)
|
||||||
|
local name = "GRAYMON"
|
||||||
|
if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end
|
||||||
return getImage(isPlayerSide and def.spriteBack or def.spriteFront,
|
return getImage(isPlayerSide and def.spriteBack or def.spriteFront,
|
||||||
colors and { name = "GRAYMON", colors = colors } or nil,
|
colors and { name = name, colors = colors } or nil,
|
||||||
def.trueColor)
|
def.trueColor)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -414,8 +442,17 @@ end
|
|||||||
function BattleState.newTrainer(game, oppClass, partyIndex)
|
function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||||
local self = newBattle(game)
|
local self = newBattle(game)
|
||||||
self.kind = "trainer"
|
self.kind = "trainer"
|
||||||
|
self.oppClass = oppClass
|
||||||
self.trainer = game.data.trainers[oppClass]
|
self.trainer = game.data.trainers[oppClass]
|
||||||
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
|
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
|
||||||
|
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
|
||||||
|
-- instead of TrainerNames ("RIVAL1" etc.). Overlay so we don't mutate
|
||||||
|
-- the shared data table.
|
||||||
|
if oppClass == "OPP_RIVAL1" or oppClass == "OPP_RIVAL2"
|
||||||
|
or oppClass == "OPP_RIVAL3" then
|
||||||
|
local rivalName = (game.save.player and game.save.player.rival) or "BLUE"
|
||||||
|
self.trainer = setmetatable({ name = rivalName }, { __index = self.trainer })
|
||||||
|
end
|
||||||
self.enemyAIMods = self.trainer.aiMods
|
self.enemyAIMods = self.trainer.aiMods
|
||||||
local partyDef = self.trainer.parties[partyIndex or 1]
|
local partyDef = self.trainer.parties[partyIndex or 1]
|
||||||
assert(partyDef, ("trainer %s has no party %s"):format(oppClass, tostring(partyIndex)))
|
assert(partyDef, ("trainer %s has no party %s"):format(oppClass, tostring(partyIndex)))
|
||||||
@@ -618,7 +655,6 @@ function BattleState:startMessage(item)
|
|||||||
self.total = self.total + #codes
|
self.total = self.total + #codes
|
||||||
end
|
end
|
||||||
self.charIndex = 0
|
self.charIndex = 0
|
||||||
self.holdTimer = nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:updateQueue()
|
function BattleState:updateQueue()
|
||||||
@@ -769,9 +805,8 @@ function BattleState:updateQueue()
|
|||||||
if self.charIndex < self.total then
|
if self.charIndex < self.total then
|
||||||
self.charIndex = math.min(self.total, self.charIndex + 2)
|
self.charIndex = math.min(self.total, self.charIndex + 2)
|
||||||
else
|
else
|
||||||
self.holdTimer = (self.holdTimer or 40) - 1
|
|
||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
if self.holdTimer <= 0 or input:wasPressed("a") or input:wasPressed("b") then
|
if input:wasPressed("a") or input:wasPressed("b") then
|
||||||
self.current = nil
|
self.current = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -817,7 +852,7 @@ function BattleState:computeMusicKind()
|
|||||||
return "final"
|
return "final"
|
||||||
elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then
|
elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then
|
||||||
return "gym"
|
return "gym"
|
||||||
elseif self.kind == "trainer" then
|
elseif self.kind == "trainer" or self.kind == "link" then
|
||||||
return "trainer"
|
return "trainer"
|
||||||
end
|
end
|
||||||
return "wild"
|
return "wild"
|
||||||
@@ -879,6 +914,17 @@ function BattleState:enter()
|
|||||||
self.showEnemyTrainer = false
|
self.showEnemyTrainer = false
|
||||||
self:startGrowIn(self.enemy)
|
self:startGrowIn(self.enemy)
|
||||||
end)
|
end)
|
||||||
|
elseif self.kind == "link" then
|
||||||
|
-- Colosseum has no foe trainer pic, but the enemy mon still grows
|
||||||
|
-- out of the ball after "X sent out Y!" (not the wild "already there"
|
||||||
|
-- intro that LinkBattle previously inherited from newWild).
|
||||||
|
self.enemySendingOut = true
|
||||||
|
self:say(("%s sent\nout %s!"):format(self.opponentName or "FOE",
|
||||||
|
self.enemy.name))
|
||||||
|
self:act(function()
|
||||||
|
self.enemySendingOut = false
|
||||||
|
self:startGrowIn(self.enemy)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
if not self.ghost then
|
if not self.ghost then
|
||||||
-- the enemy's cry plays as it appears (data/pokemon/cries.asm)
|
-- the enemy's cry plays as it appears (data/pokemon/cries.asm)
|
||||||
@@ -919,22 +965,53 @@ end
|
|||||||
-- (end_of_battle.asm clears wLowHealthAlarm when a battle ends)
|
-- (end_of_battle.asm clears wLowHealthAlarm when a battle ends)
|
||||||
function BattleState:exit()
|
function BattleState:exit()
|
||||||
require("src.core.Sound").stopLoop("Low_Health_Alarm")
|
require("src.core.Sound").stopLoop("Low_Health_Alarm")
|
||||||
|
-- Free this battle's own GPU objects now rather than waiting on a GC
|
||||||
|
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
|
||||||
|
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
|
||||||
|
-- module caches (imageCache/imagePadBottom, keyed by path+palette) are
|
||||||
|
-- reused by the next battle, so they are deliberately left alone -- only
|
||||||
|
-- the per-instance objects, which are dead once this battle is popped,
|
||||||
|
-- are released here.
|
||||||
|
local function rel(o) if o and o.release then pcall(o.release, o) end end
|
||||||
|
rel(self.bgCanvas); self.bgCanvas = nil
|
||||||
|
rel(self.waveCanvas); self.waveCanvas = nil
|
||||||
|
self.colorFxReady = nil
|
||||||
|
if self.animPlayer and self.animPlayer.release then
|
||||||
|
self.animPlayer:release()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- An action the battler is locked into (bypasses the menu), or nil.
|
-- End a trapping sequence (USING_TRAPPING_MOVE). SendOutMon clears the
|
||||||
function BattleState:lockedAction(battler)
|
-- foe's bit (core.asm:1761-1762); EnemySendOutFirstMon clears the
|
||||||
|
-- player's (core.asm:1314-1315). Any switch frees the other side.
|
||||||
|
local function clearTrapping(battler)
|
||||||
|
if not battler then return end
|
||||||
|
battler.trappingTurns = nil
|
||||||
|
battler.trapMove = nil
|
||||||
|
battler.trapDamage = nil
|
||||||
|
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
|
||||||
|
-- FIGHT to continue a trapping sequence).
|
||||||
|
function BattleState:menuLockedAction(battler)
|
||||||
if battler.mustRecharge then return { special = "recharge" } end
|
if battler.mustRecharge then return { special = "recharge" } end
|
||||||
if battler.charging then return battler.charging end
|
if battler.charging then return battler.charging end
|
||||||
if battler.thrashTurns and battler.thrashTurns > 0 then return battler.thrashMove end
|
if battler.thrashTurns and battler.thrashTurns > 0 then return battler.thrashMove end
|
||||||
|
if battler.rageMove then return battler.rageMove end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- After FIGHT: skip MoveSelectionMenu (core.asm:320-329). Own
|
||||||
|
-- trapping/Bide continues; foe trapping forces CANNOT_MOVE ($ff).
|
||||||
|
function BattleState:fightLockedAction(battler)
|
||||||
if battler.trappingTurns and battler.trappingTurns > 0 then
|
if battler.trappingTurns and battler.trappingTurns > 0 then
|
||||||
return { special = "trapping" }
|
return { special = "trapping" }
|
||||||
end
|
end
|
||||||
if battler.bideTurns then return { special = "bide" } end
|
if battler.bideTurns then return { special = "bide" } end
|
||||||
if battler.rageMove then return battler.rageMove end
|
-- held while the OPPONENT's trapping bit is set (live mirror so a
|
||||||
-- held in place while the OPPONENT's trapping move is running
|
-- trap ended early by paralysis/faint frees the victim immediately)
|
||||||
-- (core.asm:316-322 reads the live USING_TRAPPING_MOVE bit, so a
|
|
||||||
-- trap ended early by paralysis/faint frees the victim immediately);
|
|
||||||
-- boundTurns is a mirror kept for Status.beforeMove's held check
|
|
||||||
local opp = battler.isPlayer and self.enemy or self.player
|
local opp = battler.isPlayer and self.enemy or self.player
|
||||||
battler.boundTurns = opp and opp.trappingTurns
|
battler.boundTurns = opp and opp.trappingTurns
|
||||||
and math.max(1, opp.trappingTurns) or nil
|
and math.max(1, opp.trappingTurns) or nil
|
||||||
@@ -944,6 +1021,11 @@ function BattleState:lockedAction(battler)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Full lock for AI / callers that need any forced action.
|
||||||
|
function BattleState:lockedAction(battler)
|
||||||
|
return self:menuLockedAction(battler) or self:fightLockedAction(battler)
|
||||||
|
end
|
||||||
|
|
||||||
function BattleState:playerHasPP()
|
function BattleState:playerHasPP()
|
||||||
for i, mv in ipairs(self.player.curMoves) do
|
for i, mv in ipairs(self.player.curMoves) do
|
||||||
if mv.pp > 0 and self.player.disabledSlot ~= i then return true end
|
if mv.pp > 0 and self.player.disabledSlot ~= i then return true end
|
||||||
@@ -951,6 +1033,25 @@ function BattleState:playerHasPP()
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function BattleState:swapMoves(i, j)
|
||||||
|
if i == j then return end
|
||||||
|
local moves = self.player.curMoves
|
||||||
|
local a, b = moves[i], moves[j]
|
||||||
|
if not (a and b) then return end
|
||||||
|
moves[i], moves[j] = b, a
|
||||||
|
local stored = self.player.mon and self.player.mon.moves
|
||||||
|
if stored and stored ~= moves and stored[i] and stored[j] then
|
||||||
|
stored[i], stored[j] = stored[j], stored[i]
|
||||||
|
end
|
||||||
|
local disabled = self.player.disabledSlot
|
||||||
|
if disabled == i then
|
||||||
|
self.player.disabledSlot = j
|
||||||
|
elseif disabled == j then
|
||||||
|
self.player.disabledSlot = i
|
||||||
|
end
|
||||||
|
require("src.core.Sound").play(self.data, "Swap")
|
||||||
|
end
|
||||||
|
|
||||||
function BattleState:update(dt)
|
function BattleState:update(dt)
|
||||||
self.frame = self.frame + 1
|
self.frame = self.frame + 1
|
||||||
self:updateFx()
|
self:updateFx()
|
||||||
@@ -1031,8 +1132,9 @@ function BattleState:update(dt)
|
|||||||
if not (self.player.mustRecharge or self.player.rageMove) then
|
if not (self.player.mustRecharge or self.player.rageMove) then
|
||||||
self.player.flinched, self.enemy.flinched = false, false
|
self.player.flinched, self.enemy.flinched = false, false
|
||||||
end
|
end
|
||||||
-- locked multi-turn actions skip the menu entirely
|
-- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping
|
||||||
local locked = self:lockedAction(self.player)
|
-- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312)
|
||||||
|
local locked = self:menuLockedAction(self.player)
|
||||||
if locked then
|
if locked then
|
||||||
self:resolveTurn(locked)
|
self:resolveTurn(locked)
|
||||||
return
|
return
|
||||||
@@ -1056,6 +1158,13 @@ function BattleState:update(dt)
|
|||||||
end)
|
end)
|
||||||
self:act(function() self:endOfTurn() end)
|
self:act(function() self:endOfTurn() end)
|
||||||
elseif choice == "fight" then
|
elseif choice == "fight" then
|
||||||
|
-- After the menu: own trapping/Bide or foe Wrap skips the move
|
||||||
|
-- list and forces the locked action (core.asm:320-329)
|
||||||
|
local fightLock = self:fightLockedAction(self.player)
|
||||||
|
if fightLock then
|
||||||
|
self:resolveTurn(fightLock)
|
||||||
|
return
|
||||||
|
end
|
||||||
if not self:playerHasPP() then
|
if not self:playerHasPP() then
|
||||||
-- _NoMovesLeftText, then Struggle engages
|
-- _NoMovesLeftText, then Struggle engages
|
||||||
self:say(("%s has no\nmoves left!"):format(self.player.name))
|
self:say(("%s has no\nmoves left!"):format(self.player.name))
|
||||||
@@ -1064,6 +1173,7 @@ function BattleState:update(dt)
|
|||||||
end
|
end
|
||||||
self.phase = "moveSelect"
|
self.phase = "moveSelect"
|
||||||
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
|
self.moveIndex = math.min(self.moveIndex, #self.player.curMoves)
|
||||||
|
self.moveSwapIndex = nil
|
||||||
elseif choice == "run" then
|
elseif choice == "run" then
|
||||||
self:tryRun()
|
self:tryRun()
|
||||||
elseif choice == "item" then
|
elseif choice == "item" then
|
||||||
@@ -1081,9 +1191,22 @@ function BattleState:update(dt)
|
|||||||
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
|
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
|
||||||
elseif input:wasPressed("down") then
|
elseif input:wasPressed("down") then
|
||||||
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
|
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
|
||||||
|
elseif input:wasPressed("select") then
|
||||||
|
if self.moveSwapIndex then
|
||||||
|
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
||||||
|
self.moveSwapIndex = nil
|
||||||
|
else
|
||||||
|
self.moveSwapIndex = self.moveIndex
|
||||||
|
end
|
||||||
elseif input:wasPressed("b") then
|
elseif input:wasPressed("b") then
|
||||||
|
self.moveSwapIndex = nil
|
||||||
self.phase = "menu"
|
self.phase = "menu"
|
||||||
elseif input:wasPressed("a") then
|
elseif input:wasPressed("a") then
|
||||||
|
if self.moveSwapIndex then
|
||||||
|
self:swapMoves(self.moveSwapIndex, self.moveIndex)
|
||||||
|
self.moveSwapIndex = nil
|
||||||
|
return
|
||||||
|
end
|
||||||
local mv = moves[self.moveIndex]
|
local mv = moves[self.moveIndex]
|
||||||
if self.player.disabledSlot == self.moveIndex then
|
if self.player.disabledSlot == self.moveIndex then
|
||||||
self:say("The move is\ndisabled!")
|
self:say("The move is\ndisabled!")
|
||||||
@@ -1452,6 +1575,9 @@ function BattleState:resolveSwitch(newMon)
|
|||||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||||
local previous = self.player
|
local previous = self.player
|
||||||
self.player = makeBattler(self.data, newMon, true, self.game.save)
|
self.player = makeBattler(self.data, newMon, true, self.game.save)
|
||||||
|
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
|
||||||
|
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
|
||||||
|
clearTrapping(self.enemy)
|
||||||
self:syncSides()
|
self:syncSides()
|
||||||
Runtime.emit("battle.battler_switched", {
|
Runtime.emit("battle.battler_switched", {
|
||||||
battle = self, side = self.sides[1], battler = self.player,
|
battle = self, side = self.sides[1], battler = self.player,
|
||||||
@@ -1484,10 +1610,16 @@ function BattleState:endOfTurn()
|
|||||||
-- that sets the flag also zeroes the counter, so a stale value is
|
-- that sets the flag also zeroes the counter, so a stale value is
|
||||||
-- unobservable (a switch or cure downgrades Toxic to plain poison).
|
-- unobservable (a switch or cure downgrades Toxic to plain poison).
|
||||||
self.sideToxic = self.sideToxic or {}
|
self.sideToxic = self.sideToxic or {}
|
||||||
for _, pair in ipairs({ { self.player, self.enemy, "player" },
|
-- a battler whose opponent was already knocked out by a move this turn
|
||||||
{ self.enemy, self.player, "enemy" } }) do
|
-- skips its own residual (HandlePoisonBurnLeechSeed is bypassed when the
|
||||||
local b, opp, side = pair[1], pair[2], pair[3]
|
-- move faints the target); snapshot before residual so one side's
|
||||||
if b.mon.hp > 0 then
|
-- residual faint can't suppress the other's
|
||||||
|
local playerAlive = self.player.mon.hp > 0
|
||||||
|
local enemyAlive = self.enemy.mon.hp > 0
|
||||||
|
for _, pair in ipairs({ { self.player, self.enemy, "player", enemyAlive },
|
||||||
|
{ self.enemy, self.player, "enemy", playerAlive } }) do
|
||||||
|
local b, opp, side, oppAlive = pair[1], pair[2], pair[3], pair[4]
|
||||||
|
if b.mon.hp > 0 and oppAlive then
|
||||||
local msgs = Status.residual(b, opp, self)
|
local msgs = Status.residual(b, opp, self)
|
||||||
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
|
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
|
||||||
if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved
|
if #msgs > 0 then self:drainNext() end -- poison/burn/seed HP moved
|
||||||
@@ -1547,6 +1679,34 @@ function BattleState:animationsOn()
|
|||||||
return not o or o.animations ~= false
|
return not o or o.animations ~= false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Drop the announcement-time move-anim row. Gen 1 queues PlayMoveAnimation
|
||||||
|
-- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips
|
||||||
|
-- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment
|
||||||
|
-- and peel it back on miss/fail paths.
|
||||||
|
-- Dig/Fly charge leaves the user pic hidden (SLIDE_DOWN / TELEPORT); the
|
||||||
|
-- second-turn DIG/FLY anim restores it via SE_SLIDE_MON_UP / SE_SHOW_MON_PIC.
|
||||||
|
-- Cancelling that row on miss/immune would otherwise leave the digger
|
||||||
|
-- invisible until another anim's resetPicFx (#100).
|
||||||
|
function BattleState:cancelMoveAnim()
|
||||||
|
local row = self.moveAnimRow
|
||||||
|
if not row then return end
|
||||||
|
self.moveAnimRow = nil
|
||||||
|
if row.anim == "DIG" or row.anim == "FLY" then
|
||||||
|
local user = row.attackerIsPlayer and self.player or self.enemy
|
||||||
|
local pf = user and self.picFx and self.picFx[user]
|
||||||
|
if pf then pf.hidden = nil end
|
||||||
|
end
|
||||||
|
for i, item in ipairs(self.queue) do
|
||||||
|
if item == row then
|
||||||
|
table.remove(self.queue, i)
|
||||||
|
if self.nextInsert and i <= self.nextInsert then
|
||||||
|
self.nextInsert = self.nextInsert - 1
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- ------------------------------------------------------------------
|
-- ------------------------------------------------------------------
|
||||||
-- special-effect (SE_*) implementations. Palette effects are BGP
|
-- special-effect (SE_*) implementations. Palette effects are BGP
|
||||||
-- shade maps ({[i] = shade color index i displays as}); on the SGB the
|
-- shade maps ({[i] = shade color index i displays as}); on the SGB the
|
||||||
@@ -1599,14 +1759,23 @@ end
|
|||||||
|
|
||||||
-- transient pic effects reset when a new animation row starts (each
|
-- transient pic effects reset when a new animation row starts (each
|
||||||
-- PlayAnimation redraws from a clean slate); `minimized` survives --
|
-- PlayAnimation redraws from a clean slate); `minimized` survives --
|
||||||
-- the minimize sprite replaces the pic DATA, so redraws keep it until
|
-- the minimize sprite replaces the pic DATA until reload. Dig/Fly's
|
||||||
-- the pic is reloaded (switch/Transform/ChangeMonPic)
|
-- charge hide is a cleared tilemap that must survive until SE_SHOW_* /
|
||||||
|
-- SE_SLIDE_MON_UP (or cancelMoveAnim on a missed Dig/Fly release):
|
||||||
|
-- clearing it here made Dig pop in before emerge and wrap/bounce (#100).
|
||||||
|
-- Other hides (Acid Armor, etc.) still clear so the next anim restores.
|
||||||
function BattleState:resetPicFx()
|
function BattleState:resetPicFx()
|
||||||
if not self.picFx then return end
|
if not self.picFx then return end
|
||||||
for _, pf in pairs(self.picFx) do
|
local digFly = self.animName == "DIG" or self.animName == "FLY"
|
||||||
|
local digFlyUser = digFly and (self.animAttackerIsPlayer
|
||||||
|
and self.player or self.enemy) or nil
|
||||||
|
for battler, pf in pairs(self.picFx) do
|
||||||
pf.kind, pf.t = nil, nil
|
pf.kind, pf.t = nil, nil
|
||||||
pf.ox, pf.oy = 0, 0
|
pf.ox, pf.oy = 0, 0
|
||||||
pf.hidden = nil
|
local keepHide = battler.invulnerable or battler == digFlyUser
|
||||||
|
if not keepHide then
|
||||||
|
pf.hidden = nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2007,6 +2176,8 @@ function BattleState:executeAction(user, target, action)
|
|||||||
local oldName = self.enemy.name
|
local oldName = self.enemy.name
|
||||||
self.enemyIndex = action.index
|
self.enemyIndex = action.index
|
||||||
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
|
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
|
||||||
|
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||||
|
clearTrapping(self.player)
|
||||||
self:syncSides()
|
self:syncSides()
|
||||||
Runtime.emit("battle.battler_switched", {
|
Runtime.emit("battle.battler_switched", {
|
||||||
battle = self, side = self.sides[2], battler = self.enemy,
|
battle = self, side = self.sides[2], battler = self.enemy,
|
||||||
@@ -2057,6 +2228,43 @@ function BattleState:executeAction(user, target, action)
|
|||||||
self:performMove(user, target, action, false)
|
self:performMove(user, target, action, false)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Sleep / confusion onomatopoeia from Check*StatusConditions
|
||||||
|
-- (core.asm): side-specific SLP_*/CONF_* anims, not the Rest/Amnesia
|
||||||
|
-- move rows. Player sleep plays the anim before FastAsleepText;
|
||||||
|
-- enemy sleep and both confusion sides print the text first.
|
||||||
|
function BattleState:statusOnomatopoeia(user, kind)
|
||||||
|
local isPlayer = user.isPlayer
|
||||||
|
local anim
|
||||||
|
if kind == "sleep" then
|
||||||
|
anim = isPlayer and "SLP_PLAYER_ANIM" or "SLP_ANIM"
|
||||||
|
else
|
||||||
|
anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM"
|
||||||
|
end
|
||||||
|
local text = kind == "sleep"
|
||||||
|
and (displayName(user) .. "\nis fast asleep!")
|
||||||
|
or (displayName(user) .. "\nis confused!")
|
||||||
|
if kind == "sleep" and isPlayer then
|
||||||
|
self:animNext(anim, isPlayer)
|
||||||
|
self:sayNext(text)
|
||||||
|
else
|
||||||
|
self:sayNext(text)
|
||||||
|
self:animNext(anim, isPlayer)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Queue status text (+ sleep/confusion FX when the line matches).
|
||||||
|
-- Wake / snap-out / flinch / etc. stay text-only.
|
||||||
|
function BattleState:sayStatusMsg(user, msg)
|
||||||
|
local text = prefixEnemy(msg, user)
|
||||||
|
if msg:find("is fast asleep!", 1, true) then
|
||||||
|
self:statusOnomatopoeia(user, "sleep")
|
||||||
|
elseif msg:find("is confused!", 1, true) then
|
||||||
|
self:statusOnomatopoeia(user, "confused")
|
||||||
|
else
|
||||||
|
self:sayNext(text)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- The pre-recharge slice of CheckPlayerStatusConditions (core.asm:
|
-- The pre-recharge slice of CheckPlayerStatusConditions (core.asm:
|
||||||
-- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing
|
-- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing
|
||||||
-- the turn WITHOUT consuming the recharge flag. The disable/confusion/
|
-- the turn WITHOUT consuming the recharge flag. The disable/confusion/
|
||||||
@@ -2075,7 +2283,7 @@ function BattleState:preRechargeChecks(user, target)
|
|||||||
mon.status = nil
|
mon.status = nil
|
||||||
self:sayNext(displayName(user) .. "\nwoke up!")
|
self:sayNext(displayName(user) .. "\nwoke up!")
|
||||||
else
|
else
|
||||||
self:sayNext(displayName(user) .. "\nis fast asleep!")
|
self:statusOnomatopoeia(user, "sleep")
|
||||||
end
|
end
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
@@ -2102,7 +2310,7 @@ end
|
|||||||
-- returns true when the user's action is interrupted.
|
-- returns true when the user's action is interrupted.
|
||||||
function BattleState:statusInterrupt(user, target)
|
function BattleState:statusInterrupt(user, target)
|
||||||
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self)
|
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self)
|
||||||
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, user)) end
|
for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end
|
||||||
if selfHit then
|
if selfHit then
|
||||||
-- confusion self-hit (core.asm:3428-3434): clears everything in
|
-- confusion self-hit (core.asm:3428-3434): clears everything in
|
||||||
-- status1 except CONFUSED, then HandleSelfConfusionDamage deals a
|
-- status1 except CONFUSED, then HandleSelfConfusionDamage deals a
|
||||||
@@ -2151,6 +2359,20 @@ end
|
|||||||
-- Decomposed into a staged pipeline over the merged move_effects record:
|
-- Decomposed into a staged pipeline over the merged move_effects record:
|
||||||
-- announcement -> callsMove -> charge -> perform -> primary run -> the
|
-- announcement -> callsMove -> charge -> perform -> primary run -> the
|
||||||
-- damaging pipeline (EffectRegistry.runDamaging).
|
-- damaging pipeline (EffectRegistry.runDamaging).
|
||||||
|
|
||||||
|
-- Gen 1 status/stat primary effects call PlayCurrentMoveAnimation only
|
||||||
|
-- after they land; these failure texts print with no animation.
|
||||||
|
local function primaryEffectFailed(msgs)
|
||||||
|
if not msgs or #msgs == 0 then return true end
|
||||||
|
local m = msgs[1]
|
||||||
|
if m == "But, it failed!" or m == "Nothing happened!" then return true end
|
||||||
|
if m:find("didn't affect", 1, true) then return true end
|
||||||
|
if m:find("is unaffected", 1, true) then return true end
|
||||||
|
if m:find("protected by MIST", 1, true) then return true end
|
||||||
|
if m:find("Already", 1, true) then return true end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
function BattleState:performMove(user, target, moveInst, isCalled)
|
function BattleState:performMove(user, target, moveInst, isCalled)
|
||||||
local move = self:moveDef(moveInst)
|
local move = self:moveDef(moveInst)
|
||||||
if not move then
|
if not move then
|
||||||
@@ -2165,11 +2387,16 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
user.charging, user.chargeReady, user.invulnerable = nil, nil, nil
|
user.charging, user.chargeReady, user.invulnerable = nil, nil, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PP: not for continuations, struggle, or called moves
|
-- PP: not for continuations, struggle, called moves, or (under
|
||||||
|
-- gen1_faithful) wild/trainer enemies — pokered DecrementPP only ever
|
||||||
|
-- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm).
|
||||||
local isContinuation = releasing
|
local isContinuation = releasing
|
||||||
or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove)
|
or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove)
|
||||||
or moveInst == user.rageMove
|
or moveInst == user.rageMove
|
||||||
if not isContinuation and not moveInst.struggle and not isCalled then
|
local enemyUnlimited = not user.isPlayer
|
||||||
|
and self.ruleset and self.ruleset.enemyUnlimitedPP
|
||||||
|
if not isContinuation and not moveInst.struggle and not isCalled
|
||||||
|
and not enemyUnlimited then
|
||||||
moveInst.pp = math.max(0, moveInst.pp - 1)
|
moveInst.pp = math.max(0, moveInst.pp - 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2198,6 +2425,11 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
-- already said its failure text
|
-- already said its failure text
|
||||||
if record and record.callsMove then
|
if record and record.callsMove then
|
||||||
local pick = record.callsMove(ctx)
|
local pick = record.callsMove(ctx)
|
||||||
|
-- Mirror Move never plays its own anim (MetronomePickMove does;
|
||||||
|
-- MirrorMoveCopyMove only reloads the copied move or prints fail)
|
||||||
|
if move.id == "MIRROR_MOVE" or not pick then
|
||||||
|
self:cancelMoveAnim()
|
||||||
|
end
|
||||||
if pick then
|
if pick then
|
||||||
self:performMove(user, target, { id = pick, pp = 1 }, true)
|
self:performMove(user, target, { id = pick, pp = 1 }, true)
|
||||||
end
|
end
|
||||||
@@ -2210,6 +2442,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
|
-- falling back to the id tables (Fly AND Dig go semi-invulnerable:
|
||||||
-- ChargeEffect sets INVULNERABLE for both)
|
-- ChargeEffect sets INVULNERABLE for both)
|
||||||
if record and record.charge and not releasing then
|
if record and record.charge and not releasing then
|
||||||
|
self:cancelMoveAnim()
|
||||||
user.charging = moveInst
|
user.charging = moveInst
|
||||||
user.chargeReady = true
|
user.chargeReady = true
|
||||||
local invulnerable = move.semiInvulnerable
|
local invulnerable = move.semiInvulnerable
|
||||||
@@ -2219,6 +2452,15 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
if invulnerable then
|
if invulnerable then
|
||||||
user.invulnerable = true
|
user.invulnerable = true
|
||||||
end
|
end
|
||||||
|
local chargeAnim = record.charge.anim
|
||||||
|
if move.id == "DIG" then
|
||||||
|
chargeAnim = "SLIDE_DOWN_ANIM"
|
||||||
|
elseif record.charge.enemyAnim and not user.isPlayer then
|
||||||
|
chargeAnim = record.charge.enemyAnim
|
||||||
|
end
|
||||||
|
if chargeAnim then
|
||||||
|
self:animNext(chargeAnim, user.isPlayer)
|
||||||
|
end
|
||||||
local chargeText = move.chargeText or CHARGE_TEXT[move.id]
|
local chargeText = move.chargeText or CHARGE_TEXT[move.id]
|
||||||
or "%s\nis charging up!"
|
or "%s\nis charging up!"
|
||||||
self:sayNext(chargeText:format(displayName(user)))
|
self:sayNext(chargeText:format(displayName(user)))
|
||||||
@@ -2240,10 +2482,19 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
if record.accuracyChecked
|
if record.accuracyChecked
|
||||||
and (target.invulnerable
|
and (target.invulnerable
|
||||||
or not self:accuracyRoll(move, user, target)) then
|
or not self:accuracyRoll(move, user, target)) then
|
||||||
|
-- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only
|
||||||
|
-- after the effect lands; a miss skips it
|
||||||
|
self:cancelMoveAnim()
|
||||||
self:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
self:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
for _, m in ipairs(record.run(ctx)) do
|
local msgs = record.run(ctx)
|
||||||
|
-- Gen 1 status/stat effects animate only when they take effect
|
||||||
|
-- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim)
|
||||||
|
if primaryEffectFailed(msgs) then
|
||||||
|
self:cancelMoveAnim()
|
||||||
|
end
|
||||||
|
for _, m in ipairs(msgs) do
|
||||||
self:sayNext(m)
|
self:sayNext(m)
|
||||||
end
|
end
|
||||||
self:drainNext() -- REST/RECOVER/SOFTBOILED move the user's bar
|
self:drainNext() -- REST/RECOVER/SOFTBOILED move the user's bar
|
||||||
@@ -2251,6 +2502,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
end
|
end
|
||||||
if move.power == 0 and not (record and record.kind == "full") then
|
if move.power == 0 and not (record and record.kind == "full") then
|
||||||
MoveEffects.warnUnknown(move.effect)
|
MoveEffects.warnUnknown(move.effect)
|
||||||
|
self:cancelMoveAnim()
|
||||||
self:sayNext("But, it failed!")
|
self:sayNext("But, it failed!")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -2290,6 +2542,7 @@ function BattleState:continueBide(user, target)
|
|||||||
local dmg = (user.bideDamage or 0) * 2
|
local dmg = (user.bideDamage or 0) * 2
|
||||||
user.bideTurns, user.bideDamage = nil, nil
|
user.bideTurns, user.bideDamage = nil, nil
|
||||||
if dmg <= 0 then
|
if dmg <= 0 then
|
||||||
|
self:cancelMoveAnim()
|
||||||
self:sayNext("But, it failed!")
|
self:sayNext("But, it failed!")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -2335,6 +2588,9 @@ end
|
|||||||
function BattleState:onFaint(battler)
|
function BattleState:onFaint(battler)
|
||||||
if battler.faintQueued then return end
|
if battler.faintQueued then return end
|
||||||
battler.faintQueued = true
|
battler.faintQueued = true
|
||||||
|
if battler.isPlayer and self.participants then
|
||||||
|
self.participants[battler.mon] = nil
|
||||||
|
end
|
||||||
Runtime.emit("battle.fainted", { battle = self, battler = battler })
|
Runtime.emit("battle.fainted", { battle = self, battler = battler })
|
||||||
-- the faint slide + cry ride the queue (after the move animation and
|
-- the faint slide + cry ride the queue (after the move animation and
|
||||||
-- the HP-bar drain, pokered's order); the slide finishes before the
|
-- the HP-bar drain, pokered's order); the slide finishes before the
|
||||||
@@ -2371,9 +2627,9 @@ function BattleState:enemyMonFainted()
|
|||||||
-- exp is split among the mons that fought this enemy
|
-- exp is split among the mons that fought this enemy
|
||||||
-- (engine/battle/experience.asm); traded mons earn x1.5; each
|
-- (engine/battle/experience.asm); traded mons earn x1.5; each
|
||||||
-- participant gets the full stat exp
|
-- participant gets the full stat exp
|
||||||
-- the divisor counts EVERY participant, fainted ones included
|
-- a mon that fainted mid-fight has had its gain-exp flag cleared
|
||||||
-- (DivideExpDataByNumMonsGainingExp keeps their flag bits); only the
|
-- (RemoveFaintedPlayerMon), so it drops out of the divisor and only
|
||||||
-- living ones are actually paid
|
-- the surviving participants are counted and paid
|
||||||
local participants, alive = 0, {}
|
local participants, alive = 0, {}
|
||||||
for _, mon in ipairs(self.game.save.party) do
|
for _, mon in ipairs(self.game.save.party) do
|
||||||
if self.participants and self.participants[mon] then
|
if self.participants and self.participants[mon] then
|
||||||
@@ -2444,8 +2700,20 @@ function BattleState:enemyMonFainted()
|
|||||||
self.participants = {}
|
self.participants = {}
|
||||||
|
|
||||||
if self.kind == "trainer" then
|
if self.kind == "trainer" then
|
||||||
if self.enemyIndex < #self.enemyParty then
|
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
|
||||||
self.enemyIndex = self.enemyIndex + 1
|
-- the whole enemy party for the first mon with HP left. Blindly
|
||||||
|
-- doing enemyIndex+1 softlocks after an AI switch (Agatha): a later
|
||||||
|
-- slot can already be fainted, so the empty-HP mon comes out, the
|
||||||
|
-- FIGHT menu returns, and executeAction no-ops on target.hp <= 0.
|
||||||
|
local nextIndex
|
||||||
|
for i, mon in ipairs(self.enemyParty) do
|
||||||
|
if mon.hp > 0 then
|
||||||
|
nextIndex = i
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if nextIndex then
|
||||||
|
self.enemyIndex = nextIndex
|
||||||
-- SHIFT battle style (the default): announce the next mon and
|
-- SHIFT battle style (the default): announce the next mon and
|
||||||
-- offer a free switch (SET skips the prompt)
|
-- offer a free switch (SET skips the prompt)
|
||||||
local nextMon = self.enemyParty[self.enemyIndex]
|
local nextMon = self.enemyParty[self.enemyIndex]
|
||||||
@@ -2469,6 +2737,7 @@ function BattleState:enemyMonFainted()
|
|||||||
if mon ~= self.player.mon and mon.hp > 0 then
|
if mon ~= self.player.mon and mon.hp > 0 then
|
||||||
local previous = self.player
|
local previous = self.player
|
||||||
self.player = makeBattler(self.data, mon, true, game.save)
|
self.player = makeBattler(self.data, mon, true, game.save)
|
||||||
|
clearTrapping(self.enemy) -- SendOutMon clears foe trap
|
||||||
self:syncSides()
|
self:syncSides()
|
||||||
Runtime.emit("battle.battler_switched", {
|
Runtime.emit("battle.battler_switched", {
|
||||||
battle = self, side = self.sides[1],
|
battle = self, side = self.sides[1],
|
||||||
@@ -2494,6 +2763,8 @@ function BattleState:enemyMonFainted()
|
|||||||
self:act(function()
|
self:act(function()
|
||||||
local previous = self.enemy
|
local previous = self.enemy
|
||||||
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
|
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
|
||||||
|
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||||
|
clearTrapping(self.player)
|
||||||
self:syncSides()
|
self:syncSides()
|
||||||
Runtime.emit("battle.battler_switched", {
|
Runtime.emit("battle.battler_switched", {
|
||||||
battle = self, side = self.sides[2], battler = self.enemy,
|
battle = self, side = self.sides[2], battler = self.enemy,
|
||||||
@@ -2558,16 +2829,53 @@ function BattleState:learnMove(mon, moveId)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Map the battle was fought on (overworld wins; save.player.map is fallback).
|
||||||
|
function BattleState.currentMapId(self)
|
||||||
|
local game = self.game
|
||||||
|
local ow = game and game.overworld
|
||||||
|
if ow and ow.map then return ow.map.id end
|
||||||
|
local player = game and game.save and game.save.player
|
||||||
|
return player and player.map
|
||||||
|
end
|
||||||
|
|
||||||
|
-- pret HandlePlayerBlackOut: OPP_RIVAL1 in OAKS_LAB prints Rival1WinText
|
||||||
|
-- and returns without blacking out. OaksLabRivalEndBattleScript then heals.
|
||||||
|
function BattleState.isOaksLabStarterRival(self)
|
||||||
|
return self.oppClass == "OPP_RIVAL1"
|
||||||
|
and BattleState.currentMapId(self) == "OAKS_LAB"
|
||||||
|
end
|
||||||
|
|
||||||
function BattleState:playerMonFainted()
|
function BattleState:playerMonFainted()
|
||||||
if self.result then return end -- double faint: the battle is decided
|
|
||||||
local nextMon = Party.firstHealthy(self.game.save.party)
|
local nextMon = Party.firstHealthy(self.game.save.party)
|
||||||
if not nextMon then
|
-- Being out of useable POKéMON blacks you out even when the battle was
|
||||||
self:sayNext(("%s is out of\nuseable POKéMON!"):format(self.game.save.player.name))
|
-- already decided in our favour. A double faint -- our last mon dying
|
||||||
self:sayNext(("%s blacked\nout!"):format(self.game.save.player.name))
|
-- to residual damage on the turn it lands the KO -- used to hit the
|
||||||
|
-- "battle is decided" guard below and return with result = "win", so
|
||||||
|
-- afterBattle never took the lose branch: no revive, no warp to the
|
||||||
|
-- heal point, and the player was left standing on the map with a party
|
||||||
|
-- at 0 HP. Nothing recovers from that state (every later encounter
|
||||||
|
-- aborts with "no healthy party"), and it is not reachable in pokered:
|
||||||
|
-- HandlePlayerMonFainted runs the player-side check on its own, so
|
||||||
|
-- losing your last mon always blacks you out whatever the enemy did.
|
||||||
|
-- Exception: the Oak's Lab starter rival (HandlePlayerBlackOut).
|
||||||
|
if not nextMon and self.result ~= "lose" then
|
||||||
|
if self.oppClass == "OPP_RIVAL1" then
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local raw = (self.data.text and self.data.text._Rival1WinText)
|
||||||
|
or "{RIVAL}: Yeah! Am\nI great or what?"
|
||||||
|
self:sayNext(TextBox.substitute(self.game, raw))
|
||||||
|
end
|
||||||
|
-- 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
|
||||||
|
self:sayNext(("%s is out of\nuseable POKéMON!"):format(self.game.save.player.name))
|
||||||
|
self:sayNext(("%s blacked\nout!"):format(self.game.save.player.name))
|
||||||
|
end
|
||||||
self.result = "lose"
|
self.result = "lose"
|
||||||
self.afterQueue = "finish"
|
self.afterQueue = "finish"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if self.result then return end -- double faint: the battle is decided
|
||||||
-- DoUseNextMonDialogue (core.asm:1052-1078): only WILD battles ask
|
-- DoUseNextMonDialogue (core.asm:1052-1078): only WILD battles ask
|
||||||
-- "Use next POKéMON?"; NO goes through the run check with party slot
|
-- "Use next POKéMON?"; NO goes through the run check with party slot
|
||||||
-- 1's speed, and a failed run still forces the party menu. Trainer
|
-- 1's speed, and a failed run still forces the party menu. Trainer
|
||||||
@@ -2610,6 +2918,7 @@ function BattleState:openReplacementMenu()
|
|||||||
self:restoreMimicked(self.player)
|
self:restoreMimicked(self.player)
|
||||||
local previous = self.player
|
local previous = self.player
|
||||||
self.player = makeBattler(self.data, mon, true, game.save)
|
self.player = makeBattler(self.data, mon, true, game.save)
|
||||||
|
clearTrapping(self.enemy) -- SendOutMon clears foe trap
|
||||||
self:syncSides()
|
self:syncSides()
|
||||||
Runtime.emit("battle.battler_switched", {
|
Runtime.emit("battle.battler_switched", {
|
||||||
battle = self, side = self.sides[1], battler = self.player,
|
battle = self, side = self.sides[1], battler = self.player,
|
||||||
@@ -2646,6 +2955,7 @@ function BattleState:safariAction(choice)
|
|||||||
local playerName = self.game.save.player.name
|
local playerName = self.game.save.player.name
|
||||||
|
|
||||||
if choice == "run" then
|
if choice == "run" then
|
||||||
|
require("src.core.Sound").play(self.data, "Run")
|
||||||
self:say("Got away safely!")
|
self:say("Got away safely!")
|
||||||
self.result = "run"
|
self.result = "run"
|
||||||
self.afterQueue = "finish"
|
self.afterQueue = "finish"
|
||||||
@@ -2727,6 +3037,12 @@ function BattleState:safariEnemyTurn()
|
|||||||
end
|
end
|
||||||
if fled then
|
if fled then
|
||||||
self:sayNext(("Wild %s\nran!"):format(self.enemy.name))
|
self:sayNext(("Wild %s\nran!"):format(self.enemy.name))
|
||||||
|
self:actNext(function()
|
||||||
|
require("src.core.Sound").play(self.data, "Run")
|
||||||
|
startPicKind(self:picFxFor(self.enemy), "slideOff")
|
||||||
|
end)
|
||||||
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
|
table.insert(self.queue, self.nextInsert, { wait = 24 })
|
||||||
self.result = "run"
|
self.result = "run"
|
||||||
self.afterQueue = "finish"
|
self.afterQueue = "finish"
|
||||||
end
|
end
|
||||||
@@ -3026,6 +3342,19 @@ function BattleState:finish()
|
|||||||
self.phase = "messages"
|
self.phase = "messages"
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
-- Invariant: a battle can never hand the overworld a party with nothing
|
||||||
|
-- healthy in it -- except the Oak's Lab starter rival, where pret skips
|
||||||
|
-- the blackout and OaksLabRivalEndBattleScript HealParty's immediately.
|
||||||
|
-- afterBattle only revives and warps to the heal point on a normal
|
||||||
|
-- "lose", so any other result here strands the player at 0 HP with no
|
||||||
|
-- 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
|
||||||
|
Logger.warn("battle finished %s with no healthy party; forcing blackout",
|
||||||
|
tostring(self.result))
|
||||||
|
self.result = "lose"
|
||||||
|
end
|
||||||
self.lockedBall = nil
|
self.lockedBall = nil
|
||||||
-- pokered never writes Mimic's copy into the party struct; leaving
|
-- pokered never writes Mimic's copy into the party struct; leaving
|
||||||
-- battle discards the battle copy, so the original ids come back
|
-- battle discards the battle copy, so the original ids come back
|
||||||
@@ -3287,17 +3616,16 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
|
|||||||
love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale)
|
love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale)
|
||||||
end
|
end
|
||||||
elseif k == "slideUp" then
|
elseif k == "slideUp" then
|
||||||
-- AnimationSlideMonUp: cyclic upward wrap, one row per 2 frames
|
-- AnimationSlideMonUp (animations.asm): 7 row steps x 2f. After Dig's
|
||||||
local scroll = 8 * math.min(7, math.floor(t / 2) + 1)
|
-- SLIDE_DOWN the tilemap is blank; each step fills the next bottom
|
||||||
local src = math.floor(scroll / scale) % h
|
-- row so the mon emerges from underground. A cyclic wrap of a full
|
||||||
if src == 0 then
|
-- pic looked like a bounce at Dig's end (#100).
|
||||||
love.graphics.draw(img, x + ox, y, 0, scale, scale)
|
local step = math.min(7, math.floor((t - 1) / 2) + 1)
|
||||||
else
|
local visible = math.floor(h * step / 7)
|
||||||
local top = love.graphics.newQuad(0, src, w, h - src, w, h)
|
if visible > 0 then
|
||||||
love.graphics.draw(img, top, x + ox, y, 0, scale, scale)
|
local quad = love.graphics.newQuad(0, h - visible, w, visible, w, h)
|
||||||
local bottom = love.graphics.newQuad(0, 0, w, src, w, h)
|
love.graphics.draw(img, quad, x + ox,
|
||||||
love.graphics.draw(img, bottom, x + ox, y + (h - src) * scale,
|
y + (h - visible) * scale, 0, scale, scale)
|
||||||
0, scale, scale)
|
|
||||||
end
|
end
|
||||||
elseif xscale < 1 then
|
elseif xscale < 1 then
|
||||||
-- AnimationSquishMonPic: columns collapse toward the middle
|
-- AnimationSquishMonPic: columns collapse toward the middle
|
||||||
@@ -3343,9 +3671,10 @@ function BattleState:colorMode()
|
|||||||
if self.colorFxReady == nil then
|
if self.colorFxReady == nil then
|
||||||
local ready = false
|
local ready = false
|
||||||
local g = love and love.graphics
|
local g = love and love.graphics
|
||||||
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas
|
if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas
|
||||||
and love.image and self.data.palettes
|
and love.image and PaletteFX.pack(self.data)
|
||||||
and require("src.render.PaletteFX").shader() then
|
and PaletteFX.shader() then
|
||||||
local ok1, bg = pcall(g.newCanvas, 160, 144)
|
local ok1, bg = pcall(g.newCanvas, 160, 144)
|
||||||
local ok2, wv = pcall(g.newCanvas, 160, 144)
|
local ok2, wv = pcall(g.newCanvas, 160, 144)
|
||||||
if ok1 and ok2 and bg and wv then
|
if ok1 and ok2 and bg and wv then
|
||||||
@@ -3364,9 +3693,10 @@ end
|
|||||||
-- PAL_MEWMON (= MonsterPalettes[0]) while a side still shows its
|
-- PAL_MEWMON (= MonsterPalettes[0]) while a side still shows its
|
||||||
-- trainer/back pic (the species bytes are 0 then).
|
-- trainer/back pic (the species bytes are 0 then).
|
||||||
function BattleState:sgbBattlePals()
|
function BattleState:sgbBattlePals()
|
||||||
local pals = self.data.palettes and self.data.palettes.palettes
|
|
||||||
if not pals then return nil end
|
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
local pack = PaletteFX.pack(self.data)
|
||||||
|
local pals = pack and pack.palettes
|
||||||
|
if not pals then return nil end
|
||||||
local function bar(b)
|
local function bar(b)
|
||||||
if not b then return pals.GREENBAR end
|
if not b then return pals.GREENBAR end
|
||||||
local hp = b.shownHP or b.mon.hp
|
local hp = b.shownHP or b.mon.hp
|
||||||
@@ -3374,15 +3704,30 @@ function BattleState:sgbBattlePals()
|
|||||||
end
|
end
|
||||||
local function mon(b, placeholder)
|
local function mon(b, placeholder)
|
||||||
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
|
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
|
||||||
local name = self.data.palettes.pokemon[b.mon.species]
|
return PaletteFX.monPal(self.data, b.mon.species) or pals.MEWMON
|
||||||
return pals[name] or pals.MEWMON
|
|
||||||
end
|
end
|
||||||
return {
|
local out = {
|
||||||
[0] = bar(self.player),
|
[0] = bar(self.player),
|
||||||
[1] = bar(self.enemy),
|
[1] = bar(self.enemy),
|
||||||
[2] = mon(self.player, self.showPlayerBack or self.safari or self.demo),
|
[2] = mon(self.player, self.showPlayerBack or self.safari or self.demo),
|
||||||
[3] = mon(self.enemy, self.showEnemyTrainer),
|
[3] = mon(self.enemy, self.showEnemyTrainer),
|
||||||
}
|
}
|
||||||
|
-- OG RED: the Game Boy Color drew the whole battle from one BG palette --
|
||||||
|
-- white paper, black ink -- so every zone shares the same background and
|
||||||
|
-- outline; only the two mid shades differ per element (green HP bar, red
|
||||||
|
-- mon pic). The bar/base zones otherwise carry the SGB off-white
|
||||||
|
-- (255,239,255) as color 0 while the mon zones (monPal -> GBC_BG) carry a
|
||||||
|
-- true white, which is what drew a white box around each pic on the pink
|
||||||
|
-- field. Snap every zone's color 0/3 to the global GBC white/black; the
|
||||||
|
-- mid shades (and the green bar the user prefers) stay untouched.
|
||||||
|
if PaletteFX.mode == "ogred" then
|
||||||
|
local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4]
|
||||||
|
for i = 0, 3 do
|
||||||
|
local c = out[i]
|
||||||
|
out[i] = { white, c[2], c[3], black }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the SGB palette covering a screen pixel (BlkPacket_Battle regions)
|
-- the SGB palette covering a screen pixel (BlkPacket_Battle regions)
|
||||||
@@ -3492,6 +3837,20 @@ function BattleState:drawAnimLayer(colorized)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
|
||||||
|
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
|
||||||
|
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
|
||||||
|
-- vertical pad is (7-h) -- bottom-aligned inside the 7x7.
|
||||||
|
local function enemyPicXY(img, slide, sx, sy)
|
||||||
|
local tw = math.floor(img:getWidth() / 8)
|
||||||
|
local th = math.floor(img:getHeight() / 8)
|
||||||
|
if tw < 1 then tw = 1 elseif tw > 7 then tw = 7 end
|
||||||
|
if th < 1 then th = 1 elseif th > 7 then th = 7 end
|
||||||
|
local hPad = math.floor((8 - tw) / 2)
|
||||||
|
local vPad = 7 - th
|
||||||
|
return 96 + 8 * hPad - slide + sx, 8 * vPad + sy
|
||||||
|
end
|
||||||
|
|
||||||
-- the two mon pics (or the trainer/back pics), offset by the window
|
-- 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
|
-- shake -- on the GB the pics are BG tiles, so they move with it
|
||||||
function BattleState:drawPicsLayer(slide, sx, sy)
|
function BattleState:drawPicsLayer(slide, sx, sy)
|
||||||
@@ -3509,19 +3868,18 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
|||||||
g.intersectScissor(0, 0, 160, clipY)
|
g.intersectScissor(0, 0, 160, clipY)
|
||||||
clipped = true
|
clipped = true
|
||||||
end
|
end
|
||||||
-- Enemy: front sprite top-right (GB: pic at hlcoord 12,0).
|
-- Enemy: front sprite in the 7x7 slot at hlcoord 12,0.
|
||||||
if self.showEnemyTrainer and self.trainerPic then
|
if self.showEnemyTrainer and self.trainerPic then
|
||||||
-- the enemy trainer pic holds the mon slot until the send-out
|
-- the enemy trainer pic holds the mon slot until the send-out
|
||||||
local img = self:picImage(self.trainerPic)
|
local img = self:picImage(self.trainerPic)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.draw(img, 160 - 8 - img:getWidth() - slide + sx,
|
local ex, ey = enemyPicXY(img, slide, sx, sy)
|
||||||
math.max(0, 48 - img:getHeight()) + sy)
|
love.graphics.draw(img, ex, ey)
|
||||||
elseif self.enemy and self.enemy.sprite and not self.enemyHidden
|
elseif self.enemy and self.enemy.sprite and not self.enemyHidden
|
||||||
and not self.enemySendingOut and not self:fxHidden(self.enemy) then
|
and not self.enemySendingOut and not self:fxHidden(self.enemy) then
|
||||||
local img = self:picImage(self.enemy.sprite)
|
local img = self:picImage(self.enemy.sprite)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
local ex = 160 - 8 - img:getWidth() - slide + sx
|
local ex, ey = enemyPicXY(img, slide, sx, sy)
|
||||||
local ey = math.max(0, 48 - img:getHeight()) + sy
|
|
||||||
local gs = self:growInScale(self.enemy)
|
local gs = self:growInScale(self.enemy)
|
||||||
if gs then
|
if gs then
|
||||||
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
|
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
|
||||||
@@ -3535,15 +3893,18 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Player: back sprite bottom-left (2x like the GB, feet near y=100).
|
-- Player: back sprite at hlcoord 1,5 (x=8), 2x like the GB, feet at y=96.
|
||||||
|
-- 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
|
local hidePlayer = self.safari or self.demo
|
||||||
if self.showPlayerBack and self.playerBackPic then
|
if self.showPlayerBack and self.playerBackPic then
|
||||||
-- Red's (or the old man's) back pic until "Go!"; it stays up for
|
-- Red's (or the old man's) back pic until "Go!"; it stays up for
|
||||||
-- the whole safari / catch-demo battle like the original
|
-- the whole safari / catch-demo battle like the original
|
||||||
local img = self:picImage(self.playerBackPic)
|
local img = self:picImage(self.playerBackPic)
|
||||||
local pad = imagePadBottom[self.playerBackPic] or 0
|
local pad = imagePadBottom[self.playerBackPic] or 0
|
||||||
|
local padL = imagePadLeft[self.playerBackPic] or 0
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.draw(img, 16 + slide + sx,
|
love.graphics.draw(img, 8 - padL * 2 + slide + sx,
|
||||||
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
|
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
|
||||||
elseif self.player and self.player.sprite and not hidePlayer
|
elseif self.player and self.player.sprite and not hidePlayer
|
||||||
and not self.sendingOut and not self:fxHidden(self.player) then
|
and not self.sendingOut and not self:fxHidden(self.player) then
|
||||||
@@ -3551,17 +3912,19 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
|||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
-- feet flush on the text box top (y=96), ignoring baked-in padding
|
-- feet flush on the text box top (y=96), ignoring baked-in padding
|
||||||
local pad = imagePadBottom[self.player.sprite] or 0
|
local pad = imagePadBottom[self.player.sprite] or 0
|
||||||
|
local padL = imagePadLeft[self.player.sprite] or 0
|
||||||
|
local px = 8 - padL * 2 + sx
|
||||||
local gs = self:growInScale(self.player)
|
local gs = self:growInScale(self.player)
|
||||||
if gs then
|
if gs then
|
||||||
-- the player-side AnimateSendingOutMon grow (after the poof,
|
-- the player-side AnimateSendingOutMon grow (after the poof,
|
||||||
-- core.asm:1757-1762): feet pinned at y=96, center at x=16+w
|
-- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
|
||||||
if gs > 0 then
|
if gs > 0 then
|
||||||
love.graphics.draw(img, 16 + img:getWidth() * (1 - gs) + sx,
|
love.graphics.draw(img, px + img:getWidth() * (1 - gs),
|
||||||
96 - (img:getHeight() - pad) * 2 * gs + sy,
|
96 - (img:getHeight() - pad) * 2 * gs + sy,
|
||||||
0, 2 * gs, 2 * gs)
|
0, 2 * gs, 2 * gs)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
self:drawBattlerPic(self.player, 16 + sx,
|
self:drawBattlerPic(self.player, px,
|
||||||
96 - (img:getHeight() - pad) * 2 + sy, 2)
|
96 - (img:getHeight() - pad) * 2 + sy, 2)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -3616,16 +3979,22 @@ function BattleState:drawHUDs(slide)
|
|||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
|
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
|
||||||
end
|
end
|
||||||
-- trainer-battle party pokeball rows during the intro
|
-- trainer/link party pokeball rows during the intro
|
||||||
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
|
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
|
||||||
if self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack)
|
local showIntroBalls = slide == 0 and (
|
||||||
and slide == 0 then
|
(self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack))
|
||||||
|
or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut))
|
||||||
|
)
|
||||||
|
if showIntroBalls then
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
if self.showEnemyTrainer and self.enemyParty then
|
if self.enemyParty and (
|
||||||
|
(self.kind == "trainer" and self.showEnemyTrainer)
|
||||||
|
or (self.kind == "link" and self.enemySendingOut)
|
||||||
|
) then
|
||||||
self:drawBallRow(self.enemyParty, 64, 16, -8)
|
self:drawBallRow(self.enemyParty, 64, 16, -8)
|
||||||
end
|
end
|
||||||
if self.showPlayerBack then
|
if self.showPlayerBack then
|
||||||
self:drawBallRow(self.game.save.party, 88, 80, 8)
|
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local hidePlayer = self.safari or self.demo
|
local hidePlayer = self.safari or self.demo
|
||||||
@@ -3659,7 +4028,8 @@ function BattleState:drawTextArea()
|
|||||||
if self.phase == "messages" and self.current then
|
if self.phase == "messages" and self.current then
|
||||||
local shown = 0
|
local shown = 0
|
||||||
for li, codes in ipairs(self.lines) do
|
for li, codes in ipairs(self.lines) do
|
||||||
local y = 104 + li * 8
|
-- battle text uses every other tile row (hlcoord *,14 / *,16)
|
||||||
|
local y = 112 + (li - 1) * 16
|
||||||
for i = 1, #codes do
|
for i = 1, #codes do
|
||||||
if shown >= self.charIndex then break end
|
if shown >= self.charIndex then break end
|
||||||
Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
|
Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
|
||||||
@@ -3709,7 +4079,11 @@ function BattleState:drawTextArea()
|
|||||||
for i, mv in ipairs(self.player.curMoves) do
|
for i, mv in ipairs(self.player.curMoves) do
|
||||||
Font.draw(self.data.moves[mv.id].name, 48, 96 + i * 8)
|
Font.draw(self.data.moves[mv.id].name, 48, 96 + i * 8)
|
||||||
end
|
end
|
||||||
Font.drawCode(0xED, 40, 96 + self.moveIndex * 8)
|
Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED,
|
||||||
|
40, 96 + self.moveIndex * 8)
|
||||||
|
if self.moveSwapIndex and self.moveSwapIndex ~= self.moveIndex then
|
||||||
|
Font.drawCode(0xEC, 40, 96 + self.moveSwapIndex * 8)
|
||||||
|
end
|
||||||
local sel = self.player.curMoves[self.moveIndex]
|
local sel = self.player.curMoves[self.moveIndex]
|
||||||
if sel then
|
if sel then
|
||||||
if self.player.disabledSlot == self.moveIndex then
|
if self.player.disabledSlot == self.moveIndex then
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
|
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
|
||||||
-- SWIFT_EFFECT before the INVULNERABLE check)
|
-- SWIFT_EFFECT before the INVULNERABLE check)
|
||||||
if target.invulnerable and not neverMiss then
|
if target.invulnerable and not neverMiss then
|
||||||
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -97,6 +99,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
if record and record.gate then
|
if record and record.gate then
|
||||||
local ok, failMsg = record.gate(ctx)
|
local ok, failMsg = record.gate(ctx)
|
||||||
if not ok then
|
if not ok then
|
||||||
|
battle:cancelMoveAnim()
|
||||||
if failMsg then battle:sayNext(failMsg) end
|
if failMsg then battle:sayNext(failMsg) end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -108,6 +111,8 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
|
|
||||||
if not neverMiss then
|
if not neverMiss then
|
||||||
if not battle:accuracyRoll(move, user, target) then
|
if not battle:accuracyRoll(move, user, target) then
|
||||||
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||||
-- Jump Kick crash, Explode self-destruct
|
-- Jump Kick crash, Explode self-destruct
|
||||||
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
||||||
@@ -134,6 +139,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not counterable or (battle.lastDamage or 0) == 0 then
|
if not counterable or (battle.lastDamage or 0) == 0 then
|
||||||
|
battle:cancelMoveAnim()
|
||||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -144,6 +150,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- failed with that text already chosen
|
-- failed with that text already chosen
|
||||||
local chosen, extra = record.chooseDamage(ctx)
|
local chosen, extra = record.chooseDamage(ctx)
|
||||||
if not chosen then
|
if not chosen then
|
||||||
|
battle:cancelMoveAnim()
|
||||||
if extra then battle:sayNext(extra) end
|
if extra then battle:sayNext(extra) end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -154,12 +161,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if info.typeMult == 0 then
|
if info.typeMult == 0 then
|
||||||
|
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
|
||||||
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target)))
|
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target)))
|
||||||
if record and record.onMiss then record.onMiss(ctx, "immune") end
|
if record and record.onMiss then record.onMiss(ctx, "immune") end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if info.missed then
|
if info.missed then
|
||||||
-- 0.25x floored the damage to zero: the original registers a miss
|
-- 0.25x floored the damage to zero: the original registers a miss
|
||||||
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
|
||||||
if record and record.onMiss then record.onMiss(ctx, "floored") end
|
if record and record.onMiss then record.onMiss(ctx, "floored") end
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -516,8 +516,8 @@ MoveEffects.full = {
|
|||||||
|
|
||||||
-- charge moves: first turn just charges; Fly AND Dig go
|
-- charge moves: first turn just charges; Fly AND Dig go
|
||||||
-- semi-invulnerable (ChargeEffect sets INVULNERABLE for both)
|
-- semi-invulnerable (ChargeEffect sets INVULNERABLE for both)
|
||||||
CHARGE_EFFECT = { charge = {} },
|
CHARGE_EFFECT = { charge = { anim = "XSTATITEM_ANIM", enemyAnim = "XSTATITEM_DUPLICATE_ANIM" } },
|
||||||
FLY_EFFECT = { charge = { invulnerable = true } },
|
FLY_EFFECT = { charge = { invulnerable = true, anim = "TELEPORT" } },
|
||||||
|
|
||||||
TRAPPING_EFFECT = {
|
TRAPPING_EFFECT = {
|
||||||
-- TrappingEffect runs BEFORE the hit test and clears the target's
|
-- TrappingEffect runs BEFORE the hit test and clears the target's
|
||||||
@@ -616,6 +616,7 @@ MoveEffects.full = {
|
|||||||
-- roll is below opponentLevel/4. Teleport's failure text is "But
|
-- roll is below opponentLevel/4. Teleport's failure text is "But
|
||||||
-- it failed!", Roar/Whirlwind's is DidntAffectText; in trainer
|
-- it failed!", Roar/Whirlwind's is DidntAffectText; in trainer
|
||||||
-- battles Teleport fails and Roar/Whirlwind are "unaffected".
|
-- battles Teleport fails and Roar/Whirlwind are "unaffected".
|
||||||
|
-- Fail paths DelayFrames then print -- no PlayCurrentMoveAnimation.
|
||||||
perform = function(ctx)
|
perform = function(ctx)
|
||||||
local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move
|
local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move
|
||||||
if battle.kind == "wild" then
|
if battle.kind == "wild" then
|
||||||
@@ -635,13 +636,17 @@ MoveEffects.full = {
|
|||||||
battle.result = "run"
|
battle.result = "run"
|
||||||
battle.afterQueue = "finish"
|
battle.afterQueue = "finish"
|
||||||
elseif move.id == "TELEPORT" then
|
elseif move.id == "TELEPORT" then
|
||||||
|
battle:cancelMoveAnim()
|
||||||
ctx.say("But, it failed!")
|
ctx.say("But, it failed!")
|
||||||
else
|
else
|
||||||
|
battle:cancelMoveAnim()
|
||||||
ctx.say(("It didn't affect\n%s!"):format(displayName(target)))
|
ctx.say(("It didn't affect\n%s!"):format(displayName(target)))
|
||||||
end
|
end
|
||||||
elseif move.id == "TELEPORT" then
|
elseif move.id == "TELEPORT" then
|
||||||
|
battle:cancelMoveAnim()
|
||||||
ctx.say("But, it failed!")
|
ctx.say("But, it failed!")
|
||||||
else
|
else
|
||||||
|
battle:cancelMoveAnim()
|
||||||
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
|
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
-- (super-effective) or adds 1 (not-effective when a better move exists);
|
-- (super-effective) or adds 1 (not-effective when a better move exists);
|
||||||
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
|
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
|
||||||
-- tied minima (core.asm:2971-3002). A non-minimal move is never
|
-- tied minima (core.asm:2971-3002). A non-minimal move is never
|
||||||
-- selectable. Respects PP, Disable and Transform/Mimic move overrides.
|
-- selectable. Respects Disable (and PP only when the ruleset depletes
|
||||||
|
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
|
||||||
|
|
||||||
local TypeChart = require("src.battle.TypeChart")
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
|
||||||
@@ -212,9 +213,13 @@ end
|
|||||||
|
|
||||||
function TrainerAI.chooseMove(battler, rng, battle)
|
function TrainerAI.chooseMove(battler, rng, battle)
|
||||||
rng = rng or love.math.random
|
rng = rng or love.math.random
|
||||||
|
-- Gen 1: SelectEnemyMove never consults wEnemyMonPP; Struggle only when
|
||||||
|
-- every move slot is missing/disabled (core.asm:2957-2999). modern_clean
|
||||||
|
-- depletes enemy PP and falls back to Struggle when none remain.
|
||||||
|
local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP
|
||||||
local usable = {}
|
local usable = {}
|
||||||
for i, mv in ipairs(battler.curMoves) do
|
for i, mv in ipairs(battler.curMoves) do
|
||||||
if mv.pp > 0 and battler.disabledSlot ~= i then
|
if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) then
|
||||||
table.insert(usable, mv)
|
table.insert(usable, mv)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -13,4 +13,7 @@ return {
|
|||||||
randMax = 255,
|
randMax = 255,
|
||||||
-- Focus Energy famously QUARTERS the crit rate instead of x4
|
-- Focus Energy famously QUARTERS the crit rate instead of x4
|
||||||
focusEnergyBug = true,
|
focusEnergyBug = true,
|
||||||
|
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
|
||||||
|
-- player side in pokered). They therefore never Struggle from empty PP.
|
||||||
|
enemyUnlimitedPP = true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ return {
|
|||||||
randMin = 217,
|
randMin = 217,
|
||||||
randMax = 255,
|
randMax = 255,
|
||||||
focusEnergyBug = false,
|
focusEnergyBug = false,
|
||||||
|
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
|
||||||
|
enemyUnlimitedPP = false,
|
||||||
}
|
}
|
||||||
|
|||||||
+316
-801
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,760 @@
|
|||||||
|
-- Pure Game Boy audio synthesis (the DMG/GBC channel-program interpreter and
|
||||||
|
-- PCM renderer), factored out of ChipAudio so it can run on EITHER the main
|
||||||
|
-- thread (SFX/cries, and the synchronous music fallback) or the ChipAudio
|
||||||
|
-- worker thread (src/core/chip_worker.lua), which is where map/battle music is
|
||||||
|
-- synthesized so a song change never stutters the render thread.
|
||||||
|
--
|
||||||
|
-- Deliberately depends ONLY on `bit`, love.sound and love.filesystem: no
|
||||||
|
-- love.audio (Sources are a playback concern the caller owns) and no
|
||||||
|
-- src.render.Assets (hot-reload registration stays in ChipAudio). Both of
|
||||||
|
-- those are unavailable or main-thread-only inside a love.thread worker, so
|
||||||
|
-- keeping them out is what lets the same synth code run in the worker.
|
||||||
|
|
||||||
|
local bit = require("bit")
|
||||||
|
|
||||||
|
local ChipSynth = {}
|
||||||
|
|
||||||
|
local SAMPLE_RATE = 22050
|
||||||
|
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
|
||||||
|
-- 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
|
||||||
|
local MUSIC_BUFFER_COUNT = 32
|
||||||
|
|
||||||
|
ChipSynth.SAMPLE_RATE = SAMPLE_RATE
|
||||||
|
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
|
||||||
|
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT
|
||||||
|
|
||||||
|
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 }
|
||||||
|
local WAVE_LEVEL = { [0] = 0, [1] = 1, [2] = 0.5, [3] = 0.25 }
|
||||||
|
local NOISE_DIVISORS = {
|
||||||
|
[0] = 8, [1] = 16, [2] = 32, [3] = 48,
|
||||||
|
[4] = 64, [5] = 80, [6] = 96, [7] = 112,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function snapTicks(ticks)
|
||||||
|
return math.floor((ticks * 735 + 256) / 512)
|
||||||
|
end
|
||||||
|
|
||||||
|
local cachedProgramFile
|
||||||
|
local cachedBanks
|
||||||
|
|
||||||
|
local function loadBanks(data)
|
||||||
|
local audio = data.audio
|
||||||
|
if cachedProgramFile == audio.programFile and cachedBanks then
|
||||||
|
return cachedBanks
|
||||||
|
end
|
||||||
|
local raw, readError = love.filesystem.read(audio.programFile)
|
||||||
|
if not raw then error("could not read sound programs: " .. tostring(readError)) end
|
||||||
|
local banks = {}
|
||||||
|
for index, bank in ipairs(audio.bankOrder) do
|
||||||
|
local first = (index - 1) * 0x4000 + 1
|
||||||
|
banks[bank] = raw:sub(first, first + 0x3FFF)
|
||||||
|
end
|
||||||
|
cachedProgramFile, cachedBanks = audio.programFile, banks
|
||||||
|
return banks
|
||||||
|
end
|
||||||
|
|
||||||
|
-- drop the single-slot bank cache; the worker keeps its own copy of this
|
||||||
|
-- module's state, so ChipAudio.invalidate must reach it via a worker message
|
||||||
|
function ChipSynth.invalidateBanks()
|
||||||
|
cachedProgramFile, cachedBanks = nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A def-local program (ChipAsm output) is mounted as pseudo-bank 0 next to
|
||||||
|
-- the ROM banks, so the 0x4000-window byte reader and every call/loop
|
||||||
|
-- target work unchanged. The ROM's own cached bank table is never touched
|
||||||
|
-- because bank 0 differs per def, and a blob that carries its own waves and
|
||||||
|
-- drums renders even where programs.bin is unreadable.
|
||||||
|
local function engineBanks(data, chip)
|
||||||
|
if not chip then return loadBanks(data) end
|
||||||
|
local banks = {}
|
||||||
|
local ok, romBanks = pcall(loadBanks, data)
|
||||||
|
if ok then
|
||||||
|
for bank, bytes in pairs(romBanks) do banks[bank] = bytes end
|
||||||
|
end
|
||||||
|
banks[0] = chip.blob
|
||||||
|
return banks
|
||||||
|
end
|
||||||
|
|
||||||
|
local function romByte(banks, bank, address)
|
||||||
|
local bytes = assert(banks[bank], "uncached audio bank " .. tostring(bank))
|
||||||
|
local value = bytes:byte(address - 0x4000 + 1)
|
||||||
|
if not value then
|
||||||
|
error(("audio read outside bank %02X:%04X"):format(bank, address))
|
||||||
|
end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
local function romWord(banks, bank, address)
|
||||||
|
return romByte(banks, bank, address)
|
||||||
|
+ romByte(banks, bank, address + 1) * 0x100
|
||||||
|
end
|
||||||
|
|
||||||
|
local function headerChannels(banks, header)
|
||||||
|
local channels = {}
|
||||||
|
local address = header.address
|
||||||
|
local first = romByte(banks, header.bank, address)
|
||||||
|
local count = bit.rshift(bit.band(first, 0xF0), 6) + 1
|
||||||
|
for _ = 1, count do
|
||||||
|
local descriptor = romByte(banks, header.bank, address)
|
||||||
|
channels[#channels + 1] = {
|
||||||
|
number = bit.band(descriptor, 0x0F) + 1,
|
||||||
|
address = romWord(banks, header.bank, address + 1),
|
||||||
|
}
|
||||||
|
address = address + 3
|
||||||
|
end
|
||||||
|
return channels
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fadeValue(nibble)
|
||||||
|
if bit.band(nibble, 8) ~= 0 then return -bit.band(nibble, 7) end
|
||||||
|
return nibble
|
||||||
|
end
|
||||||
|
|
||||||
|
local Channel = {}
|
||||||
|
Channel.__index = Channel
|
||||||
|
|
||||||
|
function Channel.new(engine, spec, options)
|
||||||
|
options = options or {}
|
||||||
|
local hardware = (spec.number - 1) % 4 + 1
|
||||||
|
local isSfxChannel = spec.number > 4
|
||||||
|
return setmetatable({
|
||||||
|
engine = engine,
|
||||||
|
bank = options.bank,
|
||||||
|
address = spec.address,
|
||||||
|
number = spec.number,
|
||||||
|
hardware = hardware,
|
||||||
|
wave = hardware == 3,
|
||||||
|
noise = hardware == 4,
|
||||||
|
sfx = isSfxChannel,
|
||||||
|
executeMusic = not isSfxChannel,
|
||||||
|
allowLoops = options.allowLoops ~= false,
|
||||||
|
frequencyOffset = options.frequencyOffset or 0,
|
||||||
|
frameTicks = options.frameTicks or FRAME_TICKS,
|
||||||
|
speed = 12,
|
||||||
|
volume = 12,
|
||||||
|
fade = 0,
|
||||||
|
duty = 0.5,
|
||||||
|
octave = 4,
|
||||||
|
waveInstrument = 0,
|
||||||
|
waveLevel = 1,
|
||||||
|
perfectPitch = false,
|
||||||
|
vibrato = nil,
|
||||||
|
pendingSlide = nil,
|
||||||
|
sweep = nil,
|
||||||
|
callStack = {},
|
||||||
|
loopCounts = {},
|
||||||
|
event = nil,
|
||||||
|
ended = false,
|
||||||
|
phase = 0,
|
||||||
|
noiseLfsr = 0x7FFF,
|
||||||
|
noiseClock = 0,
|
||||||
|
timeTicks = 0,
|
||||||
|
}, Channel)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Channel:byte()
|
||||||
|
local value = romByte(self.engine.banks, self.bank, self.address)
|
||||||
|
self.address = self.address + 1
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
function Channel:word()
|
||||||
|
local value = romWord(self.engine.banks, self.bank, self.address)
|
||||||
|
self.address = self.address + 2
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
function Channel:frequency(note, octave)
|
||||||
|
local signed = PITCHES[note + 1] - 0x10000
|
||||||
|
local register = bit.band(
|
||||||
|
bit.arshift(signed, math.max(0, (octave or self.octave) - 1)), 0x7FF)
|
||||||
|
if self.perfectPitch then register = bit.band(register + 1, 0x7FF) end
|
||||||
|
return bit.band(register + self.frequencyOffset, 0x7FF)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Channel:durationTicks(length)
|
||||||
|
local tempo = self.sfx and self.frameTicks or self.engine.tempo
|
||||||
|
local speed = self.sfx and (self.executeMusic and self.speed or 1)
|
||||||
|
or self.speed
|
||||||
|
return length * speed * tempo
|
||||||
|
end
|
||||||
|
|
||||||
|
function Channel:timedEvent(event, ticks)
|
||||||
|
local first = snapTicks(self.timeTicks)
|
||||||
|
self.timeTicks = self.timeTicks + ticks
|
||||||
|
event.duration = ticks / TICKS_PER_SECOND
|
||||||
|
event.samples = snapTicks(self.timeTicks) - first
|
||||||
|
event.sample = 0
|
||||||
|
event.elapsed = 0
|
||||||
|
return event
|
||||||
|
end
|
||||||
|
|
||||||
|
function Channel:pan()
|
||||||
|
local mask = bit.lshift(1, self.hardware - 1)
|
||||||
|
return bit.band(bit.rshift(self.engine.pan, 4), mask) ~= 0,
|
||||||
|
bit.band(self.engine.pan, mask) ~= 0
|
||||||
|
end
|
||||||
|
|
||||||
|
function Channel:tone(ticks, register, volume, fade)
|
||||||
|
if register >= 0x800 then
|
||||||
|
return self:timedEvent({ silence = true }, ticks)
|
||||||
|
end
|
||||||
|
local duration = ticks / TICKS_PER_SECOND
|
||||||
|
local panLeft, panRight = self:pan()
|
||||||
|
local slide
|
||||||
|
if self.pendingSlide then
|
||||||
|
slide = {
|
||||||
|
target = self.pendingSlide.target,
|
||||||
|
frames = math.max(1, duration * 60 - self.pendingSlide.length),
|
||||||
|
}
|
||||||
|
self.pendingSlide = nil
|
||||||
|
end
|
||||||
|
return self:timedEvent({
|
||||||
|
register = register,
|
||||||
|
volume = volume == nil and self.volume or volume,
|
||||||
|
fade = fade == nil and self.fade or fade,
|
||||||
|
duty = self.duty,
|
||||||
|
wave = self.wave,
|
||||||
|
waveInstrument = self.waveInstrument,
|
||||||
|
waveLevel = self.waveLevel,
|
||||||
|
vibrato = slide and nil or self.vibrato,
|
||||||
|
slide = slide,
|
||||||
|
sweep = self.sfx and self.hardware == 1 and self.sweep or nil,
|
||||||
|
panLeft = panLeft,
|
||||||
|
panRight = panRight,
|
||||||
|
}, ticks)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Channel:noiseEvent(ticks, volume, fade, parameter)
|
||||||
|
local panLeft, panRight = self:pan()
|
||||||
|
return self:timedEvent({
|
||||||
|
noise = true,
|
||||||
|
volume = volume or self.volume,
|
||||||
|
fade = fade or 0,
|
||||||
|
noiseParameter = parameter,
|
||||||
|
panLeft = panLeft, panRight = panRight,
|
||||||
|
}, ticks)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Channel:drumEvent(ticks, instrument)
|
||||||
|
local panLeft, panRight = self:pan()
|
||||||
|
return self:timedEvent({
|
||||||
|
noise = true,
|
||||||
|
drum = self.engine:noiseInstrument(instrument),
|
||||||
|
panLeft = panLeft,
|
||||||
|
panRight = panRight,
|
||||||
|
}, ticks)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Channel:silenceEvent(ticks)
|
||||||
|
return self:timedEvent({ silence = true }, ticks)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Channel:nextEvent()
|
||||||
|
if self.ended then return nil end
|
||||||
|
for _ = 1, 100000 do
|
||||||
|
local commandAddress = self.address
|
||||||
|
local command = self:byte()
|
||||||
|
|
||||||
|
if (self.executeMusic or not self.sfx) and command < 0xC0 then
|
||||||
|
local note = bit.rshift(command, 4)
|
||||||
|
local length = bit.band(command, 0x0F) + 1
|
||||||
|
if self.noise then
|
||||||
|
local instrument = note
|
||||||
|
if command >= 0xB0 then instrument = self:byte() end
|
||||||
|
return self:drumEvent(self:durationTicks(length), instrument)
|
||||||
|
end
|
||||||
|
return self:tone(self:durationTicks(length), self:frequency(note))
|
||||||
|
elseif command >= 0xC0 and command < 0xD0 then
|
||||||
|
local length = bit.band(command, 0x0F) + 1
|
||||||
|
return self:silenceEvent(self:durationTicks(length))
|
||||||
|
elseif command >= 0xD0 and command < 0xE0 then
|
||||||
|
self.speed = bit.band(command, 0x0F)
|
||||||
|
if not self.noise then
|
||||||
|
local packed = self:byte()
|
||||||
|
if self.wave then
|
||||||
|
self.waveLevel = WAVE_LEVEL[bit.band(bit.rshift(packed, 4), 3)]
|
||||||
|
self.waveInstrument = bit.band(packed, 0x0F)
|
||||||
|
else
|
||||||
|
self.volume = bit.rshift(packed, 4)
|
||||||
|
self.fade = fadeValue(bit.band(packed, 0x0F))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif command >= 0xE0 and command <= 0xE7 then
|
||||||
|
self.octave = 8 - bit.band(command, 7)
|
||||||
|
elseif command == 0xE8 then
|
||||||
|
self.perfectPitch = not self.perfectPitch
|
||||||
|
elseif command == 0xE9 then
|
||||||
|
-- Unused command.
|
||||||
|
elseif command == 0xEA then
|
||||||
|
local delay, packed = self:byte(), self:byte()
|
||||||
|
local depth = bit.rshift(packed, 4)
|
||||||
|
if depth == 0 then
|
||||||
|
self.vibrato = nil
|
||||||
|
else
|
||||||
|
self.vibrato = {
|
||||||
|
delay = delay,
|
||||||
|
above = bit.rshift(depth, 1) + bit.band(depth, 1),
|
||||||
|
below = bit.rshift(depth, 1),
|
||||||
|
rate = bit.band(packed, 0x0F),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
elseif command == 0xEB then
|
||||||
|
local length, packed = self:byte(), self:byte()
|
||||||
|
local octave = 8 - bit.rshift(packed, 4)
|
||||||
|
self.pendingSlide = {
|
||||||
|
length = length,
|
||||||
|
target = self:frequency(bit.band(packed, 0x0F), octave),
|
||||||
|
}
|
||||||
|
elseif command == 0xEC then
|
||||||
|
self.duty = DUTY[bit.band(self:byte(), 3)] or 0.5
|
||||||
|
elseif command == 0xED then
|
||||||
|
self.engine.tempo = self:byte() * 0x100 + self:byte()
|
||||||
|
elseif command == 0xEE then
|
||||||
|
self.engine.pan = self:byte()
|
||||||
|
elseif command == 0xEF or command == 0xF0 then
|
||||||
|
self:byte()
|
||||||
|
elseif command == 0xF8 then
|
||||||
|
self.executeMusic = not self.executeMusic
|
||||||
|
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)],
|
||||||
|
}
|
||||||
|
elseif command == 0xFD then
|
||||||
|
self.callStack[#self.callStack + 1] = self.address + 2
|
||||||
|
self.address = self:word()
|
||||||
|
elseif command == 0xFE then
|
||||||
|
local count, target = self:byte(), self:word()
|
||||||
|
if count == 0 then
|
||||||
|
if self.allowLoops then
|
||||||
|
self.address = target
|
||||||
|
else
|
||||||
|
self.ended = true
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local remaining = self.loopCounts[commandAddress]
|
||||||
|
if remaining == nil then remaining = count end
|
||||||
|
remaining = remaining - 1
|
||||||
|
if remaining > 0 then
|
||||||
|
self.loopCounts[commandAddress] = remaining
|
||||||
|
self.address = target
|
||||||
|
else
|
||||||
|
self.loopCounts[commandAddress] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
elseif command == 0xFF then
|
||||||
|
local returnAddress = table.remove(self.callStack)
|
||||||
|
if returnAddress then
|
||||||
|
self.address = returnAddress
|
||||||
|
else
|
||||||
|
self.ended = true
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
elseif self.sfx and command >= 0x20 and command < 0x30 then
|
||||||
|
local length = bit.band(command, 0x0F) + 1
|
||||||
|
local packed = self:byte()
|
||||||
|
local volume = bit.rshift(packed, 4)
|
||||||
|
local fade = fadeValue(bit.band(packed, 0x0F))
|
||||||
|
if self.noise then
|
||||||
|
local parameter = self:byte()
|
||||||
|
return self:noiseEvent(
|
||||||
|
self:durationTicks(length), volume, fade, parameter)
|
||||||
|
end
|
||||||
|
local register = bit.band(self:word() + self.frequencyOffset, 0x7FF)
|
||||||
|
return self:tone(self:durationTicks(length), register, volume, fade)
|
||||||
|
elseif command == 0x10 then
|
||||||
|
local packed = self:byte()
|
||||||
|
self.sweep = {
|
||||||
|
pace = bit.band(bit.rshift(packed, 4), 7),
|
||||||
|
subtract = bit.band(packed, 8) ~= 0,
|
||||||
|
shift = bit.band(packed, 7),
|
||||||
|
}
|
||||||
|
else
|
||||||
|
self.ended = true
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self.ended = true
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function envelopeVolume(volume, fade, elapsed)
|
||||||
|
if fade == 0 then return volume end
|
||||||
|
local steps = math.floor(elapsed / (math.abs(fade) / 64))
|
||||||
|
if fade > 0 then return math.max(0, volume - steps) end
|
||||||
|
return math.min(15, volume + steps)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Channel:resetNoise()
|
||||||
|
self.noiseLfsr = 0x7FFF
|
||||||
|
self.noiseClock = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
function Channel:clockNoise(width7)
|
||||||
|
local feedback = bit.bxor(
|
||||||
|
bit.band(self.noiseLfsr, 1),
|
||||||
|
bit.band(bit.rshift(self.noiseLfsr, 1), 1))
|
||||||
|
self.noiseLfsr = bit.bor(
|
||||||
|
bit.rshift(self.noiseLfsr, 1),
|
||||||
|
bit.lshift(feedback, 14))
|
||||||
|
if width7 then
|
||||||
|
self.noiseLfsr = bit.bor(
|
||||||
|
bit.band(self.noiseLfsr, bit.bnot(0x40)),
|
||||||
|
bit.lshift(feedback, 6))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return area / cycles
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sweepCalculation(register, sweep)
|
||||||
|
local delta = math.floor(register / (2 ^ sweep.shift))
|
||||||
|
if sweep.subtract then return register - delta end
|
||||||
|
return register + delta
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sweptRegister(register, sweep, elapsed)
|
||||||
|
if not sweep or sweep.shift == 0 then return register end
|
||||||
|
local nextRegister = sweepCalculation(register, sweep)
|
||||||
|
if nextRegister > 0x7FF or nextRegister < 0 then return nil end
|
||||||
|
if sweep.pace == 0 then return register end
|
||||||
|
|
||||||
|
local iterations = math.floor(elapsed * 128 / sweep.pace)
|
||||||
|
for _ = 1, iterations do
|
||||||
|
register = nextRegister
|
||||||
|
nextRegister = sweepCalculation(register, sweep)
|
||||||
|
if nextRegister > 0x7FF or nextRegister < 0 then return nil end
|
||||||
|
end
|
||||||
|
return register
|
||||||
|
end
|
||||||
|
|
||||||
|
function Channel:sampleDrum(event, sampleIndex)
|
||||||
|
local index = event.drumSegmentIndex or 1
|
||||||
|
local segment = event.drum[index]
|
||||||
|
while segment and sampleIndex >= segment.endSample do
|
||||||
|
index = index + 1
|
||||||
|
segment = event.drum[index]
|
||||||
|
end
|
||||||
|
if not segment or sampleIndex < segment.startSample then return 0 end
|
||||||
|
if event.drumSegmentIndex ~= index then
|
||||||
|
event.drumSegmentIndex = index
|
||||||
|
self:resetNoise()
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
function Channel:sample()
|
||||||
|
while not self.ended
|
||||||
|
and (not self.event or self.event.sample >= self.event.samples) do
|
||||||
|
self.event = self:nextEvent()
|
||||||
|
self.phase = 0
|
||||||
|
self:resetNoise()
|
||||||
|
end
|
||||||
|
local event = self.event
|
||||||
|
if not event then return 0 end
|
||||||
|
local sampleIndex = event.sample
|
||||||
|
event.elapsed = sampleIndex / SAMPLE_RATE
|
||||||
|
event.sample = sampleIndex + 1
|
||||||
|
if event.silence then return 0 end
|
||||||
|
|
||||||
|
if event.drum then return self:sampleDrum(event, sampleIndex) end
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
local register = event.register
|
||||||
|
local frame = math.floor(event.elapsed * 60)
|
||||||
|
if event.sweep then
|
||||||
|
register = sweptRegister(register, event.sweep, event.elapsed)
|
||||||
|
if not register then return 0 end
|
||||||
|
elseif event.slide then
|
||||||
|
local amount = math.min(1, frame / event.slide.frames)
|
||||||
|
register = register + (event.slide.target - register) * amount
|
||||||
|
elseif event.vibrato and frame >= event.vibrato.delay then
|
||||||
|
local vibrato = event.vibrato
|
||||||
|
local toggles = math.floor(
|
||||||
|
(frame - vibrato.delay + 1) / (vibrato.rate + 1))
|
||||||
|
if toggles > 0 then
|
||||||
|
local low = bit.band(register, 0xFF)
|
||||||
|
local high = bit.band(register, 0x700)
|
||||||
|
if bit.band(toggles, 1) ~= 0 then
|
||||||
|
register = high + math.min(0xFF, low + vibrato.above)
|
||||||
|
else
|
||||||
|
register = high + math.max(0, low - vibrato.below)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local frequency = 131072 / (2048 - math.min(register, 2047))
|
||||||
|
if event.wave then frequency = frequency * 0.5 end
|
||||||
|
local phase = self.phase
|
||||||
|
self.phase = (phase + frequency / SAMPLE_RATE) % 1
|
||||||
|
if event.wave then
|
||||||
|
local wave = self.engine.waves[
|
||||||
|
math.min(event.waveInstrument + 1, #self.engine.waves)]
|
||||||
|
-- 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
|
||||||
|
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
|
||||||
|
end
|
||||||
|
|
||||||
|
local Engine = {}
|
||||||
|
Engine.__index = Engine
|
||||||
|
|
||||||
|
function Engine:noiseInstrument(number)
|
||||||
|
-- a def-local drum wins over the ROM engine's table for that id
|
||||||
|
local custom = self.customDrums and self.customDrums[number]
|
||||||
|
if custom then return custom end
|
||||||
|
local cached = self.noiseInstruments[number]
|
||||||
|
if cached then return cached end
|
||||||
|
|
||||||
|
local header = self.noiseHeaders[tostring(number)]
|
||||||
|
local segments = {}
|
||||||
|
if header then
|
||||||
|
local spec = headerChannels(self.banks, header)[1]
|
||||||
|
local address = spec and spec.address
|
||||||
|
local ticks = 0
|
||||||
|
for _ = 1, 64 do
|
||||||
|
local command = romByte(self.banks, header.bank, address)
|
||||||
|
address = address + 1
|
||||||
|
if command == 0xFF then break end
|
||||||
|
if command < 0x20 or command >= 0x30 then
|
||||||
|
error(("unsupported drum command %02X at %02X:%04X")
|
||||||
|
:format(command, header.bank, address - 1))
|
||||||
|
end
|
||||||
|
local packed = romByte(self.banks, header.bank, address)
|
||||||
|
local parameter = romByte(self.banks, header.bank, address + 1)
|
||||||
|
address = address + 2
|
||||||
|
local duration = (bit.band(command, 0x0F) + 1) * FRAME_TICKS
|
||||||
|
segments[#segments + 1] = {
|
||||||
|
startSample = snapTicks(ticks),
|
||||||
|
endSample = snapTicks(ticks + duration),
|
||||||
|
volume = bit.rshift(packed, 4),
|
||||||
|
fade = fadeValue(bit.band(packed, 0x0F)),
|
||||||
|
parameter = parameter,
|
||||||
|
}
|
||||||
|
ticks = ticks + duration
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
self.noiseInstruments[number] = segments
|
||||||
|
return segments
|
||||||
|
end
|
||||||
|
|
||||||
|
local function readWaves(banks, audio, engineNumber)
|
||||||
|
local spec = audio.waveBanks[tostring(engineNumber)]
|
||||||
|
local waves = {}
|
||||||
|
for wave = 0, 4 do
|
||||||
|
local values = {}
|
||||||
|
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
|
||||||
|
end
|
||||||
|
waves[#waves + 1] = values
|
||||||
|
end
|
||||||
|
local values = {}
|
||||||
|
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
|
||||||
|
end
|
||||||
|
for _ = 1, 4 do waves[#waves + 1] = values end
|
||||||
|
return waves
|
||||||
|
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
|
||||||
|
local function normalizeWaves(source)
|
||||||
|
local waves = {}
|
||||||
|
for index, values in ipairs(source) do
|
||||||
|
local nibbles = false
|
||||||
|
for _, value in ipairs(values) do
|
||||||
|
if value > 1 or value < -1 then nibbles = true break end
|
||||||
|
end
|
||||||
|
local wave = {}
|
||||||
|
for position, value in ipairs(values) do
|
||||||
|
wave[position] = nibbles and (value - 7.5) / 7.5 or value
|
||||||
|
end
|
||||||
|
waves[index] = wave
|
||||||
|
end
|
||||||
|
return waves
|
||||||
|
end
|
||||||
|
|
||||||
|
function Engine.new(data, header, options)
|
||||||
|
options = options or {}
|
||||||
|
local audio = data.audio or {}
|
||||||
|
-- shape dispatch: a def-local chip program supplies its own channels and
|
||||||
|
-- may supply its own waves/drums, falling back to a ROM engine's tables
|
||||||
|
local chip = header.chip
|
||||||
|
local banks = engineBanks(data, chip)
|
||||||
|
local engineNumber = chip and (chip.engine or 1) or header.engine
|
||||||
|
local waves
|
||||||
|
if chip and chip.waves then
|
||||||
|
waves = normalizeWaves(chip.waves)
|
||||||
|
elseif chip then
|
||||||
|
local ok, romWaves = pcall(readWaves, banks, audio, engineNumber)
|
||||||
|
waves = ok and romWaves or {}
|
||||||
|
else
|
||||||
|
waves = readWaves(banks, audio, engineNumber)
|
||||||
|
end
|
||||||
|
local engine = setmetatable({
|
||||||
|
banks = banks,
|
||||||
|
tempo = 0x100,
|
||||||
|
pan = 0xFF,
|
||||||
|
waves = waves,
|
||||||
|
noiseHeaders = audio.noiseHeaders
|
||||||
|
and audio.noiseHeaders[tostring(engineNumber)] or {},
|
||||||
|
customDrums = chip and chip.drums or nil,
|
||||||
|
noiseInstruments = {},
|
||||||
|
channels = {},
|
||||||
|
}, Engine)
|
||||||
|
for _, spec in ipairs(chip and chip.channels
|
||||||
|
or headerChannels(banks, header)) do
|
||||||
|
local frameTicks = options.frameTicks
|
||||||
|
local hardware = (spec.number - 1) % 4 + 1
|
||||||
|
if hardware == 4 then
|
||||||
|
frameTicks = FRAME_TICKS
|
||||||
|
elseif options.cryLength then
|
||||||
|
frameTicks = 0x80 + options.cryLength
|
||||||
|
end
|
||||||
|
engine.channels[#engine.channels + 1] = Channel.new(engine, spec, {
|
||||||
|
bank = chip and 0 or header.bank,
|
||||||
|
sfx = options.sfx,
|
||||||
|
allowLoops = options.allowLoops,
|
||||||
|
frequencyOffset = options.frequencyOffset,
|
||||||
|
frameTicks = frameTicks,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
return engine
|
||||||
|
end
|
||||||
|
|
||||||
|
function Engine:finished()
|
||||||
|
for _, channel in ipairs(self.channels) do
|
||||||
|
if not channel.ended or channel.event then return false end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
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))
|
||||||
|
end
|
||||||
|
|
||||||
|
function Engine:sampleStereo()
|
||||||
|
local left, right = 0, 0
|
||||||
|
for _, channel in ipairs(self.channels) do
|
||||||
|
local value = channel:sample()
|
||||||
|
local event = channel.event
|
||||||
|
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))
|
||||||
|
end
|
||||||
|
|
||||||
|
function Engine:sampleChannel(number)
|
||||||
|
local selected = 0
|
||||||
|
for _, channel in ipairs(self.channels) do
|
||||||
|
local value = channel:sample()
|
||||||
|
if channel.number == number then selected = value end
|
||||||
|
end
|
||||||
|
return math.max(-1, math.min(1, selected * 0.5))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
|
||||||
|
-- is available on worker threads, so this is the hand-off unit the worker
|
||||||
|
-- produces and the main thread queues.
|
||||||
|
local function soundData(engine, samples, channels)
|
||||||
|
local result = love.sound.newSoundData(samples, SAMPLE_RATE, 16, channels)
|
||||||
|
for index = 0, samples - 1 do
|
||||||
|
if channels == 2 then
|
||||||
|
local left, right = engine:sampleStereo()
|
||||||
|
result:setSample(index, 1, left)
|
||||||
|
result:setSample(index, 2, right)
|
||||||
|
else
|
||||||
|
result:setSample(index, engine:sample())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Render a one-shot effect (SFX/cry) to a mono SoundData, or nil when it is
|
||||||
|
-- too short to be audible. The caller wraps it in a static love.audio.Source
|
||||||
|
-- (a playback concern, hence not done here).
|
||||||
|
local function renderEffectData(data, header, options)
|
||||||
|
if not header then return nil end
|
||||||
|
options = options or {}
|
||||||
|
options.sfx = true
|
||||||
|
options.allowLoops = false
|
||||||
|
local engine = Engine.new(data, header, options)
|
||||||
|
local maximum = SAMPLE_RATE * 5
|
||||||
|
local values = {}
|
||||||
|
local count = 0
|
||||||
|
while count < maximum and not engine:finished() do
|
||||||
|
count = count + 1
|
||||||
|
values[count] = engine:sample()
|
||||||
|
end
|
||||||
|
if count < math.floor(SAMPLE_RATE / 100) then return nil end
|
||||||
|
local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 1)
|
||||||
|
for index = 1, count do result:setSample(index - 1, values[index]) end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
ChipSynth.newEngine = Engine.new
|
||||||
|
ChipSynth.soundData = soundData
|
||||||
|
ChipSynth.renderEffectData = renderEffectData
|
||||||
|
|
||||||
|
return ChipSynth
|
||||||
+29
-1
@@ -41,7 +41,11 @@ local CONSTANT_DEFAULTS = {
|
|||||||
-- field.boot is the total-conversion override point for the new game; the
|
-- field.boot is the total-conversion override point for the new game; the
|
||||||
-- values match what SaveData.newGame and the Oak speech used to inline.
|
-- values match what SaveData.newGame and the Oak speech used to inline.
|
||||||
local BOOT_DEFAULTS = {
|
local BOOT_DEFAULTS = {
|
||||||
startMap = "PALLET_TOWN", startX = 5, startY = 6, startFacing = "down",
|
-- special_warps.asm NewGameWarp: REDS_HOUSE_2F, 3, 6 -- the bedroom, not
|
||||||
|
-- the tile outside the house. lastHeal is deliberately absent: SaveData
|
||||||
|
-- derives the vanilla blackout point, and seeding it here would leak into
|
||||||
|
-- total conversions that patch the spawn without naming a heal point.
|
||||||
|
startMap = "REDS_HOUSE_2F", startX = 3, startY = 6, startFacing = "down",
|
||||||
playerName = "RED", rivalName = "BLUE",
|
playerName = "RED", rivalName = "BLUE",
|
||||||
startMoney = 3000,
|
startMoney = 3000,
|
||||||
screens = { splash = "IntroMovie", title = "TitleState", newGame = "OakSpeech" },
|
screens = { splash = "IntroMovie", title = "TitleState", newGame = "OakSpeech" },
|
||||||
@@ -93,6 +97,30 @@ function Data:seedDefaults()
|
|||||||
-- the overworld's Kanto literals, same fill-if-absent contract; required
|
-- the overworld's Kanto literals, same fill-if-absent contract; required
|
||||||
-- here rather than at the top so core keeps out of src/world at load time
|
-- here rather than at the top so core keeps out of src/world at load time
|
||||||
require("src.world.FieldDefaults").seed(self)
|
require("src.world.FieldDefaults").seed(self)
|
||||||
|
-- Cinnabar's quiz trainers are text_asm (no def_trainers), so the
|
||||||
|
-- extractor never writes headers for them. Seed the EVENT_BEAT_* /
|
||||||
|
-- after-battle rows so Blaine's SetEventRange deactivation and talk
|
||||||
|
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
|
||||||
|
self:seedCinnabarGymTrainerHeaders()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Data:seedCinnabarGymTrainerHeaders()
|
||||||
|
local headers = self.trainer_headers
|
||||||
|
if not headers or headers.CinnabarGym then return end
|
||||||
|
local gym = {}
|
||||||
|
for i = 0, 6 do
|
||||||
|
local n = i + 1
|
||||||
|
-- object indices 2..8 are SUPER_NERD1..7; range 0 -- they only
|
||||||
|
-- engage via talk / wrong quiz answer, never sight lines
|
||||||
|
gym[i + 2] = {
|
||||||
|
event = "EVENT_BEAT_CINNABAR_GYM_TRAINER_" .. i,
|
||||||
|
range = 0,
|
||||||
|
battle = "_CinnabarGymSuperNerd" .. n .. "BattleText",
|
||||||
|
won = "_CinnabarGymSuperNerd" .. n .. "EndBattleText",
|
||||||
|
after = "_CinnabarGymSuperNerd" .. n .. "AfterBattleText",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
headers.CinnabarGym = gym
|
||||||
end
|
end
|
||||||
|
|
||||||
-- POKEPORT_DATA_DIR points a test runner at another dataset root (the
|
-- POKEPORT_DATA_DIR points a test runner at another dataset root (the
|
||||||
|
|||||||
@@ -0,0 +1,658 @@
|
|||||||
|
-- Discord Rich Presence via the local Discord IPC pipe. Shows the player's
|
||||||
|
-- current map (and battle status) on their Discord profile. Desktop only;
|
||||||
|
-- no-ops on mobile, headless runs, or when Discord is not running.
|
||||||
|
--
|
||||||
|
-- Application ID is the only credential needed for presence. The Discord
|
||||||
|
-- Interactions public key is unused here (that is for verifying bot HTTP
|
||||||
|
-- payloads, not Rich Presence).
|
||||||
|
|
||||||
|
local Json = require("src.link.Json")
|
||||||
|
local Logger = require("src.core.Logger")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
|
local DiscordPresence = {
|
||||||
|
APP_ID = "1529183141267374262",
|
||||||
|
}
|
||||||
|
|
||||||
|
local OP_HANDSHAKE, OP_FRAME, OP_CLOSE = 0, 1, 2
|
||||||
|
|
||||||
|
local MIN_UPDATE_INTERVAL = 2.0
|
||||||
|
local RECONNECT_INTERVAL = 30.0
|
||||||
|
|
||||||
|
local state = {
|
||||||
|
enabled = false,
|
||||||
|
connected = false,
|
||||||
|
socket = nil, -- Windows: file handle; Unix: ffi sockfd
|
||||||
|
isWindows = false,
|
||||||
|
ffi = nil,
|
||||||
|
ffiReady = false,
|
||||||
|
startedAt = 0,
|
||||||
|
dirty = false,
|
||||||
|
lastSentKey = nil,
|
||||||
|
lastSendAt = -1e9,
|
||||||
|
nextReconnectAt = 0,
|
||||||
|
location = nil, -- display name
|
||||||
|
mapId = nil,
|
||||||
|
activity = "menu", -- menu | exploring | battle
|
||||||
|
battleLabel = nil,
|
||||||
|
unsubs = {},
|
||||||
|
pid = nil,
|
||||||
|
loggedAbsent = false, -- one quiet note when Discord isn't running
|
||||||
|
}
|
||||||
|
|
||||||
|
local function disabledByEnv()
|
||||||
|
return os.getenv("POKEPORT_NO_DISCORD") == "1"
|
||||||
|
or os.getenv("POKEPORT_AUTOPILOT") ~= nil
|
||||||
|
or os.getenv("POKEPORT_DRIVER") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isDesktop()
|
||||||
|
if not love or not love.system or not love.system.getOS then return false end
|
||||||
|
local osName = love.system.getOS()
|
||||||
|
return osName == "OS X" or osName == "Windows" or osName == "Linux"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function packU32(n)
|
||||||
|
n = math.floor(n) % 0x100000000
|
||||||
|
local b1 = n % 256; n = math.floor(n / 256)
|
||||||
|
local b2 = n % 256; n = math.floor(n / 256)
|
||||||
|
local b3 = n % 256; n = math.floor(n / 256)
|
||||||
|
local b4 = n % 256
|
||||||
|
return string.char(b1, b2, b3, b4)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function unpackU32(s)
|
||||||
|
local b1, b2, b3, b4 = s:byte(1, 4)
|
||||||
|
return b1 + b2 * 256 + b3 * 65536 + b4 * 16777216
|
||||||
|
end
|
||||||
|
|
||||||
|
local function nonce()
|
||||||
|
return string.format("%08x-%04x-4%03x-%04x-%04x%08x",
|
||||||
|
math.random(0, 0xffffffff),
|
||||||
|
math.random(0, 0xffff),
|
||||||
|
math.random(0, 0xfff),
|
||||||
|
math.random(0, 0x3fff) + 0x8000,
|
||||||
|
math.random(0, 0xffff),
|
||||||
|
math.random(0, 0xffffffff))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function resolvePid()
|
||||||
|
if state.pid then return state.pid end
|
||||||
|
local ffi = state.ffi
|
||||||
|
if ffi then
|
||||||
|
if state.isWindows then
|
||||||
|
pcall(ffi.cdef, "unsigned long GetCurrentProcessId(void);")
|
||||||
|
local ok, p = pcall(function() return ffi.C.GetCurrentProcessId() end)
|
||||||
|
if ok and p and tonumber(p) and tonumber(p) > 0 then
|
||||||
|
state.pid = tonumber(p)
|
||||||
|
return state.pid
|
||||||
|
end
|
||||||
|
else
|
||||||
|
pcall(ffi.cdef, "int getpid(void);")
|
||||||
|
local ok, p = pcall(function() return ffi.C.getpid() end)
|
||||||
|
if ok and p and tonumber(p) and tonumber(p) > 0 then
|
||||||
|
state.pid = tonumber(p)
|
||||||
|
return state.pid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
state.pid = 1000 + math.random(1, 8999)
|
||||||
|
return state.pid
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensureFfi()
|
||||||
|
if state.ffiReady then return state.ffi end
|
||||||
|
if state.ffi == false then return nil end
|
||||||
|
local ok, ffi = pcall(require, "ffi")
|
||||||
|
if not ok or not ffi then
|
||||||
|
state.ffi = false
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
state.ffi = ffi
|
||||||
|
if state.isWindows then
|
||||||
|
state.ffiReady = true
|
||||||
|
return ffi
|
||||||
|
end
|
||||||
|
-- Darwin sockaddr_un has sun_len + uint8_t family; Linux uses u16 family.
|
||||||
|
local osName = love.system.getOS()
|
||||||
|
local cdef
|
||||||
|
if osName == "OS X" then
|
||||||
|
cdef = [[
|
||||||
|
typedef unsigned int socklen_t;
|
||||||
|
typedef int ssize_t;
|
||||||
|
struct sockaddr_un {
|
||||||
|
uint8_t sun_len;
|
||||||
|
uint8_t sun_family;
|
||||||
|
char sun_path[104];
|
||||||
|
};
|
||||||
|
struct timeval { long tv_sec; int tv_usec; };
|
||||||
|
int socket(int domain, int type, int protocol);
|
||||||
|
int connect(int sockfd, const void *addr, socklen_t addrlen);
|
||||||
|
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
|
||||||
|
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
|
||||||
|
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
|
||||||
|
int close(int fd);
|
||||||
|
]]
|
||||||
|
else
|
||||||
|
cdef = [[
|
||||||
|
typedef unsigned int socklen_t;
|
||||||
|
typedef int ssize_t;
|
||||||
|
struct sockaddr_un {
|
||||||
|
unsigned short sun_family;
|
||||||
|
char sun_path[108];
|
||||||
|
};
|
||||||
|
struct timeval { long tv_sec; long tv_usec; };
|
||||||
|
int socket(int domain, int type, int protocol);
|
||||||
|
int connect(int sockfd, const void *addr, socklen_t addrlen);
|
||||||
|
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
|
||||||
|
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
|
||||||
|
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
|
||||||
|
int close(int fd);
|
||||||
|
]]
|
||||||
|
end
|
||||||
|
-- cdef may already be registered from a prior boot; either way we need
|
||||||
|
-- sockaddr_un usable before claiming readiness
|
||||||
|
pcall(ffi.cdef, cdef)
|
||||||
|
local ready = pcall(function() return ffi.new("struct sockaddr_un") end)
|
||||||
|
if not ready then
|
||||||
|
state.ffi = false
|
||||||
|
state.ffiReady = false
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
state.ffiReady = true
|
||||||
|
return ffi
|
||||||
|
end
|
||||||
|
|
||||||
|
local function closeSocket()
|
||||||
|
local sock = state.socket
|
||||||
|
state.socket = nil
|
||||||
|
state.connected = false
|
||||||
|
if not sock then return end
|
||||||
|
if state.isWindows then
|
||||||
|
pcall(function() sock:close() end)
|
||||||
|
elseif state.ffi then
|
||||||
|
pcall(state.ffi.C.close, sock)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function writeRaw(bytes)
|
||||||
|
if not state.socket then return false end
|
||||||
|
if state.isWindows then
|
||||||
|
local ok = pcall(function()
|
||||||
|
state.socket:seek("end")
|
||||||
|
local _, writeErr = state.socket:write(bytes)
|
||||||
|
state.socket:flush()
|
||||||
|
if writeErr then error(writeErr) end
|
||||||
|
end)
|
||||||
|
return ok
|
||||||
|
end
|
||||||
|
local ffi = state.ffi
|
||||||
|
if not ffi then return false end
|
||||||
|
local ok, sent = pcall(ffi.C.send, state.socket, bytes, #bytes, 0)
|
||||||
|
return ok and sent == #bytes
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sendFrame(opcode, payload)
|
||||||
|
local body = payload or ""
|
||||||
|
local ok, frame = pcall(function()
|
||||||
|
return packU32(opcode) .. packU32(#body) .. body
|
||||||
|
end)
|
||||||
|
if not ok or not frame then return false end
|
||||||
|
return writeRaw(frame)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function readExact(n)
|
||||||
|
if not state.socket or n <= 0 then return nil end
|
||||||
|
if state.isWindows then
|
||||||
|
local ok, data = pcall(function() return state.socket:read(n) end)
|
||||||
|
if not ok or not data or #data < n then return nil end
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
local ffi = state.ffi
|
||||||
|
if not ffi then return nil end
|
||||||
|
local ok, data = pcall(function()
|
||||||
|
local buf = ffi.new("char[?]", n)
|
||||||
|
local got = 0
|
||||||
|
while got < n do
|
||||||
|
local nread = ffi.C.recv(state.socket, buf + got, n - got, 0)
|
||||||
|
if not nread or nread <= 0 then return nil end
|
||||||
|
got = got + tonumber(nread)
|
||||||
|
end
|
||||||
|
return ffi.string(buf, n)
|
||||||
|
end)
|
||||||
|
if not ok then return nil end
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
|
||||||
|
local function receiveFrame()
|
||||||
|
local header = readExact(8)
|
||||||
|
if not header then return nil end
|
||||||
|
local ok, opcode, length = pcall(function()
|
||||||
|
return unpackU32(header:sub(1, 4)), unpackU32(header:sub(5, 8))
|
||||||
|
end)
|
||||||
|
if not ok or not opcode or not length or length < 0 or length > 65536 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local data = length > 0 and readExact(length) or ""
|
||||||
|
if data == nil then return nil end
|
||||||
|
return opcode, data
|
||||||
|
end
|
||||||
|
|
||||||
|
local function unixSocketPaths()
|
||||||
|
local paths = {}
|
||||||
|
local envs = { "XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP" }
|
||||||
|
local bases = {}
|
||||||
|
for _, key in ipairs(envs) do
|
||||||
|
local v = os.getenv(key)
|
||||||
|
if v and v ~= "" then
|
||||||
|
if v:sub(-1) == "/" then v = v:sub(1, -2) end
|
||||||
|
bases[#bases + 1] = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
bases[#bases + 1] = "/tmp"
|
||||||
|
local suffixes = {
|
||||||
|
"",
|
||||||
|
"/app/com.discordapp.Discord",
|
||||||
|
"/app/com.discordapp.DiscordCanary",
|
||||||
|
"/app/com.discordapp.DiscordPTB",
|
||||||
|
"/snap.discord",
|
||||||
|
"/snap.discord-canary",
|
||||||
|
}
|
||||||
|
local seen = {}
|
||||||
|
for i = 0, 9 do
|
||||||
|
local name = "discord-ipc-" .. i
|
||||||
|
for _, base in ipairs(bases) do
|
||||||
|
for _, suffix in ipairs(suffixes) do
|
||||||
|
local path = base .. suffix .. "/" .. name
|
||||||
|
if not seen[path] then
|
||||||
|
seen[path] = true
|
||||||
|
paths[#paths + 1] = path
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return paths
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setSocketTimeout(fd, seconds)
|
||||||
|
local ffi = state.ffi
|
||||||
|
if not ffi or not fd then return end
|
||||||
|
pcall(function()
|
||||||
|
local tv = ffi.new("struct timeval")
|
||||||
|
tv.tv_sec = seconds
|
||||||
|
tv.tv_usec = 0
|
||||||
|
-- SOL_SOCKET / SO_RCVTIMEO / SO_SNDTIMEO differ by OS
|
||||||
|
local sol, rcv, snd
|
||||||
|
if love.system.getOS() == "OS X" then
|
||||||
|
sol, rcv, snd = 0xffff, 0x1006, 0x1005
|
||||||
|
else
|
||||||
|
sol, rcv, snd = 1, 20, 21
|
||||||
|
end
|
||||||
|
ffi.C.setsockopt(fd, sol, rcv, tv, ffi.sizeof(tv))
|
||||||
|
ffi.C.setsockopt(fd, sol, snd, tv, ffi.sizeof(tv))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function connectUnix()
|
||||||
|
local ffi = ensureFfi()
|
||||||
|
if not ffi then return false end
|
||||||
|
local darwin = love.system.getOS() == "OS X"
|
||||||
|
local maxPath = darwin and 103 or 107
|
||||||
|
-- AF_UNIX=1, SOCK_STREAM=1 on Linux/macOS
|
||||||
|
local pathsOk, paths = pcall(unixSocketPaths)
|
||||||
|
if not pathsOk or not paths then return false end
|
||||||
|
for _, path in ipairs(paths) do
|
||||||
|
if #path <= maxPath then
|
||||||
|
local ok, connected = pcall(function()
|
||||||
|
local fd = ffi.C.socket(1, 1, 0)
|
||||||
|
if not fd or fd < 0 then return false end
|
||||||
|
local addr = ffi.new("struct sockaddr_un")
|
||||||
|
addr.sun_family = 1
|
||||||
|
if darwin then
|
||||||
|
-- SUN_LEN: sizeof(sun_len+sun_family) + path + NUL
|
||||||
|
addr.sun_len = 2 + #path + 1
|
||||||
|
end
|
||||||
|
ffi.copy(addr.sun_path, path)
|
||||||
|
local rc = ffi.C.connect(fd, ffi.cast("void*", addr), ffi.sizeof(addr))
|
||||||
|
if rc == 0 then
|
||||||
|
setSocketTimeout(fd, 1) -- handshake must not freeze the game
|
||||||
|
state.socket = fd
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
ffi.C.close(fd)
|
||||||
|
return false
|
||||||
|
end)
|
||||||
|
if ok and connected then return true end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function connectWindows()
|
||||||
|
for i = 0, 9 do
|
||||||
|
local path = "\\\\.\\pipe\\discord-ipc-" .. i
|
||||||
|
local ok, file = pcall(io.open, path, "r+")
|
||||||
|
if ok and file then
|
||||||
|
state.socket = file
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function handshake()
|
||||||
|
local ok, payload = pcall(Json.encode, { v = 1, client_id = DiscordPresence.APP_ID })
|
||||||
|
if not ok or not payload then return false end
|
||||||
|
if not sendFrame(OP_HANDSHAKE, payload) then return false end
|
||||||
|
-- Discord echoes READY as a FRAME. A timeout/missing client is a soft miss.
|
||||||
|
local opcode = receiveFrame()
|
||||||
|
if opcode == OP_FRAME then return true end
|
||||||
|
-- Windows named pipes have no easy read timeout: if the write succeeded
|
||||||
|
-- and Discord is present, proceed optimistically so boot never blocks.
|
||||||
|
if state.isWindows then return true end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function noteAbsent()
|
||||||
|
if state.loggedAbsent then return end
|
||||||
|
state.loggedAbsent = true
|
||||||
|
-- info, not warn: missing Discord is normal for many players
|
||||||
|
Logger.info("discord: rich presence unavailable (Discord not running)")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function connect()
|
||||||
|
local ok, result = pcall(function()
|
||||||
|
closeSocket()
|
||||||
|
local linked
|
||||||
|
if state.isWindows then
|
||||||
|
linked = connectWindows()
|
||||||
|
else
|
||||||
|
linked = connectUnix()
|
||||||
|
end
|
||||||
|
if not linked then return false end
|
||||||
|
if not handshake() then
|
||||||
|
closeSocket()
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
state.connected = true
|
||||||
|
state.dirty = true
|
||||||
|
state.lastSentKey = nil
|
||||||
|
state.loggedAbsent = false
|
||||||
|
Logger.info("discord: rich presence connected")
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
closeSocket()
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return result and true or false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function locationName(game, mapId)
|
||||||
|
if not mapId then return nil end
|
||||||
|
local field = game and game.data and game.data.field
|
||||||
|
local townMap = field and field.townMap
|
||||||
|
local locations = townMap and (townMap.locations or townMap)
|
||||||
|
local entry = type(locations) == "table" and locations[mapId]
|
||||||
|
if type(entry) == "table" then
|
||||||
|
local name = entry.name or entry.label
|
||||||
|
if type(name) == "string" and name ~= "" then return name end
|
||||||
|
end
|
||||||
|
local def = game and game.data and game.data.maps and game.data.maps[mapId]
|
||||||
|
if def and type(def.label) == "string" and def.label ~= "" then
|
||||||
|
-- PalletTown -> Pallet Town
|
||||||
|
return def.label:gsub("(%l)(%u)", "%1 %2")
|
||||||
|
end
|
||||||
|
return tostring(mapId):gsub("_", " ")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function speciesName(game, species)
|
||||||
|
if not species or not game or not game.data or not game.data.pokemon then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local def = game.data.pokemon[species]
|
||||||
|
return def and def.name or tostring(species):gsub("_", " ")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function buildActivity()
|
||||||
|
local details, activityState
|
||||||
|
if state.activity == "battle" then
|
||||||
|
details = state.battleLabel or "In battle"
|
||||||
|
activityState = state.location and (state.location) or "Kanto"
|
||||||
|
elseif state.activity == "exploring" and state.location then
|
||||||
|
details = state.location
|
||||||
|
activityState = "Exploring the overworld"
|
||||||
|
else
|
||||||
|
details = state.location or "Kanto"
|
||||||
|
activityState = "At the title screen"
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
details = details,
|
||||||
|
state = activityState,
|
||||||
|
timestamps = { start = state.startedAt },
|
||||||
|
assets = {
|
||||||
|
large_image = "logo",
|
||||||
|
large_text = "Pokemon Gen1Recomp",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function activityKey(activity)
|
||||||
|
return (activity.details or "") .. "|" .. (activity.state or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function flush(force)
|
||||||
|
if not state.enabled or not state.connected then return end
|
||||||
|
local ok = pcall(function()
|
||||||
|
local now = love.timer.getTime()
|
||||||
|
if not force and (now - state.lastSendAt) < MIN_UPDATE_INTERVAL then
|
||||||
|
state.dirty = true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local activity = buildActivity()
|
||||||
|
local key = activityKey(activity)
|
||||||
|
if not force and key == state.lastSentKey then
|
||||||
|
state.dirty = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local payload = Json.encode({
|
||||||
|
cmd = "SET_ACTIVITY",
|
||||||
|
args = {
|
||||||
|
pid = resolvePid(),
|
||||||
|
activity = activity,
|
||||||
|
},
|
||||||
|
nonce = nonce(),
|
||||||
|
})
|
||||||
|
if not sendFrame(OP_FRAME, payload) then
|
||||||
|
closeSocket()
|
||||||
|
state.nextReconnectAt = now + RECONNECT_INTERVAL
|
||||||
|
return
|
||||||
|
end
|
||||||
|
state.lastSentKey = key
|
||||||
|
state.lastSendAt = now
|
||||||
|
state.dirty = false
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
closeSocket()
|
||||||
|
local now = love and love.timer and love.timer.getTime and love.timer.getTime() or 0
|
||||||
|
state.nextReconnectAt = now + RECONNECT_INTERVAL
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setPresence(fields)
|
||||||
|
-- event listeners must never throw into the overworld/battle path
|
||||||
|
pcall(function()
|
||||||
|
if fields.location ~= nil then state.location = fields.location end
|
||||||
|
if fields.mapId ~= nil then state.mapId = fields.mapId end
|
||||||
|
if fields.activity ~= nil then state.activity = fields.activity end
|
||||||
|
if fields.clearBattle then
|
||||||
|
state.battleLabel = nil
|
||||||
|
elseif fields.battleLabel ~= nil then
|
||||||
|
state.battleLabel = fields.battleLabel
|
||||||
|
end
|
||||||
|
state.dirty = true
|
||||||
|
flush(false)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function subscribe(game)
|
||||||
|
local events = Runtime.events
|
||||||
|
if not events or not events.on then return end
|
||||||
|
|
||||||
|
local function track(name, fn)
|
||||||
|
state.unsubs[#state.unsubs + 1] = events:on(name, fn, 0, "discord")
|
||||||
|
end
|
||||||
|
|
||||||
|
track("map.entered", function(ev)
|
||||||
|
local mapId = ev and ev.mapId
|
||||||
|
setPresence({
|
||||||
|
mapId = mapId,
|
||||||
|
location = locationName(game, mapId),
|
||||||
|
activity = "exploring",
|
||||||
|
clearBattle = true,
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
track("battle.started", function(ev)
|
||||||
|
local battle = ev and ev.battle
|
||||||
|
local label = "In battle"
|
||||||
|
if ev and ev.kind == "wild" then
|
||||||
|
local name = speciesName(game, ev.species)
|
||||||
|
if name then
|
||||||
|
label = "Battling wild " .. name
|
||||||
|
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
|
||||||
|
else
|
||||||
|
label = "Wild battle"
|
||||||
|
end
|
||||||
|
elseif ev and ev.kind == "trainer" then
|
||||||
|
local tname = battle and battle.trainer and battle.trainer.name
|
||||||
|
label = tname and ("Battling " .. tname) or "Trainer battle"
|
||||||
|
elseif ev and ev.kind == "link" then
|
||||||
|
label = "Link battle"
|
||||||
|
end
|
||||||
|
local mapId = state.mapId
|
||||||
|
or (game.save and game.save.player and game.save.player.map)
|
||||||
|
setPresence({
|
||||||
|
activity = "battle",
|
||||||
|
battleLabel = label,
|
||||||
|
location = locationName(game, mapId) or state.location,
|
||||||
|
mapId = mapId,
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
track("battle.ended", function()
|
||||||
|
local mapId = state.mapId
|
||||||
|
or (game.save and game.save.player and game.save.player.map)
|
||||||
|
setPresence({
|
||||||
|
activity = mapId and "exploring" or "menu",
|
||||||
|
clearBattle = true,
|
||||||
|
location = locationName(game, mapId) or state.location,
|
||||||
|
mapId = mapId,
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
track("screen.pushed", function(ev)
|
||||||
|
local s = ev and ev.state
|
||||||
|
local id = s and s.screenId
|
||||||
|
if not id and s and s.onNewGame then id = "TitleState" end
|
||||||
|
if id == "TitleState" or id == "IntroMovie" then
|
||||||
|
state.mapId = nil
|
||||||
|
setPresence({
|
||||||
|
activity = "menu",
|
||||||
|
location = id == "IntroMovie" and "Watching the intro" or "Title screen",
|
||||||
|
clearBattle = true,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function DiscordPresence.init(game)
|
||||||
|
local ok, err = pcall(function()
|
||||||
|
DiscordPresence.shutdown()
|
||||||
|
if disabledByEnv() or not isDesktop() then return end
|
||||||
|
|
||||||
|
state.enabled = true
|
||||||
|
state.isWindows = love.system.getOS() == "Windows"
|
||||||
|
state.startedAt = os.time()
|
||||||
|
state.activity = "menu"
|
||||||
|
state.location = "Title screen"
|
||||||
|
state.mapId = nil
|
||||||
|
state.battleLabel = nil
|
||||||
|
state.dirty = true
|
||||||
|
state.nextReconnectAt = 0
|
||||||
|
state.loggedAbsent = false
|
||||||
|
|
||||||
|
if not state.isWindows and not ensureFfi() then
|
||||||
|
-- no FFI / broken cdef: stay disabled, never throw
|
||||||
|
state.enabled = false
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
subscribe(game)
|
||||||
|
if connect() then
|
||||||
|
flush(true)
|
||||||
|
else
|
||||||
|
noteAbsent()
|
||||||
|
local now = (love.timer and love.timer.getTime and love.timer.getTime()) or 0
|
||||||
|
state.nextReconnectAt = now + RECONNECT_INTERVAL
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
closeSocket()
|
||||||
|
state.enabled = false
|
||||||
|
-- still soft: a broken presence stack must not abort boot
|
||||||
|
Logger.info("discord: rich presence disabled (%s)", tostring(err))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function DiscordPresence.update(_dt)
|
||||||
|
if not state.enabled then return end
|
||||||
|
pcall(function()
|
||||||
|
local now = love.timer.getTime()
|
||||||
|
if not state.connected then
|
||||||
|
if now >= state.nextReconnectAt then
|
||||||
|
state.nextReconnectAt = now + RECONNECT_INTERVAL
|
||||||
|
if connect() then
|
||||||
|
flush(true)
|
||||||
|
else
|
||||||
|
noteAbsent()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if state.dirty then flush(false) end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function DiscordPresence.shutdown()
|
||||||
|
pcall(function()
|
||||||
|
for _, unsub in ipairs(state.unsubs) do
|
||||||
|
pcall(unsub)
|
||||||
|
end
|
||||||
|
state.unsubs = {}
|
||||||
|
if state.connected then
|
||||||
|
pcall(function()
|
||||||
|
-- omit activity to clear the presence
|
||||||
|
sendFrame(OP_FRAME, Json.encode({
|
||||||
|
cmd = "SET_ACTIVITY",
|
||||||
|
args = { pid = resolvePid() },
|
||||||
|
nonce = nonce(),
|
||||||
|
}))
|
||||||
|
sendFrame(OP_CLOSE, "{}")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
closeSocket()
|
||||||
|
end)
|
||||||
|
state.enabled = false
|
||||||
|
state.dirty = false
|
||||||
|
state.lastSentKey = nil
|
||||||
|
state.connected = false
|
||||||
|
state.socket = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- test / debug helpers
|
||||||
|
DiscordPresence._state = state
|
||||||
|
DiscordPresence.locationName = locationName
|
||||||
|
|
||||||
|
return DiscordPresence
|
||||||
+16
-1
@@ -12,12 +12,27 @@ function FixedStep:init(callback)
|
|||||||
self.callback = callback
|
self.callback = callback
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
|
||||||
|
-- steps), which silently throttled the high fast-forward levels: at 100X
|
||||||
|
-- a 60fps frame wants ~100 steps. Game:update raises this to fit the
|
||||||
|
-- current speed target; a stall still cannot snowball past one frame's
|
||||||
|
-- intended budget.
|
||||||
|
FixedStep.maxAccum = MAX_ACCUM
|
||||||
|
|
||||||
function FixedStep:update(dt)
|
function FixedStep:update(dt)
|
||||||
self.accum = math.min(self.accum + dt, MAX_ACCUM)
|
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
|
||||||
while self.accum >= self.STEP do
|
while self.accum >= self.STEP do
|
||||||
self.accum = self.accum - self.STEP
|
self.accum = self.accum - self.STEP
|
||||||
self.callback(self.STEP)
|
self.callback(self.STEP)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Drop any pending catch-up steps. A hitch inside one logic step (map
|
||||||
|
-- seam setMap / song start) makes the next real-time dt huge; without this
|
||||||
|
-- the while-loop above would advance many walk frames before the next
|
||||||
|
-- draw, which looks like a slide with no leg animation (issue #93).
|
||||||
|
function FixedStep:discardCatchup()
|
||||||
|
self.accum = 0
|
||||||
|
end
|
||||||
|
|
||||||
return FixedStep
|
return FixedStep
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
-- Render frame-rate cap. With a driver control panel forcing
|
||||||
|
-- vsync off, the 160x144 game is trivially cheap and love.run will present
|
||||||
|
-- thousands of frames a second; over hours that cooks the graphics driver
|
||||||
|
-- until a restart, and it wastes power whenever the window is left open in
|
||||||
|
-- the background. A hard cap bounds the present rate. Render-only: game
|
||||||
|
-- logic is fixed-step off dt (src/core/FixedStep.lua), so pacing present()
|
||||||
|
-- changes nothing about timing, audio, or determinism.
|
||||||
|
--
|
||||||
|
-- Persisted as save.options.fpsCap; applied from OptionsMenu and on boot
|
||||||
|
-- via Game:applyOptions. main.lua's love.run reads FrameCap.current each
|
||||||
|
-- frame for its sleep budget. The module never touches love.timer itself,
|
||||||
|
-- so it stays safe under the headless test stub.
|
||||||
|
|
||||||
|
local FrameCap = {}
|
||||||
|
|
||||||
|
-- Selectable steps: the normal framerate stops between the floor and the
|
||||||
|
-- ceiling. STEPS[1] == MIN and STEPS[#STEPS] == MAX, so the nearest-step
|
||||||
|
-- snap in normalize doubles as the clamp. Cycling past the last wraps.
|
||||||
|
FrameCap.STEPS = { 30, 40, 50, 60, 75, 90, 100, 120, 144, 160 }
|
||||||
|
FrameCap.MIN = 30
|
||||||
|
FrameCap.MAX = 160
|
||||||
|
FrameCap.DEFAULT = 60
|
||||||
|
|
||||||
|
-- The live cap the run loop paces to. Defaults so the launcher and the
|
||||||
|
-- save editor are paced before any save applies its stored option.
|
||||||
|
FrameCap.current = FrameCap.DEFAULT
|
||||||
|
|
||||||
|
-- Nearest valid step for an arbitrary value (a hand-edited options.lua or
|
||||||
|
-- an old save with no fpsCap key), so a bad number degrades to something
|
||||||
|
-- sane; nil / non-numbers fall back to the default. A value below MIN or
|
||||||
|
-- above MAX snaps to that end, since MIN/MAX are the first/last steps.
|
||||||
|
function FrameCap.normalize(value)
|
||||||
|
value = tonumber(value)
|
||||||
|
if not value then return FrameCap.DEFAULT end
|
||||||
|
local best, bestDiff = FrameCap.DEFAULT, math.huge
|
||||||
|
for _, step in ipairs(FrameCap.STEPS) do
|
||||||
|
local diff = math.abs(step - value)
|
||||||
|
if diff < bestDiff then best, bestDiff = step, diff end
|
||||||
|
end
|
||||||
|
return best
|
||||||
|
end
|
||||||
|
|
||||||
|
-- plain numeric text for the options row (e.g. "60")
|
||||||
|
function FrameCap.label(value)
|
||||||
|
return tostring(FrameCap.normalize(value))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- cycle to the next/previous step, wrapping (the options row idiom)
|
||||||
|
function FrameCap.cycle(value, dir)
|
||||||
|
local steps = FrameCap.STEPS
|
||||||
|
local snapped = FrameCap.normalize(value)
|
||||||
|
local cur = 1
|
||||||
|
for i, step in ipairs(steps) do
|
||||||
|
if step == snapped then cur = i break end
|
||||||
|
end
|
||||||
|
local nextIdx = (cur - 1 + (dir or 1)) % #steps + 1
|
||||||
|
return steps[nextIdx]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Store the chosen cap as the live value the run loop paces to. Never
|
||||||
|
-- touches love.timer, so it is safe headless -- the loop just reads the
|
||||||
|
-- number back. Returns the normalized value it stored.
|
||||||
|
function FrameCap.apply(value)
|
||||||
|
FrameCap.current = FrameCap.normalize(value)
|
||||||
|
return FrameCap.current
|
||||||
|
end
|
||||||
|
|
||||||
|
function FrameCap.applyOptions(opts)
|
||||||
|
FrameCap.apply(opts and opts.fpsCap)
|
||||||
|
end
|
||||||
|
|
||||||
|
return FrameCap
|
||||||
+102
-9
@@ -17,7 +17,7 @@ local Game = {}
|
|||||||
|
|
||||||
-- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev
|
-- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev
|
||||||
-- module unloaded, so a player boot never touches a byte of dev code
|
-- module unloaded, so a player boot never touches a byte of dev code
|
||||||
local devMode = os.getenv("POKEPORT_DEV") == "1"
|
local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true
|
||||||
|
|
||||||
-- the boot screen ids (field.boot.screens); a plain function so the
|
-- the boot screen ids (field.boot.screens); a plain function so the
|
||||||
-- headless harness can borrow makeTitleState onto a stub game
|
-- headless harness can borrow makeTitleState onto a stub game
|
||||||
@@ -68,6 +68,10 @@ function Game:load()
|
|||||||
local OverworldState = require("src.world.OverworldController")
|
local OverworldState = require("src.world.OverworldController")
|
||||||
self.overworld = OverworldState
|
self.overworld = OverworldState
|
||||||
|
|
||||||
|
-- Discord Rich Presence: map name / battle status on the player's profile.
|
||||||
|
-- Soft-fail: missing Discord / IPC errors must never block boot.
|
||||||
|
pcall(function() require("src.core.DiscordPresence").init(self) end)
|
||||||
|
|
||||||
-- every service is up but nothing is on the stack yet; this payload is
|
-- every service is up but nothing is on the stack yet; this payload is
|
||||||
-- the sanctioned way for a mod to obtain the Game object
|
-- the sanctioned way for a mod to obtain the Game object
|
||||||
ModRuntime.emit("game.ready", { game = self })
|
ModRuntime.emit("game.ready", { game = self })
|
||||||
@@ -95,7 +99,12 @@ end
|
|||||||
-- total conversion overrides. Threaded into SaveData so persistence stays
|
-- total conversion overrides. Threaded into SaveData so persistence stays
|
||||||
-- free of a Data dependency.
|
-- free of a Data dependency.
|
||||||
function Game:bootConfig()
|
function Game:bootConfig()
|
||||||
return self.data and self.data.field and self.data.field.boot
|
local boot = self.data and self.data.field and self.data.field.boot
|
||||||
|
-- stamp the running game version onto the boot config so a New Game records
|
||||||
|
-- it (SaveData.newGame reads boot.version); this is what routes a Blue
|
||||||
|
-- playthrough to save_blue.lua and Blue's version-gated content
|
||||||
|
if boot then boot.version = require("src.core.GameVersion").get() end
|
||||||
|
return boot
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
|
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
|
||||||
@@ -103,7 +112,7 @@ end
|
|||||||
function Game:makeTitleState()
|
function Game:makeTitleState()
|
||||||
local OverworldState = require("src.world.OverworldController")
|
local OverworldState = require("src.world.OverworldController")
|
||||||
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
|
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
|
||||||
return factory.new(self, {
|
local title = factory.new(self, {
|
||||||
onNewGame = function()
|
onNewGame = function()
|
||||||
while self.stack:top() do self.stack:pop() end
|
while self.stack:top() do self.stack:pop() end
|
||||||
-- New Game keeps the standalone options.lua preferences
|
-- New Game keeps the standalone options.lua preferences
|
||||||
@@ -126,6 +135,8 @@ function Game:makeTitleState()
|
|||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
|
title.screenId = title.screenId or "TitleState"
|
||||||
|
return title
|
||||||
end
|
end
|
||||||
|
|
||||||
-- QUIT from the START menu: back to the title like a power-cycle,
|
-- QUIT from the START menu: back to the title like a power-cycle,
|
||||||
@@ -148,17 +159,53 @@ function Game:step(dt)
|
|||||||
self.stack:update(dt)
|
self.stack:update(dt)
|
||||||
-- play time for the trainer card / save screen
|
-- play time for the trainer card / save screen
|
||||||
self.save.playTime = (self.save.playTime or 0) + dt
|
self.save.playTime = (self.save.playTime or 0) + dt
|
||||||
require("src.core.Music").update(Data)
|
-- Music.update is NOT serviced here: it decrements fade counters and
|
||||||
|
-- drives ChipAudio once per call, so running it inside the logic step
|
||||||
|
-- would pitch music and sfx up under fast-forward. Game:update advances
|
||||||
|
-- it on its own real-time 60Hz accumulator instead.
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The logic multiplier for this frame. Read live rather than cached so the
|
||||||
|
-- Options row takes effect immediately; speedOverride is the --speed /
|
||||||
|
-- POKEPORT_SPEED run argument, which wins over the saved option so a bot
|
||||||
|
-- or screenshot run does not depend on whatever the player last chose.
|
||||||
|
function Game:logicSpeed()
|
||||||
|
local GameSpeed = require("src.core.GameSpeed")
|
||||||
|
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)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:update(dt)
|
function Game:update(dt)
|
||||||
-- Touch timers / prior-frame auto-releases before the fixed step so
|
-- Touch timers / prior-frame auto-releases before the fixed step so
|
||||||
-- deferred A and edge pulses land in Input's press queue for this step.
|
-- deferred A and edge pulses land in Input's press queue for this step.
|
||||||
TouchInput:update(dt)
|
TouchInput:update(dt)
|
||||||
FixedStep:update(dt)
|
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
|
||||||
|
-- Give the accumulator room for one full frame at the current speed,
|
||||||
|
-- or the anti-spiral clamp quietly caps every level above ~15X.
|
||||||
|
local speed = self:logicSpeed()
|
||||||
|
FixedStep.maxAccum = math.max(0.25, speed * FixedStep.STEP * 1.5)
|
||||||
|
FixedStep:update(dt * speed)
|
||||||
|
-- Audio runs off real time at a fixed 60Hz regardless of game speed or
|
||||||
|
-- display refresh, so fades and chip synthesis keep their intended tempo
|
||||||
|
-- whether we are at 1X, 10X, or running with vsync disabled.
|
||||||
|
local step = FixedStep.STEP
|
||||||
|
self.audioAccum = math.min((self.audioAccum or 0) + dt, 0.25)
|
||||||
|
while self.audioAccum >= step do
|
||||||
|
self.audioAccum = self.audioAccum - step
|
||||||
|
require("src.core.Music").update(Data)
|
||||||
|
end
|
||||||
-- Overworld tilt toggle tween: presentational, so it runs on the real
|
-- Overworld tilt toggle tween: presentational, so it runs on the real
|
||||||
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
|
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
|
||||||
require("src.render.Tilt").update(dt)
|
require("src.render.Tilt").update(dt)
|
||||||
|
pcall(function() require("src.core.DiscordPresence").update(dt) end)
|
||||||
|
-- Steady-state memory backstop: advance the incremental collector one
|
||||||
|
-- small step every rendered frame. The heavy GPU objects are now freed
|
||||||
|
-- explicitly (map eviction, battle exit, canvas/renderer swaps), so this
|
||||||
|
-- only has to keep ordinary Lua-heap garbage (per-frame tables/closures)
|
||||||
|
-- from drifting upward over a long session, and to spread collection out
|
||||||
|
-- so the default lazy schedule never batches it into a visible pause.
|
||||||
|
if collectgarbage then collectgarbage("step", 1) end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- render.zones' identity default: unhooked, the zone list reaches the blit
|
-- render.zones' identity default: unhooked, the zone list reaches the blit
|
||||||
@@ -248,10 +295,22 @@ function Game:keypressed(key)
|
|||||||
self:zoomStep(1)
|
self:zoomStep(1)
|
||||||
return
|
return
|
||||||
elseif key == "2" then
|
elseif key == "2" then
|
||||||
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); always on
|
-- cycle COLORS (GBC / OG / OG INV / GBC INV / CLASSIC); the pack change
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
-- forces Game.overworld:reloadMap, which rebuilds the live NPC array, so
|
||||||
self.save.options.colors = PaletteFX.cycleMode()
|
-- hold it while a warp/transition or an on-screen scripted cutscene is
|
||||||
self:writeOptions()
|
-- driving the overworld rather than tear the escort's NPCs out mid-move
|
||||||
|
local ow = self.overworld
|
||||||
|
local top = self.stack:top()
|
||||||
|
local busy = ow and (ow.transitioning
|
||||||
|
or (top == ow and (
|
||||||
|
(ow.runner and ow.runner.isRunning and ow.runner:isRunning())
|
||||||
|
or (ow.scriptMoves and #ow.scriptMoves > 0)
|
||||||
|
or ow.engaging or ow.emote)))
|
||||||
|
if not busy then
|
||||||
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
self.save.options.colors = PaletteFX.cycleMode()
|
||||||
|
self:writeOptions()
|
||||||
|
end
|
||||||
return
|
return
|
||||||
elseif key == "3" then
|
elseif key == "3" then
|
||||||
-- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only
|
-- cycle TILT OFF → 15 → 35 → 50 → OFF (mnemonic: 3D), free-roam only
|
||||||
@@ -302,6 +361,28 @@ function Game:gamepadaxis(joystick, axis, value)
|
|||||||
Input:gamepadaxis(joystick, axis, value)
|
Input:gamepadaxis(joystick, axis, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Window focus/visibility flips: a release due while unfocused/hidden can
|
||||||
|
-- be swallowed by the OS. Reset on both edges -- gaining focus with a
|
||||||
|
-- physically held key won't re-fire keypressed, so trusting leftover
|
||||||
|
-- state is worse than asking the player to re-press.
|
||||||
|
function Game:focus(f)
|
||||||
|
Input:reset()
|
||||||
|
TouchInput:reset()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:visible(v)
|
||||||
|
Input:reset()
|
||||||
|
TouchInput:reset()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A disconnected/dropped controller can't send the button-up for whatever
|
||||||
|
-- it was holding, so drop all input state rather than try to guess which
|
||||||
|
-- flags it owned.
|
||||||
|
function Game:joystickremoved(joystick)
|
||||||
|
Input:reset()
|
||||||
|
TouchInput:reset()
|
||||||
|
end
|
||||||
|
|
||||||
function Game:touchpressed(id, x, y)
|
function Game:touchpressed(id, x, y)
|
||||||
TouchInput:touchpressed(id, x, y)
|
TouchInput:touchpressed(id, x, y)
|
||||||
end
|
end
|
||||||
@@ -363,6 +444,11 @@ function Game:applyOptions(opts)
|
|||||||
require("src.render.PaletteFX").applyOptions(opts)
|
require("src.render.PaletteFX").applyOptions(opts)
|
||||||
require("src.render.Tilt").applyOptions(opts)
|
require("src.render.Tilt").applyOptions(opts)
|
||||||
require("src.render.GBCFX").applyOptions(opts)
|
require("src.render.GBCFX").applyOptions(opts)
|
||||||
|
require("src.core.VideoMode").applyOptions(opts)
|
||||||
|
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||||
|
-- fpsCap key pace at the standard rate (issue #88)
|
||||||
|
require("src.core.FrameCap").applyOptions(opts)
|
||||||
|
Input:applyBindings(opts.bindings)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:restoreSave(loaded, recovered)
|
function Game:restoreSave(loaded, recovered)
|
||||||
@@ -374,6 +460,13 @@ function Game:restoreSave(loaded, recovered)
|
|||||||
-- SaveData.load and skip on the format guard
|
-- SaveData.load and skip on the format guard
|
||||||
local activeMods = self.modStatus and self.modStatus.loaded
|
local activeMods = self.modStatus and self.modStatus.loaded
|
||||||
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
|
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
|
||||||
|
-- Issue #103: 0.1.11 softlocks left CONTINUE in HALL_OF_FAME with
|
||||||
|
-- lastOutdoor on Indigo. One-shot relocate + heal before validate.
|
||||||
|
if SaveData.needsPostGameRescue(loaded) then
|
||||||
|
SaveData.applyPostGameHome(loaded, self:bootConfig())
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
for _, mon in ipairs(loaded.party or {}) do Pokemon.heal(mon) end
|
||||||
|
end
|
||||||
local modsDiff = SaveData.modsDiff(loaded, activeMods)
|
local modsDiff = SaveData.modsDiff(loaded, activeMods)
|
||||||
local report = SaveData.validate(loaded, self.data)
|
local report = SaveData.validate(loaded, self.data)
|
||||||
report.recovered = recovered
|
report.recovered = recovered
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- Fast-forward multiplier for game logic.
|
||||||
|
--
|
||||||
|
-- Speeding up means running the 1/60 fixed step N times per real frame
|
||||||
|
-- (Game:update), so everything driven by the step -- movement, text,
|
||||||
|
-- battle timing, scripts -- advances N times faster while staying
|
||||||
|
-- deterministic. Audio deliberately does NOT scale: Music.update drives
|
||||||
|
-- fade counters and ChipAudio synthesis off its own real-time 60Hz
|
||||||
|
-- accumulator in Game:update, so music and sfx play at normal pitch and
|
||||||
|
-- tempo at every speed.
|
||||||
|
--
|
||||||
|
-- Vsync still caps how much work a real frame can do, so 10X is a target
|
||||||
|
-- rather than a promise on a slow machine -- the logic simply runs as many
|
||||||
|
-- steps as the frame budget allows.
|
||||||
|
|
||||||
|
local GameSpeed = {}
|
||||||
|
|
||||||
|
-- 20X exists for the bot runs (tests/drivers/route.lua): a full-route
|
||||||
|
-- attempt is long enough that the iteration loop, not the engine, is the
|
||||||
|
-- bottleneck. Vsync caps how much a real frame can do, so past 10X the
|
||||||
|
-- multiplier is increasingly a ceiling rather than a rate.
|
||||||
|
GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75, 100,200 }
|
||||||
|
GameSpeed.DEFAULT = 1
|
||||||
|
|
||||||
|
function GameSpeed.levelLabel(v)
|
||||||
|
v = tonumber(v) or GameSpeed.DEFAULT
|
||||||
|
if v == 1 then return "NORMAL" end
|
||||||
|
return tostring(v) .. "X"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- nearest valid level for an arbitrary value (a hand-edited options.lua or
|
||||||
|
-- a --speed argument), so a bad number degrades to something sane
|
||||||
|
function GameSpeed.clamp(v)
|
||||||
|
v = tonumber(v)
|
||||||
|
if not v then return GameSpeed.DEFAULT end
|
||||||
|
local best, bestDiff = GameSpeed.DEFAULT, math.huge
|
||||||
|
for _, level in ipairs(GameSpeed.LEVELS) do
|
||||||
|
local diff = math.abs(level - v)
|
||||||
|
if diff < bestDiff then best, bestDiff = level, diff end
|
||||||
|
end
|
||||||
|
return best
|
||||||
|
end
|
||||||
|
|
||||||
|
-- cycle to the next/previous level, wrapping (the options row idiom)
|
||||||
|
function GameSpeed.cycle(v, dir)
|
||||||
|
local levels = GameSpeed.LEVELS
|
||||||
|
local cur = 1
|
||||||
|
for i, level in ipairs(levels) do
|
||||||
|
if level == GameSpeed.clamp(v) then cur = i break end
|
||||||
|
end
|
||||||
|
local nextIdx = (cur - 1 + (dir or 1)) % #levels + 1
|
||||||
|
return levels[nextIdx]
|
||||||
|
end
|
||||||
|
|
||||||
|
return GameSpeed
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
-- 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.
|
||||||
|
--
|
||||||
|
-- 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.
|
||||||
|
--
|
||||||
|
-- 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
|
||||||
|
-- the launcher's column choice (main.lua); it defaults to Red.
|
||||||
|
|
||||||
|
local GameVersion = {}
|
||||||
|
|
||||||
|
GameVersion.VERSIONS = {
|
||||||
|
red = {
|
||||||
|
id = "red",
|
||||||
|
label = "Red",
|
||||||
|
displayName = "Pokemon Red",
|
||||||
|
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
|
||||||
|
manifest = "tools/rom_manifest.json",
|
||||||
|
cachePrefix = "", -- Red owns the cache root (backwards compatible)
|
||||||
|
saveSuffix = "", -- save.lua / save.lua.bak / save.lua.tmp
|
||||||
|
},
|
||||||
|
blue = {
|
||||||
|
id = "blue",
|
||||||
|
label = "Blue",
|
||||||
|
displayName = "Pokemon Blue",
|
||||||
|
sha1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2",
|
||||||
|
manifest = "tools/rom_manifest_blue.json",
|
||||||
|
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
|
||||||
|
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Launcher column order (Yellow is still a placeholder, handled by the UI).
|
||||||
|
GameVersion.ORDER = { "red", "blue" }
|
||||||
|
|
||||||
|
GameVersion.current = "red"
|
||||||
|
|
||||||
|
function GameVersion.set(id)
|
||||||
|
GameVersion.current = GameVersion.VERSIONS[id] and id or "red"
|
||||||
|
return GameVersion.current
|
||||||
|
end
|
||||||
|
|
||||||
|
function GameVersion.get()
|
||||||
|
return GameVersion.current
|
||||||
|
end
|
||||||
|
|
||||||
|
function GameVersion.isBlue()
|
||||||
|
return GameVersion.current == "blue"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Metadata for a version id, defaulting to the active one.
|
||||||
|
function GameVersion.info(id)
|
||||||
|
return GameVersion.VERSIONS[id or GameVersion.current]
|
||||||
|
end
|
||||||
|
|
||||||
|
function GameVersion.saveSuffix(id)
|
||||||
|
return GameVersion.info(id).saveSuffix
|
||||||
|
end
|
||||||
|
|
||||||
|
function GameVersion.cachePrefix(id)
|
||||||
|
return GameVersion.info(id).cachePrefix
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The version a ROM belongs to, by its SHA-1, or nil for an unknown ROM.
|
||||||
|
function GameVersion.forSha1(sha1)
|
||||||
|
for id, info in pairs(GameVersion.VERSIONS) do
|
||||||
|
if info.sha1 == sha1 then return id end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return GameVersion
|
||||||
+102
-16
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
local Input = {}
|
local Input = {}
|
||||||
|
|
||||||
local BINDINGS = {
|
local DEFAULT_BINDINGS = {
|
||||||
up = "up", w = "up",
|
up = "up", w = "up",
|
||||||
down = "down", s = "down",
|
down = "down", s = "down",
|
||||||
left = "left", a = "left",
|
left = "left", a = "left",
|
||||||
@@ -18,8 +18,11 @@ local BINDINGS = {
|
|||||||
-- Escape = start for desktop friendliness.
|
-- Escape = start for desktop friendliness.
|
||||||
|
|
||||||
-- LÖVE's standard gamepad mapping (SDL game controller DB), consistent
|
-- LÖVE's standard gamepad mapping (SDL game controller DB), consistent
|
||||||
-- across Xbox/PlayStation/generic controllers on desktop and mobile.
|
-- across Xbox/PlayStation/generic controllers on desktop and mobile. Some
|
||||||
local GAMEPAD_BINDINGS = {
|
-- third-party pads report their own SDL mapping for a given physical
|
||||||
|
-- button (e.g. Select/Back/View on off-brand XInput pads), which is what
|
||||||
|
-- src/ui/BindingsMenu.lua's rebinding is for -- see applyBindings below.
|
||||||
|
local DEFAULT_GAMEPAD_BINDINGS = {
|
||||||
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
|
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
|
||||||
a = "a", b = "b",
|
a = "a", b = "b",
|
||||||
start = "start", back = "select",
|
start = "start", back = "select",
|
||||||
@@ -32,48 +35,131 @@ local STICK_ON = 0.5
|
|||||||
local STICK_OFF = 0.3
|
local STICK_OFF = 0.3
|
||||||
|
|
||||||
function Input:init()
|
function Input:init()
|
||||||
|
self:applyBindings(nil)
|
||||||
|
self:reset()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Layers a player's rebind choices (save.options.bindings, written by
|
||||||
|
-- src/ui/BindingsMenu.lua) on top of the defaults above. A rebind adds an
|
||||||
|
-- extra way to trigger that action instead of replacing the default key,
|
||||||
|
-- so e.g. Z/Enter/Space all still press A even after binding a 4th key to
|
||||||
|
-- it. Call whenever options load or change (see Game:applyOptions and
|
||||||
|
-- BindingsMenu:storeBinding) -- without this the menu records a choice
|
||||||
|
-- that never actually reaches gameplay.
|
||||||
|
function Input:applyBindings(overlay)
|
||||||
|
local keys, pads = {}, {}
|
||||||
|
for key, action in pairs(DEFAULT_BINDINGS) do keys[key] = action end
|
||||||
|
for button, action in pairs(DEFAULT_GAMEPAD_BINDINGS) do pads[button] = action end
|
||||||
|
for actionId, binding in pairs(overlay or {}) do
|
||||||
|
if type(binding) == "table" then
|
||||||
|
if binding.key then keys[binding.key] = actionId end
|
||||||
|
if binding.pad then pads[binding.pad] = actionId end
|
||||||
|
elseif type(binding) == "string" then
|
||||||
|
keys[binding] = actionId
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self.keyBindings = keys
|
||||||
|
self.padBindings = pads
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Purely event-driven state (press sets true, release sets false) has no
|
||||||
|
-- fallback if a release event never arrives -- focus loss, a minimized
|
||||||
|
-- window, or a disconnected gamepad can all swallow the key-up/button-up
|
||||||
|
-- that would have cleared a held direction. Called from Game on those
|
||||||
|
-- transitions so a stuck flag can't outlive them.
|
||||||
|
function Input:reset()
|
||||||
self.state = {}
|
self.state = {}
|
||||||
self.pressQueue = {}
|
self.pressQueue = {}
|
||||||
self.pressed = {}
|
self.pressed = {}
|
||||||
|
self.sources = {}
|
||||||
self.stickAxis = { x = 0, y = 0 }
|
self.stickAxis = { x = 0, y = 0 }
|
||||||
self.stickDir = nil
|
self.stickDir = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function Input:keypressed(key)
|
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
|
||||||
local btn = BINDINGS[key]
|
-- same GB button. Track them individually so releasing one doesn't clear
|
||||||
if btn then
|
-- a hold another source still owns, and so a press+release that both land
|
||||||
|
-- before the next FixedStep can't be revived when step() drains the queue.
|
||||||
|
local function press(self, btn, source)
|
||||||
|
local sources = self.sources[btn]
|
||||||
|
if not sources then
|
||||||
|
sources = {}
|
||||||
|
self.sources[btn] = sources
|
||||||
|
end
|
||||||
|
if not sources[source] then
|
||||||
|
sources[source] = true
|
||||||
table.insert(self.pressQueue, btn)
|
table.insert(self.pressQueue, btn)
|
||||||
end
|
end
|
||||||
|
self.state[btn] = true
|
||||||
end
|
end
|
||||||
|
|
||||||
function Input:keyreleased(key)
|
local function release(self, btn, source)
|
||||||
local btn = BINDINGS[key]
|
local sources = self.sources[btn]
|
||||||
if btn then
|
if sources then
|
||||||
|
sources[source] = nil
|
||||||
|
if next(sources) == nil then
|
||||||
|
-- Leave an empty table (not nil) so step() can tell a real
|
||||||
|
-- source was released before the queue drained, versus a
|
||||||
|
-- synthetic pressQueue inject that never had sources at all.
|
||||||
|
self.state[btn] = false
|
||||||
|
end
|
||||||
|
else
|
||||||
self.state[btn] = false
|
self.state[btn] = false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Input:keypressed(key)
|
||||||
|
local btn = self.keyBindings[key]
|
||||||
|
if btn then
|
||||||
|
press(self, btn, "key:" .. key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Input:keyreleased(key)
|
||||||
|
local btn = self.keyBindings[key]
|
||||||
|
if btn then
|
||||||
|
release(self, btn, "key:" .. key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Called once per fixed step: promote queued presses to this step's edges.
|
-- Called once per fixed step: promote queued presses to this step's edges.
|
||||||
|
-- Hold state is owned by live sources (updated in press/release), not
|
||||||
|
-- re-asserted here -- otherwise a same-frame press→release leaves the
|
||||||
|
-- button stuck on after the queue drains.
|
||||||
|
-- Synthetic injects (tests/drivers writing pressQueue directly, with no
|
||||||
|
-- source entry) still set state so scripted holds keep working.
|
||||||
function Input:step()
|
function Input:step()
|
||||||
self.pressed = {}
|
self.pressed = {}
|
||||||
for _, btn in ipairs(self.pressQueue) do
|
for _, btn in ipairs(self.pressQueue) do
|
||||||
self.pressed[btn] = true
|
self.pressed[btn] = true
|
||||||
self.state[btn] = true
|
local sources = self.sources[btn]
|
||||||
|
if sources == nil then
|
||||||
|
-- synthetic pressQueue inject (tests/drivers): no live source map
|
||||||
|
self.state[btn] = true
|
||||||
|
elseif next(sources) ~= nil then
|
||||||
|
self.state[btn] = true
|
||||||
|
end
|
||||||
|
-- sources == {}: real press fully released before this step — keep up
|
||||||
|
end
|
||||||
|
for btn, sources in pairs(self.sources) do
|
||||||
|
if next(sources) == nil then
|
||||||
|
self.sources[btn] = nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
self.pressQueue = {}
|
self.pressQueue = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
function Input:gamepadpressed(joystick, button)
|
function Input:gamepadpressed(joystick, button)
|
||||||
local btn = GAMEPAD_BINDINGS[button]
|
local btn = self.padBindings[button]
|
||||||
if btn then
|
if btn then
|
||||||
table.insert(self.pressQueue, btn)
|
press(self, btn, "pad:" .. button)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Input:gamepadreleased(joystick, button)
|
function Input:gamepadreleased(joystick, button)
|
||||||
local btn = GAMEPAD_BINDINGS[button]
|
local btn = self.padBindings[button]
|
||||||
if btn then
|
if btn then
|
||||||
self.state[btn] = false
|
release(self, btn, "pad:" .. button)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -103,10 +189,10 @@ function Input:gamepadaxis(joystick, axis, value)
|
|||||||
|
|
||||||
if newDir ~= self.stickDir then
|
if newDir ~= self.stickDir then
|
||||||
if self.stickDir then
|
if self.stickDir then
|
||||||
self.state[self.stickDir] = false
|
release(self, self.stickDir, "stick")
|
||||||
end
|
end
|
||||||
if newDir then
|
if newDir then
|
||||||
table.insert(self.pressQueue, newDir)
|
press(self, newDir, "stick")
|
||||||
end
|
end
|
||||||
self.stickDir = newDir
|
self.stickDir = newDir
|
||||||
end
|
end
|
||||||
|
|||||||
+19
-8
@@ -114,6 +114,7 @@ local SPECIAL = {
|
|||||||
oakRoute = "Music_Routes2",
|
oakRoute = "Music_Routes2",
|
||||||
bike = "Music_BikeRiding",
|
bike = "Music_BikeRiding",
|
||||||
surf = "Music_Surfing",
|
surf = "Music_Surfing",
|
||||||
|
evolution = "Music_SafariZone",
|
||||||
}
|
}
|
||||||
|
|
||||||
-- the label a scene role resolves to; call sites keep their own presence
|
-- the label a scene role resolves to; call sites keep their own presence
|
||||||
@@ -253,6 +254,7 @@ function Music.stop()
|
|||||||
require("src.core.ChipAudio").stopMusic()
|
require("src.core.ChipAudio").stopMusic()
|
||||||
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
||||||
state.chip = false
|
state.chip = false
|
||||||
|
state.pendingRestore = nil
|
||||||
if previous and Runtime.wants("music.stopped") then
|
if previous and Runtime.wants("music.stopped") then
|
||||||
Runtime.emit("music.stopped", { song = previous })
|
Runtime.emit("music.stopped", { song = previous })
|
||||||
end
|
end
|
||||||
@@ -346,18 +348,25 @@ end
|
|||||||
function Music.playOnce(data, song)
|
function Music.playOnce(data, song)
|
||||||
if not songDef(data, song) then return false end
|
if not songDef(data, song) then return false end
|
||||||
Music.play(data, song, false, { reason = "once" })
|
Music.play(data, song, false, { reason = "once" })
|
||||||
|
-- play() can no-op (hook silence, failed def); only arm restore when
|
||||||
|
-- the jingle actually became current
|
||||||
|
if state.current ~= song then return false end
|
||||||
state.pendingRestore = true
|
state.pendingRestore = true
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- is a playOnce jingle still sounding? (AnimateHealingMachine's
|
local function chipAwaitingFirstBuffer()
|
||||||
-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends)
|
return state.chip
|
||||||
|
and require("src.core.ChipAudio").awaitingFirstBuffer()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- is a playOnce jingle still in flight? (AnimateHealingMachine's
|
||||||
|
-- .waitLoop2 / Mom heal / captain rub hold until MUSIC_PKMN_HEALED ends.)
|
||||||
|
-- pendingRestore stays set from playOnce until Music.update restores the
|
||||||
|
-- map theme, covering the threaded chip "empty QueueableSource" window
|
||||||
|
-- where Source:isPlaying is briefly false before the first buffer lands.
|
||||||
function Music.oneShotPlaying()
|
function Music.oneShotPlaying()
|
||||||
if not state.pendingRestore then return false end
|
return state.pendingRestore == true
|
||||||
local src = state.source
|
|
||||||
if not src then return false end
|
|
||||||
local ok, playing = pcall(src.isPlaying, src)
|
|
||||||
return ok and playing or false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function Music.restoreMap(data)
|
function Music.restoreMap(data)
|
||||||
@@ -440,8 +449,10 @@ function Music.update(data)
|
|||||||
state.source = loopSrc
|
state.source = loopSrc
|
||||||
pcall(loopSrc.play, loopSrc)
|
pcall(loopSrc.play, loopSrc)
|
||||||
end
|
end
|
||||||
|
-- do not treat "threaded source still waiting on its first buffer" as
|
||||||
|
-- ended, or playOnce jingles get restored over before they can sound
|
||||||
if state.pendingRestore and sourceStopped(state.source)
|
if state.pendingRestore and sourceStopped(state.source)
|
||||||
and not state.loopSource then
|
and not state.loopSource and not chipAwaitingFirstBuffer() then
|
||||||
Music.restoreMap(data)
|
Music.restoreMap(data)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+264
-16
@@ -18,14 +18,157 @@ local Semver = require("src.mods.Semver")
|
|||||||
local Boxes = require("src.pokemon.Boxes")
|
local Boxes = require("src.pokemon.Boxes")
|
||||||
local Bag = require("src.inventory.Bag")
|
local Bag = require("src.inventory.Bag")
|
||||||
|
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
local SaveData = {}
|
local SaveData = {}
|
||||||
|
|
||||||
local FILENAME = "save.lua"
|
-- 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).
|
||||||
local OPTIONS_FILENAME = "options.lua"
|
local OPTIONS_FILENAME = "options.lua"
|
||||||
-- one rolling backup plus the staged-write witness; load promotes either
|
|
||||||
-- when the main file is missing or fails to parse
|
-- Main / backup / staged-witness names for a version (defaults to the active
|
||||||
local BACKUP_FILENAME = FILENAME .. ".bak"
|
-- one). The backup is a rolling copy and .tmp is the staged-write witness;
|
||||||
local TMP_FILENAME = FILENAME .. ".tmp"
|
-- load promotes either when the main file is missing or fails to parse.
|
||||||
|
local function saveNames(version)
|
||||||
|
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
|
||||||
|
return main, main .. ".bak", main .. ".tmp"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The main save filename for a version -- used by the title screen's
|
||||||
|
-- CONTINUE gate so it looks for the right game's save.
|
||||||
|
function SaveData.saveFilename(version)
|
||||||
|
local main = saveNames(version)
|
||||||
|
return main
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- portable mode
|
||||||
|
-- LÖVE's save directory is always the OS per-user path derived from the
|
||||||
|
-- identity (conf.lua), so it can't be relocated at runtime. Portable mode
|
||||||
|
-- instead drops a plain-Lua io.* filesystem next to the game whenever a
|
||||||
|
-- `portable.txt` marker sits beside the executable/source, letting a USB
|
||||||
|
-- copy carry its own save.lua/options.lua (and, through options.lua, the
|
||||||
|
-- mod enable-state) rather than leaving them on the host machine.
|
||||||
|
local PORTABLE_MARKER = "portable.txt"
|
||||||
|
local SEP = package.config:sub(1, 1)
|
||||||
|
|
||||||
|
local portableChecked = false
|
||||||
|
local portableBase = false -- resolved base dir when active, else false
|
||||||
|
local portableFsCache = nil
|
||||||
|
|
||||||
|
local function pathExists(path)
|
||||||
|
local f = io.open(path, "rb")
|
||||||
|
if not f then return false end
|
||||||
|
f:close()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- an io.* filesystem exposing the love.filesystem subset the save/options
|
||||||
|
-- round-trip needs (getInfo/read/write/remove), rooted at `dir`
|
||||||
|
local function makePortableFs(dir)
|
||||||
|
local function full(name) return dir .. SEP .. name end
|
||||||
|
return {
|
||||||
|
getInfo = function(name)
|
||||||
|
if not pathExists(full(name)) then return nil end
|
||||||
|
return { type = "file" }
|
||||||
|
end,
|
||||||
|
read = function(name)
|
||||||
|
local f = io.open(full(name), "rb")
|
||||||
|
if not f then return nil, "no file: " .. name end
|
||||||
|
local data = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
return data
|
||||||
|
end,
|
||||||
|
write = function(name, data)
|
||||||
|
local f, err = io.open(full(name), "wb")
|
||||||
|
if not f then return false, err end
|
||||||
|
f:write(data)
|
||||||
|
f:close()
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
remove = function(name)
|
||||||
|
os.remove(full(name))
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function detectPortable()
|
||||||
|
if portableChecked then return portableBase end
|
||||||
|
portableChecked = true
|
||||||
|
portableBase = false
|
||||||
|
if not (love and love.filesystem) then return false 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
|
||||||
|
-- portable mode never applies there.
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local src = love.filesystem.getSource and love.filesystem.getSource()
|
||||||
|
local sbd = love.filesystem.getSourceBaseDirectory
|
||||||
|
and love.filesystem.getSourceBaseDirectory()
|
||||||
|
-- A packaged macOS build nests the game inside gen1recomp.app/Contents/
|
||||||
|
-- Resources, so getSource()/getSourceBaseDirectory() point INSIDE the
|
||||||
|
-- bundle -- not where the player drops portable.txt (next to the .app).
|
||||||
|
-- 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
|
||||||
|
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 candidates = {}
|
||||||
|
local appDir = appContainer(src) or appContainer(sbd)
|
||||||
|
if appDir then candidates[#candidates + 1] = appDir end
|
||||||
|
if sbd then candidates[#candidates + 1] = sbd end
|
||||||
|
if src then candidates[#candidates + 1] = src end
|
||||||
|
for _, base in ipairs(candidates) do
|
||||||
|
if base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then
|
||||||
|
portableBase = base
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return portableBase
|
||||||
|
end
|
||||||
|
|
||||||
|
function SaveData.isPortable()
|
||||||
|
return detectPortable() ~= false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the raw portable-folder path (for callers building their own nested
|
||||||
|
-- paths, e.g. the ROM-derived asset cache), or nil when portable mode
|
||||||
|
-- is off
|
||||||
|
function SaveData.portableBaseDir()
|
||||||
|
return detectPortable() or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the io.* filesystem for the active portable folder, or nil when off
|
||||||
|
function SaveData.portableFs()
|
||||||
|
local base = detectPortable()
|
||||||
|
if not base then return nil end
|
||||||
|
if not portableFsCache then portableFsCache = makePortableFs(base) end
|
||||||
|
return portableFsCache
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Resolve the filesystem a persistent read/write should land on: an
|
||||||
|
-- explicitly injected non-love fs (headless tests, the mod loader's stub)
|
||||||
|
-- always wins; otherwise portable mode reroutes off the OS save directory.
|
||||||
|
local function persistFs(fs)
|
||||||
|
if fs and love and love.filesystem and fs ~= love.filesystem then
|
||||||
|
return fs
|
||||||
|
end
|
||||||
|
return SaveData.portableFs() or fs or (love and love.filesystem)
|
||||||
|
end
|
||||||
|
|
||||||
-- Port + original Options menu defaults. Missing keys on load are filled
|
-- Port + original Options menu defaults. Missing keys on load are filled
|
||||||
-- from this table so old options.lua files stay compatible.
|
-- from this table so old options.lua files stay compatible.
|
||||||
@@ -41,10 +184,16 @@ function SaveData.defaultOptions()
|
|||||||
musicVol = 7,
|
musicVol = 7,
|
||||||
sfxVol = 7,
|
sfxVol = 7,
|
||||||
musicFilter = 0,
|
musicFilter = 0,
|
||||||
|
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
|
||||||
|
speed = 1,
|
||||||
-- port display options (OptionsMenu / hotkeys 2/3/5)
|
-- port display options (OptionsMenu / hotkeys 2/3/5)
|
||||||
colors = "gbc",
|
colors = "gbc",
|
||||||
tilt = 0,
|
tilt = 0,
|
||||||
gbcfx = 0,
|
gbcfx = 0,
|
||||||
|
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
||||||
|
videoMode = "windowed",
|
||||||
|
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||||
|
fpsCap = 60,
|
||||||
-- Native mod enablement is an installation option, not save-slot data.
|
-- Native mod enablement is an installation option, not save-slot data.
|
||||||
-- Missing entries mean enabled so newly installed mods work by default.
|
-- Missing entries mean enabled so newly installed mods work by default.
|
||||||
mods = {},
|
mods = {},
|
||||||
@@ -90,7 +239,7 @@ end
|
|||||||
-- love.filesystem, so the mod loader's injected filesystem can carry the
|
-- love.filesystem, so the mod loader's injected filesystem can carry the
|
||||||
-- options round-trip headless (no love global).
|
-- options round-trip headless (no love global).
|
||||||
function SaveData.saveOptions(opts, fs)
|
function SaveData.saveOptions(opts, fs)
|
||||||
fs = fs or love.filesystem
|
fs = persistFs(fs)
|
||||||
opts = SaveData.mergeOptions(opts)
|
opts = SaveData.mergeOptions(opts)
|
||||||
-- modOptions is per-mod nested state: fold the on-disk sub-tree
|
-- modOptions is per-mod nested state: fold the on-disk sub-tree
|
||||||
-- underneath (newest value winning per key) so one caller's partial
|
-- underneath (newest value winning per key) so one caller's partial
|
||||||
@@ -119,7 +268,7 @@ function SaveData.saveOptions(opts, fs)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function SaveData.loadOptions(fs)
|
function SaveData.loadOptions(fs)
|
||||||
fs = fs or love.filesystem
|
fs = persistFs(fs)
|
||||||
local data, err = readTable(fs, OPTIONS_FILENAME)
|
local data, err = readTable(fs, OPTIONS_FILENAME)
|
||||||
if not data then
|
if not data then
|
||||||
if fs.getInfo(OPTIONS_FILENAME) then
|
if fs.getInfo(OPTIONS_FILENAME) then
|
||||||
@@ -307,7 +456,7 @@ end)
|
|||||||
-- afterwards either way
|
-- afterwards either way
|
||||||
SaveData.addCoreMigration(1, function(save)
|
SaveData.addCoreMigration(1, function(save)
|
||||||
if type(save.options) == "table"
|
if type(save.options) == "table"
|
||||||
and not love.filesystem.getInfo(OPTIONS_FILENAME) then
|
and not persistFs(nil).getInfo(OPTIONS_FILENAME) then
|
||||||
SaveData.saveOptions(save.options)
|
SaveData.saveOptions(save.options)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
@@ -318,6 +467,36 @@ SaveData.addCoreMigration(1, function(save)
|
|||||||
Boxes.ensure(save)
|
Boxes.ensure(save)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
-- game version (Red vs Blue) prep: saves written before Blue support
|
||||||
|
-- existed carry no `version` tag, and Red is the only game that ever
|
||||||
|
-- shipped, so default every untagged save to Red. from=2 so it catches
|
||||||
|
-- every pre-bump save (format 1 and 2) and is skipped once re-stamped to
|
||||||
|
-- the current format.
|
||||||
|
SaveData.addCoreMigration(2, function(save)
|
||||||
|
if not save.version then
|
||||||
|
save.version = "red"
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- #131 / follow-up to #50: Game Corner poster grunt used to stay on the
|
||||||
|
-- floor after defeat (defeatedTrainers only). The #50 script now hides
|
||||||
|
-- him via objectToggles, but saves that already beat him never got the
|
||||||
|
-- toggle -- he still blocks the hideout switch. from=3 so every pre-4
|
||||||
|
-- save reconciles once, then is skipped after re-stamp.
|
||||||
|
SaveData.addCoreMigration(3, function(save)
|
||||||
|
local defeated = save.defeatedTrainers
|
||||||
|
if not defeated or not defeated["GAME_CORNER_obj_11"] then return end
|
||||||
|
save.objectToggles = save.objectToggles or {}
|
||||||
|
local mapToggles = save.objectToggles.GAME_CORNER
|
||||||
|
if not mapToggles then
|
||||||
|
mapToggles = {}
|
||||||
|
save.objectToggles.GAME_CORNER = mapToggles
|
||||||
|
end
|
||||||
|
if mapToggles.GAMECORNER_ROCKET ~= false then
|
||||||
|
mapToggles.GAMECORNER_ROCKET = false
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
-- ------- write
|
-- ------- write
|
||||||
|
|
||||||
-- Game progress only; options are written separately via saveOptions.
|
-- Game progress only; options are written separately via saveOptions.
|
||||||
@@ -327,6 +506,9 @@ end)
|
|||||||
-- itself rolls the last good save into .bak and stages the new bytes as
|
-- itself rolls the last good save into .bak and stages the new bytes as
|
||||||
-- a .tmp witness before the swap, so a crash mid-write is recoverable.
|
-- a .tmp witness before the swap, so a crash mid-write is recoverable.
|
||||||
function SaveData.save(data, mods)
|
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
|
||||||
|
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
|
||||||
if data.options then
|
if data.options then
|
||||||
SaveData.saveOptions(data.options)
|
SaveData.saveOptions(data.options)
|
||||||
end
|
end
|
||||||
@@ -338,7 +520,7 @@ function SaveData.save(data, mods)
|
|||||||
if k ~= "options" then gameOnly[k] = v end
|
if k ~= "options" then gameOnly[k] = v end
|
||||||
end
|
end
|
||||||
local encoded = SaveSerializer.encode(gameOnly)
|
local encoded = SaveSerializer.encode(gameOnly)
|
||||||
local fs = love.filesystem
|
local fs = persistFs(nil)
|
||||||
if fs.getInfo(FILENAME) then
|
if fs.getInfo(FILENAME) then
|
||||||
local prev = fs.read(FILENAME)
|
local prev = fs.read(FILENAME)
|
||||||
if prev then fs.write(BACKUP_FILENAME, prev) end
|
if prev then fs.write(BACKUP_FILENAME, prev) end
|
||||||
@@ -366,8 +548,11 @@ end
|
|||||||
-- returns the parsed save plus "tmp"/"bak" when the main file was gone
|
-- returns the parsed save plus "tmp"/"bak" when the main file was gone
|
||||||
-- or corrupt and a staged/backup copy was promoted; Game surfaces the
|
-- or corrupt and a staged/backup copy was promoted; Game surfaces the
|
||||||
-- recovery on the load report
|
-- recovery on the load report
|
||||||
function SaveData.load()
|
function SaveData.load(version)
|
||||||
local fs = love.filesystem
|
-- version defaults to the active game (set at boot from the launcher);
|
||||||
|
-- an explicit version lets callers/tests load a specific game's save.
|
||||||
|
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(version)
|
||||||
|
local fs = persistFs(nil)
|
||||||
local data, err = readTable(fs, FILENAME)
|
local data, err = readTable(fs, FILENAME)
|
||||||
local recovered
|
local recovered
|
||||||
if not data then
|
if not data then
|
||||||
@@ -516,8 +701,8 @@ end
|
|||||||
|
|
||||||
local function scrubMaps(save, data, report)
|
local function scrubMaps(save, data, report)
|
||||||
local boot = (data.field and data.field.boot) or {}
|
local boot = (data.field and data.field.boot) or {}
|
||||||
local spawn = { map = boot.startMap or "PALLET_TOWN",
|
local spawn = { map = boot.startMap or "REDS_HOUSE_2F",
|
||||||
x = boot.startX or 5, y = boot.startY or 6 }
|
x = boot.startX or 3, y = boot.startY or 6 }
|
||||||
-- heal point first, so the player fallback below always lands somewhere
|
-- heal point first, so the player fallback below always lands somewhere
|
||||||
-- valid; boot's heal cell (threaded from field.boot) is the last resort
|
-- valid; boot's heal cell (threaded from field.boot) is the last resort
|
||||||
if save.lastHeal and not known(data.maps, save.lastHeal.map) then
|
if save.lastHeal and not known(data.maps, save.lastHeal.map) then
|
||||||
@@ -627,13 +812,66 @@ end
|
|||||||
-- boot is Data.field.boot, threaded in by Game: this module must not reach
|
-- boot is Data.field.boot, threaded in by Game: this module must not reach
|
||||||
-- into Data itself. Every read falls back to the Red literal it replaced,
|
-- into Data itself. Every read falls back to the Red literal it replaced,
|
||||||
-- so an absent or partial config still produces the vanilla new game.
|
-- so an absent or partial config still produces the vanilla new game.
|
||||||
|
-- Where blackouts and ESCAPE ROPE return to for a given boot config.
|
||||||
|
--
|
||||||
|
-- In vanilla this is NOT the spawn. wLastBlackoutMap is zero-filled at new
|
||||||
|
-- game and PALLET_TOWN is map 0, so the player starts in the bedroom
|
||||||
|
-- (special_warps.asm NewGameWarp) but blacks out to Pallet Town's fly_warp
|
||||||
|
-- cell (5, 6). A world that moves the spawn without naming a heal point
|
||||||
|
-- keeps the two together -- it may have no Pallet Town at all.
|
||||||
|
--
|
||||||
|
-- Shared with the Hall of Fame reset, which pokered writes as a literal
|
||||||
|
-- (HallOfFameResetEventsAndSaveScript: wLastBlackoutMap := PALLET_TOWN)
|
||||||
|
-- rather than deriving from the spawn.
|
||||||
|
function SaveData.defaultHeal(boot)
|
||||||
|
boot = type(boot) == "table" and boot or {}
|
||||||
|
local h = boot.lastHeal
|
||||||
|
if h then return { map = h.map, x = h.x, y = h.y } end
|
||||||
|
local map = boot.startMap or "REDS_HOUSE_2F"
|
||||||
|
if map == "REDS_HOUSE_2F" then return { map = "PALLET_TOWN", x = 5, y = 6 } end
|
||||||
|
return { map = map, x = boot.startX or 3, y = boot.startY or 6 }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Post-credits home (issue #103). pokered left the player in HALL_OF_FAME
|
||||||
|
-- after jp Init; this port places CONTINUE at the NewGameWarp bedroom and
|
||||||
|
-- retargets LAST_MAP exits (Red's house mats) at the heal-point town so
|
||||||
|
-- leaving the house does not dump the player back at Indigo Plateau.
|
||||||
|
-- Marks postGameHomeOk so a later intentional HoF save is not relocated.
|
||||||
|
function SaveData.applyPostGameHome(save, boot)
|
||||||
|
boot = type(boot) == "table" and boot or {}
|
||||||
|
local heal = SaveData.defaultHeal(boot)
|
||||||
|
save.lastHeal = { map = heal.map, x = heal.x, y = heal.y }
|
||||||
|
save.lastOutdoor = { id = heal.map, x = heal.x, y = heal.y }
|
||||||
|
save.player = save.player or {}
|
||||||
|
save.player.map = boot.startMap or "REDS_HOUSE_2F"
|
||||||
|
save.player.x = boot.startX or 3
|
||||||
|
save.player.y = boot.startY or 6
|
||||||
|
save.player.facing = boot.startFacing or "down"
|
||||||
|
save.postGameHomeOk = true
|
||||||
|
return heal
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits,
|
||||||
|
-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE.
|
||||||
|
function SaveData.needsPostGameRescue(save)
|
||||||
|
if not (save and save.player and save.player.map == "HALL_OF_FAME") then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if save.postGameHomeOk then return false end
|
||||||
|
local hof = save.hallOfFame
|
||||||
|
return type(hof) == "table" and #hof > 0
|
||||||
|
end
|
||||||
|
|
||||||
function SaveData.newGame(boot)
|
function SaveData.newGame(boot)
|
||||||
boot = type(boot) == "table" and boot or {}
|
boot = type(boot) == "table" and boot or {}
|
||||||
local map = boot.startMap or "PALLET_TOWN"
|
local map = boot.startMap or "REDS_HOUSE_2F"
|
||||||
local x, y = boot.startX or 5, boot.startY or 6
|
local x, y = boot.startX or 3, boot.startY or 6
|
||||||
local heal = boot.lastHeal or {}
|
local heal = SaveData.defaultHeal(boot)
|
||||||
local save = {
|
local save = {
|
||||||
meta = { format = Version.saveFormat, mods = {} },
|
meta = { format = Version.saveFormat, mods = {} },
|
||||||
|
-- which game this playthrough is (Red vs Blue). Only Red ships today;
|
||||||
|
-- boot carries the choice once Blue support lands.
|
||||||
|
version = boot.version or "red",
|
||||||
player = {
|
player = {
|
||||||
map = map,
|
map = map,
|
||||||
x = x,
|
x = x,
|
||||||
@@ -647,6 +885,10 @@ function SaveData.newGame(boot)
|
|||||||
},
|
},
|
||||||
flags = {},
|
flags = {},
|
||||||
inventory = {},
|
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.
|
||||||
|
pcItems = { POTION = 1 },
|
||||||
party = {},
|
party = {},
|
||||||
box = {},
|
box = {},
|
||||||
money = boot.startMoney or 3000,
|
money = boot.startMoney or 3000,
|
||||||
@@ -655,6 +897,12 @@ function SaveData.newGame(boot)
|
|||||||
-- where blackouts and ESCAPE ROPE return to (updated by nurses);
|
-- where blackouts and ESCAPE ROPE return to (updated by nurses);
|
||||||
-- copied, never aliased, so a save never writes back into Data
|
-- copied, never aliased, so a save never writes back into Data
|
||||||
lastHeal = { map = heal.map or map, x = heal.x or x, y = heal.y or y },
|
lastHeal = { map = heal.map or map, x = heal.x or x, y = heal.y or y },
|
||||||
|
-- Interiors inherit the SGB palette of the last outdoor map. wLastMap
|
||||||
|
-- is zero-filled at new game and PALLET_TOWN is map 0, so before the
|
||||||
|
-- player has ever been outdoors that palette is Pallet Town's -- which
|
||||||
|
-- matters because the vanilla spawn (REDS_HOUSE_2F) is itself indoors.
|
||||||
|
-- Without this the palette falls through to the ROUTE default.
|
||||||
|
lastOutdoor = { id = heal.map or map, x = heal.x or x, y = heal.y or y },
|
||||||
repelSteps = 0,
|
repelSteps = 0,
|
||||||
-- per-mod persistence (mod.save) lives under here, keyed by mod id
|
-- per-mod persistence (mod.save) lives under here, keyed by mod id
|
||||||
modData = {},
|
modData = {},
|
||||||
|
|||||||
@@ -75,6 +75,21 @@ function TouchInput:init()
|
|||||||
self.selectFired = false -- one SELECT per two-finger gesture cluster
|
self.selectFired = false -- one SELECT per two-finger gesture cluster
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- LÖVE has no touchcancelled event, so a touch interrupted by the OS (app
|
||||||
|
-- backgrounded mid-touch, a system gesture stealing the finger) never fires
|
||||||
|
-- touchreleased and would otherwise strand its direction held forever.
|
||||||
|
-- Called from Game alongside Input:reset() on focus/visibility loss.
|
||||||
|
function TouchInput:reset()
|
||||||
|
for _, touch in pairs(self.touches) do
|
||||||
|
releaseDir(self, touch)
|
||||||
|
end
|
||||||
|
self.touches = {}
|
||||||
|
self.pendingA = nil
|
||||||
|
self.armed = {}
|
||||||
|
self.autoRelease = {}
|
||||||
|
self.selectFired = false
|
||||||
|
end
|
||||||
|
|
||||||
local function pulse(self, key)
|
local function pulse(self, key)
|
||||||
Input:keypressed(key)
|
Input:keypressed(key)
|
||||||
self.armed[#self.armed + 1] = key
|
self.armed[#self.armed + 1] = key
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ local Version = {
|
|||||||
engine = "1.0.0", -- game/engine release (semver triple)
|
engine = "1.0.0", -- game/engine release (semver triple)
|
||||||
modApi = 2, -- mod API major (manifest `api`)
|
modApi = 2, -- mod API major (manifest `api`)
|
||||||
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
|
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
|
||||||
saveFormat = 2, -- save.meta.format
|
saveFormat = 4, -- save.meta.format
|
||||||
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
||||||
}
|
}
|
||||||
|
|
||||||
-- "Pokemon Red (Gen 1 Recompilation Project) v1.0.0"
|
-- "gen1recomp v1.0.0"
|
||||||
function Version.title(base)
|
function Version.title(base)
|
||||||
return (base or "Pokemon Red (Gen 1 Recompilation Project)")
|
return (base or "gen1recomp")
|
||||||
.. " v" .. Version.engine
|
.. " v" .. Version.engine
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- Windowed vs borderless (desktop) fullscreen.
|
||||||
|
--
|
||||||
|
-- Persisted as save.options.videoMode. Applied from OptionsMenu and on
|
||||||
|
-- boot via Game:applyOptions. No-ops on mobile and in headless stubs
|
||||||
|
-- that lack love.window.
|
||||||
|
|
||||||
|
local VideoMode = {}
|
||||||
|
|
||||||
|
VideoMode.MODES = { "windowed", "borderless" }
|
||||||
|
VideoMode.DEFAULT = "windowed"
|
||||||
|
|
||||||
|
local LABELS = {
|
||||||
|
windowed = "WINDOWED",
|
||||||
|
borderless = "BORDERLESS",
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoMode.normalize(mode)
|
||||||
|
if mode == "borderless" then return "borderless" end
|
||||||
|
return VideoMode.DEFAULT
|
||||||
|
end
|
||||||
|
|
||||||
|
function VideoMode.modeLabel(mode)
|
||||||
|
return LABELS[VideoMode.normalize(mode)] or LABELS[VideoMode.DEFAULT]
|
||||||
|
end
|
||||||
|
|
||||||
|
function VideoMode.isMobile()
|
||||||
|
if not love or not love.system or not love.system.getOS then return false end
|
||||||
|
local osName = love.system.getOS()
|
||||||
|
return osName == "Android" or osName == "iOS"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cycle windowed <-> borderless (dir ignored; two modes).
|
||||||
|
function VideoMode.cycle(mode, _dir)
|
||||||
|
return VideoMode.normalize(mode) == "borderless" and "windowed" or "borderless"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Push the mode into the live window. Safe when love.window is missing.
|
||||||
|
function VideoMode.apply(mode)
|
||||||
|
if VideoMode.isMobile() then return end
|
||||||
|
if not love or not love.window or not love.window.setFullscreen then return end
|
||||||
|
mode = VideoMode.normalize(mode)
|
||||||
|
if mode == "borderless" then
|
||||||
|
-- "desktop" = borderless fullscreen matching the display
|
||||||
|
love.window.setFullscreen(true, "desktop")
|
||||||
|
else
|
||||||
|
love.window.setFullscreen(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function VideoMode.applyOptions(opts)
|
||||||
|
VideoMode.apply(opts and opts.videoMode)
|
||||||
|
end
|
||||||
|
|
||||||
|
return VideoMode
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
-- ChipAudio synthesis worker (love.thread). Runs the Game Boy audio synth
|
||||||
|
-- (src/core/ChipSynth.lua) off the main thread so a map/battle song change
|
||||||
|
-- never stutters the render thread: filling the ~6s playback queue from
|
||||||
|
-- scratch is ~200ms of Lua synthesis, and this is where it now happens.
|
||||||
|
--
|
||||||
|
-- Protocol -- main thread pushes command tables onto the "chipaudio_cmd"
|
||||||
|
-- channel and drains produced buffers off "chipaudio_out":
|
||||||
|
-- cmd = "play" { gen, header, allowLoops, audio } start a song
|
||||||
|
-- cmd = "stop" halt production
|
||||||
|
-- cmd = "invalidate" drop the bank cache
|
||||||
|
-- cmd = "quit" end the thread
|
||||||
|
-- out buffers are tagged with the play's `gen` so the main thread can
|
||||||
|
-- discard anything left over from a superseded song:
|
||||||
|
-- { gen, sd = SoundData } one rendered buffer
|
||||||
|
-- { gen, done = true } the song ended (non-looping jingle finished)
|
||||||
|
-- { gen, error = msg } build/synth failed; main logs and gives up
|
||||||
|
|
||||||
|
require("love.thread")
|
||||||
|
require("love.timer")
|
||||||
|
require("love.sound")
|
||||||
|
require("love.filesystem")
|
||||||
|
|
||||||
|
-- Load the synth explicitly via love.filesystem (a fresh thread Lua state does
|
||||||
|
-- not necessarily carry the package searcher that resolves "src.core..."):
|
||||||
|
local ChipSynth = assert(love.filesystem.load("src/core/ChipSynth.lua"))()
|
||||||
|
|
||||||
|
local cmdCh = love.thread.getChannel("chipaudio_cmd")
|
||||||
|
local outCh = love.thread.getChannel("chipaudio_out")
|
||||||
|
|
||||||
|
local BUF = ChipSynth.MUSIC_BUFFER_SAMPLES
|
||||||
|
-- how many finished buffers may sit in the hand-off channel before the worker
|
||||||
|
-- pauses. The deep (~6s) playback depth lives in the main-thread Source; this
|
||||||
|
-- only bounds the worker's look-ahead (and its memory) between drains.
|
||||||
|
local LOOKAHEAD = 8
|
||||||
|
|
||||||
|
local gen = nil -- active song generation, or nil when stopped
|
||||||
|
local engine = nil -- the ChipSynth engine producing the current song
|
||||||
|
local finished = false -- the current song ran out (non-looping)
|
||||||
|
local data = nil -- { audio = <slim audio tables> } for ROM bank/wave reads
|
||||||
|
|
||||||
|
local function handle(cmd)
|
||||||
|
if cmd.cmd == "play" then
|
||||||
|
gen = cmd.gen
|
||||||
|
finished = false
|
||||||
|
engine = nil
|
||||||
|
outCh:clear() -- drop any buffers left from the previous song
|
||||||
|
data = { audio = cmd.audio }
|
||||||
|
local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
|
||||||
|
{ allowLoops = cmd.allowLoops })
|
||||||
|
if ok then
|
||||||
|
engine = eng
|
||||||
|
else
|
||||||
|
outCh:push({ gen = gen, error = tostring(eng) })
|
||||||
|
finished = true
|
||||||
|
end
|
||||||
|
elseif cmd.cmd == "stop" then
|
||||||
|
gen = nil
|
||||||
|
engine = nil
|
||||||
|
finished = false
|
||||||
|
outCh:clear()
|
||||||
|
elseif cmd.cmd == "invalidate" then
|
||||||
|
ChipSynth.invalidateBanks()
|
||||||
|
elseif cmd.cmd == "quit" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
while true do
|
||||||
|
-- drain every pending command first, so a stop/new-play is seen promptly
|
||||||
|
local quit = false
|
||||||
|
local cmd = cmdCh:pop()
|
||||||
|
while cmd do
|
||||||
|
if handle(cmd) then quit = true end
|
||||||
|
cmd = cmdCh:pop()
|
||||||
|
end
|
||||||
|
if quit then break end
|
||||||
|
|
||||||
|
if engine and not finished and gen and outCh:getCount() < LOOKAHEAD then
|
||||||
|
local activeGen = gen
|
||||||
|
local ok, sd = pcall(ChipSynth.soundData, engine, BUF, 2)
|
||||||
|
if not ok then
|
||||||
|
outCh:push({ gen = activeGen, error = tostring(sd) })
|
||||||
|
finished = true
|
||||||
|
else
|
||||||
|
outCh:push({ gen = activeGen, sd = sd })
|
||||||
|
if engine:finished() then
|
||||||
|
outCh:push({ gen = activeGen, done = true })
|
||||||
|
finished = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- nothing to do (idle, or the look-ahead is full): yield the core
|
||||||
|
love.timer.sleep(0.001)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
-- Routes ROM-derived cache I/O (data/generated, assets/generated and the
|
||||||
|
-- rom-cache.complete marker) to the right place.
|
||||||
|
--
|
||||||
|
-- Normally the cache lives in LÖVE's per-user OS save directory and is
|
||||||
|
-- written through love.filesystem. In portable mode it lives in the game
|
||||||
|
-- folder next to the executable instead (the folder holding portable.txt --
|
||||||
|
-- see SaveData), so nothing is left on the host machine. That folder is
|
||||||
|
-- written with raw io.* (love.filesystem can only write to the save dir) and
|
||||||
|
-- read back through love.filesystem, require and love.graphics.newImage --
|
||||||
|
-- which works because the folder is on the physfs read path:
|
||||||
|
--
|
||||||
|
-- * Source runs (`love <gamedir>`, what the Play-* launchers use): the
|
||||||
|
-- folder IS the physfs source, so it is already readable.
|
||||||
|
-- * Fused builds (the packaged .app/.exe): the folder sits next to the
|
||||||
|
-- executable and is NOT normally readable, so CacheFs mounts it onto the
|
||||||
|
-- read path via PhysFS. love.filesystem.mount refuses external folders,
|
||||||
|
-- but the underlying PHYSFS_mount (exported from love's framework) allows
|
||||||
|
-- them; we call it through LuaJIT's FFI.
|
||||||
|
--
|
||||||
|
-- Directories in the portable folder are created with a plain mkdir syscall
|
||||||
|
-- via FFI rather than os.execute, so importing never flashes a console window
|
||||||
|
-- on Windows (issue #74 -- the old per-file `os.execute("mkdir")` froze the
|
||||||
|
-- app behind a storm of one-frame cmd.exe windows).
|
||||||
|
--
|
||||||
|
-- Portable mode is desktop-only (Windows/Linux/macOS); on Android/iOS the
|
||||||
|
-- source is a read-only package with no game folder to write into, so
|
||||||
|
-- SaveData.isPortable() is false there and this module falls back to the
|
||||||
|
-- ordinary love.filesystem/save-directory behaviour.
|
||||||
|
|
||||||
|
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.
|
||||||
|
CacheFs.prefix = ""
|
||||||
|
|
||||||
|
local function withPrefix(rel)
|
||||||
|
local p = CacheFs.prefix
|
||||||
|
if p == nil or p == "" then return rel end
|
||||||
|
return p .. rel
|
||||||
|
end
|
||||||
|
|
||||||
|
-- lazily-resolved windowless mkdir: function(absolutePath) or false when
|
||||||
|
-- FFI is unavailable (the cache then stays on the save directory)
|
||||||
|
local mkdirFn = nil
|
||||||
|
|
||||||
|
local function resolveMkdir()
|
||||||
|
if mkdirFn ~= nil then return mkdirFn end
|
||||||
|
mkdirFn = false
|
||||||
|
local ok, ffi = pcall(require, "ffi")
|
||||||
|
if not ok then return mkdirFn end
|
||||||
|
if ffi.os == "Windows" then
|
||||||
|
-- kernel32 is reliably resolvable through ffi.C on Windows (the engine
|
||||||
|
-- already binds it in DiscordPresence); CreateDirectoryA returns
|
||||||
|
-- nonzero on success and 0 when the directory already exists -- both
|
||||||
|
-- fine, the result is ignored.
|
||||||
|
pcall(ffi.cdef,
|
||||||
|
"int CreateDirectoryA(const char *lpPathName, void *lpSecurityAttributes);")
|
||||||
|
local resolved = pcall(function() return ffi.C.CreateDirectoryA end)
|
||||||
|
if resolved then
|
||||||
|
mkdirFn = function(path) pcall(ffi.C.CreateDirectoryA, path, nil) end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
pcall(ffi.cdef, "int mkdir(const char *pathname, unsigned int mode);")
|
||||||
|
local resolved = pcall(function() return ffi.C.mkdir end)
|
||||||
|
if resolved then
|
||||||
|
mkdirFn = function(path) pcall(ffi.C.mkdir, path, 493) end -- 0755
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return mkdirFn
|
||||||
|
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.
|
||||||
|
--
|
||||||
|
-- PHYSFS_mount is exported by love's own binary. How ffi finds it differs
|
||||||
|
-- per platform: on macOS/Linux the symbol is in the default namespace, so
|
||||||
|
-- ffi.C resolves it; on Windows it lives in love.dll, which ffi.C does NOT
|
||||||
|
-- search, so love.dll is loaded explicitly with ffi.load("love"). Try the
|
||||||
|
-- default first, then love.
|
||||||
|
local physfsMountFn = nil
|
||||||
|
local function resolveMount()
|
||||||
|
if physfsMountFn ~= nil then return physfsMountFn end
|
||||||
|
physfsMountFn = false
|
||||||
|
local ok, ffi = pcall(require, "ffi")
|
||||||
|
if not ok then return physfsMountFn end
|
||||||
|
pcall(ffi.cdef,
|
||||||
|
"int PHYSFS_mount(const char *newDir, const char *mountPoint, int appendToPath);")
|
||||||
|
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_mount end)
|
||||||
|
if oks and fn then
|
||||||
|
physfsMountFn = function(d, append)
|
||||||
|
if append == nil then append = true end
|
||||||
|
local okr, ret = pcall(fn, d, "", append and 1 or 0)
|
||||||
|
return okr and ret ~= 0
|
||||||
|
end
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return physfsMountFn
|
||||||
|
end
|
||||||
|
|
||||||
|
-- append (default true): the game's own source wins a name clash, matching
|
||||||
|
-- how the portable cache root has always been mounted. Pass false to
|
||||||
|
-- prepend, so the mounted tree wins -- used to overlay the active version's
|
||||||
|
-- cache on top of the root (Red) copy and the source.
|
||||||
|
local function mountReadable(dir, append)
|
||||||
|
local fn = resolveMount()
|
||||||
|
if not fn then return false end
|
||||||
|
return fn(dir, append)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The portable game folder when the cache should live there, else nil.
|
||||||
|
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
|
||||||
|
-- desktop portable install (SaveData) and a working windowless mkdir.
|
||||||
|
local portableRoot = nil
|
||||||
|
local portableResolved = false
|
||||||
|
local function resolvePortableRoot()
|
||||||
|
if portableResolved then return portableRoot end
|
||||||
|
portableResolved = true
|
||||||
|
portableRoot = nil
|
||||||
|
if not resolveMkdir() then return nil end
|
||||||
|
local base = require("src.core.SaveData").portableBaseDir()
|
||||||
|
if not base then return nil end
|
||||||
|
if love.filesystem.getSource and base == love.filesystem.getSource() then
|
||||||
|
-- source run: the folder is already the physfs source
|
||||||
|
portableRoot = base
|
||||||
|
elseif mountReadable(base) then
|
||||||
|
-- fused build: base is next to the executable; mount it so io.* writes
|
||||||
|
-- there are visible to love.filesystem/require/newImage
|
||||||
|
portableRoot = base
|
||||||
|
end
|
||||||
|
return portableRoot
|
||||||
|
end
|
||||||
|
|
||||||
|
function CacheFs.root()
|
||||||
|
return resolvePortableRoot()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function realPath(root, rel)
|
||||||
|
return root .. SEP .. rel:gsub("/", SEP)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- create every parent directory of `rel` under `root` (best effort; an
|
||||||
|
-- already-existing directory is fine, a genuine failure surfaces when the
|
||||||
|
-- subsequent io.open write fails)
|
||||||
|
local function ensureParents(root, rel)
|
||||||
|
local mkdir = resolveMkdir()
|
||||||
|
if not mkdir then return end
|
||||||
|
local parts = {}
|
||||||
|
for part in rel:gmatch("[^/]+") do parts[#parts + 1] = part end
|
||||||
|
local cur = root
|
||||||
|
for i = 1, #parts - 1 do
|
||||||
|
cur = cur .. SEP .. parts[i]
|
||||||
|
mkdir(cur)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- write cache-relative `rel` (forward-slash path) with the given bytes;
|
||||||
|
-- returns ok, err like love.filesystem.write
|
||||||
|
function CacheFs.write(rel, data)
|
||||||
|
rel = withPrefix(rel)
|
||||||
|
local root = CacheFs.root()
|
||||||
|
if root then
|
||||||
|
ensureParents(root, rel)
|
||||||
|
local f, err = io.open(realPath(root, rel), "wb")
|
||||||
|
if not f then return false, err end
|
||||||
|
f:write(data)
|
||||||
|
f:close()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local parent = rel:match("^(.*)/[^/]+$")
|
||||||
|
if parent and not love.filesystem.createDirectory(parent) then
|
||||||
|
local info = love.filesystem.getInfo(parent)
|
||||||
|
local reason = info and ("a " .. info.type .. " already exists there")
|
||||||
|
or "unknown reason"
|
||||||
|
return false, "could not create " .. parent .. ": " .. reason
|
||||||
|
end
|
||||||
|
return love.filesystem.write(rel, data)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- read cache-relative `rel`; returns the bytes or nil
|
||||||
|
function CacheFs.read(rel)
|
||||||
|
rel = withPrefix(rel)
|
||||||
|
local root = CacheFs.root()
|
||||||
|
if root then
|
||||||
|
local f = io.open(realPath(root, rel), "rb")
|
||||||
|
if not f then return nil end
|
||||||
|
local data = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
return love.filesystem.read(rel)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- does cache-relative `rel` exist as a file?
|
||||||
|
function CacheFs.exists(rel)
|
||||||
|
rel = withPrefix(rel)
|
||||||
|
local root = CacheFs.root()
|
||||||
|
if root then
|
||||||
|
local f = io.open(realPath(root, rel), "rb")
|
||||||
|
if not f then return false end
|
||||||
|
f:close()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return love.filesystem.getInfo(rel, "file") ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- remove a single cache-relative file
|
||||||
|
function CacheFs.remove(rel)
|
||||||
|
rel = withPrefix(rel)
|
||||||
|
local root = CacheFs.root()
|
||||||
|
if root then
|
||||||
|
os.remove(realPath(root, rel))
|
||||||
|
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
|
||||||
|
-- RomImporter's own removeTree). The tree is enumerated through
|
||||||
|
-- love.filesystem (the game folder is mounted) and the real files deleted
|
||||||
|
-- with os.remove; empty directories are harmless and left in place.
|
||||||
|
function CacheFs.removeTree(rel)
|
||||||
|
rel = withPrefix(rel)
|
||||||
|
local root = CacheFs.root()
|
||||||
|
if not root then return end
|
||||||
|
local function walk(r)
|
||||||
|
local info = love.filesystem.getInfo(r)
|
||||||
|
if not info then return end
|
||||||
|
if info.type == "directory" then
|
||||||
|
for _, child in ipairs(love.filesystem.getDirectoryItems(r)) do
|
||||||
|
walk(r .. "/" .. child)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
os.remove(realPath(root, r))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
walk(rel)
|
||||||
|
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.
|
||||||
|
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"
|
||||||
|
-- The cache root is the portable game folder when active, else LÖVE's OS
|
||||||
|
-- save directory (where love.filesystem wrote blue/...).
|
||||||
|
local base = CacheFs.root()
|
||||||
|
if not base and love.filesystem.getSaveDirectory then
|
||||||
|
base = love.filesystem.getSaveDirectory()
|
||||||
|
end
|
||||||
|
if not base then return false end
|
||||||
|
if mountReadable(base .. SEP .. sub, false) then return true end
|
||||||
|
-- Fallback when FFI/PHYSFS_mount is unavailable: LÖVE can mount a folder
|
||||||
|
-- that lives in the save directory by name (prepended: appendToPath=false).
|
||||||
|
if love.filesystem.mount then
|
||||||
|
return love.filesystem.mount(sub, "", false)
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
return CacheFs
|
||||||
@@ -126,14 +126,14 @@ function ImageWriter.columnsToRows(raw, tilesWide, tilesHigh, bytesPerTile)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function ImageWriter.save(image, path)
|
function ImageWriter.save(image, path)
|
||||||
local parent = path:match("^(.*)/[^/]+$")
|
|
||||||
if parent then
|
|
||||||
local ok, err = love.filesystem.createDirectory(parent)
|
|
||||||
if not ok then error("could not create " .. parent .. ": " .. tostring(err)) end
|
|
||||||
end
|
|
||||||
local ok, fileData = pcall(image.encode, image, "png")
|
local ok, fileData = pcall(image.encode, image, "png")
|
||||||
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end
|
if not ok then error("could not encode " .. path .. ": " .. tostring(fileData)) end
|
||||||
local written, writeError = love.filesystem.write(path, fileData)
|
-- CacheFs routes this to the OS save directory (normal builds) or straight
|
||||||
|
-- into the game folder (portable installs), creating parent directories as
|
||||||
|
-- needed. io.* needs the bytes as a string; love.filesystem would also
|
||||||
|
-- take the FileData, but getString() keeps one code path.
|
||||||
|
local CacheFs = require("src.import.CacheFs")
|
||||||
|
local written, writeError = CacheFs.write(path, fileData:getString())
|
||||||
if not written then
|
if not written then
|
||||||
error("could not write " .. path .. ": " .. tostring(writeError))
|
error("could not write " .. path .. ": " .. tostring(writeError))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -87,12 +87,11 @@ function LuaWriter.encode(value)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function LuaWriter.write(path, value)
|
function LuaWriter.write(path, value)
|
||||||
local parent = path:match("^(.*)/[^/]+$")
|
-- CacheFs routes this to the OS save directory (normal builds) or straight
|
||||||
if parent then
|
-- into the game folder (portable installs); it also creates the parent
|
||||||
local ok, err = love.filesystem.createDirectory(parent)
|
-- directories. See src/import/CacheFs.lua.
|
||||||
if not ok then error("could not create " .. parent .. ": " .. tostring(err)) end
|
local CacheFs = require("src.import.CacheFs")
|
||||||
end
|
local ok, err = CacheFs.write(path, LuaWriter.encode(value))
|
||||||
local ok, err = love.filesystem.write(path, LuaWriter.encode(value))
|
|
||||||
if not ok then error("could not write " .. path .. ": " .. tostring(err)) end
|
if not ok then error("could not write " .. path .. ": " .. tostring(err)) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1253,14 +1253,24 @@ function RomExtractor:extractTrainers()
|
|||||||
"battle/trainers/" .. picture.imageBase .. ".png")
|
"battle/trainers/" .. picture.imageBase .. ".png")
|
||||||
written[picture.imageBase] = true
|
written[picture.imageBase] = true
|
||||||
end
|
end
|
||||||
|
local parties = self:trainerParties(
|
||||||
|
pointers.bank, partyStarts[index], partyEnds[index])
|
||||||
|
-- ChiefData is empty in the ROM (the Celadon Chief battle is unused/
|
||||||
|
-- cut content); tools/rom_manifest.json carries a hand-authored party
|
||||||
|
-- for the trainers this project reimplements, since no ROM data exists
|
||||||
|
-- to extract for them.
|
||||||
|
if #parties == 0 then
|
||||||
|
local override = self.manifest.trainerPartyOverrides
|
||||||
|
and self.manifest.trainerPartyOverrides[trainerId]
|
||||||
|
if override then parties = { copy(override) } end
|
||||||
|
end
|
||||||
out[trainerId] = {
|
out[trainerId] = {
|
||||||
id = trainerId, index = index, name = decodedNames[index],
|
id = trainerId, index = index, name = decodedNames[index],
|
||||||
source = "ROM:TrainerDataPointers",
|
source = "ROM:TrainerDataPointers",
|
||||||
pic = picture and picture.path or nil,
|
pic = picture and picture.path or nil,
|
||||||
baseMoney = math.floor(Rom.bcd(rawMoney) / 100),
|
baseMoney = math.floor(Rom.bcd(rawMoney) / 100),
|
||||||
aiMods = aiMods[index],
|
aiMods = aiMods[index],
|
||||||
parties = self:trainerParties(
|
parties = parties,
|
||||||
pointers.bank, partyStarts[index], partyEnds[index]),
|
|
||||||
}
|
}
|
||||||
self:tick("Trainers", index, #order)
|
self:tick("Trainers", index, #order)
|
||||||
end
|
end
|
||||||
@@ -1426,7 +1436,7 @@ end
|
|||||||
|
|
||||||
function RomExtractor:extractField()
|
function RomExtractor:extractField()
|
||||||
self:beginStage("Interface artwork")
|
self:beginStage("Interface artwork")
|
||||||
local done, total = 0, 48
|
local done, total = 0, 49
|
||||||
local function tick()
|
local function tick()
|
||||||
done = done + 1
|
done = done + 1
|
||||||
self:tick("Interface artwork", math.min(done, total), total)
|
self:tick("Interface artwork", math.min(done, total), total)
|
||||||
@@ -1576,6 +1586,34 @@ function RomExtractor:extractField()
|
|||||||
"fx/" .. spec[4], { transparent = true })
|
"fx/" .. spec[4], { transparent = true })
|
||||||
tick()
|
tick()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The cuttable tree's own sprite: InitCutAnimOAM (engine/overworld/cut.asm)
|
||||||
|
-- copies Overworld_GFX tiles $2d-$2e (top half) and $3d-$3e (bottom half)
|
||||||
|
-- into the OAM tiles the Cut animation slides apart, so this comes out of
|
||||||
|
-- the OVERWORLD tileset's own graphics blob rather than a named symbol.
|
||||||
|
do
|
||||||
|
local overworldIndex
|
||||||
|
for i, name in ipairs(self.manifest.constants.tilesetOrder) do
|
||||||
|
if name == "OVERWORLD" then overworldIndex = i break end
|
||||||
|
end
|
||||||
|
assert(overworldIndex, "OVERWORLD tileset not found in tilesetOrder")
|
||||||
|
local tilesetHeaders = self:symbol("Tilesets")
|
||||||
|
local rowAddress = tilesetHeaders.address + (overworldIndex - 1) * 12
|
||||||
|
local gfxBank = self.rom:byte(tilesetHeaders.bank, rowAddress)
|
||||||
|
local gfxPointer = self.rom:word(tilesetHeaders.bank, rowAddress + 3)
|
||||||
|
local cutTree = ImageWriter.blank(16, 16, 1, 1, 1, 0)
|
||||||
|
for _, spec in ipairs({
|
||||||
|
{ 0x2d, 0, 0 }, { 0x2e, 8, 0 }, { 0x3d, 0, 8 }, { 0x3e, 8, 8 },
|
||||||
|
}) do
|
||||||
|
local tileIndex, dx, dy = spec[1], spec[2], spec[3]
|
||||||
|
local tile = ImageWriter.decode2bpp(
|
||||||
|
self.rom:bytes(gfxBank, gfxPointer + tileIndex * 16, 16),
|
||||||
|
8, 8, true)
|
||||||
|
ImageWriter.blit(cutTree, tile, dx, dy)
|
||||||
|
end
|
||||||
|
self:save(cutTree, "fx/cut_tree.png"); tick()
|
||||||
|
end
|
||||||
|
|
||||||
self:raw2bpp("BattleTransitionTile", 8, 8,
|
self:raw2bpp("BattleTransitionTile", 8, 8,
|
||||||
"fx/battle_transition.png"); tick()
|
"fx/battle_transition.png"); tick()
|
||||||
self:raw2bpp("PokedexTileGraphics", 24, 48,
|
self:raw2bpp("PokedexTileGraphics", 24, 48,
|
||||||
@@ -1631,10 +1669,11 @@ function RomExtractor:extractAudio()
|
|||||||
chunks[index] = self.rom.data:sub(first, first + 0x3FFF)
|
chunks[index] = self.rom.data:sub(first, first + 0x3FFF)
|
||||||
self:tick("Sound programs", index, #bankOrder + 2)
|
self:tick("Sound programs", index, #bankOrder + 2)
|
||||||
end
|
end
|
||||||
local ok, writeError = love.filesystem.createDirectory(
|
-- CacheFs (not love.filesystem directly) so a portable install lands this
|
||||||
"assets/generated/audio")
|
-- in the game folder with the rest of the cache; it creates the parent
|
||||||
if ok == false then error("could not create audio cache: " .. tostring(writeError)) end
|
-- directory too.
|
||||||
ok, writeError = love.filesystem.write(
|
local CacheFs = require("src.import.CacheFs")
|
||||||
|
local ok, writeError = CacheFs.write(
|
||||||
"assets/generated/audio/programs.bin", table.concat(chunks))
|
"assets/generated/audio/programs.bin", table.concat(chunks))
|
||||||
if not ok then error("could not write audio programs: " .. tostring(writeError)) end
|
if not ok then error("could not write audio programs: " .. tostring(writeError)) end
|
||||||
|
|
||||||
|
|||||||
+858
-162
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,10 @@ local function isBadge(id)
|
|||||||
return id:find("BADGE", 1, true) ~= nil
|
return id:find("BADGE", 1, true) ~= nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- exported so item lists that share save.inventory (e.g. the PC deposit
|
||||||
|
-- menu) can exclude badges the same way the bag does
|
||||||
|
Bag.isBadge = isBadge
|
||||||
|
|
||||||
function Bag.slots(save)
|
function Bag.slots(save)
|
||||||
local n = 0
|
local n = 0
|
||||||
for id in pairs(save.inventory) do
|
for id in pairs(save.inventory) do
|
||||||
|
|||||||
+49
-27
@@ -36,18 +36,13 @@ local function makeRng(seed)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- battler builder shared by both sides -- NO badge boosts, so both
|
-- Battlers go through BattleState.makeBattler so pics get the same
|
||||||
-- machines compute identical stats
|
-- Assets.resolve + SGB/GBC palette + padBottom path as wild/trainer
|
||||||
|
-- battles. save=nil skips badge boosts so both machines keep identical
|
||||||
|
-- stats (Gen 1 cable battles famously kept badges; we still diverge).
|
||||||
local function mkBattler(data, mon, isPlayer)
|
local function mkBattler(data, mon, isPlayer)
|
||||||
local def = data.pokemon[mon.species]
|
local BattleState = require("src.battle.BattleState")
|
||||||
local ok, img = pcall(love.graphics.newImage,
|
return BattleState.makeBattler(data, mon, isPlayer, nil)
|
||||||
isPlayer and def.spriteBack or def.spriteFront)
|
|
||||||
return {
|
|
||||||
mon = mon, def = def, isPlayer = isPlayer, stages = {},
|
|
||||||
name = mon.nickname or def.name,
|
|
||||||
curStats = mon.stats, curTypes = def.types, curMoves = mon.moves,
|
|
||||||
sprite = ok and img or nil,
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- canonical (host-side-first) state hash, unchanged since v1: it stays on
|
-- canonical (host-side-first) state hash, unchanged since v1: it stays on
|
||||||
@@ -213,6 +208,8 @@ function LinkBattle.new(game, net, opts)
|
|||||||
self.player = mkBattler(game.data, myParty[1], true)
|
self.player = mkBattler(game.data, myParty[1], true)
|
||||||
self.enemy = mkBattler(game.data, theirParty[1], false)
|
self.enemy = mkBattler(game.data, theirParty[1], false)
|
||||||
self.enemyParty = theirParty
|
self.enemyParty = theirParty
|
||||||
|
self.playerParty = myParty -- intro ball row uses the clamped copies
|
||||||
|
self.opponentName = theirName
|
||||||
self.introText = ("%s wants\nto battle!"):format(theirName)
|
self.introText = ("%s wants\nto battle!"):format(theirName)
|
||||||
self.remoteHashes = {}
|
self.remoteHashes = {}
|
||||||
self.localHashes = {}
|
self.localHashes = {}
|
||||||
@@ -235,6 +232,43 @@ function LinkBattle.new(game, net, opts)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- player-side SendOutMon (poof + grow-in + cry); mirrors BattleState
|
||||||
|
local function sendOutPlayer(s, mon)
|
||||||
|
local previous = s.player
|
||||||
|
s.player = mkBattler(game.data, mon, true)
|
||||||
|
s:syncSides()
|
||||||
|
Runtime.emit("battle.battler_switched", {
|
||||||
|
battle = s, side = s.sides[1], battler = s.player, previous = previous,
|
||||||
|
})
|
||||||
|
s.sendingOut = true
|
||||||
|
s:sayNext(s:sendOutText(s.player.name))
|
||||||
|
s:animNext("POOF_ANIM", false)
|
||||||
|
s:actNext(function()
|
||||||
|
s.sendingOut = false
|
||||||
|
s:startGrowIn(s.player)
|
||||||
|
require("src.core.Sound").playCry(s.data, s.player.mon.species)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- enemy-side EnemySendOut (grow-in + cry; no poof)
|
||||||
|
local function sendOutEnemy(s, mon)
|
||||||
|
local previous = s.enemy
|
||||||
|
s.enemy = mkBattler(game.data, mon, false)
|
||||||
|
s:syncSides()
|
||||||
|
Runtime.emit("battle.battler_switched", {
|
||||||
|
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
||||||
|
})
|
||||||
|
s.enemySendingOut = true
|
||||||
|
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
||||||
|
s:actNext(function()
|
||||||
|
s.enemySendingOut = false
|
||||||
|
s:startGrowIn(s.enemy)
|
||||||
|
s:actNext(function()
|
||||||
|
require("src.core.Sound").playCry(s.data, s.enemy.mon.species)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
-- decode a remote action message against the enemy battler
|
-- decode a remote action message against the enemy battler
|
||||||
local function decodeTheirAction(s, msg)
|
local function decodeTheirAction(s, msg)
|
||||||
if msg.kind == "move" then
|
if msg.kind == "move" then
|
||||||
@@ -306,17 +340,11 @@ function LinkBattle.new(game, net, opts)
|
|||||||
-- switches happen before attacks (both may switch)
|
-- switches happen before attacks (both may switch)
|
||||||
if myMsg.kind == "switch" then
|
if myMsg.kind == "switch" then
|
||||||
local idx = myMsg.index
|
local idx = myMsg.index
|
||||||
s:act(function()
|
s:act(function() sendOutPlayer(s, myParty[idx]) end)
|
||||||
s.player = mkBattler(game.data, myParty[idx], true)
|
|
||||||
s:sayNext(("Go! %s!"):format(s.player.name))
|
|
||||||
end)
|
|
||||||
myAction = nil
|
myAction = nil
|
||||||
end
|
end
|
||||||
if theirSwitch then
|
if theirSwitch then
|
||||||
s:act(function()
|
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
|
||||||
s.enemy = mkBattler(game.data, theirParty[theirSwitch], false)
|
|
||||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
s:act(function()
|
s:act(function()
|
||||||
@@ -452,10 +480,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
self.playerMonFainted = function(s)
|
self.playerMonFainted = function(s)
|
||||||
for _, mon in ipairs(myParty) do
|
for _, mon in ipairs(myParty) do
|
||||||
if mon.hp > 0 then
|
if mon.hp > 0 then
|
||||||
s:act(function()
|
s:act(function() sendOutPlayer(s, mon) end)
|
||||||
s.player = mkBattler(game.data, mon, true)
|
|
||||||
s:sayNext(("Go! %s!"):format(s.player.name))
|
|
||||||
end)
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -468,10 +493,7 @@ function LinkBattle.new(game, net, opts)
|
|||||||
self.enemyMonFainted = function(s)
|
self.enemyMonFainted = function(s)
|
||||||
for _, mon in ipairs(theirParty) do
|
for _, mon in ipairs(theirParty) do
|
||||||
if mon.hp > 0 then
|
if mon.hp > 0 then
|
||||||
s:act(function()
|
s:act(function() sendOutEnemy(s, mon) end)
|
||||||
s.enemy = mkBattler(game.data, mon, false)
|
|
||||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
|
||||||
end)
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+3
-1
@@ -129,7 +129,9 @@ end
|
|||||||
-- tripwire on for tests that cannot set the environment.
|
-- tripwire on for tests that cannot set the environment.
|
||||||
function Loader.new(opts)
|
function Loader.new(opts)
|
||||||
local dev = opts and opts.dev
|
local dev = opts and opts.dev
|
||||||
if dev == nil then dev = os.getenv("POKEPORT_DEV") == "1" end
|
if dev == nil then
|
||||||
|
dev = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true
|
||||||
|
end
|
||||||
local self = setmetatable({
|
local self = setmetatable({
|
||||||
mods = {}, loaded = {}, errors = {}, initialized = false,
|
mods = {}, loaded = {}, errors = {}, initialized = false,
|
||||||
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ ManagerState.isOpaque = true
|
|||||||
-- Game:keypressed recognizes a directly-pushed instance
|
-- Game:keypressed recognizes a directly-pushed instance
|
||||||
ManagerState.screenId = "ManagerState"
|
ManagerState.screenId = "ManagerState"
|
||||||
|
|
||||||
|
-- Same as OptionsMenu: without this, title LOGO zones leak through when
|
||||||
|
-- MODS is opened from the title-screen options path.
|
||||||
|
function ManagerState:sgbPalettes(game)
|
||||||
|
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
|
||||||
|
end
|
||||||
|
|
||||||
-- the charmap has no * ~ + < > glyphs, so the status gutter uses what it
|
-- the charmap has no * ~ + < > glyphs, so the status gutter uses what it
|
||||||
-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy
|
-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy
|
||||||
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" }
|
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" }
|
||||||
|
|||||||
@@ -872,11 +872,15 @@ R.palettes = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
-- keyed by species id, unlike the vanilla byDex array: a species past the
|
-- keyed by species id, unlike the vanilla byDex array: a species past the
|
||||||
-- end of the dex gets an icon without punching a hole in the list
|
-- end of the dex gets an icon without punching a hole in the list. The party
|
||||||
|
-- menu (src/ui/PartyMenu.lua) reads this per-species entry before the vanilla
|
||||||
|
-- dex-indexed default. The value is a built-in icon NAME -- one of BALL, BIRD,
|
||||||
|
-- BUG, FAIRY, GRASS, HELIX, MON, QUADRUPED, SNAKE, WATER (uppercase) -- or a
|
||||||
|
-- { image = <bundled file path>, frames? } table of your own art.
|
||||||
R.icons = {
|
R.icons = {
|
||||||
semantics = "record", target = "icons.bySpecies",
|
semantics = "record", target = "icons.bySpecies",
|
||||||
value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } },
|
value = f.union{ f.str, f.rec{ image = f.path, frames = f.opt(f.int(1)) } },
|
||||||
example = 'mod.content.icons:register("MISSINGNO", { image = "glitch.png" })',
|
example = 'mod.content.icons:register("MODMON", "QUADRUPED") -- a built-in name, or { image = mod.assets:path("icon.png"), frames = 2 }',
|
||||||
}
|
}
|
||||||
|
|
||||||
-- glyph codes are not bytes: the vanilla pages sit at $60/$80 but a
|
-- glyph codes are not bytes: the vanilla pages sit at $60/$80 but a
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
-- "trade" | "manual" | <mod>, item = id?, ... }), wrapped by the
|
-- "trade" | "manual" | <mod>, item = id?, ... }), wrapped by the
|
||||||
-- evolution.check hook so a mod can cancel or force any evolution.
|
-- evolution.check hook so a mod can cancel or force any evolution.
|
||||||
|
|
||||||
|
local Music = require("src.core.Music")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
@@ -112,11 +113,15 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
|||||||
Screens.push(game, "EvolutionState", mon, newSpecies, onDone)
|
Screens.push(game, "EvolutionState", mon, newSpecies, onDone)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||||
Evolution.apply(game, mon, newSpecies, via)
|
Evolution.apply(game, mon, newSpecies, via)
|
||||||
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!")
|
local msg = ("What?\n%s is\nevolving!\fCongratulations!\nYour %s\nevolved into\n%s!")
|
||||||
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
|
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
|
||||||
game.stack:push(TextBox.new(game, msg, onDone))
|
game.stack:push(TextBox.new(game, msg, function()
|
||||||
|
Music.restoreMap(game.data)
|
||||||
|
if onDone then onDone() end
|
||||||
|
end))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Entry point for mods whose methods fire outside the vanilla moments
|
-- Entry point for mods whose methods fire outside the vanilla moments
|
||||||
|
|||||||
@@ -9,12 +9,18 @@ local Pokemon = {}
|
|||||||
-- keeping the most recent four (engine/pokemon/learn_move.asm behavior).
|
-- keeping the most recent four (engine/pokemon/learn_move.asm behavior).
|
||||||
function Pokemon.movesAtLevel(speciesDef, level)
|
function Pokemon.movesAtLevel(speciesDef, level)
|
||||||
local moves = {}
|
local moves = {}
|
||||||
|
local function add(id)
|
||||||
|
for _, existing in ipairs(moves) do
|
||||||
|
if existing == id then return end
|
||||||
|
end
|
||||||
|
table.insert(moves, id)
|
||||||
|
end
|
||||||
for _, m in ipairs(speciesDef.level1Moves) do
|
for _, m in ipairs(speciesDef.level1Moves) do
|
||||||
table.insert(moves, m)
|
add(m)
|
||||||
end
|
end
|
||||||
for _, entry in ipairs(speciesDef.learnset) do
|
for _, entry in ipairs(speciesDef.learnset) do
|
||||||
if entry.level <= level then
|
if entry.level <= level then
|
||||||
table.insert(moves, entry.move)
|
add(entry.move)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
while #moves > 4 do
|
while #moves > 4 do
|
||||||
|
|||||||
@@ -148,6 +148,17 @@ function Font.advanceOf(code)
|
|||||||
return page and page.advance or GLYPH
|
return page and page.advance or GLYPH
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Pixel width of a string (glyph advances, not UTF-8 byte length).
|
||||||
|
-- Multi-byte charmap entries like "¥" are one glyph; callers that
|
||||||
|
-- right-align with `#text * 8` mis-place them.
|
||||||
|
function Font.width(text)
|
||||||
|
local w = 0
|
||||||
|
for _, code in ipairs(Font.encode(text)) do
|
||||||
|
w = w + Font.advanceOf(code)
|
||||||
|
end
|
||||||
|
return w
|
||||||
|
end
|
||||||
|
|
||||||
-- Draw a plain single-line string at pixel (x, y). Returns the width
|
-- Draw a plain single-line string at pixel (x, y). Returns the width
|
||||||
-- drawn, which is #codes * 8 for every fixed-width page.
|
-- drawn, which is #codes * 8 for every fixed-width page.
|
||||||
function Font.draw(text, x, y)
|
function Font.draw(text, x, y)
|
||||||
|
|||||||
@@ -86,10 +86,11 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType)
|
|||||||
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
|
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
|
||||||
end
|
end
|
||||||
local tint
|
local tint
|
||||||
local pals = data.palettes
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
if pals then
|
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
||||||
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
|
local colors = PaletteFX.pal(data, name)
|
||||||
local c = pals.palettes[name][3] -- GB color 2 is the fill shade
|
if colors then
|
||||||
|
local c = colors[3] -- GB color 2 is the fill shade
|
||||||
-- the fill pixels are the 2/3-gray shade; divide so they land on
|
-- the fill pixels are the 2/3-gray shade; divide so they land on
|
||||||
-- the palette color exactly (the black outline stays black)
|
-- the palette color exactly (the black outline stays black)
|
||||||
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
|
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
|
||||||
|
|||||||
+356
-17
@@ -5,18 +5,28 @@
|
|||||||
-- then drawn once per zone through a shader that remaps the four DMG
|
-- then drawn once per zone through a shader that remaps the four DMG
|
||||||
-- shades to that zone's palette.
|
-- shades to that zone's palette.
|
||||||
--
|
--
|
||||||
-- Port display option: COLORS (GBC / OG / OG INV / GBC INV / CLASSIC)
|
-- Port display option: COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC)
|
||||||
-- transforms every zone's palette at send time via effectiveColors.
|
-- transforms every zone's palette at send time via effectiveColors.
|
||||||
|
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
|
||||||
|
-- (data/palettes_gbc.lua), including per-species mon colors.
|
||||||
|
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
local PaletteFX = {}
|
local PaletteFX = {}
|
||||||
|
|
||||||
local shader -- false = unavailable (headless / no shader support)
|
local shader -- false = unavailable (headless / no shader support)
|
||||||
|
local gbcPack -- false = missing; nil = not loaded yet
|
||||||
|
|
||||||
-- Cycle order matches OptionsMenu / hotkey 2
|
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
|
||||||
PaletteFX.MODES = { "gbc", "og", "og_inv", "gbc_inv", "classic" }
|
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, RED++ =
|
||||||
|
-- pokered-gbc per-tile), then the DMG-shade novelty modes.
|
||||||
|
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
|
||||||
|
-- `gbc`/`gbc_inv` keep their save-value ids for back-compat; their LABELS are
|
||||||
|
-- "SGB"/"SGB INV" because that is what the mode actually is (the old "GBC"
|
||||||
|
-- label was a misnomer -- it never was the real Game Boy Color palette).
|
||||||
PaletteFX.MODE_LABELS = {
|
PaletteFX.MODE_LABELS = {
|
||||||
gbc = "GBC", og = "OG", og_inv = "OG INV",
|
ogred = "OG RED", gbc = "SGB", redpp = "RED++", og = "OG",
|
||||||
gbc_inv = "GBC INV", classic = "CLASSIC",
|
og_inv = "OG INV", gbc_inv = "SGB INV", classic = "CLASSIC",
|
||||||
}
|
}
|
||||||
PaletteFX.mode = "gbc"
|
PaletteFX.mode = "gbc"
|
||||||
|
|
||||||
@@ -25,6 +35,40 @@ PaletteFX.CLASSIC = {
|
|||||||
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
|
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- OG RED: the Game Boy Color boot-ROM auto-palette for Pokemon Red. Pokemon
|
||||||
|
-- Red ships no CGB code (pokered's wOnCGB is hardwired 0), so on a Game Boy
|
||||||
|
-- Color the boot ROM colorizes it with ONE global palette pair -- a red
|
||||||
|
-- background and green objects -- applied to the whole game with no per-map
|
||||||
|
-- variation (that variety was the Super Game Boy's doing, i.e. SGB mode).
|
||||||
|
-- Lightest shade first, matching the SGB palette tables. Values verified
|
||||||
|
-- against hardware captures of Pallet Town and Oak's Lab.
|
||||||
|
PaletteFX.GBC_BG = {
|
||||||
|
{ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 },
|
||||||
|
}
|
||||||
|
PaletteFX.GBC_OBJ = {
|
||||||
|
{ 255, 255, 255 }, { 123, 255, 49 }, { 0, 132, 0 }, { 0, 0, 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- OG BLUE: Pokemon Blue's Game Boy Color boot-ROM auto-palette. Same
|
||||||
|
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the
|
||||||
|
-- boot ROM colorizes the background blue instead of red -- so "OG RED" for a
|
||||||
|
-- Blue playthrough is white -> light blue -> dark blue -> black, mirroring
|
||||||
|
-- GBC_BG channel-for-channel so the blue reads at the same brightness. The
|
||||||
|
-- OBJ (sprite) palette stays the same green, matching how Red and Blue share
|
||||||
|
-- the green-character look on a Game Boy Color.
|
||||||
|
PaletteFX.GBC_BG_BLUE = {
|
||||||
|
{ 255, 255, 255 }, { 132, 132, 255 }, { 58, 58, 148 }, { 0, 0, 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- The active game's OG boot-ROM background palette: blue for a 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.
|
||||||
|
function PaletteFX.ogBg()
|
||||||
|
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
|
||||||
|
return PaletteFX.GBC_BG
|
||||||
|
end
|
||||||
|
|
||||||
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
|
||||||
|
|
||||||
function PaletteFX.shader()
|
function PaletteFX.shader()
|
||||||
@@ -124,22 +168,289 @@ function PaletteFX.whole(colors)
|
|||||||
return PaletteFX.zone(colors, 0, 0, 19, 17)
|
return PaletteFX.zone(colors, 0, 0, 19, 17)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- named palette from data/generated/palettes.lua (nil on stale builds)
|
-- Red++ / pokered-gbc SuperPalette pack (committed; optional if absent).
|
||||||
|
function PaletteFX.gbcPack()
|
||||||
|
if gbcPack == nil then
|
||||||
|
local ok, pack = pcall(require, "data.palettes_gbc")
|
||||||
|
gbcPack = ok and pack or false
|
||||||
|
end
|
||||||
|
return gbcPack or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaletteFX.usesGbcPack(mode)
|
||||||
|
mode = mode or PaletteFX.mode
|
||||||
|
return mode == "redpp"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
|
||||||
|
-- (the OBP bake + post-zone redraw path). ONLY OG RED does: it wears the
|
||||||
|
-- GBC boot-ROM green object palette (PaletteFX.GBC_OBJ) so the player and
|
||||||
|
-- NPCs stay green over the red background, exactly like Pokemon Red on a
|
||||||
|
-- Game Boy Color. SGB mode deliberately does NOT: an SGB OBJ carries no
|
||||||
|
-- palette of its own, so the characters tint with the whole-map region
|
||||||
|
-- palette along with the terrain (the Super Game Boy never colored Pokemon
|
||||||
|
-- Red's sprites separately -- baking a per-sprite palette there was the
|
||||||
|
-- "reds coloring on the player/NPCs" bug). RED++ colors sprites through
|
||||||
|
-- the usesGbcPack() path in SpriteRenderer instead.
|
||||||
|
function PaletteFX.usesSpriteObp(mode)
|
||||||
|
return (mode or PaletteFX.mode) == "ogred"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- post-zone sprite redraw (GBC mode)
|
||||||
|
--
|
||||||
|
-- In GBC mode the world canvas still runs through the per-map zone
|
||||||
|
-- shade-remap shader, which would corrupt an OBP-baked sprite's true-color
|
||||||
|
-- pixels. 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
|
||||||
|
-- late. Entries carrying `colors` are re-colorized draws (the tall-grass
|
||||||
|
-- feet overdraw, which must keep hiding sprite feet) issued through the
|
||||||
|
-- color-0-keyed shade-remap shader. World pass only; cleared per frame.
|
||||||
|
local spriteRedraws = {}
|
||||||
|
|
||||||
|
function PaletteFX.clearSpriteRedraws()
|
||||||
|
for i = #spriteRedraws, 1, -1 do spriteRedraws[i] = nil end
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaletteFX.markSpriteRedraw(image, quad, x, y, sx, colors, keyed)
|
||||||
|
if currentPass ~= "world" then return end
|
||||||
|
spriteRedraws[#spriteRedraws + 1] =
|
||||||
|
{ image = image, quad = quad, x = x, y = y, sx = sx or 1,
|
||||||
|
colors = colors, keyed = keyed }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- whether a draw issued right now would land in the redraw list -- the
|
||||||
|
-- OBP bake is only correct when the replay can restore it after the zone
|
||||||
|
-- pass (tilt's upright pass colorizes per-billboard instead, so sprites
|
||||||
|
-- there keep the raw sheet)
|
||||||
|
function PaletteFX.spriteRedrawPassActive()
|
||||||
|
return currentPass == "world"
|
||||||
|
end
|
||||||
|
|
||||||
|
function PaletteFX.spriteRedraws()
|
||||||
|
return spriteRedraws
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Active named-palette table for COLORS: RED++ uses data/palettes_gbc.lua,
|
||||||
|
-- everything else uses the ROM-imported data.palettes.
|
||||||
|
function PaletteFX.pack(data)
|
||||||
|
if PaletteFX.usesGbcPack() then
|
||||||
|
local g = PaletteFX.gbcPack()
|
||||||
|
if g then return g end
|
||||||
|
end
|
||||||
|
return data and data.palettes or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- SuperPalettes that differ between Red and Blue (pokered data/sgb/
|
||||||
|
-- sgb_palettes.asm IF DEF(_RED)/_BLUE). data/palettes_gbc.lua is the
|
||||||
|
-- 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).
|
||||||
|
local BLUE_VERSIONED = {
|
||||||
|
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function romNamedPal(data, name)
|
||||||
|
local p = data and data.palettes
|
||||||
|
return p and p.palettes and p.palettes[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
|
||||||
|
-- (the hardware had a single BGP for the whole game), so terrain zones,
|
||||||
|
-- battle HP bars / text, and menu boxes all come out red -- everything a
|
||||||
|
-- background tile drew. Objects do not come through here (they bake
|
||||||
|
-- GBC_OBJ green), so this stays a BG-only hook.
|
||||||
function PaletteFX.pal(data, name)
|
function PaletteFX.pal(data, name)
|
||||||
local p = data.palettes
|
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
|
||||||
return p and p.palettes[name] or nil
|
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
|
||||||
|
local fromRom = romNamedPal(data, name)
|
||||||
|
if fromRom then return fromRom end
|
||||||
|
end
|
||||||
|
local p = PaletteFX.pack(data)
|
||||||
|
local c = p and p.palettes[name]
|
||||||
|
if c then return c end
|
||||||
|
if PaletteFX.usesGbcPack() then
|
||||||
|
return romNamedPal(data, name)
|
||||||
|
end
|
||||||
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the species' palette (data/pokemon/palettes.asm), MEWMON for unknowns.
|
-- the species' palette (data/pokemon/palettes.asm), MEWMON for unknowns.
|
||||||
-- transformed forces PAL_GRAYMON (Ditto's palette) regardless of species
|
-- transformed forces PAL_GRAYMON (Ditto's palette) regardless of species
|
||||||
-- (engine/gfx/palettes.asm DeterminePaletteID: bit TRANSFORMED, a; a
|
-- (engine/gfx/palettes.asm DeterminePaletteID: bit TRANSFORMED, a; a
|
||||||
-- Transformed mon's pic is tinted gray, not the copied species' own
|
-- Transformed mon's pic is tinted gray, not the copied species' own
|
||||||
-- SGB color).
|
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
|
||||||
function PaletteFX.monPal(data, species, transformed)
|
function PaletteFX.monPal(data, species, transformed)
|
||||||
local p = data.palettes
|
-- OG RED: a battle mon pic is a BG tile on the Game Boy Color (drawn into
|
||||||
|
-- the tilemap, colored by BGP), so it wears the global red BG palette, not
|
||||||
|
-- a per-species one -- matching the hardware capture where both mons are
|
||||||
|
-- red/pink on the white field.
|
||||||
|
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
|
||||||
|
local p = PaletteFX.pack(data)
|
||||||
if not p then return nil end
|
if not p then return nil end
|
||||||
if transformed then return p.palettes.GRAYMON end
|
if transformed then
|
||||||
return p.palettes[p.pokemon[species] or "MEWMON"]
|
return p.palettes.GRAYMON
|
||||||
|
or (data and data.palettes and data.palettes.palettes.GRAYMON)
|
||||||
|
end
|
||||||
|
local name = p.pokemon[species] or "MEWMON"
|
||||||
|
local c = p.palettes[name]
|
||||||
|
if c then return c end
|
||||||
|
if PaletteFX.usesGbcPack() and data and data.palettes then
|
||||||
|
name = data.palettes.pokemon[species] or "MEWMON"
|
||||||
|
return data.palettes.palettes[name]
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- palette name a species currently resolves to (for image-cache keys)
|
||||||
|
function PaletteFX.monPalName(data, species, transformed)
|
||||||
|
if transformed then return "GRAYMON" end
|
||||||
|
local p = PaletteFX.pack(data)
|
||||||
|
if p and p.pokemon[species] then return p.pokemon[species] end
|
||||||
|
if data and data.palettes and data.palettes.pokemon[species] then
|
||||||
|
return data.palettes.pokemon[species]
|
||||||
|
end
|
||||||
|
return "MEWMON"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- true GBC overworld coloring (color/loadpalettes.asm,
|
||||||
|
-- color/data/*, color/sprites.asm ColorOverworldSprite) -------------------
|
||||||
|
--
|
||||||
|
-- RED++ pairs its named-palette battle/mon colors above with pokered-gbc's
|
||||||
|
-- real per-tile system: LoadTilesetPalette assigns one of 8 four-color BG
|
||||||
|
-- palettes to every tile GRAPHIC in a tileset (by tile id, not by map
|
||||||
|
-- position), and LoadTownPalette swaps just the ROOF slot (index 6) per
|
||||||
|
-- town/route. `data/palettes_gbc.lua`'s `world` table holds the extracted
|
||||||
|
-- data (tools/extract/palettes.py extract_gbc_world); these queries are
|
||||||
|
-- mode-independent (only check the pack exists) so TileRenderer can
|
||||||
|
-- precompute geometry once regardless of the active COLORS mode -- callers
|
||||||
|
-- that resolve to actual on-screen COLOR should gate on usesGbcPack()
|
||||||
|
-- themselves, the same way they already gate other RED++-only behavior.
|
||||||
|
--
|
||||||
|
-- LoadTilesetPalette's 3 hardcoded single-tile fixes (Celadon Mart) and
|
||||||
|
-- LoadTownPalette's Route 6/Saffron y<2 roof split are control flow, not
|
||||||
|
-- data, so they are not in the extracted pack -- they live here instead.
|
||||||
|
local TILE_GROUP_EXCEPTIONS = {
|
||||||
|
-- tile ids $4b-$4f -> BLUE (outside sky, seen through the mart's roof)
|
||||||
|
CELADON_MART_ROOF = { tiles = { [0x4b] = true, [0x4c] = true, [0x4d] = true,
|
||||||
|
[0x4e] = true, [0x4f] = true }, group = 3 },
|
||||||
|
-- tile $37 -> BROWN (counter miscoloration fix)
|
||||||
|
CELADON_MART_3F = { tiles = { [0x37] = true }, group = 5 },
|
||||||
|
-- tiles $07/$08/$17/$18 -> YELLOW (bench, blue by default)
|
||||||
|
CELADON_MART_1F = { tiles = { [0x07] = true, [0x08] = true,
|
||||||
|
[0x17] = true, [0x18] = true }, group = 4 },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- keyed by tileset id (applies on every map that uses it), consulted after
|
||||||
|
-- the per-map table above
|
||||||
|
local TILESET_GROUP_EXCEPTIONS = {
|
||||||
|
-- tile $22 (the hollow-square grave marker) -> GRAY: the extracted pack
|
||||||
|
-- files it under the bright blue family, which makes a purely
|
||||||
|
-- decorative floor marker read as an interactive pad
|
||||||
|
CEMETERY = { tiles = { [0x22] = true }, group = 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- pokered-gbc's lobby.bst repoints the Celadon roof table's flat top
|
||||||
|
-- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the
|
||||||
|
-- tabletop and the checkerboard floor -- both raw tile $37 -- can take
|
||||||
|
-- different palettes; the vanilla-derived blockset shares the one tile
|
||||||
|
-- id, so the RED++ atlas path re-creates the duplicate: the alias slot
|
||||||
|
-- is baked as a copy of `tile` in `group`'s colors, and the listed
|
||||||
|
-- 0-based block cells draw the alias instead of the shared tile.
|
||||||
|
PaletteFX.TILE_ALIASES = {
|
||||||
|
CELADON_MART_ROOF = {
|
||||||
|
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
|
||||||
|
tile = 0x37, alias = 0x5a, group = 5 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
local ROOF_GROUP = 6
|
||||||
|
local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 }
|
||||||
|
|
||||||
|
-- whether the extracted pack has real per-tile GBC data for this tileset
|
||||||
|
-- (false for a mod tileset with no pokered-gbc counterpart, or when the
|
||||||
|
-- pack failed to load at all)
|
||||||
|
function PaletteFX.hasWorldTileset(tileset)
|
||||||
|
local pack = PaletteFX.gbcPack()
|
||||||
|
local w = pack and pack.world
|
||||||
|
return (w and w.tileGroups[tileset]) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset,
|
||||||
|
-- with the current map's tile-id exceptions (if any) applied first
|
||||||
|
function PaletteFX.worldGroupAt(tileset, mapId, tileId)
|
||||||
|
local pack = PaletteFX.gbcPack()
|
||||||
|
local w = pack and pack.world
|
||||||
|
local groups = w and w.tileGroups[tileset]
|
||||||
|
if not groups then return nil end
|
||||||
|
local exc = TILE_GROUP_EXCEPTIONS[mapId]
|
||||||
|
if exc and exc.tiles[tileId] then return exc.group end
|
||||||
|
exc = TILESET_GROUP_EXCEPTIONS[tileset]
|
||||||
|
if exc and exc.tiles[tileId] then return exc.group end
|
||||||
|
return groups[tileId] or 7 -- TEXT: tile ids past the tileset's 96 (menus)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- this tileset's resolved 8-entry {r,g,b}x4 palette array, with the ROOF
|
||||||
|
-- slot swapped to the current town/route (Route 6's north end uses
|
||||||
|
-- Saffron's roof colors while the player stands in its top 2 cell rows,
|
||||||
|
-- like pokered's wYCoord check -- data is Game.data, for the map lookup)
|
||||||
|
function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
||||||
|
local pack = PaletteFX.gbcPack()
|
||||||
|
local w = pack and pack.world
|
||||||
|
local base = w and w.groupColors[tileset]
|
||||||
|
if not base then return nil end
|
||||||
|
if not w.roofGroup[tileset] then return base end
|
||||||
|
local roofMapId = mapId
|
||||||
|
if mapId == ROUTE_6_SAFFRON.mapId and playerCellY
|
||||||
|
and playerCellY < ROUTE_6_SAFFRON.cellYBelow then
|
||||||
|
roofMapId = ROUTE_6_SAFFRON.useMapId
|
||||||
|
end
|
||||||
|
local roofMap = data and data.maps and data.maps[roofMapId]
|
||||||
|
local roof = roofMap and w.roofByMapIndex[roofMap.index]
|
||||||
|
if not roof then return base end
|
||||||
|
local out = {}
|
||||||
|
for i = 1, 8 do out[i] = base[i] end
|
||||||
|
-- LoadTownPalette only overwrites W2_BgPaletteData + $32, i.e. colors 1
|
||||||
|
-- and 2 (0-indexed) of the 4-color ROOF slot -- color 0 (background,
|
||||||
|
-- typically the sky-through-gaps white) and color 3 (outline black) keep
|
||||||
|
-- the tileset's own OUTDOOR_ROOF/INDOOR_ROOF base, only the roof
|
||||||
|
-- material's 2 middle shades are town-specific
|
||||||
|
local base4 = base[ROOF_GROUP + 1]
|
||||||
|
out[ROOF_GROUP + 1] = { base4[1], roof[1], roof[2], base4[4] }
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- an overworld sprite's resolved 4-color OBJ palette (ColorOverworldSprite),
|
||||||
|
-- or nil when unassigned/unavailable, plus the resolved group index (for
|
||||||
|
-- callers that want a stable cache key without hashing the colors table).
|
||||||
|
-- spriteDef carries the ROM picture-id crosswalk in its `source` field
|
||||||
|
-- ("ROM:SpriteSheetPointerTable[N]"); seed (any stable per-instance value,
|
||||||
|
-- e.g. an NPC's `id`) resolves the "random" sentinel -- a deliberate
|
||||||
|
-- approximation of ColorOverworldSprite's per-OAM-slot pseudo-random pick
|
||||||
|
-- (`swap a; and 3` on the sprite's OAM offset, which has no equivalent
|
||||||
|
-- here): a stable hash instead, so the same NPC instance always shows the
|
||||||
|
-- same one of the 4 SPR_PAL_* colors.
|
||||||
|
function PaletteFX.spriteObp(spriteDef, seed)
|
||||||
|
local pack = PaletteFX.gbcPack()
|
||||||
|
local w = pack and pack.world
|
||||||
|
local src = spriteDef and spriteDef.source
|
||||||
|
if not (w and src) then return nil end
|
||||||
|
local idx = tonumber(src:match("%[(%d+)%]"))
|
||||||
|
-- RedBikeSprite loads outside SpriteSheetPointerTable
|
||||||
|
-- (LoadBikePlayerSpriteGraphics), so its source carries no bracketed
|
||||||
|
-- index; it wears the player's own palette, same as SPRITE_RED
|
||||||
|
if not idx and src:find("RedBikeSprite", 1, true) then idx = 0 end
|
||||||
|
local group = idx and w.spriteAssignment[idx]
|
||||||
|
if group == nil then return nil end
|
||||||
|
if group == "random" then
|
||||||
|
local h = 0
|
||||||
|
seed = tostring(seed or "")
|
||||||
|
for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end
|
||||||
|
group = h % 4
|
||||||
|
end
|
||||||
|
return w.spritePalettes[group], group
|
||||||
end
|
end
|
||||||
|
|
||||||
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
|
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
|
||||||
@@ -173,13 +484,35 @@ function PaletteFX.permute(colors, map)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function PaletteFX.setMode(mode)
|
function PaletteFX.setMode(mode)
|
||||||
|
local prev = PaletteFX.mode
|
||||||
|
local ok = false
|
||||||
for _, m in ipairs(PaletteFX.MODES) do
|
for _, m in ipairs(PaletteFX.MODES) do
|
||||||
if m == mode then
|
if m == mode then
|
||||||
PaletteFX.mode = mode
|
PaletteFX.mode = mode
|
||||||
return
|
ok = true
|
||||||
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
PaletteFX.mode = "gbc"
|
if not ok then PaletteFX.mode = "gbc" end
|
||||||
|
-- battle pics and overworld sprites bake the active pack into ImageData;
|
||||||
|
-- drop those caches when the pack (or any COLORS mode) changes so the
|
||||||
|
-- next draw re-tints
|
||||||
|
if prev ~= PaletteFX.mode then
|
||||||
|
pcall(function() require("src.battle.BattleState").invalidate() end)
|
||||||
|
pcall(function() require("src.render.SpriteRenderer").invalidate() end)
|
||||||
|
-- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once
|
||||||
|
-- per loaded map, so a mode toggle needs every cached Map/TileRenderer
|
||||||
|
-- dropped and the currently-visible one rebuilt in place -- otherwise
|
||||||
|
-- the on-screen map keeps its stale (wrong-mode) atlas until the next
|
||||||
|
-- map transition happens to reload it.
|
||||||
|
pcall(function()
|
||||||
|
require("src.world.MapLoader").invalidateAll()
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then
|
||||||
|
Game.overworld:reloadMap(Game.overworld.map.id, "colors")
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function PaletteFX.cycleMode()
|
function PaletteFX.cycleMode()
|
||||||
@@ -188,7 +521,7 @@ function PaletteFX.cycleMode()
|
|||||||
for i, m in ipairs(PaletteFX.MODES) do
|
for i, m in ipairs(PaletteFX.MODES) do
|
||||||
if m == cur then idx = i; break end
|
if m == cur then idx = i; break end
|
||||||
end
|
end
|
||||||
PaletteFX.mode = PaletteFX.MODES[idx % #PaletteFX.MODES + 1]
|
PaletteFX.setMode(PaletteFX.MODES[idx % #PaletteFX.MODES + 1])
|
||||||
return PaletteFX.mode
|
return PaletteFX.mode
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -197,12 +530,16 @@ function PaletteFX.applyOptions(opts)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function PaletteFX.modeLabel(mode)
|
function PaletteFX.modeLabel(mode)
|
||||||
return PaletteFX.MODE_LABELS[mode or PaletteFX.mode] or "GBC"
|
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".
|
||||||
|
if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end
|
||||||
|
return PaletteFX.MODE_LABELS[mode] or "GBC"
|
||||||
end
|
end
|
||||||
|
|
||||||
-- When a state exposes no SGB zones but COLORS needs a forced palette
|
-- When a state exposes no SGB zones but COLORS needs a forced palette
|
||||||
-- (OG / OG INV / CLASSIC), invent a whole-screen zone so the shade-remap
|
-- (OG / OG INV / CLASSIC), invent a whole-screen zone so the shade-remap
|
||||||
-- shader still runs. GBC / GBC INV leave nil alone (raw DMG canvas).
|
-- shader still runs. GBC / RED++ / GBC INV leave nil alone (raw DMG canvas).
|
||||||
function PaletteFX.ensureZones(zones)
|
function PaletteFX.ensureZones(zones)
|
||||||
if zones and zones[1] then return zones end
|
if zones and zones[1] then return zones end
|
||||||
local mode = PaletteFX.mode or "gbc"
|
local mode = PaletteFX.mode or "gbc"
|
||||||
@@ -213,6 +550,8 @@ function PaletteFX.ensureZones(zones)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Transform a 4-color palette for the active COLORS display mode.
|
-- Transform a 4-color palette for the active COLORS display mode.
|
||||||
|
-- GBC and RED++ pass the zone colors through (RED++ already swapped the
|
||||||
|
-- pack in pal/monPal); OG* / CLASSIC replace; GBC INV permutes shades.
|
||||||
function PaletteFX.effectiveColors(c)
|
function PaletteFX.effectiveColors(c)
|
||||||
if not c then return nil end
|
if not c then return nil end
|
||||||
local mode = PaletteFX.mode or "gbc"
|
local mode = PaletteFX.mode or "gbc"
|
||||||
|
|||||||
+100
-16
@@ -23,6 +23,29 @@ Renderer.HEIGHT = 144
|
|||||||
-- endFrame composites the padded canvas back with a matching offset.
|
-- endFrame composites the padded canvas back with a matching offset.
|
||||||
Renderer.UPRIGHT_MARGIN = 160
|
Renderer.UPRIGHT_MARGIN = 160
|
||||||
|
|
||||||
|
-- LOVE units + framebuffer pixels + the live unit→pixel ratio.
|
||||||
|
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often
|
||||||
|
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB
|
||||||
|
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven /
|
||||||
|
-- non-square "pixels", and movement judder (issue #87). Always derive the
|
||||||
|
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
|
||||||
|
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
|
||||||
|
local function displayMetrics()
|
||||||
|
local ww, wh = love.graphics.getDimensions()
|
||||||
|
local pw, ph = ww, wh
|
||||||
|
if love.graphics.getPixelDimensions then
|
||||||
|
pw, ph = love.graphics.getPixelDimensions()
|
||||||
|
end
|
||||||
|
local dpi = 1
|
||||||
|
if ww > 0 and pw > 0 then
|
||||||
|
dpi = pw / ww
|
||||||
|
elseif love.graphics.getDPIScale then
|
||||||
|
dpi = love.graphics.getDPIScale()
|
||||||
|
end
|
||||||
|
if not dpi or dpi < 1e-6 then dpi = 1 end
|
||||||
|
return ww, wh, pw, ph, dpi
|
||||||
|
end
|
||||||
|
|
||||||
function Renderer:init()
|
function Renderer:init()
|
||||||
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
|
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
|
||||||
self.canvas:setFilter("nearest", "nearest")
|
self.canvas:setFilter("nearest", "nearest")
|
||||||
@@ -36,10 +59,12 @@ function Renderer:init()
|
|||||||
self.uprightActive = false
|
self.uprightActive = false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- integer scale that fits the GB UI viewport in the window
|
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
|
||||||
|
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
|
||||||
|
-- units via / dpi when drawing.
|
||||||
function Renderer:fitScale()
|
function Renderer:fitScale()
|
||||||
local ww, wh = love.graphics.getDimensions()
|
local _, _, pw, ph = displayMetrics()
|
||||||
return math.max(1, math.floor(math.min(ww / self.WIDTH, wh / self.HEIGHT)))
|
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- world-pass canvas size in world pixels: enough to fill the window at s'.
|
-- world-pass canvas size in world pixels: enough to fill the window at s'.
|
||||||
@@ -48,9 +73,13 @@ end
|
|||||||
-- background peeking at the receded top/bottom corners; flat mode returns
|
-- background peeking at the receded top/bottom corners; flat mode returns
|
||||||
-- exactly today's size (growth factor is 1 when tilt is inactive).
|
-- exactly today's size (growth factor is 1 when tilt is inactive).
|
||||||
function Renderer:worldViewSize()
|
function Renderer:worldViewSize()
|
||||||
local ww, wh = love.graphics.getDimensions()
|
local ww, wh, _, _, dpi = displayMetrics()
|
||||||
local s = Zoom.scale(self:fitScale())
|
local s = Zoom.scale(self:fitScale()) / dpi
|
||||||
local vw, vh = Zoom.fillViewSize(s, ww, wh)
|
local vw, vh = Zoom.fillViewSize(s, ww, wh)
|
||||||
|
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
|
||||||
|
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
|
||||||
|
if vw % 2 ~= 0 then vw = vw + 1 end
|
||||||
|
if vh % 2 ~= 0 then vh = vh + 1 end
|
||||||
if Tilt.active() then
|
if Tilt.active() then
|
||||||
local g = Tilt.viewGrowth()
|
local g = Tilt.viewGrowth()
|
||||||
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
|
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
|
||||||
@@ -62,8 +91,13 @@ end
|
|||||||
function Renderer:beginFrame(transparent)
|
function Renderer:beginFrame(transparent)
|
||||||
self.worldActive = false
|
self.worldActive = false
|
||||||
self.uprightActive = false
|
self.uprightActive = false
|
||||||
-- last frame's trueColor rects go before anything draws this one
|
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
||||||
|
-- a popped transition cannot leave a sticky black veil
|
||||||
|
self.worldFadeAlpha = nil
|
||||||
|
-- last frame's trueColor rects and sprite redraws go before anything
|
||||||
|
-- draws this one
|
||||||
PaletteFX.clearTrueColor()
|
PaletteFX.clearTrueColor()
|
||||||
|
PaletteFX.clearSpriteRedraws()
|
||||||
PaletteFX.setPass("ui")
|
PaletteFX.setPass("ui")
|
||||||
love.graphics.setCanvas(self.canvas)
|
love.graphics.setCanvas(self.canvas)
|
||||||
if transparent then
|
if transparent then
|
||||||
@@ -77,6 +111,10 @@ function Renderer:beginWorldPass()
|
|||||||
local vw, vh = self:worldViewSize()
|
local vw, vh = self:worldViewSize()
|
||||||
if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw
|
if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw
|
||||||
or self.worldCanvas:getHeight() ~= vh then
|
or self.worldCanvas:getHeight() ~= vh then
|
||||||
|
-- free the old canvas before replacing it: a zoom/tilt tween changes
|
||||||
|
-- the view size every frame, so without this the superseded canvases
|
||||||
|
-- pile up in VRAM until a GC finalizer happens to run
|
||||||
|
if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end
|
||||||
self.worldCanvas = love.graphics.newCanvas(vw, vh)
|
self.worldCanvas = love.graphics.newCanvas(vw, vh)
|
||||||
self.worldCanvas:setFilter("nearest", "nearest")
|
self.worldCanvas:setFilter("nearest", "nearest")
|
||||||
end
|
end
|
||||||
@@ -105,6 +143,7 @@ function Renderer:beginUprightPass()
|
|||||||
local cw, ch = vw + 2 * M, vh + 2 * M
|
local cw, ch = vw + 2 * M, vh + 2 * M
|
||||||
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
|
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
|
||||||
or self.uprightCanvas:getHeight() ~= ch then
|
or self.uprightCanvas:getHeight() ~= ch then
|
||||||
|
if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end
|
||||||
self.uprightCanvas = love.graphics.newCanvas(cw, ch)
|
self.uprightCanvas = love.graphics.newCanvas(cw, ch)
|
||||||
self.uprightCanvas:setFilter("nearest", "nearest")
|
self.uprightCanvas:setFilter("nearest", "nearest")
|
||||||
end
|
end
|
||||||
@@ -198,6 +237,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
|
|||||||
-- 2x for extra crispness.
|
-- 2x for extra crispness.
|
||||||
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
|
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
|
||||||
or self.tiltCanvas:getHeight() ~= wvh then
|
or self.tiltCanvas:getHeight() ~= wvh then
|
||||||
|
if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end
|
||||||
self.tiltCanvas = love.graphics.newCanvas(wvw, wvh)
|
self.tiltCanvas = love.graphics.newCanvas(wvw, wvh)
|
||||||
self.tiltCanvas:setFilter("linear", "linear")
|
self.tiltCanvas:setFilter("linear", "linear")
|
||||||
end
|
end
|
||||||
@@ -278,11 +318,14 @@ end
|
|||||||
-- presented through the GBC FX shader as a final pass.
|
-- presented through the GBC FX shader as a final pass.
|
||||||
function Renderer:endFrame(zones, worldZones)
|
function Renderer:endFrame(zones, worldZones)
|
||||||
love.graphics.setCanvas()
|
love.graphics.setCanvas()
|
||||||
local ww, wh = love.graphics.getDimensions()
|
local ww, wh, pw, ph, dpi = displayMetrics()
|
||||||
local S = self:fitScale()
|
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale.
|
||||||
|
local Sp = self:fitScale()
|
||||||
|
local S = Sp / dpi
|
||||||
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
|
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
|
||||||
local ox = math.floor((ww - vpw) / 2)
|
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||||
local oy = math.floor((wh - vph) / 2)
|
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi
|
||||||
|
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||||
@@ -310,9 +353,9 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
|
||||||
-- blit `canvas` at integer `scale` into origin (bx, by), scissored to
|
-- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to
|
||||||
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
|
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
|
||||||
-- coords (canvas-space) into screen pixels.
|
-- coords (canvas-space) into screen units.
|
||||||
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
|
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
|
||||||
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
||||||
if not shader then
|
if not shader then
|
||||||
@@ -344,11 +387,12 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if self.worldActive then
|
if self.worldActive then
|
||||||
local s = Zoom.scale(S)
|
local sp = Zoom.scale(Sp)
|
||||||
|
local s = sp / dpi
|
||||||
local wvw = self.worldCanvas:getWidth()
|
local wvw = self.worldCanvas:getWidth()
|
||||||
local wvh = self.worldCanvas:getHeight()
|
local wvh = self.worldCanvas:getHeight()
|
||||||
local wox = math.floor((ww - wvw * s) / 2)
|
local wox = math.floor((pw - wvw * sp) / 2) / dpi
|
||||||
local woy = math.floor((wh - wvh * s) / 2)
|
local woy = math.floor((ph - wvh * sp) / 2) / dpi
|
||||||
-- Tilt mode projects the ground world pass through the perspective mesh
|
-- Tilt mode projects the ground world pass through the perspective mesh
|
||||||
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
||||||
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
||||||
@@ -363,6 +407,35 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
else
|
else
|
||||||
blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh)
|
blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh)
|
||||||
end
|
end
|
||||||
|
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
|
||||||
|
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
|
||||||
|
-- feet-overdraw entries carry `colors` and re-colorize through the
|
||||||
|
-- color-0-keyed shade-remap shader so they keep hiding sprite feet.
|
||||||
|
local redraws = PaletteFX.spriteRedraws()
|
||||||
|
if redraws[1] then
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.setScissor(0, 0, ww, wh)
|
||||||
|
local activeShader = nil
|
||||||
|
for _, r in ipairs(redraws) do
|
||||||
|
local wanted = r.colors
|
||||||
|
and (r.keyed and PaletteFX.keyedShader() or PaletteFX.shader())
|
||||||
|
or nil
|
||||||
|
if wanted ~= activeShader then
|
||||||
|
activeShader = wanted
|
||||||
|
love.graphics.setShader(wanted)
|
||||||
|
end
|
||||||
|
if wanted then PaletteFX.sendColors(wanted, r.colors) end
|
||||||
|
if r.quad then
|
||||||
|
love.graphics.draw(r.image, r.quad, wox + r.x * s, woy + r.y * s,
|
||||||
|
0, s * r.sx, s)
|
||||||
|
else
|
||||||
|
love.graphics.draw(r.image, wox + r.x * s, woy + r.y * s,
|
||||||
|
0, s * r.sx, s)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if activeShader then love.graphics.setShader() end
|
||||||
|
love.graphics.setScissor()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
-- Composite the tilt upright pass over the ground (projected or, in the
|
-- Composite the tilt upright pass over the ground (projected or, in the
|
||||||
-- rare no-shader fallback, flat). It already carries its billboards'
|
-- rare no-shader fallback, flat). It already carries its billboards'
|
||||||
@@ -377,13 +450,24 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
|
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
end
|
end
|
||||||
|
-- Screen-space warp fade (Transition) over the full world composite so
|
||||||
|
-- survey zoom / tilt edges darken with the center, not only the 160x144
|
||||||
|
-- UI letterbox. Drawn before the UI blit so menus above a fade still
|
||||||
|
-- composite normally if one is ever stacked that way.
|
||||||
|
local fade = self.worldFadeAlpha
|
||||||
|
if fade and fade > 0 then
|
||||||
|
love.graphics.setColor(0, 0, 0, fade)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
-- UI stays in the classic centered GB letterbox
|
-- UI stays in the classic centered GB letterbox
|
||||||
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
|
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
|
||||||
|
|
||||||
if present then
|
if present then
|
||||||
love.graphics.setCanvas()
|
love.graphics.setCanvas()
|
||||||
GBCFX.present(present, S)
|
-- shader grid/shadow math is in framebuffer pixels
|
||||||
|
GBCFX.present(present, Sp)
|
||||||
end
|
end
|
||||||
self.worldActive = false
|
self.worldActive = false
|
||||||
self.uprightActive = false
|
self.uprightActive = false
|
||||||
|
|||||||
@@ -18,10 +18,49 @@ local function getImage(path)
|
|||||||
return imageCache[path]
|
return imageCache[path]
|
||||||
end
|
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).
|
||||||
|
--
|
||||||
|
-- Sprite sheets carry no real alpha (every pixel, including the
|
||||||
|
-- background, is opaque -- confirmed by sampling the extracted PNGs): the
|
||||||
|
-- "transparent" look in every other draw path is a coincidence of the
|
||||||
|
-- whole-canvas shade-remap shader, where shade 0 (white) happens to map to
|
||||||
|
-- a similarly light color in whatever terrain zone the sprite stands over.
|
||||||
|
-- That coincidence breaks once terrain is colored per-tile instead of one
|
||||||
|
-- flat color per map (different tiles can have very different color-0s),
|
||||||
|
-- so shade 0 is keyed to alpha 0 here explicitly -- matching real GBC OBJ
|
||||||
|
-- hardware, where sprite palette index 0 is unconditionally transparent
|
||||||
|
-- (same rule TileRenderer's getColor0KeyShader documents for tall grass).
|
||||||
|
local obpCache = {}
|
||||||
|
|
||||||
|
local function getObpImage(path, colors, group)
|
||||||
|
local key = path .. "#obp" .. group
|
||||||
|
if not obpCache[key] then
|
||||||
|
local img
|
||||||
|
if love.image and love.image.newImageData then
|
||||||
|
local id = Assets.imageData(path)
|
||||||
|
id:mapPixel(function(_, _, r, g, b, a)
|
||||||
|
if a == 0 then return r, g, b, a end
|
||||||
|
if r > 0.83 then return r, g, b, 0 end -- OBJ color 0: always transparent
|
||||||
|
local col = r > 0.5 and colors[2] or r > 0.17 and colors[3] or colors[4]
|
||||||
|
return col[1] / 255, col[2] / 255, col[3] / 255, a
|
||||||
|
end)
|
||||||
|
img = love.graphics.newImage(id)
|
||||||
|
else
|
||||||
|
img = getImage(path) -- headless stub: no pixel access
|
||||||
|
end
|
||||||
|
obpCache[key] = img
|
||||||
|
end
|
||||||
|
return obpCache[key]
|
||||||
|
end
|
||||||
|
|
||||||
-- hot reload drops the sheets; live instances hold their own image, so
|
-- hot reload drops the sheets; live instances hold their own image, so
|
||||||
-- the world rebuilds them (MapLoader.invalidateAll) rather than this
|
-- the world rebuilds them (MapLoader.invalidateAll) rather than this
|
||||||
function SpriteRenderer.invalidate()
|
function SpriteRenderer.invalidate()
|
||||||
imageCache = {}
|
imageCache = {}
|
||||||
|
obpCache = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
Assets.register(SpriteRenderer.invalidate)
|
Assets.register(SpriteRenderer.invalidate)
|
||||||
@@ -29,9 +68,12 @@ Assets.register(SpriteRenderer.invalidate)
|
|||||||
local STAND = { down = 0, up = 1, left = 2, right = 2 }
|
local STAND = { down = 0, up = 1, left = 2, right = 2 }
|
||||||
local WALK = { down = 3, up = 4, left = 5, right = 5 }
|
local WALK = { down = 3, up = 4, left = 5, right = 5 }
|
||||||
|
|
||||||
function SpriteRenderer.new(spriteDef)
|
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
|
||||||
|
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
|
||||||
|
function SpriteRenderer.new(spriteDef, seed)
|
||||||
local self = setmetatable({}, SpriteRenderer)
|
local self = setmetatable({}, SpriteRenderer)
|
||||||
self.def = spriteDef
|
self.def = spriteDef
|
||||||
|
self.seed = seed
|
||||||
self.image = getImage(spriteDef.image)
|
self.image = getImage(spriteDef.image)
|
||||||
local iw, ih = self.image:getDimensions()
|
local iw, ih = self.image:getDimensions()
|
||||||
self.frames = {}
|
self.frames = {}
|
||||||
@@ -43,16 +85,47 @@ end
|
|||||||
|
|
||||||
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
||||||
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
|
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
|
||||||
|
local function blitFrame(image, quad, x, y, flip, redraw)
|
||||||
|
if flip then
|
||||||
|
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
|
||||||
|
if redraw then PaletteFX.markSpriteRedraw(image, quad, x + 16, y, -1) end
|
||||||
|
else
|
||||||
|
love.graphics.draw(image, quad, x, y)
|
||||||
|
if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||||
local x = math.floor(px - camX)
|
local x = math.floor(px - camX)
|
||||||
local y = math.floor(py - camY) - 4
|
local y = math.floor(py - camY) - 4
|
||||||
|
local image = self.image
|
||||||
|
local redraw = false
|
||||||
-- full-color art claims its 16x16 cell out of the shade-remap pass
|
-- full-color art claims its 16x16 cell out of the shade-remap pass
|
||||||
if self.def.trueColor then PaletteFX.markTrueColor(x, y, 16, 16) end
|
if self.def.trueColor then
|
||||||
|
PaletteFX.markTrueColor(x, y, 16, 16)
|
||||||
|
elseif PaletteFX.usesGbcPack() then
|
||||||
|
-- RED++: the world canvas is already true-color (TileRenderer bakes
|
||||||
|
-- terrain, this bakes the sprite) and the world pass runs unshaded
|
||||||
|
-- (OverworldState.sgbWorldZones), so this draws like any normal sprite
|
||||||
|
-- -- opaque character pixels over a real-alpha-transparent background,
|
||||||
|
-- no trueColor rect needed (there is no shader left to exempt it from).
|
||||||
|
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
||||||
|
if colors then
|
||||||
|
image = getObpImage(self.def.image, colors, group)
|
||||||
|
end
|
||||||
|
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||||
|
-- OG RED (GBC boot-ROM look): every OBJ wears the one global green
|
||||||
|
-- object palette. The red BG zone shader still runs over the world
|
||||||
|
-- canvas, so the baked sprite is queued for a post-zone redraw
|
||||||
|
-- (PaletteFX.markSpriteRedraw) that restores its green pixels on top.
|
||||||
|
image = getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
|
||||||
|
redraw = true
|
||||||
|
end
|
||||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||||
-- still 3-frame sprites turn to face (the nurse at her machine,
|
-- still 3-frame sprites turn to face (the nurse at her machine,
|
||||||
-- facePlayer on STAY NPCs) but never show walk frames
|
-- facePlayer on STAY NPCs) but never show walk frames
|
||||||
if self.def.frames <= 1 then
|
if self.def.frames <= 1 then
|
||||||
love.graphics.draw(self.image, self.frames[0], x, y)
|
blitFrame(image, self.frames[0], x, y, false, redraw)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local frame = (self.def.walker and walkPhase == 1)
|
local frame = (self.def.walker and walkPhase == 1)
|
||||||
@@ -64,11 +137,7 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
|||||||
flip = true
|
flip = true
|
||||||
end
|
end
|
||||||
local quad = self.frames[frame] or self.frames[0]
|
local quad = self.frames[frame] or self.frames[0]
|
||||||
if flip then
|
blitFrame(image, quad, x, y, flip, redraw)
|
||||||
love.graphics.draw(self.image, quad, x + 16, y, 0, -1, 1)
|
|
||||||
else
|
|
||||||
love.graphics.draw(self.image, quad, x, y)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return SpriteRenderer
|
return SpriteRenderer
|
||||||
|
|||||||
+339
-88
@@ -90,8 +90,8 @@ function TileRenderer.setSpinning(active)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- true while the spinner arrow tiles should show the 'blur' graphic; false
|
-- true while the spinner arrow tiles should show the 'blur' graphic; false
|
||||||
-- means draw nothing extra (the static mapBatch/ringBatch tile shows
|
-- means draw nothing extra (the static window tile shows through,
|
||||||
-- through, matching the asm's restore-to-original behavior). The 8-tick
|
-- matching the asm's restore-to-original behavior). The 8-tick
|
||||||
-- half-period approximates one GB movement step (2px/frame); this is a
|
-- half-period approximates one GB movement step (2px/frame); this is a
|
||||||
-- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not
|
-- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not
|
||||||
-- a cycle-accurate replication -- the port's tweened scriptMove has no
|
-- a cycle-accurate replication -- the port's tweened scriptMove has no
|
||||||
@@ -108,10 +108,23 @@ end
|
|||||||
-- the water/flower branches did before they were data.
|
-- the water/flower branches did before they were data.
|
||||||
-- ------------------------------------------------------------------
|
-- ------------------------------------------------------------------
|
||||||
|
|
||||||
-- the 8 shifted variants of one tile (built once per sheet + tile id)
|
-- shade 0-3 -> one of `colors`' 4 entries (same cutoffs PaletteFX's shader
|
||||||
|
-- uses), alpha passed through unchanged; nil colors leaves r,g,b as-is.
|
||||||
|
-- Shared by the whole-atlas bake (getGbcAtlas) and the animated-tile
|
||||||
|
-- variants below, so water/flowers/spinners match the static tiles around
|
||||||
|
-- them under RED++ instead of showing their un-recolored grayscale.
|
||||||
|
local function recolorSample(r, g, b, a, colors)
|
||||||
|
if not (colors and a > 0) then return r, g, b, a end
|
||||||
|
local col = r > 0.83 and colors[1] or r > 0.5 and colors[2]
|
||||||
|
or r > 0.17 and colors[3] or colors[4]
|
||||||
|
return col[1] / 255, col[2] / 255, col[3] / 255, a
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the 8 shifted variants of one tile (built once per sheet + tile id [+
|
||||||
|
-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim])
|
||||||
local shiftVariants = {}
|
local shiftVariants = {}
|
||||||
local function getShiftVariants(tilesetImagePath, perRow, tile)
|
local function getShiftVariants(tilesetImagePath, perRow, tile, colors, gbcKey)
|
||||||
local key = tilesetImagePath .. "#" .. tile
|
local key = tilesetImagePath .. "#" .. tile .. (gbcKey or "")
|
||||||
if shiftVariants[key] ~= nil then return shiftVariants[key] end
|
if shiftVariants[key] ~= nil then return shiftVariants[key] end
|
||||||
if not (love.image and love.image.newImageData) then
|
if not (love.image and love.image.newImageData) then
|
||||||
shiftVariants[key] = false
|
shiftVariants[key] = false
|
||||||
@@ -126,6 +139,7 @@ local function getShiftVariants(tilesetImagePath, perRow, tile)
|
|||||||
for y = 0, 7 do
|
for y = 0, 7 do
|
||||||
for x = 0, 7 do
|
for x = 0, 7 do
|
||||||
local r, g, b, a = id:getPixel(sx + x, sy + y)
|
local r, g, b, a = id:getPixel(sx + x, sy + y)
|
||||||
|
r, g, b, a = recolorSample(r, g, b, a, colors)
|
||||||
v:setPixel((x + o) % 8, y, r, g, b, a)
|
v:setPixel((x + o) % 8, y, r, g, b, a)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -136,12 +150,27 @@ local function getShiftVariants(tilesetImagePath, perRow, tile)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local frameImages = {}
|
local frameImages = {}
|
||||||
local function getFrameImages(paths)
|
local function getFrameImages(paths, colors, gbcKey)
|
||||||
local key = table.concat(paths, "|")
|
local key = table.concat(paths, "|") .. (gbcKey or "")
|
||||||
if frameImages[key] ~= nil then return frameImages[key] end
|
if frameImages[key] ~= nil then return frameImages[key] end
|
||||||
local out = {}
|
local out = {}
|
||||||
for i, path in ipairs(paths) do
|
for i, path in ipairs(paths) do
|
||||||
local ok, img = pcall(getImage, path)
|
local ok, img = pcall(function()
|
||||||
|
if not (colors and love.image and love.image.newImageData) then
|
||||||
|
return getImage(path)
|
||||||
|
end
|
||||||
|
local id = Assets.imageData(path)
|
||||||
|
local w, h = id:getDimensions()
|
||||||
|
local out2 = love.image.newImageData(w, h)
|
||||||
|
for y = 0, h - 1 do
|
||||||
|
for x = 0, w - 1 do
|
||||||
|
local r, g, b, a = id:getPixel(x, y)
|
||||||
|
r, g, b, a = recolorSample(r, g, b, a, colors)
|
||||||
|
out2:setPixel(x, y, r, g, b, a)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return love.graphics.newImage(out2)
|
||||||
|
end)
|
||||||
if not ok then
|
if not ok then
|
||||||
frameImages[key] = false
|
frameImages[key] = false
|
||||||
return false
|
return false
|
||||||
@@ -237,17 +266,33 @@ end
|
|||||||
-- one entry's runtime form: the tile ids it claims, the textures a step
|
-- one entry's runtime form: the tile ids it claims, the textures a step
|
||||||
-- picks from, and either a step sequence (hshift/frames) or a gate
|
-- picks from, and either a step sequence (hshift/frames) or a gate
|
||||||
-- (toggle). nil when the entry's pixels could not be built.
|
-- (toggle). nil when the entry's pixels could not be built.
|
||||||
local function buildAnim(spec, tilesetImagePath, perRow, quads)
|
--
|
||||||
|
-- gbc, when present (RED++ with a baked atlas -- see getGbcAtlas), recolors
|
||||||
|
-- hshift/frames entries (water/flowers) the same way the atlas bakes their
|
||||||
|
-- static tile, so they match their surroundings instead of showing raw
|
||||||
|
-- grayscale over an otherwise fully-colored map. The "toggle" kind
|
||||||
|
-- (spinner puzzle blur, gfx/overworld/spinners.png) is a whole-atlas clone
|
||||||
|
-- built from the ORIGINAL grayscale atlas, not worth recoloring for a rare,
|
||||||
|
-- gameplay-gated blur -- it is skipped under gbc, same as the buildAnim
|
||||||
|
-- caller already does for a texture-build failure (the static, correctly-
|
||||||
|
-- colored tile shows through unanimated).
|
||||||
|
local function buildAnim(spec, tilesetImagePath, perRow, quads, gbc)
|
||||||
local tiles = spec.tiles
|
local tiles = spec.tiles
|
||||||
if not tiles then
|
if not tiles then
|
||||||
if spec.tile == nil then return nil end
|
if spec.tile == nil then return nil end
|
||||||
tiles = { spec.tile }
|
tiles = { spec.tile }
|
||||||
end
|
end
|
||||||
local period = spec.period or ANIM_PERIOD
|
local period = spec.period or ANIM_PERIOD
|
||||||
|
local colors
|
||||||
|
if gbc then
|
||||||
|
local group = PaletteFX.worldGroupAt(gbc.tilesetId, gbc.mapId, tiles[1])
|
||||||
|
colors = group and gbc.groupColors[group + 1]
|
||||||
|
end
|
||||||
if spec.kind == "hshift" then
|
if spec.kind == "hshift" then
|
||||||
local offsets = spec.offsets
|
local offsets = spec.offsets
|
||||||
if not offsets or #offsets == 0 then return nil end
|
if not offsets or #offsets == 0 then return nil end
|
||||||
local textures = getShiftVariants(tilesetImagePath, perRow, tiles[1])
|
local textures = getShiftVariants(tilesetImagePath, perRow, tiles[1],
|
||||||
|
colors, gbc and gbc.key)
|
||||||
if not textures then return nil end
|
if not textures then return nil end
|
||||||
local sequence = {}
|
local sequence = {}
|
||||||
for i, offset in ipairs(offsets) do sequence[i] = offset + 1 end
|
for i, offset in ipairs(offsets) do sequence[i] = offset + 1 end
|
||||||
@@ -256,11 +301,12 @@ local function buildAnim(spec, tilesetImagePath, perRow, quads)
|
|||||||
elseif spec.kind == "frames" then
|
elseif spec.kind == "frames" then
|
||||||
local sequence = spec.sequence
|
local sequence = spec.sequence
|
||||||
if not (spec.images and sequence and #sequence > 0) then return nil end
|
if not (spec.images and sequence and #sequence > 0) then return nil end
|
||||||
local textures = getFrameImages(spec.images)
|
local textures = getFrameImages(spec.images, colors, gbc and gbc.key)
|
||||||
if not textures then return nil end
|
if not textures then return nil end
|
||||||
return { tiles = tiles, textures = textures, sequence = sequence,
|
return { tiles = tiles, textures = textures, sequence = sequence,
|
||||||
period = period }
|
period = period }
|
||||||
elseif spec.kind == "toggle" then
|
elseif spec.kind == "toggle" then
|
||||||
|
if gbc then return nil end
|
||||||
local image = getToggleImage(spec, tilesetImagePath, perRow)
|
local image = getToggleImage(spec, tilesetImagePath, perRow)
|
||||||
if not image then return nil end
|
if not image then return nil end
|
||||||
-- the patch texture is a whole-atlas clone, so each cell needs the
|
-- the patch texture is a whole-atlas clone, so each cell needs the
|
||||||
@@ -271,10 +317,102 @@ local function buildAnim(spec, tilesetImagePath, perRow, quads)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function TileRenderer.new(map)
|
-- True GBC overworld coloring (COLORS=RED++): recolor the WHOLE tileset
|
||||||
|
-- atlas once, per (tileset image, map), rather than trying to retrofit the
|
||||||
|
-- SGB zone/shade-remap-shader post-process (built for a handful of coarse
|
||||||
|
-- screen regions) into per-tile precision -- pokered-gbc's real model is
|
||||||
|
-- "one of 8 four-color BG palettes baked per tile GRAPHIC"
|
||||||
|
-- (color/loadpalettes.asm LoadTilesetPalette), which is exactly a
|
||||||
|
-- recolored atlas, not a shader pass. Every existing draw path (batches,
|
||||||
|
-- quads, border fill) then just works unmodified, with no shader at
|
||||||
|
-- draw time; OverworldState.sgbWorldZones skips the shade-remap zone pass
|
||||||
|
-- entirely when this is active (re-running it over already-true-color
|
||||||
|
-- pixels would corrupt them), and SpriteRenderer's own OBP bake composites
|
||||||
|
-- on top with ordinary alpha blending -- no trueColor exemption needed,
|
||||||
|
-- because there is no shader left for it to be exempted from.
|
||||||
|
--
|
||||||
|
-- Only the ROOF group (index 6, OVERWORLD/PLATEAU only) varies by town
|
||||||
|
-- (LoadTownPalette); Route 6's mid-map Saffron-roof y<2 split is not
|
||||||
|
-- reproduced (it would need a rebuild on crossing the boundary for two
|
||||||
|
-- tile-rows of one route -- not worth the complexity), so it bakes with
|
||||||
|
-- the route's own default roof (Vermilion's) throughout.
|
||||||
|
local gbcAtlasCache = {}
|
||||||
|
|
||||||
|
local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data)
|
||||||
|
local key = imagePath .. "#gbc:" .. mapId
|
||||||
|
if gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end
|
||||||
|
local img = false
|
||||||
|
if love.image and love.image.newImageData then
|
||||||
|
local groupColors = PaletteFX.worldGroupColors(data, tilesetId, mapId, nil)
|
||||||
|
if groupColors then
|
||||||
|
local src = Assets.imageData(imagePath)
|
||||||
|
local iw, ih = src:getDimensions()
|
||||||
|
local total = (iw / 8) * (ih / 8)
|
||||||
|
local out = love.image.newImageData(iw, ih)
|
||||||
|
local tileColors = {}
|
||||||
|
for t = 0, total - 1 do
|
||||||
|
local colors = tileColors[t]
|
||||||
|
if colors == nil then
|
||||||
|
local group = PaletteFX.worldGroupAt(tilesetId, mapId, t)
|
||||||
|
colors = (group and groupColors[group + 1]) or false
|
||||||
|
tileColors[t] = colors
|
||||||
|
end
|
||||||
|
local ox, oy = (t % perRow) * 8, math.floor(t / perRow) * 8
|
||||||
|
for py = 0, 7 do
|
||||||
|
for px = 0, 7 do
|
||||||
|
local sx, sy = ox + px, oy + py
|
||||||
|
local r, g, b, a = src:getPixel(sx, sy)
|
||||||
|
r, g, b, a = recolorSample(r, g, b, a, colors)
|
||||||
|
out:setPixel(sx, sy, r, g, b, a)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- duplicate-tile aliases: bake a copy of a shared tile graphic into
|
||||||
|
-- a spare slot under a different palette group, so block cells that
|
||||||
|
-- draw the alias can color apart from cells sharing the raw tile
|
||||||
|
for _, al in ipairs(PaletteFX.TILE_ALIASES and PaletteFX.TILE_ALIASES[mapId] or {}) do
|
||||||
|
if al.alias < total then
|
||||||
|
local colors = groupColors[al.group + 1]
|
||||||
|
local sxo = (al.tile % perRow) * 8
|
||||||
|
local syo = math.floor(al.tile / perRow) * 8
|
||||||
|
local dxo = (al.alias % perRow) * 8
|
||||||
|
local dyo = math.floor(al.alias / perRow) * 8
|
||||||
|
for py = 0, 7 do
|
||||||
|
for px = 0, 7 do
|
||||||
|
local r, g, b, a = src:getPixel(sxo + px, syo + py)
|
||||||
|
r, g, b, a = recolorSample(r, g, b, a, colors)
|
||||||
|
out:setPixel(dxo + px, dyo + py, r, g, b, a)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
img = love.graphics.newImage(out)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
gbcAtlasCache[key] = img
|
||||||
|
return img or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- data: Game.data (threaded through explicitly, not required lazily, so
|
||||||
|
-- headless tests that build a map from a plain local table still work)
|
||||||
|
function TileRenderer.new(map, data)
|
||||||
local self = setmetatable({}, TileRenderer)
|
local self = setmetatable({}, TileRenderer)
|
||||||
self.map = map
|
self.map = map
|
||||||
|
self.data = data
|
||||||
self.image = getImage(map.tileset.image)
|
self.image = getImage(map.tileset.image)
|
||||||
|
local gbcCtx
|
||||||
|
if data and PaletteFX.usesGbcPack() and PaletteFX.hasWorldTileset(map.tileset.id) then
|
||||||
|
local gbc = getGbcAtlas(map.tileset.image, map.tileset.id, map.id,
|
||||||
|
map.tileset.tilesPerRow, data)
|
||||||
|
if gbc then
|
||||||
|
self.image = gbc
|
||||||
|
self.gbcAtlas = true
|
||||||
|
-- also recolors the animated water/flower entries below, so they
|
||||||
|
-- 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) }
|
||||||
|
end
|
||||||
|
end
|
||||||
-- a full-color atlas colors everything it paints, ring and border fill
|
-- a full-color atlas colors everything it paints, ring and border fill
|
||||||
-- included, so every draw entry point claims its rect out of the pass
|
-- included, so every draw entry point claims its rect out of the pass
|
||||||
self.trueColor = map.tileset.trueColor or nil
|
self.trueColor = map.tileset.trueColor or nil
|
||||||
@@ -288,22 +426,24 @@ function TileRenderer.new(map)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local def = map.def
|
local def = map.def
|
||||||
local wB, hB = def.width, def.height
|
-- The map body measured in 8px tiles (each block is 4x4 tiles). The tile
|
||||||
-- two batches: the border-block ring around the map, and the map body.
|
-- layer is drawn windowed to the camera (see :ensureWindow) instead of
|
||||||
-- Connected-map strips draw body-only on top of this map's ring.
|
-- baked into a whole-map SpriteBatch, so a map becoming visible -- a warp,
|
||||||
local total = (wB + 2 * BORDER_BLOCKS) * (hB + 2 * BORDER_BLOCKS) * 16
|
-- a connection seam -- costs nothing to "build": there is no per-map batch
|
||||||
self.ringBatch = love.graphics.newSpriteBatch(self.image, total, "static")
|
-- construction that scales with map size, which is what stuttered.
|
||||||
self.mapBatch = love.graphics.newSpriteBatch(self.image, wB * hB * 16, "static")
|
self.bodyTilesW = def.width * 4
|
||||||
-- animated tiles overdraw the static batches each frame. Entry order
|
self.bodyTilesH = def.height * 4
|
||||||
-- decides which one claims a tile listed twice, so the vanilla defaults
|
-- Animated tiles overdraw the static window each frame. Only the per-entry
|
||||||
-- keep the old water-then-flower-then-spinner precedence.
|
-- render spec (textures/sequence/gate) is kept here; the animated cells are
|
||||||
|
-- gathered per camera window in :ensureWindow, so nothing here scales with
|
||||||
|
-- map size either. Entry order decides which entry claims a tile listed
|
||||||
|
-- twice (the vanilla water-then-flower-then-spinner precedence).
|
||||||
local anims, claimedBy = {}, {}
|
local anims, claimedBy = {}, {}
|
||||||
local declared = map.tileset.animatedTiles
|
local declared = map.tileset.animatedTiles
|
||||||
or TileRenderer.defaultAnimatedTiles(map.tileset)
|
or TileRenderer.defaultAnimatedTiles(map.tileset)
|
||||||
for _, spec in ipairs(declared) do
|
for _, spec in ipairs(declared) do
|
||||||
local anim = buildAnim(spec, map.tileset.image, perRow, self.quads)
|
local anim = buildAnim(spec, map.tileset.image, perRow, self.quads, gbcCtx)
|
||||||
if anim then
|
if anim then
|
||||||
anim.cells = {}
|
|
||||||
anims[#anims + 1] = anim
|
anims[#anims + 1] = anim
|
||||||
for _, tile in ipairs(anim.tiles) do
|
for _, tile in ipairs(anim.tiles) do
|
||||||
if claimedBy[tile] == nil then claimedBy[tile] = anim end
|
if claimedBy[tile] == nil then claimedBy[tile] = anim end
|
||||||
@@ -311,53 +451,20 @@ function TileRenderer.new(map)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
for by = -BORDER_BLOCKS, hB + BORDER_BLOCKS - 1 do
|
-- duplicate-tile alias remap (RED++ atlas only): [blockId][0-based cell]
|
||||||
for bx = -BORDER_BLOCKS, wB + BORDER_BLOCKS - 1 do
|
-- -> alias tile id (see PaletteFX.TILE_ALIASES / getGbcAtlas's bake)
|
||||||
local inside = bx >= 0 and by >= 0 and bx < wB and by < hB
|
local aliasMap
|
||||||
local batch = inside and self.mapBatch or self.ringBatch
|
if gbcCtx then
|
||||||
local block = map.tileset.blocks[map:blockAt(bx, by) + 1]
|
for _, al in ipairs(PaletteFX.TILE_ALIASES and PaletteFX.TILE_ALIASES[map.id] or {}) do
|
||||||
for ty = 0, 3 do
|
aliasMap = aliasMap or {}
|
||||||
for tx = 0, 3 do
|
local cells = aliasMap[al.block] or {}
|
||||||
local tile = block[ty * 4 + tx + 1]
|
for ci in pairs(al.cells) do cells[ci] = al.alias end
|
||||||
local quad = self.quads[tile]
|
aliasMap[al.block] = cells
|
||||||
if quad then
|
|
||||||
batch:add(quad, bx * 32 + tx * 8, by * 32 + ty * 8)
|
|
||||||
end
|
|
||||||
local anim = claimedBy[tile]
|
|
||||||
if anim then
|
|
||||||
local cells = anim.cells
|
|
||||||
cells[#cells + 1] = { bx * 32 + tx * 8, by * 32 + ty * 8, inside, tile }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
self.aliasMap = aliasMap
|
||||||
-- animated overdraw batches: the full set (ring + body) for the
|
|
||||||
-- current map, and a body-only set for connected-map drawing --
|
|
||||||
-- a neighbor's water ring must never overdraw this map's tiles.
|
|
||||||
-- `quadFor`, when given, looks up a per-entry quad (used by toggle
|
|
||||||
-- entries, whose texture is a full tileset-atlas clone rather than a
|
|
||||||
-- single-tile image like the hshift/frames variants).
|
|
||||||
local function animBatches(entries, image, quadFor)
|
|
||||||
if #entries == 0 then return nil, nil end
|
|
||||||
local all = love.graphics.newSpriteBatch(image, #entries, "static")
|
|
||||||
local body
|
|
||||||
for _, c in ipairs(entries) do
|
|
||||||
if quadFor then all:add(quadFor(c[4]), c[1], c[2]) else all:add(c[1], c[2]) end
|
|
||||||
if c[3] then
|
|
||||||
body = body or love.graphics.newSpriteBatch(image, #entries, "static")
|
|
||||||
if quadFor then body:add(quadFor(c[4]), c[1], c[2]) else body:add(c[1], c[2]) end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return all, body
|
|
||||||
end
|
|
||||||
for _, anim in ipairs(anims) do
|
|
||||||
anim.batch, anim.bodyBatch =
|
|
||||||
animBatches(anim.cells, anim.textures[1], anim.quadFor)
|
|
||||||
anim.cells = nil
|
|
||||||
end
|
|
||||||
self.anims = anims
|
self.anims = anims
|
||||||
|
self.claimedBy = claimedBy
|
||||||
|
|
||||||
-- a repeating 32x32 image of the border block, tiled behind
|
-- a repeating 32x32 image of the border block, tiled behind
|
||||||
-- everything the 3-block ring doesn't cover (the survey zoom sees
|
-- everything the 3-block ring doesn't cover (the survey zoom sees
|
||||||
@@ -392,8 +499,16 @@ function TileRenderer:drawBorderFill(camX, camY, vw, vh)
|
|||||||
if not self.borderFill then return end
|
if not self.borderFill then return end
|
||||||
if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end
|
if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end
|
||||||
local x, y = math.floor(camX), math.floor(camY)
|
local x, y = math.floor(camX), math.floor(camY)
|
||||||
local quad = love.graphics.newQuad(x, y, vw, vh, 32, 32)
|
-- one reused Quad per renderer, mutated in place: this runs every
|
||||||
love.graphics.draw(self.borderFill, quad, 0, 0)
|
-- overworld frame, so allocating a fresh Quad here churned the GC
|
||||||
|
local q = self.borderQuad
|
||||||
|
if q then
|
||||||
|
q:setViewport(x, y, vw, vh, 32, 32)
|
||||||
|
else
|
||||||
|
q = love.graphics.newQuad(x, y, vw, vh, 32, 32)
|
||||||
|
self.borderQuad = q
|
||||||
|
end
|
||||||
|
love.graphics.draw(self.borderFill, q, 0, 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- GB OBJ-to-BG priority: sprites show through BG color 0 and hide under
|
-- GB OBJ-to-BG priority: sprites show through BG color 0 and hide under
|
||||||
@@ -438,19 +553,114 @@ function TileRenderer:drawCellBottom(cx, cy, camX, camY)
|
|||||||
if shader then love.graphics.setShader() end
|
if shader then love.graphics.setShader() end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- animated overdraw at the current step; bodyOnly skips the ring
|
-- queue the same bottom tile row for the post-zone sprite-redraw pass
|
||||||
-- positions (connected maps draw body-only)
|
-- (GBC mode: OBP-baked sprites replay after the zone shader, so the
|
||||||
function TileRenderer:drawAnimated(camX, camY, bodyOnly)
|
-- grass patch that hides their feet must replay over them, colorized
|
||||||
|
-- with the map's palette and color-0 keyed)
|
||||||
|
function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors)
|
||||||
|
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
|
||||||
|
PaletteFX.markSpriteRedraw(self.image, quad, tx * 8 - math.floor(camX),
|
||||||
|
ty * 8 - math.floor(camY), 1, colors, true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Window cover for the static tile layer. Refill the reusable window batch
|
||||||
|
-- (and the per-entry animated batches) only when the camera has scrolled past
|
||||||
|
-- what they already cover; a small margin keeps small scrolls free. Cost
|
||||||
|
-- scales with the view, never the map -- crossing a seam or warping in builds
|
||||||
|
-- nothing. The beyond-body area (what the old 3-block ring drew) is painted
|
||||||
|
-- by :drawBorderFill, whose world-aligned border-block tiling is identical
|
||||||
|
-- there, so only body tiles are gathered here.
|
||||||
|
local WINDOW_MARGIN = 8 -- tiles of slack kept around the view between refills
|
||||||
|
|
||||||
|
function TileRenderer:ensureWindow(camX, camY, vw, vh)
|
||||||
|
local W, H = self.bodyTilesW, self.bodyTilesH
|
||||||
|
vw = vw or W * 8 -- a nil view (headless draw) means the whole body
|
||||||
|
vh = vh or H * 8
|
||||||
|
-- visible body-tile range (8px tiles), clamped to the map body
|
||||||
|
local tx0 = math.min(W, math.max(0, math.floor(camX / 8)))
|
||||||
|
local ty0 = math.min(H, math.max(0, math.floor(camY / 8)))
|
||||||
|
local tx1 = math.max(0, math.min(W, math.floor((camX + vw) / 8) + 1))
|
||||||
|
local ty1 = math.max(0, math.min(H, math.floor((camY + vh) / 8) + 1))
|
||||||
|
local win = self.win
|
||||||
|
if win and tx0 >= win.tx0 and ty0 >= win.ty0
|
||||||
|
and tx1 <= win.tx1 and ty1 <= win.ty1 then
|
||||||
|
return -- still inside the last fill
|
||||||
|
end
|
||||||
|
-- refill with margin so the next few scrolled pixels stay covered
|
||||||
|
tx0 = math.max(0, tx0 - WINDOW_MARGIN)
|
||||||
|
ty0 = math.max(0, ty0 - WINDOW_MARGIN)
|
||||||
|
tx1 = math.min(W, tx1 + WINDOW_MARGIN)
|
||||||
|
ty1 = math.min(H, ty1 + WINDOW_MARGIN)
|
||||||
|
if not self.winBatch then
|
||||||
|
self.winBatch = love.graphics.newSpriteBatch(self.image, 1024, "dynamic")
|
||||||
|
end
|
||||||
|
self.winBatch:clear()
|
||||||
|
local anims = self.anims
|
||||||
|
for _, anim in ipairs(anims) do
|
||||||
|
if not anim.batch then
|
||||||
|
anim.batch = love.graphics.newSpriteBatch(anim.textures[1], 256, "dynamic")
|
||||||
|
end
|
||||||
|
anim.batch:clear()
|
||||||
|
end
|
||||||
|
local map, quads = self.map, self.quads
|
||||||
|
local claimedBy, aliasMap = self.claimedBy, self.aliasMap
|
||||||
|
for ty = ty0, ty1 - 1 do
|
||||||
|
local by = math.floor(ty / 4)
|
||||||
|
local ty4 = ty % 4
|
||||||
|
for tx = tx0, tx1 - 1 do
|
||||||
|
local blockId = map:blockAt(math.floor(tx / 4), by)
|
||||||
|
local block = map.tileset.blocks[blockId + 1]
|
||||||
|
if block then
|
||||||
|
local ci = ty4 * 4 + (tx % 4)
|
||||||
|
local tile = block[ci + 1]
|
||||||
|
local remap = aliasMap and aliasMap[blockId]
|
||||||
|
if remap and remap[ci] then tile = remap[ci] end
|
||||||
|
local wx, wy = tx * 8, ty * 8
|
||||||
|
local quad = quads[tile]
|
||||||
|
if quad then self.winBatch:add(quad, wx, wy) end
|
||||||
|
local anim = claimedBy[tile]
|
||||||
|
if anim then
|
||||||
|
if anim.quadFor then
|
||||||
|
anim.batch:add(anim.quadFor(tile), wx, wy)
|
||||||
|
else
|
||||||
|
anim.batch:add(wx, wy)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
self.win = { tx0 = tx0, ty0 = ty0, tx1 = tx1, ty1 = ty1 }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- draw the static tile window, then its animated overdraw, at the camera offset
|
||||||
|
function TileRenderer:drawWindow(camX, camY, vw, vh)
|
||||||
|
self:ensureWindow(camX, camY, vw, vh)
|
||||||
|
if self.winBatch then
|
||||||
|
love.graphics.draw(self.winBatch, -math.floor(camX), -math.floor(camY))
|
||||||
|
end
|
||||||
|
self:drawAnimated(camX, camY)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- animated overdraw at the current step, over the static window batch. The
|
||||||
|
-- cells were gathered for the current camera window by :ensureWindow, so this
|
||||||
|
-- only ever touches on-screen animated tiles.
|
||||||
|
function TileRenderer:drawAnimated(camX, camY)
|
||||||
local anims = self.anims
|
local anims = self.anims
|
||||||
if not anims then return end
|
if not anims then return end
|
||||||
local x, y = -math.floor(camX), -math.floor(camY)
|
local x, y = -math.floor(camX), -math.floor(camY)
|
||||||
for _, anim in ipairs(anims) do
|
for _, anim in ipairs(anims) do
|
||||||
local batch = bodyOnly and anim.bodyBatch or anim.batch
|
local batch = anim.batch
|
||||||
if batch then
|
if batch then
|
||||||
if anim.gate then
|
if anim.gate then
|
||||||
-- a gated entry has only the two frames the asm has (patch /
|
-- a gated entry has only the two frames the asm has (patch /
|
||||||
-- restore-to-static); when the gate is shut draw nothing so the
|
-- restore-to-static); when the gate is shut draw nothing so the
|
||||||
-- already-static mapBatch/ringBatch tile shows through unchanged
|
-- already-static window tile shows through unchanged
|
||||||
if gateOpen(anim.gate) then love.graphics.draw(batch, x, y) end
|
if gateOpen(anim.gate) then love.graphics.draw(batch, x, y) end
|
||||||
else
|
else
|
||||||
local step = math.floor(animFrame / anim.period) % #anim.sequence + 1
|
local step = math.floor(animFrame / anim.period) % #anim.sequence + 1
|
||||||
@@ -471,27 +681,68 @@ function TileRenderer:markTrueColor(camX, camY, blocks)
|
|||||||
(def.height + 2 * blocks) * 32)
|
(def.height + 2 * blocks) * 32)
|
||||||
end
|
end
|
||||||
|
|
||||||
function TileRenderer:draw(camX, camY)
|
function TileRenderer:draw(camX, camY, vw, vh)
|
||||||
if self.trueColor then self:markTrueColor(camX, camY, BORDER_BLOCKS) end
|
if self.trueColor then self:markTrueColor(camX, camY, BORDER_BLOCKS) end
|
||||||
love.graphics.draw(self.ringBatch, -math.floor(camX), -math.floor(camY))
|
self:drawWindow(camX, camY, vw, vh)
|
||||||
love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY))
|
|
||||||
self:drawAnimated(camX, camY)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- body only, for connected-map strips
|
-- body only, for connected-map strips. Identical to :draw now that the
|
||||||
function TileRenderer:drawMapOnly(camX, camY)
|
-- border ring is served by :drawBorderFill for the current map too -- the
|
||||||
|
-- only remaining difference is the trueColor mark extent.
|
||||||
|
function TileRenderer:drawMapOnly(camX, camY, vw, vh)
|
||||||
if self.trueColor then self:markTrueColor(camX, camY, 0) end
|
if self.trueColor then self:markTrueColor(camX, camY, 0) end
|
||||||
love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY))
|
self:drawWindow(camX, camY, vw, vh)
|
||||||
self:drawAnimated(camX, camY, true)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- rebuild after a block change (Cut trees)
|
local function safeRelease(o)
|
||||||
|
if o and o.release then pcall(o.release, o) end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Release only the GPU objects this instance built and uniquely owns: the
|
||||||
|
-- two SpriteBatches, the border-fill image and its quad, the per-tile
|
||||||
|
-- quads, and the animated-overdraw batches. Deliberately leaves the
|
||||||
|
-- tileset atlas (self.image, shared through Assets/imageCache) and the
|
||||||
|
-- animation textures (shared module caches) alone -- other maps still use
|
||||||
|
-- them. Used by :rebuild before it swaps in fresh batches, and by
|
||||||
|
-- :release on eviction.
|
||||||
|
function TileRenderer:releaseBatches()
|
||||||
|
safeRelease(self.winBatch); self.winBatch = nil
|
||||||
|
safeRelease(self.borderFill); self.borderFill = nil
|
||||||
|
safeRelease(self.borderQuad); self.borderQuad = nil
|
||||||
|
self.win = nil
|
||||||
|
if self.quads then
|
||||||
|
for _, q in pairs(self.quads) do safeRelease(q) end
|
||||||
|
self.quads = nil
|
||||||
|
end
|
||||||
|
if self.anims then
|
||||||
|
for _, a in ipairs(self.anims) do
|
||||||
|
safeRelease(a.batch); a.batch = nil
|
||||||
|
-- a.textures are shared, module-cached: never released here
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Full teardown for eviction (MapLoader.evict): the owned batches, plus
|
||||||
|
-- the RED++ per-map recolored atlas, which -- unlike the plain tileset
|
||||||
|
-- atlas -- is unique to this map (gbcAtlasCache is keyed by map id).
|
||||||
|
function TileRenderer:release()
|
||||||
|
self:releaseBatches()
|
||||||
|
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
|
||||||
|
safeRelease(self.image)
|
||||||
|
self.image = nil
|
||||||
|
self.gbcAtlas = nil
|
||||||
|
end
|
||||||
|
self.anims = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- rebuild after a block change (Cut trees, card-key doors). The tile layer
|
||||||
|
-- is read live from the map on every window fill, so a block swap only needs
|
||||||
|
-- the cached window dropped -- the next draw re-reads the changed blocks. No
|
||||||
|
-- SpriteBatch is reconstructed; that is the whole point of the windowed draw.
|
||||||
function TileRenderer:rebuild()
|
function TileRenderer:rebuild()
|
||||||
local fresh = TileRenderer.new(self.map)
|
self.win = nil
|
||||||
self.ringBatch = fresh.ringBatch
|
|
||||||
self.mapBatch = fresh.mapBatch
|
|
||||||
self.anims = fresh.anims
|
|
||||||
self.borderFill = fresh.borderFill
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- drop every atlas and every derived animation texture so the next
|
-- drop every atlas and every derived animation texture so the next
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ end
|
|||||||
function Transition:draw()
|
function Transition:draw()
|
||||||
local alpha = self.t / self.frames
|
local alpha = self.t / self.frames
|
||||||
if self.phase == "in" then alpha = 1 - alpha end
|
if self.phase == "in" then alpha = 1 - alpha end
|
||||||
|
-- Survey zoom draws the overworld into a window-filling world canvas
|
||||||
|
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
|
||||||
|
-- UI canvas only darkens that center box (issue #121); when the world
|
||||||
|
-- pass ran this frame, hand the alpha to Renderer:endFrame so it paints
|
||||||
|
-- a screen-space overlay over the full composite instead.
|
||||||
|
local r = self.game and self.game.renderer
|
||||||
|
if r and r.worldActive then
|
||||||
|
r.worldFadeAlpha = alpha
|
||||||
|
return
|
||||||
|
end
|
||||||
love.graphics.setColor(0, 0, 0, alpha)
|
love.graphics.setColor(0, 0, 0, alpha)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
|||||||
+160
-28
@@ -5,6 +5,7 @@
|
|||||||
-- Conditionals: check_flag stores its result in ctx.lastCheck;
|
-- Conditionals: check_flag stores its result in ctx.lastCheck;
|
||||||
-- jump_if_true/jump_if_false jump to an absolute row index.
|
-- jump_if_true/jump_if_false jump to an absolute row index.
|
||||||
|
|
||||||
|
local Collision = require("src.world.Collision")
|
||||||
local Flags = require("src.script.Flags")
|
local Flags = require("src.script.Flags")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
@@ -225,7 +226,7 @@ function Commands.start_battle(ctx, kind, a, b)
|
|||||||
ctx.lastBattleResult = result
|
ctx.lastBattleResult = result
|
||||||
ctx.lastCheck = result == "win"
|
ctx.lastCheck = result == "win"
|
||||||
if ctx.overworld then
|
if ctx.overworld then
|
||||||
ctx.overworld:afterBattle(result)
|
ctx.overworld:afterBattle(result, battle)
|
||||||
end
|
end
|
||||||
runner:resume()
|
runner:resume()
|
||||||
end
|
end
|
||||||
@@ -269,7 +270,10 @@ end
|
|||||||
local DIRS4 = { { 0, -1, "up" }, { 0, 1, "down" },
|
local DIRS4 = { { 0, -1, "up" }, { 0, 1, "down" },
|
||||||
{ -1, 0, "left" }, { 1, 0, "right" } }
|
{ -1, 0, "left" }, { 1, 0, "right" } }
|
||||||
|
|
||||||
local function bfsPath(map, sx, sy, tx, ty)
|
-- entities/mover let the walk route around other entities' cells (the
|
||||||
|
-- player especially), so a scripted NPC never clips through them; the
|
||||||
|
-- target cell is exempt so an NPC can still walk up onto its goal
|
||||||
|
local function bfsPath(map, sx, sy, tx, ty, entities, mover)
|
||||||
local key = function(x, y) return y * 1000 + x end
|
local key = function(x, y) return y * 1000 + x end
|
||||||
local prev = { [key(sx, sy)] = false }
|
local prev = { [key(sx, sy)] = false }
|
||||||
local queue = { { sx, sy } }
|
local queue = { { sx, sy } }
|
||||||
@@ -289,8 +293,11 @@ local function bfsPath(map, sx, sy, tx, ty)
|
|||||||
for _, d in ipairs(DIRS4) do
|
for _, d in ipairs(DIRS4) do
|
||||||
local nx, ny = cx + d[1], cy + d[2]
|
local nx, ny = cx + d[1], cy + d[2]
|
||||||
local nk = key(nx, ny)
|
local nk = key(nx, ny)
|
||||||
|
local isTarget = nx == tx and ny == ty
|
||||||
if prev[nk] == nil and map:inBounds(nx, ny)
|
if prev[nk] == nil and map:inBounds(nx, ny)
|
||||||
and (map:isWalkableCell(nx, ny) or (nx == tx and ny == ty)) then
|
and (map:isWalkableCell(nx, ny) or isTarget)
|
||||||
|
and (isTarget or not entities
|
||||||
|
or not Collision.occupied(entities, nx, ny, mover)) then
|
||||||
prev[nk] = { cx, cy, d[3] }
|
prev[nk] = { cx, cy, d[3] }
|
||||||
table.insert(queue, { nx, ny })
|
table.insert(queue, { nx, ny })
|
||||||
end
|
end
|
||||||
@@ -303,7 +310,7 @@ function Commands.move_npc_to(ctx, objIndex, tx, ty)
|
|||||||
local ow = ctx.overworld
|
local ow = ctx.overworld
|
||||||
local npc = ow:npcByIndex(objIndex)
|
local npc = ow:npcByIndex(objIndex)
|
||||||
if not npc then return end
|
if not npc then return end
|
||||||
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty)
|
local path = bfsPath(ow.map, npc.cellX, npc.cellY, tx, ty, ow.entities, npc)
|
||||||
if not path then
|
if not path then
|
||||||
Logger.warn("move_npc_to: no path to (%d,%d)", tx, ty)
|
Logger.warn("move_npc_to: no path to (%d,%d)", tx, ty)
|
||||||
return
|
return
|
||||||
@@ -390,6 +397,7 @@ local function toggleObject(ctx, mapId, objName, visible)
|
|||||||
-- positions mid-cutscene)
|
-- positions mid-cutscene)
|
||||||
local ow = ctx.overworld
|
local ow = ctx.overworld
|
||||||
if not ow or ow.map.id ~= mapId then return end
|
if not ow or ow.map.id ~= mapId then return end
|
||||||
|
ow.npcPool = ow.npcPool or {}
|
||||||
if visible then
|
if visible then
|
||||||
for _, n in ipairs(ow.npcs) do
|
for _, n in ipairs(ow.npcs) do
|
||||||
if n.def.name == objName then return end
|
if n.def.name == objName then return end
|
||||||
@@ -400,12 +408,16 @@ local function toggleObject(ctx, mapId, objName, visible)
|
|||||||
local npc = NPC.new(ctx.game.data, mapId, obj)
|
local npc = NPC.new(ctx.game.data, mapId, obj)
|
||||||
table.insert(ow.npcs, npc)
|
table.insert(ow.npcs, npc)
|
||||||
table.insert(ow.entities, npc)
|
table.insert(ow.entities, npc)
|
||||||
|
ow.npcPool[npc.id] = npc
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
for i = #ow.npcs, 1, -1 do
|
for i = #ow.npcs, 1, -1 do
|
||||||
if ow.npcs[i].def.name == objName then table.remove(ow.npcs, i) end
|
if ow.npcs[i].def.name == objName then
|
||||||
|
ow.npcPool[ow.npcs[i].id] = nil
|
||||||
|
table.remove(ow.npcs, i)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
for i = #ow.entities, 1, -1 do
|
for i = #ow.entities, 1, -1 do
|
||||||
local e = ow.entities[i]
|
local e = ow.entities[i]
|
||||||
@@ -426,6 +438,20 @@ function Commands.play_sound(ctx, soundId)
|
|||||||
require("src.core.Sound").play(ctx.game.data, soundId)
|
require("src.core.Sound").play(ctx.game.data, soundId)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
|
||||||
|
-- until it finishes so heal-rest scripts (Mom, captain text_asm) match
|
||||||
|
-- Gen1's wait-on-channel loop. The map theme resumes when it ends
|
||||||
|
-- (Music.playOnce / pendingRestore).
|
||||||
|
function Commands.play_once(ctx, songId)
|
||||||
|
local Music = require("src.core.Music")
|
||||||
|
if not Music.playOnce(ctx.game.data, songId) then return end
|
||||||
|
local runner = ctx.runner
|
||||||
|
runner.waitingCheck = function()
|
||||||
|
return not Music.oneShotPlaying()
|
||||||
|
end
|
||||||
|
runner:yield()
|
||||||
|
end
|
||||||
|
|
||||||
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
|
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
|
||||||
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
|
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
|
||||||
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
|
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
|
||||||
@@ -458,11 +484,51 @@ function Commands.heal_party(ctx)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen.
|
||||||
|
-- AddPartyMon only offers this for party mons (box deposits skip it).
|
||||||
|
-- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the
|
||||||
|
-- yes/no result (scripts jump_if_false on give failure afterwards).
|
||||||
|
local function askNickname(ctx, mon)
|
||||||
|
local runner = ctx.runner
|
||||||
|
if not runner then return end
|
||||||
|
local success = ctx.lastCheck
|
||||||
|
local name = ctx.game.stringBuffer
|
||||||
|
or (ctx.game.data.pokemon[mon.species]
|
||||||
|
and ctx.game.data.pokemon[mon.species].name)
|
||||||
|
or mon.species
|
||||||
|
-- Prefer the real text label; fall back to the BattleState wording.
|
||||||
|
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
|
||||||
|
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
|
||||||
|
else
|
||||||
|
Commands.show_text(ctx, ("Do you want to\ngive a nickname\nto %s?"):format(name))
|
||||||
|
end
|
||||||
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
|
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
|
||||||
|
if not yes then
|
||||||
|
ctx.lastCheck = success
|
||||||
|
runner:resume()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
Screens.push(ctx.game, "NamingScreen", {
|
||||||
|
title = "NICKNAME?", maxLen = 10,
|
||||||
|
onDone = function(nick)
|
||||||
|
if nick and #nick > 0 then mon.nickname = nick end
|
||||||
|
ctx.lastCheck = success
|
||||||
|
runner:resume()
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end))
|
||||||
|
runner:yield()
|
||||||
|
ctx.lastCheck = success
|
||||||
|
end
|
||||||
|
|
||||||
-- give_pokemon <species> <level>: _GivePokemon (engine/events/
|
-- give_pokemon <species> <level>: _GivePokemon (engine/events/
|
||||||
-- give_pokemon.asm) -- party first, then the box. ctx.lastCheck gets
|
-- give_pokemon.asm) -- party first, then the box. ctx.lastCheck gets
|
||||||
-- the asm's carry: true when the mon was given, false when both the
|
-- the asm's carry: true when the mon was given, false when both the
|
||||||
-- party and every box are full (that .boxFull path leaves the giver's
|
-- party and every box are full (that .boxFull path leaves the giver's
|
||||||
-- script able to offer again later, e.g. the Celadon Eevee ball).
|
-- script able to offer again later, e.g. the Celadon Eevee ball).
|
||||||
|
-- Party adds run AskName (AddPartyMon) when a script runner is present;
|
||||||
|
-- mods that pre-set gift.nickname skip the prompt.
|
||||||
function Commands.give_pokemon(ctx, species, level)
|
function Commands.give_pokemon(ctx, species, level)
|
||||||
-- Native mods can transform a gift before the Pokémon object is created.
|
-- 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:
|
-- This is intentionally an event rather than a special-case starter hook:
|
||||||
@@ -479,7 +545,8 @@ function Commands.give_pokemon(ctx, species, level)
|
|||||||
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
|
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
|
||||||
ctx.pendingPokemonName = species
|
ctx.pendingPokemonName = species
|
||||||
require("src.battle.BattleState").stampOT(ctx.save, mon)
|
require("src.battle.BattleState").stampOT(ctx.save, mon)
|
||||||
if not Party.add(ctx.save.party, mon) then
|
local addedToParty = Party.add(ctx.save.party, mon)
|
||||||
|
if not addedToParty then
|
||||||
if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
|
if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
|
||||||
ctx.lastCheck = false
|
ctx.lastCheck = false
|
||||||
return
|
return
|
||||||
@@ -491,18 +558,40 @@ function Commands.give_pokemon(ctx, species, level)
|
|||||||
dex.owned[species] = true
|
dex.owned[species] = true
|
||||||
end
|
end
|
||||||
ctx.lastCheck = true
|
ctx.lastCheck = true
|
||||||
|
-- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames,
|
||||||
|
-- and callback-style callers that have no script runner to yield on.
|
||||||
|
if addedToParty and not gift.nickname and ctx.runner then
|
||||||
|
askNickname(ctx, mon)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Commands.give_money(ctx, amount)
|
function Commands.give_money(ctx, amount)
|
||||||
ctx.save.money = math.max(0, ctx.save.money + amount)
|
ctx.save.money = math.max(0, ctx.save.money + amount)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the
|
||||||
|
-- live overworld memory in sync so a scripted home warp from the HoF PC
|
||||||
|
-- does not leave Red's house mats aimed at Indigo Plateau (#103).
|
||||||
|
function Commands.remember_outdoor(ctx, mapId, x, y)
|
||||||
|
local outdoor = { id = mapId, x = x, y = y }
|
||||||
|
ctx.save.lastOutdoor = outdoor
|
||||||
|
if ctx.overworld and ctx.overworld.rememberOutdoor then
|
||||||
|
ctx.overworld:rememberOutdoor(mapId, x, y)
|
||||||
|
elseif ctx.overworld then
|
||||||
|
ctx.overworld.lastOutdoor = outdoor
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Hall of Fame: snapshot the winning party (SaveHallOfFameTeams inside
|
-- Hall of Fame: snapshot the winning party (SaveHallOfFameTeams inside
|
||||||
-- AnimateHallOfFame), run the induction showcase and the end credits,
|
-- AnimateHallOfFame), run the induction showcase and the end credits,
|
||||||
-- autosave while THE END is up, then soft-reset to the title -- the whole
|
-- autosave while THE END is up, then soft-reset to the title -- the whole
|
||||||
-- predef HallOfFamePC + tail of HallOfFameResetEventsAndSaveScript
|
-- predef HallOfFamePC + tail of HallOfFameResetEventsAndSaveScript
|
||||||
-- (engine/movie/hall_of_fame.asm, engine/movie/credits.asm,
|
-- (engine/movie/hall_of_fame.asm, engine/movie/credits.asm,
|
||||||
-- scripts/HallOfFame.asm).
|
-- scripts/HallOfFame.asm).
|
||||||
|
--
|
||||||
|
-- Departure from pokered: CONTINUE lands in the NewGameWarp bedroom with
|
||||||
|
-- a healed party and lastOutdoor on Pallet Town, instead of remaining
|
||||||
|
-- softlocked in HALL_OF_FAME with LAST_MAP still aimed at Indigo (#103).
|
||||||
function Commands.record_hall_of_fame(ctx)
|
function Commands.record_hall_of_fame(ctx)
|
||||||
ctx.save.hallOfFame = ctx.save.hallOfFame or {}
|
ctx.save.hallOfFame = ctx.save.hallOfFame or {}
|
||||||
local entry = {}
|
local entry = {}
|
||||||
@@ -518,17 +607,25 @@ function Commands.record_hall_of_fame(ctx)
|
|||||||
Screens.push(game, "Credits", function()
|
Screens.push(game, "Credits", function()
|
||||||
runner:resume()
|
runner:resume()
|
||||||
end, function()
|
end, function()
|
||||||
-- THE END is on screen: HallOfFameResetEventsAndSaveScript sets
|
-- THE END is on screen: heal, place the player at post-game home,
|
||||||
-- wLastBlackoutMap := PALLET_TOWN and runs SaveGameData, so the
|
-- retarget LAST_MAP, then SaveGameData. (E4 room-script/event
|
||||||
-- save keeps the player standing in the HALL_OF_FAME room. (The
|
-- resets that precede the save in pokered are the Indigo lobby's
|
||||||
-- E4 room-script/event resets that precede the save in pokered are
|
-- re-entry reset here, data/scripts/story6.lua.)
|
||||||
-- the Indigo lobby's re-entry reset here, data/scripts/story6.lua.)
|
local SaveData = require("src.core.SaveData")
|
||||||
-- The reset heal point is field.boot's spawn, PALLET_TOWN (5,6)
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
-- in the vanilla dataset.
|
|
||||||
local boot = game.data.field and game.data.field.boot or {}
|
local boot = game.data.field and game.data.field.boot or {}
|
||||||
ctx.save.lastHeal = { map = boot.startMap or "PALLET_TOWN",
|
for _, mon in ipairs(ctx.save.party or {}) do
|
||||||
x = boot.startX or 5, y = boot.startY or 6 }
|
Pokemon.heal(mon)
|
||||||
|
end
|
||||||
|
SaveData.applyPostGameHome(ctx.save, boot)
|
||||||
|
if game.overworld then
|
||||||
|
game.overworld.lastOutdoor = ctx.save.lastOutdoor
|
||||||
|
end
|
||||||
if game.writeSave then game:writeSave() end
|
if game.writeSave then game:writeSave() end
|
||||||
|
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
|
||||||
|
-- re-apply home and persist so CONTINUE resumes in the bedroom.
|
||||||
|
SaveData.applyPostGameHome(ctx.save, boot)
|
||||||
|
SaveData.save(ctx.save)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
runner:yield()
|
runner:yield()
|
||||||
@@ -823,7 +920,12 @@ end
|
|||||||
-- registry and block until the screen pops itself
|
-- registry and block until the screen pops itself
|
||||||
function Commands.push_screen(ctx, screenId, args)
|
function Commands.push_screen(ctx, screenId, args)
|
||||||
local screens = ctx.game.data.screens
|
local screens = ctx.game.data.screens
|
||||||
if not (screens and screens[screenId]) then
|
local known = screens and screens[screenId] ~= nil
|
||||||
|
if not known then
|
||||||
|
-- builtin engine screens (src/ui/<id>.lua) resolve through Screens too
|
||||||
|
known = pcall(Screens.get, ctx.game, screenId)
|
||||||
|
end
|
||||||
|
if not known then
|
||||||
error(("push_screen: unknown screen '%s'"):format(tostring(screenId)), 0)
|
error(("push_screen: unknown screen '%s'"):format(tostring(screenId)), 0)
|
||||||
end
|
end
|
||||||
local runner = ctx.runner
|
local runner = ctx.runner
|
||||||
@@ -838,15 +940,21 @@ function Commands.push_screen(ctx, screenId, args)
|
|||||||
runner:yield()
|
runner:yield()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- fade "out"|"in" [frames]: screen fade without warping (the Transition
|
-- fade "out"|"in" [frames|"white"|"black"] [frames|"white"|"black"]:
|
||||||
-- ramp startWarpTo uses, split in two). "out" pushes a black overlay
|
-- screen fade without warping (the Transition ramp startWarpTo uses,
|
||||||
-- that stays up; the held state keeps ticking the runner's frame-waits
|
-- split in two). "out" pushes an overlay that stays up; the held state
|
||||||
-- so a script can wait/replace_block under it. "in" ramps it away.
|
-- keeps ticking the runner's frame-waits so a script can wait /
|
||||||
|
-- heal_party / play_once under it. "in" ramps it away.
|
||||||
|
-- Color defaults to black (warp-style); "white" matches GBFadeOutToWhite
|
||||||
|
-- / GBFadeInFromWhite (Mom heal, Silph Co. nurse). White defaults to
|
||||||
|
-- 24 frames (3 palettes x 8); black defaults to 12 (Transition).
|
||||||
local FadeOverlay = {}
|
local FadeOverlay = {}
|
||||||
FadeOverlay.__index = FadeOverlay
|
FadeOverlay.__index = FadeOverlay
|
||||||
|
|
||||||
function FadeOverlay.new(game, ow)
|
function FadeOverlay.new(game, ow, color)
|
||||||
return setmetatable({ game = game, ow = ow, alpha = 0 }, FadeOverlay)
|
return setmetatable({
|
||||||
|
game = game, ow = ow, alpha = 0, color = color or "black",
|
||||||
|
}, FadeOverlay)
|
||||||
end
|
end
|
||||||
|
|
||||||
function FadeOverlay:update()
|
function FadeOverlay:update()
|
||||||
@@ -872,28 +980,51 @@ function FadeOverlay:update()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function FadeOverlay:draw()
|
function FadeOverlay:draw()
|
||||||
love.graphics.setColor(0, 0, 0, self.alpha)
|
if self.color == "white" then
|
||||||
|
love.graphics.setColor(1, 1, 1, self.alpha)
|
||||||
|
else
|
||||||
|
love.graphics.setColor(0, 0, 0, self.alpha)
|
||||||
|
end
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Commands.fade(ctx, dir, frames)
|
local function parseFadeArgs(a, b)
|
||||||
|
local frames, color
|
||||||
|
if type(a) == "string" then
|
||||||
|
color = a
|
||||||
|
if type(b) == "number" then frames = b end
|
||||||
|
elseif type(a) == "number" then
|
||||||
|
frames = a
|
||||||
|
if type(b) == "string" then color = b end
|
||||||
|
end
|
||||||
|
color = color or "black"
|
||||||
|
if not frames then
|
||||||
|
frames = (color == "white") and 24 or 12
|
||||||
|
end
|
||||||
|
return frames, color
|
||||||
|
end
|
||||||
|
|
||||||
|
function Commands.fade(ctx, dir, framesOrColor, colorOrFrames)
|
||||||
local ow = ctx.overworld
|
local ow = ctx.overworld
|
||||||
if not ow then return end
|
if not ow then return end
|
||||||
local runner = ctx.runner
|
local runner = ctx.runner
|
||||||
frames = frames or 12 -- Transition's ramp length
|
local frames, color = parseFadeArgs(framesOrColor, colorOrFrames)
|
||||||
local overlay = ow.fadeOverlay
|
local overlay = ow.fadeOverlay
|
||||||
if dir == "out" then
|
if dir == "out" then
|
||||||
if not overlay then
|
if not overlay then
|
||||||
overlay = FadeOverlay.new(ctx.game, ow)
|
overlay = FadeOverlay.new(ctx.game, ow, color)
|
||||||
ow.fadeOverlay = overlay
|
ow.fadeOverlay = overlay
|
||||||
ctx.game.stack:push(overlay)
|
ctx.game.stack:push(overlay)
|
||||||
|
else
|
||||||
|
overlay.color = color
|
||||||
end
|
end
|
||||||
overlay.ramp = { from = overlay.alpha, to = 1, frames = frames, t = 0,
|
overlay.ramp = { from = overlay.alpha, to = 1, frames = frames, t = 0,
|
||||||
onDone = function() runner:resume() end }
|
onDone = function() runner:resume() end }
|
||||||
runner:yield()
|
runner:yield()
|
||||||
elseif dir == "in" then
|
elseif dir == "in" then
|
||||||
if not overlay then return end
|
if not overlay then return end
|
||||||
|
overlay.color = color or overlay.color
|
||||||
overlay.ramp = { from = overlay.alpha, to = 0, frames = frames, t = 0,
|
overlay.ramp = { from = overlay.alpha, to = 0, frames = frames, t = 0,
|
||||||
onDone = function() runner:resume() end }
|
onDone = function() runner:resume() end }
|
||||||
runner:yield()
|
runner:yield()
|
||||||
@@ -981,9 +1112,10 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
|||||||
end
|
end
|
||||||
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
||||||
"open_mart", "trade", "push_screen", "record_hall_of_fame",
|
"open_mart", "trade", "push_screen", "record_hall_of_fame",
|
||||||
"old_man_demo", "static_battle", "rival_battle", "give_item", "wait",
|
"old_man_demo", "static_battle", "rival_battle", "give_item",
|
||||||
|
"give_pokemon", "wait",
|
||||||
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
|
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
|
||||||
"emote", "fade", "pan_camera" }) do
|
"emote", "fade", "pan_camera", "play_once" }) do
|
||||||
local meta = Commands.meta[verb] or {}
|
local meta = Commands.meta[verb] or {}
|
||||||
Commands.meta[verb] = meta
|
Commands.meta[verb] = meta
|
||||||
meta.blocking = true
|
meta.blocking = true
|
||||||
|
|||||||
+52
-29
@@ -71,6 +71,12 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
consume(game, id)
|
consume(game, id)
|
||||||
list:close()
|
list:close()
|
||||||
showMessages(game, payload, function()
|
showMessages(game, payload, function()
|
||||||
|
-- ItemUsePokeDoll sets wEscapedFromBattle and never touches
|
||||||
|
-- wBattleResult, so a script that reads the result afterwards sees
|
||||||
|
-- 0 -- "defeated". The ghost MAROWAK's script keys on exactly that
|
||||||
|
-- (the Poke Doll trick); the flag lets it tell this escape from an
|
||||||
|
-- ordinary RUN, which writes $2.
|
||||||
|
battle.pokeDollEscape = true
|
||||||
battle.result = "run"
|
battle.result = "run"
|
||||||
battle.afterQueue = "finish"
|
battle.afterQueue = "finish"
|
||||||
battle.phase = "messages"
|
battle.phase = "messages"
|
||||||
@@ -210,7 +216,9 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
consume(game, id)
|
consume(game, id)
|
||||||
require("src.core.Sound").play(game.data, "Teleport_Exit1")
|
require("src.core.Sound").play(game.data, "Teleport_Exit1")
|
||||||
ow.player.surfing = false
|
ow.player.surfing = false
|
||||||
ow:warpToHealPoint()
|
-- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path);
|
||||||
|
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
|
||||||
|
ow:warpToHealPoint(nil, { arrive = "teleport" })
|
||||||
else
|
else
|
||||||
showMessages(game, { "OAK: " .. game.save.player.name
|
showMessages(game, { "OAK: " .. game.save.player.name
|
||||||
.. "!\nThis isn't the\ntime to use that!" })
|
.. "!\nThis isn't the\ntime to use that!" })
|
||||||
@@ -290,37 +298,52 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
|||||||
showMessages(game, payload) -- failed
|
showMessages(game, payload) -- failed
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function pickTargetAndUse(game, battle, id, list)
|
||||||
|
-- pick a target from the party
|
||||||
|
-- the ETHERs and PP UP open the move menu after picking a mon
|
||||||
|
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
|
||||||
|
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
|
||||||
|
require("src.ui.Screens").push(game, "PartyMenu", {
|
||||||
|
pickOnly = true,
|
||||||
|
onSwitch = function(mon)
|
||||||
|
if not wantsMove then
|
||||||
|
useOn(game, battle, id, mon, list)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local rows = {}
|
||||||
|
for mi, mv in ipairs(mon.moves) do
|
||||||
|
local mdef = game.data.moves[mv.id]
|
||||||
|
table.insert(rows, {
|
||||||
|
value = mi,
|
||||||
|
label = mdef and mdef.name or mv.id,
|
||||||
|
right = ("%d"):format(mv.pp),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
game.stack:push(ListMenu.new(game, "Which move?", rows, {
|
||||||
|
onChoose = function(row, l)
|
||||||
|
l:close()
|
||||||
|
useOn(game, battle, id, mon, list, row.value)
|
||||||
|
end,
|
||||||
|
}))
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
local function useItem(game, battle, id, list)
|
local function useItem(game, battle, id, list)
|
||||||
local def = game.data.items[id]
|
local def = game.data.items[id]
|
||||||
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
|
if ItemEffects.needsTarget(id, def) and not ItemEffects.isBall(id) then
|
||||||
-- pick a target from the party
|
-- TMs/HMs boot up and announce their move before the target picker
|
||||||
-- the ETHERs and PP UP open the move menu after picking a mon
|
-- (ItemUseTMHM: BootedUpTMText / BootedUpHMText + TeachMachineMoveText)
|
||||||
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
|
if def and def.machine then
|
||||||
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
|
local moveDef = game.data.moves[def.machine.move]
|
||||||
require("src.ui.Screens").push(game, "PartyMenu", {
|
local moveName = moveDef and moveDef.name or def.machine.move
|
||||||
pickOnly = true,
|
local booted = def.machine.kind == "HM"
|
||||||
onSwitch = function(mon)
|
and "Booted up an HM!" or "Booted up a TM!"
|
||||||
if not wantsMove then
|
showMessages(game, { booted, ("It contained\n%s!"):format(moveName) },
|
||||||
useOn(game, battle, id, mon, list)
|
function() pickTargetAndUse(game, battle, id, list) end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local rows = {}
|
pickTargetAndUse(game, battle, id, list)
|
||||||
for mi, mv in ipairs(mon.moves) do
|
|
||||||
local mdef = game.data.moves[mv.id]
|
|
||||||
table.insert(rows, {
|
|
||||||
value = mi,
|
|
||||||
label = mdef and mdef.name or mv.id,
|
|
||||||
right = ("%d"):format(mv.pp),
|
|
||||||
})
|
|
||||||
end
|
|
||||||
game.stack:push(ListMenu.new(game, "Which move?", rows, {
|
|
||||||
onChoose = function(row, l)
|
|
||||||
l:close()
|
|
||||||
useOn(game, battle, id, mon, list, row.value)
|
|
||||||
end,
|
|
||||||
}))
|
|
||||||
end,
|
|
||||||
})
|
|
||||||
else
|
else
|
||||||
useOn(game, battle, id, nil, list)
|
useOn(game, battle, id, nil, list)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
-- Rebinding over the logical Game Boy buttons (gap C2's file-12 half,
|
-- Rebinding over the logical Game Boy buttons (gap C2's file-12 half,
|
||||||
-- 12-ui-extensibility 4.4): one row per button, A arms a "PRESS A BUTTON"
|
-- 12-ui-extensibility 4.4): one row per button, A arms a "PRESS A BUTTON"
|
||||||
-- capture and the captured key or pad button lands in
|
-- capture and the captured key or pad button lands in
|
||||||
-- save.options.bindings -- the overlay src/core/Bindings.lua
|
-- save.options.bindings, which Input:applyBindings layers over its fixed
|
||||||
-- (04-mod-api-core) reads back over Input's fixed map.
|
-- default map (see src/core/Input.lua and Game:applyOptions).
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local ListMenu = require("src.ui.ListMenu")
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
|
local Input = require("src.core.Input")
|
||||||
|
|
||||||
local BindingsMenu = setmetatable({}, { __index = ListMenu })
|
local BindingsMenu = setmetatable({}, { __index = ListMenu })
|
||||||
BindingsMenu.__index = BindingsMenu
|
BindingsMenu.__index = BindingsMenu
|
||||||
@@ -78,6 +79,7 @@ function BindingsMenu:storeBinding(slot, value)
|
|||||||
b[slot] = value
|
b[slot] = value
|
||||||
opts.bindings[item.button.id] = b
|
opts.bindings[item.button.id] = b
|
||||||
item.right = boundKey(opts.bindings, item.button):upper()
|
item.right = boundKey(opts.bindings, item.button):upper()
|
||||||
|
Input:applyBindings(opts.bindings)
|
||||||
if game.writeOptions then game:writeOptions() end
|
if game.writeOptions then game:writeOptions() end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -145,6 +145,10 @@ end
|
|||||||
|
|
||||||
function BoxMenu.new(game)
|
function BoxMenu.new(game)
|
||||||
Boxes.ensure(game.save)
|
Boxes.ensure(game.save)
|
||||||
|
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
|
||||||
|
-- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the
|
||||||
|
-- full interior (cursor col + label); the old tw=12 right-side box
|
||||||
|
-- drew the final glyph on the border.
|
||||||
return Menu.new(game, {
|
return Menu.new(game, {
|
||||||
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
|
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
|
||||||
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
|
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
|
||||||
@@ -153,7 +157,7 @@ function BoxMenu.new(game)
|
|||||||
{ label = "SEE YA!" },
|
{ label = "SEE YA!" },
|
||||||
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
|
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
|
||||||
-- engine/menus/pokemon_pc.asm)
|
-- engine/menus/pokemon_pc.asm)
|
||||||
}, { tx = 8, ty = 0, tw = 12, th = 12, noSound = true })
|
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
|
||||||
end
|
end
|
||||||
|
|
||||||
return BoxMenu
|
return BoxMenu
|
||||||
|
|||||||
+2
-1
@@ -217,7 +217,8 @@ function Credits:update(dt)
|
|||||||
self.timer = FADE_FRAMES
|
self.timer = FADE_FRAMES
|
||||||
elseif self.phase == "end_fade" then
|
elseif self.phase == "end_fade" then
|
||||||
-- Credits returns to HallOfFameResetEventsAndSaveScript here: the
|
-- Credits returns to HallOfFameResetEventsAndSaveScript here: the
|
||||||
-- save happens now, then 5 x 120 DelayFrames before the button wait
|
-- save (plus post-game home relocate, #103) happens now, then
|
||||||
|
-- 5 x 120 DelayFrames before the button wait
|
||||||
if self.onTheEnd then self.onTheEnd() end
|
if self.onTheEnd then self.onTheEnd() end
|
||||||
self.phase = "end_hold"
|
self.phase = "end_hold"
|
||||||
self.timer = 600
|
self.timer = 600
|
||||||
|
|||||||
+23
-4
@@ -1,5 +1,11 @@
|
|||||||
-- Pokédex entry page: front sprite, kind, height/weight and the real
|
-- Pokédex entry page: front sprite, kind, height/weight and the real
|
||||||
-- dex description (data/pokemon/dex_entries.asm + dex_text.asm).
|
-- dex description (data/pokemon/dex_entries.asm + dex_text.asm).
|
||||||
|
--
|
||||||
|
-- `species` may be a species id string, or a table
|
||||||
|
-- `{ species = id, forceOwned = true }`. forceOwned mirrors pret's
|
||||||
|
-- StarterDex (engine/events/starter_dex.asm), which temporarily sets the
|
||||||
|
-- owned bit so Oak's lab ball previews show height/weight/description
|
||||||
|
-- without permanently marking the mon owned.
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
|
||||||
@@ -16,8 +22,17 @@ function DexEntryMenu:sgbPalettes(game)
|
|||||||
P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) }
|
P.zone(P.monPal(game.data, self.def and self.def.id), 1, 1, 8, 8) }
|
||||||
end
|
end
|
||||||
|
|
||||||
function DexEntryMenu.new(game, species)
|
local function resolveArgs(speciesOrOpts)
|
||||||
local self = setmetatable({ game = game }, DexEntryMenu)
|
if type(speciesOrOpts) == "table" then
|
||||||
|
return speciesOrOpts.species or speciesOrOpts[1],
|
||||||
|
speciesOrOpts.forceOwned and true or false
|
||||||
|
end
|
||||||
|
return speciesOrOpts, false
|
||||||
|
end
|
||||||
|
|
||||||
|
function DexEntryMenu.new(game, speciesOrOpts)
|
||||||
|
local species, forceOwned = resolveArgs(speciesOrOpts)
|
||||||
|
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
|
||||||
self.def = game.data.pokemon[species]
|
self.def = game.data.pokemon[species]
|
||||||
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
|
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
|
||||||
self.sprite = ok and img or nil
|
self.sprite = ok and img or nil
|
||||||
@@ -42,12 +57,16 @@ function DexEntryMenu:draw()
|
|||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(def.name, 72, 8)
|
Font.draw(def.name, 72, 8)
|
||||||
local e = def.dexEntry or {}
|
local e = def.dexEntry or {}
|
||||||
Font.draw((e.kind or "?") .. " POKéMON", 72, 20)
|
-- English R/B prints only the kind string (hlcoord 9,4 PlaceString).
|
||||||
|
-- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm;
|
||||||
|
-- appending " POKéMON" here clipped longer kinds ("LIZARD POKé").
|
||||||
|
Font.draw(e.kind or "?", 72, 20)
|
||||||
-- same number width as the list (constants.dexDigits), so a dex past 999
|
-- same number width as the list (constants.dexDigits), so a dex past 999
|
||||||
-- prints the extra digit everywhere at once
|
-- prints the extra digit everywhere at once
|
||||||
local digits = (self.game.data.constants or {}).dexDigits or 3
|
local digits = (self.game.data.constants or {}).dexDigits or 3
|
||||||
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
|
Font.draw(("No.%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
|
||||||
local owned = self.game.save.pokedex and self.game.save.pokedex.owned[def.id]
|
local owned = self.forceOwned
|
||||||
|
or (self.game.save.pokedex and self.game.save.pokedex.owned[def.id])
|
||||||
-- height/weight print only once owned, like the description
|
-- height/weight print only once owned, like the description
|
||||||
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
|
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
|
||||||
-- height, weight, or description")
|
-- height, weight, or description")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
-- has no cancel; the flash always completes).
|
-- has no cancel; the flash always completes).
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local Music = require("src.core.Music")
|
||||||
|
|
||||||
local EvolutionState = {}
|
local EvolutionState = {}
|
||||||
EvolutionState.__index = EvolutionState
|
EvolutionState.__index = EvolutionState
|
||||||
@@ -39,6 +40,7 @@ function EvolutionState.new(game, mon, newSpecies, onDone)
|
|||||||
self.newSprite = frontSprite(game, newSpecies)
|
self.newSprite = frontSprite(game, newSpecies)
|
||||||
self.t = 0
|
self.t = 0
|
||||||
self.done = false
|
self.done = false
|
||||||
|
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ function EvolutionState:update(dt)
|
|||||||
("Congratulations!\nYour %s\nevolved into\n%s!")
|
("Congratulations!\nYour %s\nevolved into\n%s!")
|
||||||
:format(self.oldName, newName),
|
:format(self.oldName, newName),
|
||||||
function()
|
function()
|
||||||
|
Music.restoreMap(game.data)
|
||||||
game.stack:pop() -- the evolution screen itself
|
game.stack:pop() -- the evolution screen itself
|
||||||
if self.onDone then self.onDone() end
|
if self.onDone then self.onDone() end
|
||||||
end))
|
end))
|
||||||
|
|||||||
+204
-79
@@ -1,14 +1,16 @@
|
|||||||
-- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party
|
-- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party
|
||||||
-- member's front sprite scrolls onto the screen (HoFShowMonOrPlayer's
|
-- member's front sprite scrolls onto the right side of the screen
|
||||||
-- .ScrollPic), then its name/level shows and its cry plays
|
-- (HoFShowMonOrPlayer's .ScrollPic), then HoFDisplayMonInfo draws the
|
||||||
-- (HoFDisplayAndRecordMonInfo). After the last mon, HoFDisplayPlayerStats
|
-- left-side LEVEL/TYPE box, plays the cry, holds, and pops the bottom
|
||||||
-- shows the trainer name, play time, money and Prof. Oak's dex rating.
|
-- "HALL OF FAME" text box before fading to the next mon. After the
|
||||||
-- Plays Music_HallOfFame when the audio data has it. Calls onDone() after
|
-- party, the player pic scrolls in and HoFDisplayPlayerStats shows the
|
||||||
-- popping itself.
|
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
|
||||||
|
-- when the audio data has it. Calls onDone() after popping itself.
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
|
local TypeChart = require("src.battle.TypeChart")
|
||||||
|
|
||||||
local HallOfFame = {}
|
local HallOfFame = {}
|
||||||
HallOfFame.__index = HallOfFame
|
HallOfFame.__index = HallOfFame
|
||||||
@@ -17,6 +19,10 @@ HallOfFame.isOpaque = true
|
|||||||
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
||||||
function HallOfFame:sgbPalettes(game)
|
function HallOfFame:sgbPalettes(game)
|
||||||
local P = require("src.render.PaletteFX")
|
local P = require("src.render.PaletteFX")
|
||||||
|
if self.phase == "player" or self.phase == "player_stats"
|
||||||
|
or self.phase == "player_dex" or self.phase == "player_rating" then
|
||||||
|
return P.wholeNamed(game.data, "MEWMON")
|
||||||
|
end
|
||||||
local mon = game.save.party[self.index or 0]
|
local mon = game.save.party[self.index or 0]
|
||||||
if mon then
|
if mon then
|
||||||
local c = P.monPal(game.data, mon.species)
|
local c = P.monPal(game.data, mon.species)
|
||||||
@@ -26,17 +32,20 @@ function HallOfFame:sgbPalettes(game)
|
|||||||
return P.wholeNamed(game.data, "MEWMON")
|
return P.wholeNamed(game.data, "MEWMON")
|
||||||
end
|
end
|
||||||
|
|
||||||
local MON_FRAMES = 150 -- ~2.5s per inductee (A advances early)
|
|
||||||
|
|
||||||
-- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per
|
-- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per
|
||||||
-- DelayFrame (doubled on SGB) until it settles. The back pic (an
|
-- DelayFrame (doubled on SGB) until it settles. The front pic rests at
|
||||||
-- enlarged, blurred 2x scale of the back sprite) sweeps right-to-left
|
-- hlcoord 12,5 (engine/movie/hall_of_fame.asm HoFLoadMonPlayerPicTileIDs).
|
||||||
-- and off the left edge first; tracing the actual hSCX/hSCY math shows
|
|
||||||
-- the real front pic that follows enters from the *left* edge and
|
|
||||||
-- slides *right* into its resting tile, at that same 4px/frame rate --
|
|
||||||
-- that's the half we port here (the back-pic wipe is a VRAM/scroll-
|
|
||||||
-- register trick with no equivalent in this sprite-based renderer).
|
|
||||||
local SCROLL_SPEED = 4 -- px/frame @ 60fps
|
local SCROLL_SPEED = 4 -- px/frame @ 60fps
|
||||||
|
local PIC_X, PIC_Y = 12 * 8, 5 * 8
|
||||||
|
|
||||||
|
-- After HoFDisplayAndRecordMonInfo: 80 DelayFrames, then the bottom
|
||||||
|
-- HALL OF FAME box for 180 DelayFrames, then GBFadeOutToWhite.
|
||||||
|
local INFO_HOLD = 80
|
||||||
|
local HOF_HOLD = 180
|
||||||
|
local FADE_FRAMES = 20
|
||||||
|
|
||||||
|
-- HoFPrintTextAndDelay after each dex line
|
||||||
|
local DEX_HOLD = 120
|
||||||
|
|
||||||
local function tryImage(path)
|
local function tryImage(path)
|
||||||
if not path then return nil end
|
if not path then return nil end
|
||||||
@@ -57,7 +66,7 @@ local function drawTextBlock(text, x, y, maxY)
|
|||||||
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
|
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
|
||||||
if maxY and y > maxY then break end
|
if maxY and y > maxY then break end
|
||||||
Font.draw(line, x, y)
|
Font.draw(line, x, y)
|
||||||
y = y + 10
|
y = y + 8
|
||||||
end
|
end
|
||||||
return y
|
return y
|
||||||
end
|
end
|
||||||
@@ -70,6 +79,10 @@ function HallOfFame.new(game, onDone)
|
|||||||
self.timer = 0
|
self.timer = 0
|
||||||
self.phase = "mons"
|
self.phase = "mons"
|
||||||
self.sprites = {} -- species -> image or false
|
self.sprites = {} -- species -> image or false
|
||||||
|
self.playerPic = tryImage("assets/generated/trainer_card/red.png")
|
||||||
|
self.scrollX = PIC_X
|
||||||
|
self.showHofBanner = false
|
||||||
|
self.fade = 0
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -84,16 +97,21 @@ end
|
|||||||
function HallOfFame:nextMon()
|
function HallOfFame:nextMon()
|
||||||
self.index = self.index + 1
|
self.index = self.index + 1
|
||||||
local mon = self.game.save.party[self.index]
|
local mon = self.game.save.party[self.index]
|
||||||
|
self.showHofBanner = false
|
||||||
|
self.fade = 0
|
||||||
if mon then
|
if mon then
|
||||||
self.timer = MON_FRAMES
|
self.phase = "mons"
|
||||||
|
self.timer = INFO_HOLD
|
||||||
Sound.playCry(self.game.data, mon.species)
|
Sound.playCry(self.game.data, mon.species)
|
||||||
-- scroll the new inductee's pic in from the left (see SCROLL_SPEED)
|
|
||||||
local sprite = self:spriteFor(mon.species)
|
local sprite = self:spriteFor(mon.species)
|
||||||
local w = sprite and sprite:getWidth() or 0
|
local w = sprite and sprite:getWidth() or 56
|
||||||
self.scrollRestX = math.floor((160 - w) / 2)
|
|
||||||
self.scrollX = -w
|
self.scrollX = -w
|
||||||
else
|
else
|
||||||
self.phase = "congrats"
|
-- HoFShowMonOrPlayer with wHoFMonOrPlayer = player
|
||||||
|
self.phase = "player"
|
||||||
|
self.timer = 0
|
||||||
|
local w = self.playerPic and self.playerPic:getWidth() or 56
|
||||||
|
self.scrollX = -w
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -117,77 +135,184 @@ function HallOfFame:dexSeenOwned()
|
|||||||
return seen, owned
|
return seen, owned
|
||||||
end
|
end
|
||||||
|
|
||||||
function HallOfFame:update(dt)
|
function HallOfFame:advanceMonPhase()
|
||||||
local input = self.game.input
|
if self.phase == "mons" and not self.showHofBanner then
|
||||||
if self.phase == "mons" then
|
-- 80-frame info hold done: TextBoxBorder at (2,13) + "HALL OF FAME"
|
||||||
if self.scrollX and self.scrollX < self.scrollRestX then
|
self.showHofBanner = true
|
||||||
self.scrollX = math.min(self.scrollRestX, self.scrollX + SCROLL_SPEED)
|
self.timer = HOF_HOLD
|
||||||
end
|
elseif self.phase == "mons" then
|
||||||
self.timer = self.timer - 1
|
self.phase = "fade"
|
||||||
if input:wasPressed("a") or self.timer <= 0 then
|
self.timer = FADE_FRAMES
|
||||||
self:nextMon()
|
self.fade = 0
|
||||||
end
|
elseif self.phase == "fade" then
|
||||||
elseif input:wasPressed("a") then
|
self:nextMon()
|
||||||
Sound.play(self.game.data, "Press_AB")
|
|
||||||
self.game.stack:pop()
|
|
||||||
if self.onDone then self.onDone() end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function HallOfFame:draw()
|
function HallOfFame:update(dt)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
local input = self.game.input
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
local skip = input:wasPressed("a")
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
|
||||||
if self.phase == "mons" then
|
|
||||||
Font.draw("HALL OF FAME", (160 - 12 * 8) / 2, 8)
|
|
||||||
local mon = self.game.save.party[self.index]
|
|
||||||
if mon then
|
|
||||||
local def = self.game.data.pokemon[mon.species]
|
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
|
||||||
local sprite = self:spriteFor(mon.species)
|
|
||||||
if sprite then
|
|
||||||
local w, h = sprite:getDimensions()
|
|
||||||
love.graphics.draw(sprite, self.scrollX or math.floor((160 - w) / 2), 96 - h)
|
|
||||||
end
|
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
|
||||||
local name = mon.nickname or (def and def.name) or mon.species
|
|
||||||
Font.draw(name, 32, 108)
|
|
||||||
Font.draw((":L%d"):format(mon.level), 112, 108)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
-- HoFDisplayPlayerStats (no "HALL OF FAME" banner here -- the real
|
|
||||||
-- screen is a fresh ClearScreen): trainer name, play time, money,
|
|
||||||
-- then the POKéDEX seen/owned tally and Prof. Oak's rating text,
|
|
||||||
-- using the same real save-data fields as TrainerCard.lua
|
|
||||||
-- (save.player.name/playTime/money) and PokedexMenu.lua/
|
|
||||||
-- OverworldController:dexRating (save.pokedex.seen/owned).
|
|
||||||
local save = self.game.save
|
|
||||||
local text = self.game.data.text or {}
|
|
||||||
local y = 8
|
|
||||||
Font.draw(save.player.name or "RED", 8, y)
|
|
||||||
y = y + 16
|
|
||||||
local t = math.floor(save.playTime or 0)
|
|
||||||
Font.draw(("PLAY TIME %3d:%02d"):format(math.floor(t / 3600),
|
|
||||||
math.floor(t / 60) % 60), 8, y)
|
|
||||||
y = y + 12
|
|
||||||
Font.draw(("MONEY ¥%d"):format(save.money or 0), 8, y)
|
|
||||||
y = y + 16
|
|
||||||
|
|
||||||
|
if self.phase == "mons" or self.phase == "fade" then
|
||||||
|
if self.phase == "mons" and self.scrollX < PIC_X then
|
||||||
|
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if self.phase == "fade" then
|
||||||
|
self.timer = self.timer - 1
|
||||||
|
self.fade = 1 - math.max(0, self.timer) / FADE_FRAMES
|
||||||
|
if self.timer <= 0 or skip then self:advanceMonPhase() end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self.timer = self.timer - 1
|
||||||
|
if skip or self.timer <= 0 then
|
||||||
|
self:advanceMonPhase()
|
||||||
|
end
|
||||||
|
elseif self.phase == "player" then
|
||||||
|
if self.scrollX < PIC_X then
|
||||||
|
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self.phase = "player_stats"
|
||||||
|
self.timer = DEX_HOLD
|
||||||
|
elseif self.phase == "player_stats" then
|
||||||
|
-- name / play time / money boxes are up; then DexSeenOwnedText
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- HoFDisplayMonInfo: TextBoxBorder (0,2) b=9,c=10 + LEVEL/TYPE labels
|
||||||
|
function HallOfFame:drawMonInfo(mon)
|
||||||
|
local def = self.game.data.pokemon[mon.species]
|
||||||
|
Font.drawBox(0, 2, 12, 11)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
local name = mon.nickname or (def and def.name) or mon.species
|
||||||
|
Font.draw(name, 1 * 8, 4 * 8)
|
||||||
|
Font.draw("LEVEL/", 2 * 8, 6 * 8)
|
||||||
|
Font.draw("TYPE1/", 2 * 8, 7 * 8)
|
||||||
|
local t1 = def and def.types and def.types[1]
|
||||||
|
local t2 = def and def.types and def.types[2]
|
||||||
|
local dual = t2 and t2 ~= t1
|
||||||
|
if dual then
|
||||||
|
Font.draw("TYPE2/", 2 * 8, 8 * 8)
|
||||||
|
end
|
||||||
|
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
|
||||||
|
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
|
||||||
|
-- PrintMonType at (3,9) / +2 rows for type 2
|
||||||
|
if t1 then
|
||||||
|
Font.draw(TypeChart.displayName(t1), 3 * 8, 9 * 8)
|
||||||
|
end
|
||||||
|
if dual then
|
||||||
|
Font.draw(TypeChart.displayName(t2), 3 * 8, 11 * 8)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Bottom HALL OF FAME banner: TextBoxBorder (2,13) b=3,c=14
|
||||||
|
function HallOfFame:drawHofBanner()
|
||||||
|
Font.drawBox(2, 13, 16, 5)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw("HALL OF FAME", 4 * 8, 15 * 8)
|
||||||
|
end
|
||||||
|
|
||||||
|
function HallOfFame:drawPic(img)
|
||||||
|
if not img then return end
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.draw(img, self.scrollX or PIC_X, PIC_Y)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- HoFDisplayPlayerStats boxes + labels (player pic already on the right)
|
||||||
|
function HallOfFame:drawPlayerStats()
|
||||||
|
local save = self.game.save
|
||||||
|
-- name box: TextBoxBorder (5,0) b=2,c=9 → drawBox(5,0,11,4)
|
||||||
|
Font.drawBox(5, 0, 11, 4)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw(save.player.name or "RED", 7 * 8, 2 * 8)
|
||||||
|
|
||||||
|
-- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8)
|
||||||
|
Font.drawBox(0, 4, 12, 8)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw("PLAY TIME", 1 * 8, 6 * 8)
|
||||||
|
local t = math.floor(save.playTime or 0)
|
||||||
|
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
|
||||||
|
5 * 8, 7 * 8)
|
||||||
|
Font.draw("MONEY", 1 * 8, 9 * 8)
|
||||||
|
-- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard
|
||||||
|
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
|
||||||
|
end
|
||||||
|
|
||||||
|
function HallOfFame:drawDexBox(kind)
|
||||||
|
local save = self.game.save
|
||||||
|
local text = self.game.data.text or {}
|
||||||
|
Font.drawBox(0, 12, 20, 6)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
if kind == "seen" then
|
||||||
local seen, owned = self:dexSeenOwned()
|
local seen, owned = self:dexSeenOwned()
|
||||||
local seenOwned = text._DexSeenOwnedText
|
local seenOwned = text._DexSeenOwnedText
|
||||||
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"
|
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"
|
||||||
seenOwned = seenOwned
|
seenOwned = seenOwned
|
||||||
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
||||||
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
||||||
y = drawTextBlock(seenOwned, 8, y) + 6
|
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
|
||||||
|
else
|
||||||
|
local _, owned = self:dexSeenOwned()
|
||||||
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
|
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
|
||||||
Font.draw(ratingHeader, 8, y)
|
Font.draw(ratingHeader, 1 * 8, 14 * 8)
|
||||||
y = y + 12
|
|
||||||
|
|
||||||
local rating = text[dexRatingKey(owned)] or "Keep it up!"
|
local rating = text[dexRatingKey(owned)] or "Keep it up!"
|
||||||
drawTextBlock(rating, 8, y, 136)
|
drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function HallOfFame:draw()
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
|
|
||||||
|
if self.phase == "mons" or self.phase == "fade" then
|
||||||
|
local mon = self.game.save.party[self.index]
|
||||||
|
if mon then
|
||||||
|
self:drawPic(self:spriteFor(mon.species))
|
||||||
|
if self.scrollX >= PIC_X then
|
||||||
|
self:drawMonInfo(mon)
|
||||||
|
if self.showHofBanner then
|
||||||
|
self:drawHofBanner()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if self.phase == "fade" and self.fade > 0 then
|
||||||
|
love.graphics.setColor(1, 1, 1, self.fade)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
|
end
|
||||||
|
elseif self.phase == "player" then
|
||||||
|
self:drawPic(self.playerPic)
|
||||||
|
elseif self.phase == "player_stats" then
|
||||||
|
self:drawPic(self.playerPic)
|
||||||
|
self:drawPlayerStats()
|
||||||
|
elseif self.phase == "player_dex" then
|
||||||
|
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)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -117,7 +117,7 @@ function ListMenu:draw()
|
|||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
end
|
end
|
||||||
if item.right then
|
if item.right then
|
||||||
Font.draw(item.right, 160 - 8 - #item.right * 8, y)
|
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
|
||||||
end
|
end
|
||||||
if i == self.index then
|
if i == self.index then
|
||||||
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
|
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
|
||||||
@@ -136,7 +136,7 @@ function ListMenu:draw()
|
|||||||
Font.drawBox(11, 0, 9, 3)
|
Font.drawBox(11, 0, 9, 3)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
local money = ("¥%d"):format(self.money and self.money() or 0)
|
local money = ("¥%d"):format(self.money and self.money() or 0)
|
||||||
Font.draw(money, 152 - #money * 8, 8)
|
Font.draw(money, 152 - Font.width(money), 8)
|
||||||
-- the clerk's line in the standard bottom text box; long prompts
|
-- the clerk's line in the standard bottom text box; long prompts
|
||||||
-- wrap and keep their last two lines, like the GB's scrolled box
|
-- wrap and keep their last two lines, like the GB's scrolled box
|
||||||
Font.drawBox(0, 12, 20, 6)
|
Font.drawBox(0, 12, 20, 6)
|
||||||
@@ -153,7 +153,17 @@ function ListMenu:draw()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif self.footer then
|
elseif self.footer then
|
||||||
Font.draw(self.footer, 8, 136)
|
-- PC deposit/withdraw footers use "\n"; draw the last two lines so
|
||||||
|
-- long item names are not clipped at the screen edge (#115).
|
||||||
|
local flat = {}
|
||||||
|
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
|
||||||
|
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||||
|
end
|
||||||
|
local y = (#flat >= 2) and 120 or 136
|
||||||
|
for i = math.max(1, #flat - 1), #flat do
|
||||||
|
Font.draw(flat[i], 8, y)
|
||||||
|
y = y + 16
|
||||||
|
end
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|||||||
+43
-4
@@ -19,7 +19,16 @@ function Menu.new(game, items, opts)
|
|||||||
self.tx = opts.tx or 10
|
self.tx = opts.tx or 10
|
||||||
self.ty = opts.ty or 0
|
self.ty = opts.ty or 0
|
||||||
self.tw = opts.tw or 10
|
self.tw = opts.tw or 10
|
||||||
self.th = opts.th or (#items * 2 + 2)
|
self.rowStep = opts.rowStep or 2
|
||||||
|
-- maxVisible: cap the box to this many rows and scroll the rest instead
|
||||||
|
-- of growing past it (e.g. the start menu, whose row count varies with
|
||||||
|
-- save state and mod hooks); nil/unset keeps every caller's old
|
||||||
|
-- behavior of sizing the box to fit all items.
|
||||||
|
self.maxVisible = opts.maxVisible
|
||||||
|
self.scroll = 0
|
||||||
|
local visible = (self.maxVisible and math.min(self.maxVisible, #items))
|
||||||
|
or #items
|
||||||
|
self.th = opts.th or (visible * self.rowStep + 2)
|
||||||
self.cancelable = opts.cancelable ~= false
|
self.cancelable = opts.cancelable ~= false
|
||||||
-- Whether START closes the menu. In pokered a menu responds only to the
|
-- Whether START closes the menu. In pokered a menu responds only to the
|
||||||
-- keys in its wMenuWatchedKeys mask; the common PAD_A | PAD_B (and the
|
-- keys in its wMenuWatchedKeys mask; the common PAD_A | PAD_B (and the
|
||||||
@@ -31,9 +40,25 @@ function Menu.new(game, items, opts)
|
|||||||
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
|
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
|
||||||
-- menus silent (home/window.asm HandleMenuInput_)
|
-- menus silent (home/window.asm HandleMenuInput_)
|
||||||
self.noSound = opts.noSound or false
|
self.noSound = opts.noSound or false
|
||||||
|
self:clampScroll()
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- keeps self.index inside the visible [scroll+1, scroll+maxVisible] window;
|
||||||
|
-- callers that move self.index directly (e.g. restoring a saved cursor
|
||||||
|
-- position) should call this afterwards to scroll it into view
|
||||||
|
function Menu:clampScroll()
|
||||||
|
if not (self.maxVisible and #self.items > self.maxVisible) then
|
||||||
|
self.scroll = 0
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if self.index - self.scroll > self.maxVisible then
|
||||||
|
self.scroll = self.index - self.maxVisible
|
||||||
|
elseif self.index - self.scroll < 1 then
|
||||||
|
self.scroll = self.index - 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function Menu:update(dt)
|
function Menu:update(dt)
|
||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
if input:wasPressed("up") then
|
if input:wasPressed("up") then
|
||||||
@@ -61,15 +86,29 @@ function Menu:update(dt)
|
|||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
if self.onCancel then self.onCancel() end
|
if self.onCancel then self.onCancel() end
|
||||||
end
|
end
|
||||||
|
self:clampScroll()
|
||||||
end
|
end
|
||||||
|
|
||||||
function Menu:draw()
|
function Menu:draw()
|
||||||
Font.drawBox(self.tx, self.ty, self.tw, self.th)
|
Font.drawBox(self.tx, self.ty, self.tw, self.th)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
for i, item in ipairs(self.items) do
|
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
|
||||||
Font.draw(item.label, (self.tx + 2) * 8, (self.ty + i * 2 - 1) * 8)
|
or #self.items
|
||||||
|
for row = 1, visible do
|
||||||
|
local item = self.items[self.scroll + row]
|
||||||
|
if not item then break end
|
||||||
|
Font.draw(item.label, (self.tx + 2) * 8,
|
||||||
|
(self.ty + row * self.rowStep - (self.rowStep - 1)) * 8)
|
||||||
|
end
|
||||||
|
local cursorRow = self.index - self.scroll
|
||||||
|
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
|
||||||
|
(self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8)
|
||||||
|
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
|
||||||
|
-- use, sat on the bottom border like TextBox's page-advance cursor
|
||||||
|
if self.maxVisible and self.scroll + self.maxVisible < #self.items then
|
||||||
|
Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8,
|
||||||
|
(self.ty + self.th - 2) * 8)
|
||||||
end
|
end
|
||||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, (self.ty + self.index * 2 - 1) * 8)
|
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user