mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
Compare commits
158 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac01135ca8 | |||
| 5e89e35e02 | |||
| 7e7afeaf82 | |||
| c6fa6d294b | |||
| fc2d17fc78 | |||
| 9f072285a6 | |||
| c58ebe4b9b | |||
| 0dd187fe30 | |||
| ea78792c03 | |||
| 9954225701 | |||
| 6bb2e078c0 | |||
| 323c59e54f | |||
| d26d63ed38 | |||
| 2009df3dd1 | |||
| f0f3e9634b | |||
| ca1beaefe7 | |||
| 24cf1b3317 | |||
| 88e2ec2042 | |||
| be60c7c1d5 | |||
| 9be21380c7 | |||
| 22fd196045 | |||
| d5293dffb8 | |||
| 9326b07583 | |||
| 90437a5d8d | |||
| c4ec5dc1c5 | |||
| 3b19826f1e | |||
| 95e4715bc3 | |||
| 81edc97d86 | |||
| 92c8bac714 | |||
| 1df04aa18d | |||
| 218b9249a2 | |||
| 50947eec2a | |||
| 2624bb0210 | |||
| 6e9685f302 | |||
| 898bf0c71e | |||
| 5a7add8eaa | |||
| 66334dfacd | |||
| a33f3b1ceb | |||
| 6e724cedf7 | |||
| e949c78639 | |||
| 9bcfdb1f0d | |||
| fccb122c59 | |||
| e2b2a5fb9f | |||
| f2a3e5f05b | |||
| 5f855568c8 | |||
| 24696e3be2 | |||
| b17dce232e | |||
| e1b20723fd | |||
| fdab15a6dc | |||
| 33899c237e | |||
| 0fad222569 | |||
| 9c5f33e187 | |||
| ddcce5b95a | |||
| 6fd9741fab | |||
| 4916fc8f4b | |||
| fc19e58678 | |||
| 9610bcfb72 | |||
| 1bc252741a | |||
| caa65182dc | |||
| 97e3fb296c | |||
| 74eeb411ba | |||
| 040ca3f332 | |||
| 5ec440792e | |||
| 9d3ee6a597 | |||
| 6c4c9d14e2 | |||
| d165189032 | |||
| 84bf25fcd5 | |||
| 3945b9d078 | |||
| 6796804593 | |||
| 858c5bc9b8 | |||
| 71bdbb7e48 | |||
| c346c78697 | |||
| ba2bac5bed | |||
| bcfc94d448 | |||
| bebfc87cd4 | |||
| c8e035d332 | |||
| e58929428c | |||
| a389416b7b | |||
| 0a8bca1a6d | |||
| efad8b325b | |||
| 4c5f776128 | |||
| c1495e0d5e | |||
| a97168a4f0 | |||
| a50ad30176 | |||
| c1f65106de | |||
| 4250e3aa32 | |||
| cecb24bb0e | |||
| b99b497893 | |||
| e16a45d6ba | |||
| 0e29046c31 | |||
| 5cbada493a | |||
| 4c79db3d5d | |||
| c7e46b6563 | |||
| fe1714d50e | |||
| 2765e3d145 | |||
| c62f1334bc | |||
| eea0dec4a6 | |||
| 25d241f12c | |||
| 0b55ab083a | |||
| 6b9d4f2470 | |||
| b9a89c1fd2 | |||
| 8a8340ffc8 | |||
| 0af69e2df6 | |||
| 4b9a326f6b | |||
| a92761dc13 | |||
| 8a649e8d95 | |||
| 5c5c970e81 | |||
| 7b208043b9 | |||
| 26dfad0682 | |||
| 902f0d73d4 | |||
| f6ae967796 | |||
| 29afbdaec7 | |||
| 350402b179 | |||
| 0f581e2f69 | |||
| 17eee1ca0e | |||
| 7aa06942be | |||
| d3010dc060 | |||
| 53e1a535dd | |||
| e63fa70700 | |||
| 27fb6eedc6 | |||
| 5c0a7d1456 | |||
| e10f6e2032 | |||
| c61643edaa | |||
| 0b7b0b48d1 | |||
| 2ff459e2c4 | |||
| 8c877a64f0 | |||
| 8fdfd5c843 | |||
| 579c1744bd | |||
| 118c45f8f6 | |||
| beaa502ff9 | |||
| 730b7b842c | |||
| 784832ca95 | |||
| bbe894fb4f | |||
| 4c17e606fd | |||
| 73d81a72b8 | |||
| 8b0f770c57 | |||
| a89d43f315 | |||
| 96a64d439e | |||
| 401f79877c | |||
| c52a35288a | |||
| a37d1a0676 | |||
| 0e56e0a539 | |||
| cc67be341f | |||
| ff829569b0 | |||
| bc4afcba71 | |||
| dde25ec7d0 | |||
| 9eb80575e5 | |||
| 590e082328 | |||
| 2f52a0651a | |||
| e393ec06e3 | |||
| cd33858020 | |||
| 0dddb32305 | |||
| f3165ceb71 | |||
| d6e36d457f | |||
| 52e3a0d23f | |||
| fb58fa788b | |||
| d09cf80da5 | |||
| aab980b05a |
@@ -18,6 +18,19 @@ body:
|
|||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: game
|
||||||
|
attributes:
|
||||||
|
label: Which game were you playing
|
||||||
|
description: Pick every version you saw the bug in.
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Red
|
||||||
|
- Blue
|
||||||
|
- Yellow
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: os
|
id: os
|
||||||
attributes:
|
attributes:
|
||||||
@@ -27,10 +40,31 @@ body:
|
|||||||
- Windows
|
- Windows
|
||||||
- Linux
|
- Linux
|
||||||
- Android
|
- Android
|
||||||
|
- iOS
|
||||||
- Multiple platforms
|
- Multiple platforms
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: mods_enabled
|
||||||
|
attributes:
|
||||||
|
label: Were any mods on
|
||||||
|
description: Check the MODS tab in the launcher if you're not sure.
|
||||||
|
options:
|
||||||
|
- "No"
|
||||||
|
- "Yes"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: mods_which
|
||||||
|
attributes:
|
||||||
|
label: Which mods (if any were on)
|
||||||
|
description: List the enabled mods. Leave blank if none were on.
|
||||||
|
placeholder: nuzlocke 1.0.0, running-shoes 0.3
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ body:
|
|||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: game
|
||||||
|
attributes:
|
||||||
|
label: Which game is this about
|
||||||
|
description: Pick every version it applies to.
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Red
|
||||||
|
- Blue
|
||||||
|
- Yellow
|
||||||
|
- Not version-specific
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: discord
|
id: discord
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -23,6 +23,20 @@ body:
|
|||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: game
|
||||||
|
attributes:
|
||||||
|
label: Which game is this for
|
||||||
|
description: Pick every version the mod should cover.
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Red
|
||||||
|
- Blue
|
||||||
|
- Yellow
|
||||||
|
- Not version-specific
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
id: discord
|
id: discord
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
|
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
||||||
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
|
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on
|
||||||
# Mac runner, and publishes them as a GitHub Release.
|
# 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.
|
||||||
@@ -45,6 +45,12 @@ jobs:
|
|||||||
runs-on: [self-hosted, macOS]
|
runs-on: [self-hosted, macOS]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
# The self-hosted runner lives under the machine owner's home
|
||||||
|
# directory; mask it first so absolute paths in every later step's
|
||||||
|
# output show up as *** in the public workflow logs.
|
||||||
|
- name: Mask runner paths
|
||||||
|
run: echo "::add-mask::$HOME"
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -170,6 +176,20 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||||
|
|
||||||
|
- name: Install xcbeautify
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
|
||||||
|
|
||||||
|
- name: Build iOS
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Device Release IPA; signs with the Apple Development identity on
|
||||||
|
# the runner (auto team detection). Users on other Apple IDs still
|
||||||
|
# re-sign or build via docs/ios-install.md.
|
||||||
|
scripts/build_ios.sh --fetch --device --release \
|
||||||
|
--version "${{ steps.ver.outputs.version }}"
|
||||||
|
|
||||||
- name: Build Anbernic RG34XXSP port
|
- name: Build Anbernic RG34XXSP port
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -226,6 +246,10 @@ jobs:
|
|||||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||||
|
|
||||||
|
ipa="dist/ios/gen1recomp.ipa"
|
||||||
|
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
|
||||||
|
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
|
||||||
|
|
||||||
# Anbernic handheld port (suffix names the CFW it targets, so a
|
# Anbernic handheld port (suffix names the CFW it targets, so a
|
||||||
# future RG35XX/other-CFW pack can ship alongside it).
|
# future RG35XX/other-CFW pack can ship alongside it).
|
||||||
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
||||||
@@ -254,19 +278,37 @@ 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
|
# Issues this release closes. Three sources, deduped by number:
|
||||||
# (works for squash, rebase, and merge commits alike). Scans every
|
# 1. GitHub's own "closing issues" links on every PR whose
|
||||||
# commit since the previous tag so a skipped release run doesn't drop
|
# commits are in the range (works for squash, rebase, and
|
||||||
# issues on the floor.
|
# merge commits alike) -- including PRs that were merged
|
||||||
|
# into a branch this release PR is itself merging in.
|
||||||
|
# 2. CLOSES/fixes/resolves text in those PRs' titles + bodies
|
||||||
|
# that GitHub didn't turn into a closing link (a PR into a
|
||||||
|
# non-default branch never gets one).
|
||||||
|
# 3. The same text in raw commit messages, so a direct push
|
||||||
|
# with "CLOSES #N #M" still lands in the notes.
|
||||||
|
# Scans every commit since the previous tag so a skipped release
|
||||||
|
# run doesn't drop issues on the floor.
|
||||||
prev_tag="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${tag}$" | head -1 || true)"
|
prev_tag="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${tag}$" | head -1 || true)"
|
||||||
range="${prev_tag:+${prev_tag}..}$GITHUB_SHA"
|
range="${prev_tag:+${prev_tag}..}$GITHUB_SHA"
|
||||||
|
|
||||||
closed=""
|
# every #N on a line that carries a closing keyword (handles the
|
||||||
for pr in $(git log --pretty=%H "$range" \
|
# multi-issue "CLOSES #1 #2 #3" form the tracker uses)
|
||||||
| xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \
|
scan_closes() {
|
||||||
--jq '.[].number' 2>/dev/null \
|
grep -iE '\b(close[sd]?|fix(es|ed)?|resolve[sd]?)\b' \
|
||||||
| sort -un || true); do
|
| grep -oE '#[0-9]+' | tr -d '#' || true
|
||||||
closed+="$(gh api graphql \
|
}
|
||||||
|
|
||||||
|
nums=""
|
||||||
|
nums+=" $(git log --pretty=%B "$range" | scan_closes | tr '\n' ' ')"
|
||||||
|
|
||||||
|
prs="$(git log --pretty=%H "$range" \
|
||||||
|
| xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \
|
||||||
|
--jq '.[].number' 2>/dev/null \
|
||||||
|
| sort -un || true)"
|
||||||
|
for pr in $prs; do
|
||||||
|
nums+=" $(gh api graphql \
|
||||||
-f owner="${GITHUB_REPOSITORY%/*}" \
|
-f owner="${GITHUB_REPOSITORY%/*}" \
|
||||||
-f name="${GITHUB_REPOSITORY#*/}" \
|
-f name="${GITHUB_REPOSITORY#*/}" \
|
||||||
-F pr="$pr" \
|
-F pr="$pr" \
|
||||||
@@ -274,19 +316,48 @@ jobs:
|
|||||||
query($owner:String!, $name:String!, $pr:Int!) {
|
query($owner:String!, $name:String!, $pr:Int!) {
|
||||||
repository(owner:$owner, name:$name) {
|
repository(owner:$owner, name:$name) {
|
||||||
pullRequest(number:$pr) {
|
pullRequest(number:$pr) {
|
||||||
closingIssuesReferences(first:50) { nodes { number title } }
|
closingIssuesReferences(first:50) { nodes { number } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}' \
|
}' \
|
||||||
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[]
|
--jq '.data.repository.pullRequest.closingIssuesReferences.nodes[].number' \
|
||||||
| "- #\(.number) \(.title)"' 2>/dev/null || true)"$'\n'
|
2>/dev/null | grep -E '^[0-9]+$' | tr '\n' ' ' || true)"
|
||||||
|
nums+=" $(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr" \
|
||||||
|
--jq '.title + " " + (.body // "")' 2>/dev/null \
|
||||||
|
| scan_closes | tr '\n' ' ' || true)"
|
||||||
|
done
|
||||||
|
|
||||||
|
# dedupe, drop anything that is a PR or does not exist, keep
|
||||||
|
# titles (gh api prints the response body to stdout on an HTTP
|
||||||
|
# error, so only a zero exit counts)
|
||||||
|
closed=""
|
||||||
|
for n in $(printf '%s' "$nums" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -un); do
|
||||||
|
if title="$(gh api "repos/$GITHUB_REPOSITORY/issues/$n" \
|
||||||
|
--jq 'if .pull_request then empty else .title end' \
|
||||||
|
2>/dev/null)"; then
|
||||||
|
[ -n "$title" ] && closed+="- #$n $title"$'\n'
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
closed="$(printf '%s' "$closed" | grep . | sort -t'#' -k2 -n || true)"
|
closed="$(printf '%s' "$closed" | grep . | sort -t'#' -k2 -n || true)"
|
||||||
|
|
||||||
|
# Everyone whose commits are in the range: GitHub login when the
|
||||||
|
# commit is linked to an account, the raw git author name when not;
|
||||||
|
# CI bots filtered out.
|
||||||
|
base="${prev_tag:-$(git rev-list --max-parents=0 "$GITHUB_SHA" | tail -1)}"
|
||||||
|
contributors="$(gh api --paginate \
|
||||||
|
"repos/$GITHUB_REPOSITORY/compare/${base}...${GITHUB_SHA}" \
|
||||||
|
--jq '.commits[] | if .author and .author.login
|
||||||
|
then "@" + .author.login
|
||||||
|
else .commit.author.name end' 2>/dev/null \
|
||||||
|
| grep -viE '\[bot\]$' | sort -uf | sed 's/^/- /' || true)"
|
||||||
|
|
||||||
notes="Download the correct version for your computer below."
|
notes="Download the correct version for your computer below."
|
||||||
if [ -n "$closed" ]; then
|
if [ -n "$closed" ]; then
|
||||||
notes+=$'\n\n## Issues closed\n\n'"$closed"
|
notes+=$'\n\n## Issues closed\n\n'"$closed"
|
||||||
fi
|
fi
|
||||||
|
if [ -n "$contributors" ]; then
|
||||||
|
notes+=$'\n\n## Contributors\n\n'"$contributors"
|
||||||
|
fi
|
||||||
printf 'Release notes:\n%s\n' "$notes"
|
printf 'Release notes:\n%s\n' "$notes"
|
||||||
|
|
||||||
gh release create "$tag" \
|
gh release create "$tag" \
|
||||||
@@ -297,6 +368,7 @@ jobs:
|
|||||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||||
"dist/release/gen1recomp-${v}-android.apk" \
|
"dist/release/gen1recomp-${v}-android.apk" \
|
||||||
|
"dist/release/gen1recomp-${v}-ios.ipa" \
|
||||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
|
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
|
||||||
"dist/release/gen1recomp-${v}.love" \
|
"dist/release/gen1recomp-${v}.love" \
|
||||||
"dist/release/sha256sums.txt"
|
"dist/release/sha256sums.txt"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ __pycache__/
|
|||||||
.*
|
.*
|
||||||
!.github/
|
!.github/
|
||||||
!.gitignore
|
!.gitignore
|
||||||
|
!.luacheckrc
|
||||||
|
|
||||||
# Android build outputs / local SDK path / packaged payload (keep love-android sources)
|
# Android build outputs / local SDK path / packaged payload (keep love-android sources)
|
||||||
mobile/android/app/build/
|
mobile/android/app/build/
|
||||||
@@ -35,3 +36,10 @@ mobile/ios/build/
|
|||||||
|
|
||||||
# Legacy manual convenience-copy location (superseded by /dist/android/)
|
# Legacy manual convenience-copy location (superseded by /dist/android/)
|
||||||
mobile/dist/
|
mobile/dist/
|
||||||
|
|
||||||
|
# Tiled map-editing workspace (tools/tiled_export.py). Derived from the imported
|
||||||
|
# ROM cache exactly like data/generated/, so it is never committable.
|
||||||
|
/build/tiled/
|
||||||
|
|
||||||
|
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
|
||||||
|
mobile/ios/bundle_id.local
|
||||||
|
|||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
-- Static-analysis config for `luacheck` (https://luacheck.readthedocs.io).
|
||||||
|
--
|
||||||
|
-- Run it over the engine with: luacheck src (or scripts/lint.sh)
|
||||||
|
--
|
||||||
|
-- The point is a high-signal baseline: the categories left on are the ones
|
||||||
|
-- that catch real defects -- undefined globals/locals (the class that hid a
|
||||||
|
-- `music.volume` crash: applyVolume read a `state` that was still the nil
|
||||||
|
-- global), unused values, unreachable code, redefinitions. The cosmetic
|
||||||
|
-- categories the codebase deliberately lives with (a `self`/`dt` an
|
||||||
|
-- interface requires but a given method ignores, documented empty
|
||||||
|
-- fall-through branches, the odd long line) are muted so they don't drown
|
||||||
|
-- the signal.
|
||||||
|
|
||||||
|
std = "luajit"
|
||||||
|
|
||||||
|
-- LÖVE exposes `love` as a mutable table: games assign their callbacks onto
|
||||||
|
-- it (love.wheelmoved, love.run, ...), so it is a regular global, not
|
||||||
|
-- read-only -- otherwise every callback registration reads as a violation.
|
||||||
|
globals = { "love" }
|
||||||
|
|
||||||
|
read_globals = {
|
||||||
|
"jit",
|
||||||
|
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
||||||
|
-- it; without this, every `table.unpack` reads as an undefined field.
|
||||||
|
table = { fields = { "unpack" } },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Vendored/native trees and the test suites have their own conventions.
|
||||||
|
exclude_files = {
|
||||||
|
"mobile/",
|
||||||
|
"tests/",
|
||||||
|
"tools/save-editor/",
|
||||||
|
}
|
||||||
|
|
||||||
|
ignore = {
|
||||||
|
"212", -- unused argument -- self/dt kept for a shared method signature
|
||||||
|
"213", -- unused loop variable -- `for _, v in` where only v is wanted
|
||||||
|
"421", -- shadowing a local -- deliberate re-use in a few tight scopes
|
||||||
|
"431", -- shadowing an upvalue
|
||||||
|
"432", -- shadowing an argument
|
||||||
|
"542", -- empty if branch -- documented fall-throughs, not gaps
|
||||||
|
"631", -- line is too long
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Copyright 2026 BOIS CLUB GAMES, LLC
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
@@ -1,31 +1,75 @@
|
|||||||
# Gen1Recomp
|
# Gen1Recomp
|
||||||
|
|
||||||
A native LÖVE2D recreation of Poke Red and Blue. The engine and map
|
A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
|
||||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||||
supplied by the player.
|
supplied by the player.
|
||||||
|
|
||||||
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
|
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
|
||||||
|
|
||||||
|
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
|
||||||
|
<a href="https://www.youtube.com/@bryanthaboi">
|
||||||
|
<img src="https://img.shields.io/badge/YouTube-FF0000?style=for-the-badge&logo=youtube&logoColor=white" alt="YouTube">
|
||||||
|
</a>
|
||||||
|
<a href="https://www.tiktok.com/@bryanthaboi">
|
||||||
|
<img src="https://img.shields.io/badge/TikTok-000000?style=for-the-badge&logo=tiktok&logoColor=white" alt="TikTok">
|
||||||
|
</a>
|
||||||
|
<a href="https://x.com/bryanthaboi">
|
||||||
|
<img src="https://img.shields.io/badge/X-000000?style=for-the-badge&logo=x&logoColor=white" alt="X">
|
||||||
|
</a>
|
||||||
|
<a href="https://bsky.app/profile/bryanthaboi.live">
|
||||||
|
<img src="https://img.shields.io/badge/Bluesky-0285FF?style=for-the-badge&logo=bluesky&logoColor=white" alt="Bluesky">
|
||||||
|
</a>
|
||||||
|
<a href="https://www.instagram.com/bryanthaboi">
|
||||||
|
<img src="https://img.shields.io/badge/Instagram-E4405F?style=for-the-badge&logo=instagram&logoColor=white" alt="Instagram">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
<p align="center"> <a href="https://www.polygon.com/pokemon-red-blue-3d-voxel-mod-battle-pixels-gameplay-footage-remake/"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-POLYGON-ea2e49?style=for-the-badge" alt="As seen on Polygon"> </a>
|
||||||
|
<a href="https://kotaku.com/pokemon-red-blue-recompilation-project-voxel-3d-mod-2000720281"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-KOTAKU-ea2e49?style=for-the-badge" alt="As seen on KOTAKU"> </a>
|
||||||
|
|
||||||
|
<a href="https://www.digitalfoundry.net/news/2026/07/pokemon-yellow-voxel-mod-turns-the-original-gameboy-code-into-a-stunning-world">
|
||||||
|
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-DIGITAL%20FOUNDRY-ea2e49?style=for-the-badge" alt="As seen on Digital Foundry">
|
||||||
|
</a>
|
||||||
|
<a href="https://www.androidauthority.com/unofficial-android-port-pokemon-red-blue-yellow-3692724/">
|
||||||
|
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-ANDROID%20AUTHORITY-ea2e49?style=for-the-badge" alt="As seen on Android Authority">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://www.xda-developers.com/this-amazing-pokemon-red-and-blue-voxel-mod-adds-a-3d-perspective-without-an-emulator/">
|
||||||
|
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-XDA%20DEVELOPERS-ea2e49?style=for-the-badge" alt="As seen on XDA Developers">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
### Watch the latest update video
|
||||||
|
|
||||||
|
[](https://www.youtube.com/watch?v=8IOgqbe4YvA)
|
||||||
|
|
||||||
|
|
||||||
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 Poke Red or Blue ROM is the only
|
or download a disassembly. A canonical US Poke Red, Blue, or Yellow ROM is the
|
||||||
game content input.
|
only 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. Red and Blue can both be imported and played
|
do not ask for the ROM again. Red, Blue, and Yellow can all be imported and
|
||||||
side by side.
|
played side by side.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Open the desktop app. On first boot, choose your legally obtained `.gb` file
|
Open the desktop app. On first boot, choose your legally obtained `.gb` /
|
||||||
or drop it onto the window. Import takes a few seconds and the game starts
|
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
||||||
automatically.
|
game starts automatically.
|
||||||
|
|
||||||
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
|
Only the canonical 1 MiB US Red, Blue, and Yellow ROMs are accepted. The
|
||||||
verifies SHA-1 before creating any game data:
|
importer verifies SHA-1 before creating any game data:
|
||||||
|
|
||||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||||
|
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||||
|
|
||||||
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
|
||||||
@@ -64,6 +108,17 @@ supported out of the box.
|
|||||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||||
and persist in `options.lua`.
|
and persist in `options.lua`.
|
||||||
|
|
||||||
|
### Low-end devices
|
||||||
|
|
||||||
|
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
|
||||||
|
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
|
||||||
|
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
|
||||||
|
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
|
||||||
|
normal desktops → HIGH, unchanged). It only scales presentation; the
|
||||||
|
fixed-step game logic is identical on every tier, and a lower tier hides
|
||||||
|
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
|
||||||
|
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
|
||||||
|
|
||||||
### Rulesets
|
### Rulesets
|
||||||
|
|
||||||
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
|
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
|
||||||
@@ -98,11 +153,11 @@ famous bugs included:
|
|||||||
|
|
||||||
## Running From Source
|
## Running From Source
|
||||||
|
|
||||||
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
|
Requires LÖVE 11.x. Place a Red, Blue, or Yellow ROM in the project folder and
|
||||||
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
|
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Blue.gb / Yellow.gbc
|
||||||
scripts/run.sh
|
scripts/run.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -133,6 +188,13 @@ even on a different computer, as long as the same folder comes along.
|
|||||||
already written to either location is touched automatically, so copy files
|
already written to either location is touched automatically, so copy files
|
||||||
over yourself if you want to carry existing progress across the switch.
|
over yourself if you want to carry existing progress across the switch.
|
||||||
|
|
||||||
|
## iOS
|
||||||
|
|
||||||
|
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
|
||||||
|
(Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To
|
||||||
|
build and install from source on a Mac instead, see
|
||||||
|
[docs/ios-install.md](docs/ios-install.md).
|
||||||
|
|
||||||
## Handhelds
|
## Handhelds
|
||||||
|
|
||||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||||
@@ -150,6 +212,11 @@ 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/)`.
|
||||||
|
|
||||||
|
Maps can be edited in our own build of [Tiled](https://www.mapeditor.org),
|
||||||
|
[bryanthaboi/tiled_gen1recomp](https://github.com/bryanthaboi/tiled_gen1recomp/releases),
|
||||||
|
and exported back out as a mod; see
|
||||||
|
[docs/tiled-map-editing.md](docs/tiled-map-editing.md).
|
||||||
|
|
||||||
## Bugs and Ideas
|
## Bugs and Ideas
|
||||||
|
|
||||||
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
|
Found a bug? A warp dropping you somewhere it shouldn't, a battle doing math
|
||||||
@@ -181,3 +248,5 @@ request with real detail is one that can actually get built.
|
|||||||
This project would not be possible without [pret](https://github.com/pret) >
|
This project would not be possible without [pret](https://github.com/pret) >
|
||||||
the pret band of decompiling maniacs > and their
|
the pret band of decompiling maniacs > and their
|
||||||
[pokered](https://github.com/pret/pokered) disassembly.
|
[pokered](https://github.com/pret/pokered) disassembly.
|
||||||
|
|
||||||
|
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
function love.conf(t)
|
function love.conf(t)
|
||||||
|
-- PhysFS ignores symlinks unless told otherwise, so a mod dev-linked into
|
||||||
|
-- mods/ (ln -s, matching the mklink /J workflow on Windows) is invisible
|
||||||
|
-- to love.filesystem.getDirectoryItems without this.
|
||||||
|
love.filesystem.setSymlinksEnabled(true)
|
||||||
|
|
||||||
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
||||||
local developer = os.getenv("POKEPORT_DEV") == "1"
|
local developer = os.getenv("POKEPORT_DEV") == "1"
|
||||||
if arg then
|
if arg then
|
||||||
@@ -35,8 +40,16 @@ function love.conf(t)
|
|||||||
-- starting size, not the game's resolution.
|
-- starting size, not the game's resolution.
|
||||||
t.window.width = 1024
|
t.window.width = 1024
|
||||||
t.window.height = 768
|
t.window.height = 768
|
||||||
|
-- Floor for the resizable desktop window. The launcher's single-column
|
||||||
|
-- layout is laid out against ~420 logical px of content, and the game
|
||||||
|
-- canvas letterboxes fine below that, so this only stops a drag that would
|
||||||
|
-- squeeze the cards past the point where their buttons still read. Kept
|
||||||
|
-- well under the smallest supported desktop display; mobile ignores it
|
||||||
|
-- (fullscreen), so the handheld ports are unaffected.
|
||||||
|
t.window.minwidth = 480
|
||||||
|
t.window.minheight = 360
|
||||||
end
|
end
|
||||||
t.version = "11.5"
|
t.version = love._os == "iOS" and "12.0" or "11.5"
|
||||||
t.window.vsync = 1
|
t.window.vsync = 1
|
||||||
t.modules.joystick = true
|
t.modules.joystick = true
|
||||||
t.modules.physics = false
|
t.modules.physics = false
|
||||||
@@ -54,7 +67,10 @@ function love.conf(t)
|
|||||||
-- the game follow the device. The renderer letterboxes the 160x144
|
-- the game follow the device. The renderer letterboxes the 160x144
|
||||||
-- viewport into whatever size results, and the on-screen touch controls
|
-- viewport into whatever size results, and the on-screen touch controls
|
||||||
-- re-lay themselves out from the new window size, so both orientations
|
-- re-lay themselves out from the new window size, so both orientations
|
||||||
-- just work. iOS follows the Info.plist orientations
|
-- just work. FULL_SENSOR ignores the device's rotation lock, so
|
||||||
|
-- GameActivity.setOrientationBis remaps it to FULL_USER after SDL has
|
||||||
|
-- run: same orientations allowed, but auto-rotate being off now wins.
|
||||||
|
-- iOS follows the Info.plist orientations
|
||||||
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
|
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
|
||||||
t.window.resizable = true
|
t.window.resizable = true
|
||||||
-- Starting size is a tall portrait hint; the OS resizes to the real
|
-- Starting size is a tall portrait hint; the OS resizes to the real
|
||||||
@@ -70,6 +86,14 @@ function love.conf(t)
|
|||||||
-- player to copy their ROM there instead of needing a native file
|
-- 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).
|
-- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua).
|
||||||
t.externalstorage = osName == "Android"
|
t.externalstorage = osName == "Android"
|
||||||
|
-- LOVE 11.x exposes the phone's accelerometer as a 3-axis joystick by
|
||||||
|
-- default. Gravity keeps one axis pinned near +/-1.0 whenever the phone
|
||||||
|
-- is held upright, so it streams past-deadzone joystickaxis events that
|
||||||
|
-- both hid the touch overlay every instant (it reads as "a controller is
|
||||||
|
-- in use") and, via the generic-joystick axis mapping (#459), walked the
|
||||||
|
-- player around by tilt. Nothing in the game reads the accelerometer,
|
||||||
|
-- so drop the device entirely (#468).
|
||||||
|
t.accelerometerjoystick = false
|
||||||
else
|
else
|
||||||
t.window.resizable = true
|
t.window.resizable = true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,686 @@
|
|||||||
|
-- Generated by tools/build_data.py. DO NOT EDIT.
|
||||||
|
-- Pokemon Yellow palettes (COLORS=OG YELLOW): SuperPalettes +
|
||||||
|
-- CGBBasePalettes (authentic GBC look) as 4 8-bit RGB colors
|
||||||
|
-- per palette (color 0 first), plus MonsterPalettes.
|
||||||
|
return {
|
||||||
|
cgbBase = {
|
||||||
|
["0F"] = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 107, 8, 255 },
|
||||||
|
{ 0, 74, 255 },
|
||||||
|
{ 8, 8, 8 },
|
||||||
|
},
|
||||||
|
BADGE = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 189, 66, 0 },
|
||||||
|
{ 140, 115, 90 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
BLACK = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
BLUEMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 132, 148, 255 },
|
||||||
|
{ 0, 8, 206 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
BROWNMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 239, 148, 82 },
|
||||||
|
{ 140, 74, 41 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
CAVE = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 189, 66, 0 },
|
||||||
|
{ 140, 115, 90 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
CELADON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 41, 255, 41 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
CERULEAN = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 41, 66, 255 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
CINNABAR = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 66, 66 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
CYANMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 132, 214, 255 },
|
||||||
|
{ 0, 140, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
FUCHSIA = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 123, 123 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
GAMEFREAK = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 156, 0 },
|
||||||
|
{ 156, 156, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
GRAYMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 165, 189, 82 },
|
||||||
|
{ 90, 90, 41 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
GREENBAR = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 0, 255, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
GREENMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 140, 255, 90 },
|
||||||
|
{ 8, 181, 49 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
INDIGO = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 90, 66, 255 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
LAVENDER = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 206, 33, 255 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
LOGO1 = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 255, 0, 0 },
|
||||||
|
{ 255, 0, 0 },
|
||||||
|
},
|
||||||
|
LOGO2 = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 58, 58, 206 },
|
||||||
|
{ 0, 0, 140 },
|
||||||
|
},
|
||||||
|
MEWMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 255, 8, 8 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
PALLET = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 189, 140, 255 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
PEWTER = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 148, 148, 123 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
PIKACHUS_BEACH = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
PIKACHUS_BEACH_TITLE = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 74, 74, 74 },
|
||||||
|
{ 255, 173, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
PIKACHU_PORTRAIT = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 148, 0 },
|
||||||
|
{ 156, 58, 8 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
PINKMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 123, 148 },
|
||||||
|
{ 255, 0, 49 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
PURPLEMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 206, 123, 255 },
|
||||||
|
{ 156, 0, 181 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
REDBAR = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 255, 0, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
REDMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 140, 0 },
|
||||||
|
{ 255, 0, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
ROUTE = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 132, 255, 33 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
SAFFRON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
SLOTS1 = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 206, 8, 255 },
|
||||||
|
{ 255, 0, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
SLOTS2 = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 33, 156 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
SLOTS3 = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 66, 255, 0 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
SLOTS4 = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 0, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
TOWNMAP = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 0, 173, 255 },
|
||||||
|
{ 82, 230, 0 },
|
||||||
|
{ 8, 8, 8 },
|
||||||
|
},
|
||||||
|
VERMILION = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 156, 0 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
VIRIDIAN = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 156, 255, 0 },
|
||||||
|
{ 90, 189, 255 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
YELLOWBAR = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 255, 148, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
YELLOWMON = {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ 255, 255, 0 },
|
||||||
|
{ 230, 115, 0 },
|
||||||
|
{ 25, 25, 25 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order = {
|
||||||
|
"ROUTE",
|
||||||
|
"PALLET",
|
||||||
|
"VIRIDIAN",
|
||||||
|
"PEWTER",
|
||||||
|
"CERULEAN",
|
||||||
|
"LAVENDER",
|
||||||
|
"VERMILION",
|
||||||
|
"CELADON",
|
||||||
|
"FUCHSIA",
|
||||||
|
"CINNABAR",
|
||||||
|
"INDIGO",
|
||||||
|
"SAFFRON",
|
||||||
|
"TOWNMAP",
|
||||||
|
"LOGO1",
|
||||||
|
"LOGO2",
|
||||||
|
"0F",
|
||||||
|
"MEWMON",
|
||||||
|
"BLUEMON",
|
||||||
|
"REDMON",
|
||||||
|
"CYANMON",
|
||||||
|
"PURPLEMON",
|
||||||
|
"BROWNMON",
|
||||||
|
"GREENMON",
|
||||||
|
"PINKMON",
|
||||||
|
"YELLOWMON",
|
||||||
|
"GRAYMON",
|
||||||
|
"SLOTS1",
|
||||||
|
"SLOTS2",
|
||||||
|
"SLOTS3",
|
||||||
|
"SLOTS4",
|
||||||
|
"BLACK",
|
||||||
|
"GREENBAR",
|
||||||
|
"YELLOWBAR",
|
||||||
|
"REDBAR",
|
||||||
|
"BADGE",
|
||||||
|
"CAVE",
|
||||||
|
"GAMEFREAK",
|
||||||
|
"PIKACHUS_BEACH",
|
||||||
|
"PIKACHU_PORTRAIT",
|
||||||
|
"PIKACHUS_BEACH_TITLE",
|
||||||
|
},
|
||||||
|
palettes = {
|
||||||
|
["0F"] = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 197, 165, 247 },
|
||||||
|
{ 90, 165, 247 },
|
||||||
|
{ 25, 16, 16 },
|
||||||
|
},
|
||||||
|
BADGE = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 165, 123, 90 },
|
||||||
|
{ 181, 173, 165 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
BLACK = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
BLUEMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 173, 181, 255 },
|
||||||
|
{ 74, 82, 165 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
BROWNMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 214, 189, 148 },
|
||||||
|
{ 148, 115, 82 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
CAVE = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 165, 123, 90 },
|
||||||
|
{ 181, 173, 165 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
CELADON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 181, 255, 181 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
CERULEAN = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 181, 189, 255 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
CINNABAR = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 123, 115 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
CYANMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 214, 230, 255 },
|
||||||
|
{ 58, 197, 230 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
FUCHSIA = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 214, 214 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
GAMEFREAK = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 230, 197, 115 },
|
||||||
|
{ 165, 165, 90 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
GRAYMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 206, 206, 148 },
|
||||||
|
{ 132, 132, 115 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
GREENBAR = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 255, 156 },
|
||||||
|
{ 0, 173, 0 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
GREENMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 197, 230, 148 },
|
||||||
|
{ 107, 173, 123 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
INDIGO = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 140, 140, 206 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
LAVENDER = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 222, 189, 239 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
LOGO1 = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 247, 247, 140 },
|
||||||
|
{ 173, 0, 33 },
|
||||||
|
{ 173, 0, 33 },
|
||||||
|
},
|
||||||
|
LOGO2 = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 247, 247, 140 },
|
||||||
|
{ 148, 148, 197 },
|
||||||
|
{ 58, 58, 132 },
|
||||||
|
},
|
||||||
|
MEWMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 247, 181 },
|
||||||
|
{ 222, 132, 132 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
PALLET = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 230, 222, 255 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
PEWTER = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 189, 189, 181 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
PIKACHUS_BEACH = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 247, 181 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
PIKACHUS_BEACH_TITLE = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 132, 132, 132 },
|
||||||
|
{ 255, 206, 74 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
PIKACHU_PORTRAIT = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 230, 189, 74 },
|
||||||
|
{ 148, 115, 82 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
PINKMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 197, 214 },
|
||||||
|
{ 255, 148, 173 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
PURPLEMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 222, 181, 247 },
|
||||||
|
{ 181, 123, 189 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
REDBAR = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 255, 156 },
|
||||||
|
{ 214, 74, 49 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
REDMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 197, 90 },
|
||||||
|
{ 214, 74, 49 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
ROUTE = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 189, 214, 156 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
SAFFRON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 255, 156 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
SLOTS1 = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 222, 181, 247 },
|
||||||
|
{ 214, 74, 49 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
SLOTS2 = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 189, 214 },
|
||||||
|
{ 239, 239, 66 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
SLOTS3 = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 189, 255, 165 },
|
||||||
|
{ 239, 239, 66 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
SLOTS4 = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 189, 239, 255 },
|
||||||
|
{ 239, 239, 66 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
TOWNMAP = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 165, 214, 255 },
|
||||||
|
{ 140, 189, 82 },
|
||||||
|
{ 25, 16, 16 },
|
||||||
|
},
|
||||||
|
VERMILION = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 206, 132 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
VIRIDIAN = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 214, 255, 173 },
|
||||||
|
{ 189, 222, 255 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
YELLOWBAR = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 255, 156 },
|
||||||
|
{ 230, 189, 74 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
YELLOWMON = {
|
||||||
|
{ 255, 255, 247 },
|
||||||
|
{ 255, 255, 156 },
|
||||||
|
{ 230, 189, 74 },
|
||||||
|
{ 49, 49, 49 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pokemon = {
|
||||||
|
ABRA = "YELLOWMON",
|
||||||
|
AERODACTYL = "GRAYMON",
|
||||||
|
ALAKAZAM = "YELLOWMON",
|
||||||
|
ARBOK = "PURPLEMON",
|
||||||
|
ARCANINE = "REDMON",
|
||||||
|
ARTICUNO = "BLUEMON",
|
||||||
|
BEEDRILL = "YELLOWMON",
|
||||||
|
BELLSPROUT = "GREENMON",
|
||||||
|
BLASTOISE = "CYANMON",
|
||||||
|
BULBASAUR = "GREENMON",
|
||||||
|
BUTTERFREE = "CYANMON",
|
||||||
|
CATERPIE = "GREENMON",
|
||||||
|
CHANSEY = "PINKMON",
|
||||||
|
CHARIZARD = "REDMON",
|
||||||
|
CHARMANDER = "REDMON",
|
||||||
|
CHARMELEON = "REDMON",
|
||||||
|
CLEFABLE = "PINKMON",
|
||||||
|
CLEFAIRY = "PINKMON",
|
||||||
|
CLOYSTER = "GRAYMON",
|
||||||
|
CUBONE = "GRAYMON",
|
||||||
|
DEWGONG = "BLUEMON",
|
||||||
|
DIGLETT = "BROWNMON",
|
||||||
|
DITTO = "GRAYMON",
|
||||||
|
DODRIO = "BROWNMON",
|
||||||
|
DODUO = "BROWNMON",
|
||||||
|
DRAGONAIR = "BLUEMON",
|
||||||
|
DRAGONITE = "BROWNMON",
|
||||||
|
DRATINI = "GRAYMON",
|
||||||
|
DROWZEE = "YELLOWMON",
|
||||||
|
DUGTRIO = "BROWNMON",
|
||||||
|
EEVEE = "GRAYMON",
|
||||||
|
EKANS = "PURPLEMON",
|
||||||
|
ELECTABUZZ = "YELLOWMON",
|
||||||
|
ELECTRODE = "YELLOWMON",
|
||||||
|
EXEGGCUTE = "PINKMON",
|
||||||
|
EXEGGUTOR = "GREENMON",
|
||||||
|
FARFETCHD = "BROWNMON",
|
||||||
|
FEAROW = "BROWNMON",
|
||||||
|
FLAREON = "REDMON",
|
||||||
|
GASTLY = "PURPLEMON",
|
||||||
|
GENGAR = "PURPLEMON",
|
||||||
|
GEODUDE = "GRAYMON",
|
||||||
|
GLOOM = "REDMON",
|
||||||
|
GOLBAT = "BLUEMON",
|
||||||
|
GOLDEEN = "REDMON",
|
||||||
|
GOLDUCK = "CYANMON",
|
||||||
|
GOLEM = "GRAYMON",
|
||||||
|
GRAVELER = "GRAYMON",
|
||||||
|
GRIMER = "PURPLEMON",
|
||||||
|
GROWLITHE = "BROWNMON",
|
||||||
|
GYARADOS = "BLUEMON",
|
||||||
|
HAUNTER = "PURPLEMON",
|
||||||
|
HITMONCHAN = "BROWNMON",
|
||||||
|
HITMONLEE = "BROWNMON",
|
||||||
|
HORSEA = "CYANMON",
|
||||||
|
HYPNO = "YELLOWMON",
|
||||||
|
IVYSAUR = "GREENMON",
|
||||||
|
JIGGLYPUFF = "PINKMON",
|
||||||
|
JOLTEON = "YELLOWMON",
|
||||||
|
JYNX = "MEWMON",
|
||||||
|
KABUTO = "BROWNMON",
|
||||||
|
KABUTOPS = "BROWNMON",
|
||||||
|
KADABRA = "YELLOWMON",
|
||||||
|
KAKUNA = "YELLOWMON",
|
||||||
|
KANGASKHAN = "BROWNMON",
|
||||||
|
KINGLER = "REDMON",
|
||||||
|
KOFFING = "PURPLEMON",
|
||||||
|
KRABBY = "REDMON",
|
||||||
|
LAPRAS = "CYANMON",
|
||||||
|
LICKITUNG = "PINKMON",
|
||||||
|
MACHAMP = "GRAYMON",
|
||||||
|
MACHOKE = "GRAYMON",
|
||||||
|
MACHOP = "GRAYMON",
|
||||||
|
MAGIKARP = "REDMON",
|
||||||
|
MAGMAR = "REDMON",
|
||||||
|
MAGNEMITE = "GRAYMON",
|
||||||
|
MAGNETON = "GRAYMON",
|
||||||
|
MANKEY = "BROWNMON",
|
||||||
|
MAROWAK = "GRAYMON",
|
||||||
|
MEOWTH = "YELLOWMON",
|
||||||
|
METAPOD = "GREENMON",
|
||||||
|
MEW = "MEWMON",
|
||||||
|
MEWTWO = "MEWMON",
|
||||||
|
MOLTRES = "REDMON",
|
||||||
|
MR_MIME = "PINKMON",
|
||||||
|
MUK = "PURPLEMON",
|
||||||
|
NIDOKING = "PURPLEMON",
|
||||||
|
NIDOQUEEN = "BLUEMON",
|
||||||
|
NIDORAN_F = "BLUEMON",
|
||||||
|
NIDORAN_M = "PURPLEMON",
|
||||||
|
NIDORINA = "BLUEMON",
|
||||||
|
NIDORINO = "PURPLEMON",
|
||||||
|
NINETALES = "YELLOWMON",
|
||||||
|
ODDISH = "GREENMON",
|
||||||
|
OMANYTE = "BLUEMON",
|
||||||
|
OMASTAR = "BLUEMON",
|
||||||
|
ONIX = "GRAYMON",
|
||||||
|
PARAS = "REDMON",
|
||||||
|
PARASECT = "REDMON",
|
||||||
|
PERSIAN = "YELLOWMON",
|
||||||
|
PIDGEOT = "BROWNMON",
|
||||||
|
PIDGEOTTO = "BROWNMON",
|
||||||
|
PIDGEY = "BROWNMON",
|
||||||
|
PIKACHU = "YELLOWMON",
|
||||||
|
PINSIR = "BROWNMON",
|
||||||
|
POLIWAG = "BLUEMON",
|
||||||
|
POLIWHIRL = "BLUEMON",
|
||||||
|
POLIWRATH = "BLUEMON",
|
||||||
|
PONYTA = "REDMON",
|
||||||
|
PORYGON = "GRAYMON",
|
||||||
|
PRIMEAPE = "BROWNMON",
|
||||||
|
PSYDUCK = "YELLOWMON",
|
||||||
|
RAICHU = "YELLOWMON",
|
||||||
|
RAPIDASH = "REDMON",
|
||||||
|
RATICATE = "GRAYMON",
|
||||||
|
RATTATA = "GRAYMON",
|
||||||
|
RHYDON = "GRAYMON",
|
||||||
|
RHYHORN = "GRAYMON",
|
||||||
|
SANDSHREW = "BROWNMON",
|
||||||
|
SANDSLASH = "BROWNMON",
|
||||||
|
SCYTHER = "GREENMON",
|
||||||
|
SEADRA = "CYANMON",
|
||||||
|
SEAKING = "REDMON",
|
||||||
|
SEEL = "BLUEMON",
|
||||||
|
SHELLDER = "GRAYMON",
|
||||||
|
SLOWBRO = "PINKMON",
|
||||||
|
SLOWPOKE = "PINKMON",
|
||||||
|
SNORLAX = "PINKMON",
|
||||||
|
SPEAROW = "BROWNMON",
|
||||||
|
SQUIRTLE = "CYANMON",
|
||||||
|
STARMIE = "GRAYMON",
|
||||||
|
STARYU = "REDMON",
|
||||||
|
TANGELA = "BLUEMON",
|
||||||
|
TAUROS = "GRAYMON",
|
||||||
|
TENTACOOL = "CYANMON",
|
||||||
|
TENTACRUEL = "CYANMON",
|
||||||
|
VAPOREON = "CYANMON",
|
||||||
|
VENOMOTH = "PURPLEMON",
|
||||||
|
VENONAT = "PURPLEMON",
|
||||||
|
VENUSAUR = "GREENMON",
|
||||||
|
VICTREEBEL = "GREENMON",
|
||||||
|
VILEPLUME = "REDMON",
|
||||||
|
VOLTORB = "YELLOWMON",
|
||||||
|
VULPIX = "REDMON",
|
||||||
|
WARTORTLE = "CYANMON",
|
||||||
|
WEEDLE = "YELLOWMON",
|
||||||
|
WEEPINBELL = "GREENMON",
|
||||||
|
WEEZING = "PURPLEMON",
|
||||||
|
WIGGLYTUFF = "PINKMON",
|
||||||
|
ZAPDOS = "YELLOWMON",
|
||||||
|
ZUBAT = "BLUEMON",
|
||||||
|
},
|
||||||
|
source = "pokeyellow data/sgb/sgb_palettes.asm + data/pokemon/palettes.asm",
|
||||||
|
}
|
||||||
@@ -11,8 +11,70 @@
|
|||||||
-- equivalent, kept for save compatibility: rows 1-4 also self-heal
|
-- equivalent, kept for save compatibility: rows 1-4 also self-heal
|
||||||
-- older saves (flag set before the port hid the ball) by hiding the
|
-- older saves (flag set before the port hid the ball) by hiding the
|
||||||
-- leftover ball on the next interaction.
|
-- leftover ball on the next interaction.
|
||||||
|
--
|
||||||
|
-- The room's two readables (data/events/hidden_events.asm,
|
||||||
|
-- hidden_events_for CELADON_MANSION_ROOF_HOUSE):
|
||||||
|
-- hidden_text_predef 3, 0 / 4, 0 PrintBlackboardLinkCableText, LinkCableHelp
|
||||||
|
-- hidden_text_predef 3, 4 PrintNotebookText, TMNotebook
|
||||||
|
-- tools/extract/field.py only parses `hidden_event` rows, so no
|
||||||
|
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
|
||||||
|
-- were dead A presses (#391). Same hook shape as the bedroom SNES in
|
||||||
|
-- data/scripts/flavor/reds_house_2f.lua (#135). hidden_text_predef puts
|
||||||
|
-- the predef id in the facing byte, so neither tile gates on facing.
|
||||||
|
|
||||||
|
local Menu = require("src.ui.Menu")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
|
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the
|
||||||
|
-- extractor never collects it and the pamphlet's text is inlined.
|
||||||
|
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
|
||||||
|
.. "There are 50 TMs\nin all.\f"
|
||||||
|
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
|
||||||
|
.. "SILPH CO."
|
||||||
|
|
||||||
|
-- LinkCableHelp (engine/events/hidden_events/school_blackboard.asm):
|
||||||
|
-- HowToLinkText's four headings in the 15x10 box at the top left; picking
|
||||||
|
-- a heading prints its LinkCableInfoText and returns to the menu, B or
|
||||||
|
-- STOP READING closes.
|
||||||
|
local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" }
|
||||||
|
|
||||||
|
local function linkCableHelp(game)
|
||||||
|
local text = game.data.text or {}
|
||||||
|
local items, showMenu, askHeading
|
||||||
|
function showMenu()
|
||||||
|
game.stack:push(Menu.new(game, items,
|
||||||
|
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 }))
|
||||||
|
end
|
||||||
|
function askHeading()
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
|
||||||
|
showMenu))
|
||||||
|
end
|
||||||
|
items = {}
|
||||||
|
for i, label in ipairs(LINK_HEADINGS) do
|
||||||
|
items[i] = { label = label, onSelect = function()
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
text["_LinkCableInfoText" .. i] or label, askHeading))
|
||||||
|
end }
|
||||||
|
end
|
||||||
|
items[#items + 1] = { label = "STOP READING" }
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable",
|
||||||
|
askHeading))
|
||||||
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
onInteract = function(game, ow, fx, fy)
|
||||||
|
if fy == 0 and (fx == 3 or fx == 4) then
|
||||||
|
linkCableHelp(game)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if fx == 3 and fy == 4 then
|
||||||
|
game.stack:push(TextBox.new(game, TM_NOTEBOOK_TEXT))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL = {
|
TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL = {
|
||||||
{ "check_flag", "EVENT_GOT_EEVEE" }, -- 1
|
{ "check_flag", "EVENT_GOT_EEVEE" }, -- 1
|
||||||
@@ -22,11 +84,16 @@ return {
|
|||||||
{ "jump", 13 }, -- 4
|
{ "jump", 13 }, -- 4
|
||||||
{ "give_pokemon", "EEVEE", 25 }, -- 5
|
{ "give_pokemon", "EEVEE", 25 }, -- 5
|
||||||
{ "jump_if_false", 12 }, -- 6 (party+box full)
|
{ "jump_if_false", 12 }, -- 6 (party+box full)
|
||||||
{ "play_sound", "Get_Item1" }, -- 7 (GotMonText jingle)
|
-- flag + HideObject before the jingle and GotMonText: the nickname
|
||||||
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 8
|
-- prompt inside give_pokemon and the text row both yield, and a
|
||||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 9
|
-- script that dies there would leave the EEVEE taken with the ball
|
||||||
|
-- still on the table and the gift claimable again (#426). The row
|
||||||
|
-- count is unchanged, so the numeric jump targets still hold.
|
||||||
|
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
||||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||||
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 10
|
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
|
||||||
|
{ "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
|
||||||
|
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
|
||||||
{ "jump", 13 }, -- 11
|
{ "jump", 13 }, -- 11
|
||||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
{ "show_text", "_BoxIsFullText" }, -- 12
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
-- BikeShop (BIKE_SHOP) flavor dialogue
|
-- BikeShop (BIKE_SHOP) flavor dialogue
|
||||||
-- Source: pokered/scripts/BikeShop.asm, pokered/text/BikeShop.asm
|
-- Source: pokered/scripts/BikeShop.asm, pokered/text/BikeShop.asm
|
||||||
--
|
--
|
||||||
-- TEXT_BIKESHOP_CLERK is skipped: it drives the actual voucher-for-bicycle
|
-- TEXT_BIKESHOP_CLERK lives in data/scripts/story2.lua (M.BIKE_SHOP): the
|
||||||
-- exchange (YesNoChoice purchase menu, GiveItem, RemoveItemByID, SetEvent
|
-- voucher exchange and the BICYCLE/CANCEL price window need more than
|
||||||
-- EVENT_GOT_BICYCLE). That is a significant standalone feature outside the
|
-- command rows (#568).
|
||||||
-- scope of these two flavor NPCs and is left unported here.
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
BIKE_SHOP = {
|
BIKE_SHOP = {
|
||||||
@@ -20,12 +19,15 @@ return {
|
|||||||
-- CheckEvent EVENT_GOT_BICYCLE ; jr nz, .gotBike
|
-- CheckEvent EVENT_GOT_BICYCLE ; jr nz, .gotBike
|
||||||
-- before the player owns a bike -> TheseBikesAreExpensiveText
|
-- before the player owns a bike -> TheseBikesAreExpensiveText
|
||||||
-- after the player owns a bike -> CoolBikeText
|
-- after the player owns a bike -> CoolBikeText
|
||||||
|
-- The check reads the bag, not the event: the port hands out the
|
||||||
|
-- BICYCLE itself (a key item, so it cannot be tossed), and that also
|
||||||
|
-- reads right on saves written before the clerk set the event (#567).
|
||||||
TEXT_BIKESHOP_YOUNGSTER = {
|
TEXT_BIKESHOP_YOUNGSTER = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_GOT_BICYCLE" }, -- 2
|
{ "check_item", "BICYCLE" }, -- 2
|
||||||
{ "jump_if_true", 5 }, -- 3
|
{ "jump_if_true", 6 }, -- 3
|
||||||
{ "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4
|
{ "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4
|
||||||
{ "jump", 6 }, -- 5
|
{ "jump", "end" }, -- 5
|
||||||
{ "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6
|
{ "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,33 +1,88 @@
|
|||||||
-- Celadon Mansion 3F Game Designer (pokered/scripts/CeladonMansion3F.asm
|
-- Celadon Mansion 3F, the GAME FREAK dev floor
|
||||||
-- CeladonMansion3FGameDesignerText): text_asm counts set bits in
|
-- (pokered+pokeyellow/scripts/CeladonMansion3F.asm). Every dev's
|
||||||
-- wPokedexOwned and compares against NUM_POKEMON - 1 (discounts Mew).
|
-- text_asm counts set bits in wPokedexOwned against NUM_POKEMON - 1
|
||||||
-- If the player owns >= 150 species, shows the "completed" text
|
-- (150, discounting Mew). The game designer shows the diploma
|
||||||
-- (originally followed by DisplayDiploma, which has no equivalent UI
|
-- (DisplayDiploma -> src/ui/Diploma.lua) on a completed dex; Yellow's
|
||||||
-- in this port, so we just show the congratulatory text); otherwise
|
-- graphic artist then offers the printed copy (PrintDiploma, stood in
|
||||||
-- shows the normal encouragement text.
|
-- by src/core/Printer.lua's PNG export like the Pokédex PRNT item).
|
||||||
|
|
||||||
|
local function ownedCount(game)
|
||||||
|
local dex = game.save.pokedex
|
||||||
|
local owned = 0
|
||||||
|
if dex and dex.owned then
|
||||||
|
for _ in pairs(dex.owned) do owned = owned + 1 end
|
||||||
|
end
|
||||||
|
return owned
|
||||||
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
CELADON_MANSION_3F = {
|
CELADON_MANSION_3F = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_CELADONMANSION3F_GAME_DESIGNER = function(game, ow, npc, done)
|
TEXT_CELADONMANSION3F_GAME_DESIGNER = function(game, ow, npc, done)
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
local dex = game.save.pokedex
|
|
||||||
local owned = 0
|
|
||||||
if dex and dex.owned then
|
|
||||||
for _ in pairs(dex.owned) do
|
|
||||||
owned = owned + 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
-- NUM_POKEMON - 1 = 150 (discounts Mew, per pokered)
|
|
||||||
local label, fallback
|
|
||||||
if owned >= 150 then
|
|
||||||
label, fallback = "_CeladonMansion3FGameDesignerCompletedDexText",
|
|
||||||
"Wow! Excellent!\nYou completed\nyour POKeDEX!\nCongratulations!"
|
|
||||||
else
|
|
||||||
label, fallback = "_CeladonMansion3FGameDesignerText",
|
|
||||||
"Is that right?\nI'm the game\ndesigner!\nFilling up your\nPOKeDEX is tough,\nbut don't quit!\nWhen you finish,\ncome tell me!"
|
|
||||||
end
|
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
game.stack:push(TextBox.new(game, t[label] or fallback, done))
|
if ownedCount(game) < 150 then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._CeladonMansion3FGameDesignerText
|
||||||
|
or "Is that right?\nI'm the game\ndesigner!\fFilling up your\nPOKéDEX is tough,\nbut don't quit!",
|
||||||
|
done))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._CeladonMansion3FGameDesignerCompletedDexText
|
||||||
|
or "Wow! Excellent!\nYou completed\nyour POKéDEX!\nCongratulations!",
|
||||||
|
function()
|
||||||
|
local Diploma = require("src.ui.Diploma")
|
||||||
|
game.stack:push(Diploma.new(game, function()
|
||||||
|
-- Yellow tags on the unlocked-printing line (CompletedDexText2)
|
||||||
|
local after = require("src.core.GameVersion").isYellow()
|
||||||
|
and (t._CeladonMansion3FGameDesignerCompletedDexText2
|
||||||
|
or "You can print out\nyour diploma with\nthe GAME BOY\nPrinter!")
|
||||||
|
or nil
|
||||||
|
if after then
|
||||||
|
game.stack:push(TextBox.new(game, after, done))
|
||||||
|
else
|
||||||
|
done()
|
||||||
|
end
|
||||||
|
end))
|
||||||
|
end))
|
||||||
|
end,
|
||||||
|
|
||||||
|
TEXT_CELADONMANSION3F_GRAPHIC_ARTIST = function(game, ow, npc, done)
|
||||||
|
local t = game.data.text
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local yellow = require("src.core.GameVersion").isYellow()
|
||||||
|
if not (yellow and ownedCount(game) >= 150) then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._CeladonMansion3FGraphicArtistText
|
||||||
|
or "I'm the graphic\nartist!\nI drew you!", done))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- _CeladonMansion3FGraphicArtistText2: offer to print the diploma
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._CeladonMansion3FGraphicArtistText2
|
||||||
|
or "I'm the graphic\nartist!\fShould I print\nyour diploma?",
|
||||||
|
function()
|
||||||
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
|
if not yes then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._CeladonMansion3FGraphicArtistText3
|
||||||
|
or "Oh. But it's a\nspecial diploma!", done))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local Printer = require("src.core.Printer")
|
||||||
|
local Diploma = require("src.ui.Diploma")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
local saved, err = Printer.save("diploma", 160, 144, function()
|
||||||
|
Diploma.render(game)
|
||||||
|
end)
|
||||||
|
-- the PRNT stand-in always reports where the PNG landed
|
||||||
|
game.stack:push(TextBox.new(game, saved
|
||||||
|
and Strings("There you go!\fSaved as\n%s\vin the save\nfolder.", saved)
|
||||||
|
or Strings("Printer error!\n%s", tostring(err)), done))
|
||||||
|
end))
|
||||||
|
end))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,13 +35,25 @@ local function middleAgedMan(game, ow, npc, done)
|
|||||||
-- .loop: print WhichBadgeText, then show the badge list menu again
|
-- .loop: print WhichBadgeText, then show the badge list menu again
|
||||||
push(game, t._CeruleanBadgeHouseMiddleAgedManWhichBadgeText, function()
|
push(game, t._CeruleanBadgeHouseMiddleAgedManWhichBadgeText, function()
|
||||||
local ListMenu = require("src.ui.ListMenu")
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
local items = {}
|
local items = {}
|
||||||
for _, id in ipairs(BADGE_ORDER) do
|
for _, id in ipairs(BADGE_ORDER) do
|
||||||
local idef = game.data.items[id]
|
local idef = game.data.items[id]
|
||||||
items[#items + 1] = { label = idef and idef.name or id, value = id }
|
items[#items + 1] = { label = idef and idef.name or id, value = id }
|
||||||
end
|
end
|
||||||
|
-- PrintListMenuEntries prints ListMenuCancelText once it hits the
|
||||||
|
-- list's $FF terminator (home/list_menu.asm), so every
|
||||||
|
-- DisplayListMenuID list ends in an on-screen CANCEL row; picking it
|
||||||
|
-- takes DisplayListMenuIDLoop's ExitListMenu path, the same .done
|
||||||
|
-- exit as B (#569)
|
||||||
|
items[#items + 1] = { label = Strings("CANCEL") }
|
||||||
local menu = ListMenu.new(game, "", items, {
|
local menu = ListMenu.new(game, "", items, {
|
||||||
onChoose = function(item)
|
onChoose = function(item)
|
||||||
|
game.stack:pop()
|
||||||
|
if not item.value then
|
||||||
|
push(game, t._CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText, done)
|
||||||
|
return
|
||||||
|
end
|
||||||
push(game, t[BADGE_TEXT[item.value]], loop)
|
push(game, t[BADGE_TEXT[item.value]], loop)
|
||||||
end,
|
end,
|
||||||
onCancel = function()
|
onCancel = function()
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
-- Both NPCs use text_asm with an hRandomAdd roll to pick one of several
|
-- Both NPCs use text_asm with an hRandomAdd roll to pick one of several
|
||||||
-- flavor lines (no flags, no branching outcome) -- ported as a weighted
|
-- flavor lines (no flags, no branching outcome) -- ported as a weighted
|
||||||
-- math.random pick each time the NPC is talked to.
|
-- math.random pick each time the NPC is talked to.
|
||||||
|
--
|
||||||
|
-- Yellow renames Slowbro -> Electrode in the text labels (see
|
||||||
|
-- tools/yellow_symbol_aliases.py); look up both so Red/Blue and Yellow
|
||||||
|
-- share this script.
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -12,6 +16,29 @@ local function push(game, ow, npc, done, text)
|
|||||||
game.stack:push(TextBox.new(game, text, done))
|
game.stack:push(TextBox.new(game, text, done))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- CeruleanCitySlowbroText / CeruleanCityElectrodeText
|
||||||
|
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
|
||||||
|
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
|
||||||
|
local function talkMon(game, ow, npc, done)
|
||||||
|
local t = game.data.text
|
||||||
|
local roll = math.random(0, 255)
|
||||||
|
local text
|
||||||
|
if roll >= 180 then
|
||||||
|
text = t._CeruleanCitySlowbroTookASnoozeText
|
||||||
|
or t._CeruleanCityElectrodeTookASnoozeText
|
||||||
|
elseif roll >= 120 then
|
||||||
|
text = t._CeruleanCitySlowbroIsLoafingAroundText
|
||||||
|
or t._CeruleanCityElectrodeIsLoafingAroundText
|
||||||
|
elseif roll >= 60 then
|
||||||
|
text = t._CeruleanCitySlowbroTurnedAwayText
|
||||||
|
or t._CeruleanCityElectrodeTurnedAwayText
|
||||||
|
else
|
||||||
|
text = t._CeruleanCitySlowbroIgnoredOrdersText
|
||||||
|
or t._CeruleanCityElectrodeIgnoredOrdersText
|
||||||
|
end
|
||||||
|
push(game, ow, npc, done, text)
|
||||||
|
end
|
||||||
|
|
||||||
M.CERULEAN_CITY = {
|
M.CERULEAN_CITY = {
|
||||||
talk = {
|
talk = {
|
||||||
-- CeruleanCityCooltrainerF1Text (scripts/CeruleanCity.asm:362-393)
|
-- CeruleanCityCooltrainerF1Text (scripts/CeruleanCity.asm:362-393)
|
||||||
@@ -23,32 +50,20 @@ M.CERULEAN_CITY = {
|
|||||||
local text
|
local text
|
||||||
if roll >= 180 then
|
if roll >= 180 then
|
||||||
text = t._CeruleanCityCooltrainerF1SlowbroUseSonicboomText
|
text = t._CeruleanCityCooltrainerF1SlowbroUseSonicboomText
|
||||||
|
or t._CeruleanCityCooltrainerF1ElectrodeUseSonicboomText
|
||||||
elseif roll >= 100 then
|
elseif roll >= 100 then
|
||||||
text = t._CeruleanCityCooltrainerF1SlowbroPunchText
|
text = t._CeruleanCityCooltrainerF1SlowbroPunchText
|
||||||
|
or t._CeruleanCityCooltrainerF1ElectrodePunchText
|
||||||
else
|
else
|
||||||
text = t._CeruleanCityCooltrainerF1SlowbroWithdrawText
|
text = t._CeruleanCityCooltrainerF1SlowbroWithdrawText
|
||||||
|
or t._CeruleanCityCooltrainerF1ElectrodeWithdrawText
|
||||||
end
|
end
|
||||||
push(game, ow, npc, done, text)
|
push(game, ow, npc, done, text)
|
||||||
end,
|
end,
|
||||||
|
|
||||||
-- CeruleanCitySlowbroText (scripts/CeruleanCity.asm:395-436)
|
TEXT_CERULEANCITY_SLOWBRO = talkMon,
|
||||||
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
|
-- Yellow map object uses TEXT_CERULEANCITY_ELECTRODE
|
||||||
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
|
TEXT_CERULEANCITY_ELECTRODE = talkMon,
|
||||||
TEXT_CERULEANCITY_SLOWBRO = function(game, ow, npc, done)
|
|
||||||
local t = game.data.text
|
|
||||||
local roll = math.random(0, 255)
|
|
||||||
local text
|
|
||||||
if roll >= 180 then
|
|
||||||
text = t._CeruleanCitySlowbroTookASnoozeText
|
|
||||||
elseif roll >= 120 then
|
|
||||||
text = t._CeruleanCitySlowbroIsLoafingAroundText
|
|
||||||
elseif roll >= 60 then
|
|
||||||
text = t._CeruleanCitySlowbroTurnedAwayText
|
|
||||||
else
|
|
||||||
text = t._CeruleanCitySlowbroIgnoredOrdersText
|
|
||||||
end
|
|
||||||
push(game, ow, npc, done, text)
|
|
||||||
end,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,47 @@ return {
|
|||||||
alreadyGotLabel = "_GameCornerGentlemanCloselyWatchTheReelsText",
|
alreadyGotLabel = "_GameCornerGentlemanCloselyWatchTheReelsText",
|
||||||
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
|
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
-- Yellow keeps all three giveaways with the same events and amounts
|
||||||
|
-- but renames the objects and their text labels: FISHING_GURU ->
|
||||||
|
-- FISHING_GURU1, CLERK2 -> MIDDLE_AGED_MAN2, GENTLEMAN ->
|
||||||
|
-- FISHING_GURU2 (pokeyellow/scripts/GameCorner.asm). The Red/Blue
|
||||||
|
-- keys above never match there, so Yellow needs its own three (#552).
|
||||||
|
TEXT_GAMECORNER_FISHING_GURU1 = coinGiver({
|
||||||
|
event = "EVENT_GOT_10_COINS",
|
||||||
|
amount = 10,
|
||||||
|
askLabel = "_GameCornerFishingGuru1WantToPlayText",
|
||||||
|
askFallback = "Kid, do you want\nto play?",
|
||||||
|
receivedLabel = "_GameCornerFishingGuru1Received10CoinsText",
|
||||||
|
coinCaseFullLabel = "_GameCornerFishingGuru1DontNeedMyCoinsText",
|
||||||
|
coinCaseFullFallback = "You don't need my\ncoins!",
|
||||||
|
alreadyGotLabel = "_GameCornerFishingGuru1WinsComeAndGoText",
|
||||||
|
alreadyGotFallback = "Wins seem to come\nand go.",
|
||||||
|
}),
|
||||||
|
|
||||||
|
TEXT_GAMECORNER_MIDDLE_AGED_MAN2 = coinGiver({
|
||||||
|
event = "EVENT_GOT_20_COINS_2",
|
||||||
|
amount = 20,
|
||||||
|
askLabel = "_GameCornerMiddleAgedMan2WantSomeCoinsText",
|
||||||
|
askFallback = "What's up? Want\nsome coins?",
|
||||||
|
receivedLabel = "_GameCornerMiddleAgedMan2Received20CoinsText",
|
||||||
|
coinCaseFullLabel = "_GameCornerMiddleAgedMan2YouHaveLotsOfCoinsText",
|
||||||
|
coinCaseFullFallback = "You have lots of\ncoins!",
|
||||||
|
alreadyGotLabel = "_GameCornerMiddleAgedMan2INeedMoreCoinsText",
|
||||||
|
alreadyGotFallback = "Darn! I need more\ncoins for the\vPOKéMON I want!",
|
||||||
|
}),
|
||||||
|
|
||||||
|
TEXT_GAMECORNER_FISHING_GURU2 = coinGiver({
|
||||||
|
event = "EVENT_GOT_20_COINS",
|
||||||
|
amount = 20,
|
||||||
|
askLabel = "_GameCornerFishingGuru2ThrowingMeOffText",
|
||||||
|
askFallback = "Hey, what? You're\nthrowing me off!\vHere are some\vcoins, shoo!",
|
||||||
|
receivedLabel = "_GameCornerFishingGuru2Received20CoinsText",
|
||||||
|
coinCaseFullLabel = "_GameCornerFishingGuru2YouGotYourOwnCoinsText",
|
||||||
|
coinCaseFullFallback = "You've got your\nown coins!",
|
||||||
|
alreadyGotLabel = "_GameCornerFishingGuru2CloselyWatchTheReelsText",
|
||||||
|
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,19 +7,19 @@
|
|||||||
-- CheckFightingMapTrainers/TalkToTrainer via the existing trainer
|
-- CheckFightingMapTrainers/TalkToTrainer via the existing trainer
|
||||||
-- system, and the item pickups carry no talk text of their own here.
|
-- system, and the item pickups carry no talk text of their own here.
|
||||||
--
|
--
|
||||||
-- TEXT_SSANNE2FROOMS_GENTLEMAN3 also opens the POKéDEX entry for
|
-- TEXT_SSANNE2FROOMS_GENTLEMAN3 opens SNORLAX's POKéDEX entry after its
|
||||||
-- SNORLAX after the text box (DisplayPokedex in the original), which
|
-- text box, like DisplayPokedex in the original. The preview marks it seen
|
||||||
-- has no equivalent talk-script hook in this port (DexEntryMenu has
|
-- but does not mark it owned.
|
||||||
-- no done-callback the way other pushed UI states do) -- only the
|
|
||||||
-- flavor line is ported here.
|
|
||||||
return {
|
return {
|
||||||
SS_ANNE_2F_ROOMS = {
|
SS_ANNE_2F_ROOMS = {
|
||||||
talk = {
|
talk = {
|
||||||
-- SSAnne2FRoomsGentleman3Text: PrintText(_SSAnne2FRoomsGentleman3Text)
|
-- SSAnne2FRoomsGentleman3Text: PrintText(_SSAnne2FRoomsGentleman3Text),
|
||||||
-- (then DisplayPokedex SNORLAX -- not ported, see note above)
|
-- then DisplayPokedex SNORLAX.
|
||||||
TEXT_SSANNE2FROOMS_GENTLEMAN3 = {
|
TEXT_SSANNE2FROOMS_GENTLEMAN3 = {
|
||||||
{ "face_player" },
|
{ "face_player" },
|
||||||
{ "show_text", "_SSAnne2FRoomsGentleman3Text" },
|
{ "show_text", "_SSAnne2FRoomsGentleman3Text" },
|
||||||
|
{ "mark_seen", "SNORLAX" },
|
||||||
|
{ "push_screen", "DexEntryMenu", "SNORLAX" },
|
||||||
},
|
},
|
||||||
|
|
||||||
-- SSAnne2FRoomsGentleman4Text: PrintText(_SSAnne2FRoomsGentleman4Text)
|
-- SSAnne2FRoomsGentleman4Text: PrintText(_SSAnne2FRoomsGentleman4Text)
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
-- Viridian School House's two readables (data/events/hidden_events.asm,
|
||||||
|
-- hidden_events_for VIRIDIAN_SCHOOL_HOUSE):
|
||||||
|
-- hidden_text_predef 3, 0 PrintBlackboardLinkCableText, ViridianSchoolBlackboard
|
||||||
|
-- hidden_text_predef 3, 4 PrintNotebookText, ViridianSchoolNotebook
|
||||||
|
-- tools/extract/field.py only parses `hidden_event` rows, so neither
|
||||||
|
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
|
||||||
|
-- were dead A presses (#503). Same hook shape, and the same sibling asm
|
||||||
|
-- file, as the Celadon roof house in data/scripts/celadon_eevee.lua (#391);
|
||||||
|
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither
|
||||||
|
-- tile gates on facing.
|
||||||
|
|
||||||
|
local Menu = require("src.ui.Menu")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
|
-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm):
|
||||||
|
-- StatusAilmentText1/2 are the two columns of the 12x8 box at the top left
|
||||||
|
-- (hlcoord 0, 0 + `lb bc, 6, 10`); picking a status prints its
|
||||||
|
-- ViridianBlackboardStatusPointers entry and jumps back to .blackboardLoop,
|
||||||
|
-- QUIT or B falls through to .exitBlackboard.
|
||||||
|
local STATUS_LABELS = {
|
||||||
|
{ " SLP", "_ViridianBlackboardSleepText" },
|
||||||
|
{ " PSN", "_ViridianBlackboardPoisonText" },
|
||||||
|
{ " PAR", "_ViridianBlackboardPrlzText" },
|
||||||
|
{ " BRN", "_ViridianBlackboardBurnText" },
|
||||||
|
{ " FRZ", "_ViridianBlackboardFrozenText" },
|
||||||
|
}
|
||||||
|
|
||||||
|
local function blackboard(game)
|
||||||
|
local text = game.data.text or {}
|
||||||
|
local items, showMenu, askHeading
|
||||||
|
function showMenu()
|
||||||
|
game.stack:push(Menu.new(game, items,
|
||||||
|
{ tx = 0, ty = 0, tw = 12, th = 8, rowStep = 1 }))
|
||||||
|
end
|
||||||
|
-- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop
|
||||||
|
-- pass, immediately before HandleMenuInput
|
||||||
|
function askHeading()
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
|
||||||
|
showMenu))
|
||||||
|
end
|
||||||
|
items = {}
|
||||||
|
for i, row in ipairs(STATUS_LABELS) do
|
||||||
|
local label, key = row[1], row[2]
|
||||||
|
items[i] = { label = label, onSelect = function()
|
||||||
|
game.stack:push(TextBox.new(game, text[key] or label, askHeading))
|
||||||
|
end }
|
||||||
|
end
|
||||||
|
-- no onSelect: Menu's own pop closes the box, matching .exitBlackboard
|
||||||
|
items[#items + 1] = { label = " QUIT" }
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
text._ViridianSchoolBlackboardText1
|
||||||
|
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
|
||||||
|
askHeading))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
|
||||||
|
-- pages 1-3 each end in TurnPageSchoolNotebook (TurnPageText + YesNoChoice)
|
||||||
|
-- and NO stops the read; page 4 turns without asking and runs straight into
|
||||||
|
-- page 5, the girl catching you at it.
|
||||||
|
local function notebook(game)
|
||||||
|
local text = game.data.text or {}
|
||||||
|
local function page(n, after)
|
||||||
|
return TextBox.new(game, text["_ViridianSchoolNotebookText" .. n] or "", after)
|
||||||
|
end
|
||||||
|
local function turnPage(nextPage)
|
||||||
|
return function()
|
||||||
|
game.stack:push(TextBox.new(game, text._TurnPageText or "Turn the page?",
|
||||||
|
nil, { choice = function(yes)
|
||||||
|
if yes then game.stack:push(nextPage()) end
|
||||||
|
end }))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local function page5() return page(5) end
|
||||||
|
local function page4() return page(4, function() game.stack:push(page5()) end) end
|
||||||
|
local function page3() return page(3, turnPage(page4)) end
|
||||||
|
local function page2() return page(2, turnPage(page3)) end
|
||||||
|
game.stack:push(page(1, turnPage(page2)))
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
VIRIDIAN_SCHOOL_HOUSE = {
|
||||||
|
onInteract = function(game, ow, fx, fy)
|
||||||
|
if fx == 3 and fy == 0 then
|
||||||
|
blackboard(game)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if fx == 3 and fy == 4 then
|
||||||
|
notebook(game)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ local files = {
|
|||||||
"data.scripts.flavor.victory_road_2f",
|
"data.scripts.flavor.victory_road_2f",
|
||||||
"data.scripts.flavor.viridian_city",
|
"data.scripts.flavor.viridian_city",
|
||||||
"data.scripts.flavor.viridian_nickname_house",
|
"data.scripts.flavor.viridian_nickname_house",
|
||||||
|
"data.scripts.flavor.viridian_school_house", -- #503
|
||||||
"data.scripts.flavor.wardens_house",
|
"data.scripts.flavor.wardens_house",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -12,11 +12,18 @@
|
|||||||
-- different files can each add NPCs to the same map), and mod
|
-- different files can each add NPCs to the same map), and mod
|
||||||
-- contributions from the map_scripts registry compose on top of it.
|
-- contributions from the map_scripts registry compose on top of it.
|
||||||
|
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local MapScripts = require("src.script.MapScripts")
|
local MapScripts = require("src.script.MapScripts")
|
||||||
|
|
||||||
|
-- OaksLab is a full Yellow rewrite (one Eevee ball + forced Pikachu);
|
||||||
|
-- Red/Blue keep the three-starter choose flow.
|
||||||
|
local oaksLab = GameVersion.isYellow()
|
||||||
|
and "data.scripts.oaks_lab_yellow"
|
||||||
|
or "data.scripts.oaks_lab"
|
||||||
|
|
||||||
for _, mapEntry in ipairs({
|
for _, mapEntry in ipairs({
|
||||||
{ "PALLET_TOWN", "data.scripts.pallet_town" },
|
{ "PALLET_TOWN", "data.scripts.pallet_town" },
|
||||||
{ "OAKS_LAB", "data.scripts.oaks_lab" },
|
{ "OAKS_LAB", oaksLab },
|
||||||
{ "REDS_HOUSE_1F", "data.scripts.reds_house" },
|
{ "REDS_HOUSE_1F", "data.scripts.reds_house" },
|
||||||
{ "CELADON_MANSION_ROOF_HOUSE", "data.scripts.celadon_eevee" },
|
{ "CELADON_MANSION_ROOF_HOUSE", "data.scripts.celadon_eevee" },
|
||||||
}) do
|
}) do
|
||||||
@@ -36,6 +43,18 @@ for _, file in ipairs({ "data.scripts.story", "data.scripts.story2",
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Yellow-only content on top of the shared tables (talk keys merge per
|
||||||
|
-- TEXT constant): the Kanto-starter gift quests and Jessie & James.
|
||||||
|
if GameVersion.isYellow() then
|
||||||
|
for _, file in ipairs({ "data.scripts.yellow_gifts",
|
||||||
|
"data.scripts.yellow_jessie_james",
|
||||||
|
"data.scripts.yellow_beach_house" }) do
|
||||||
|
for mapId, mod in pairs(require(file)) do
|
||||||
|
MapScripts.attachBase(mapId, mod)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
function M.get(mapId)
|
function M.get(mapId)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
-- takes it ("I'll take this one, then!") and both balls disappear.
|
-- takes it ("I'll take this one, then!") and both balls disappear.
|
||||||
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
|
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
|
||||||
-- OaksLabRivalTakePokeBallScript.
|
-- OaksLabRivalTakePokeBallScript.
|
||||||
|
-- * Leftover ball (after the pick): Oak turns and reads the last-mon
|
||||||
|
-- line instead of re-offering the starter (OaksLabLastMonScript, #601).
|
||||||
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
|
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
|
||||||
-- has walked you in, else "gramps isn't around" (#218); with
|
-- has walked you in, else "gramps isn't around" (#218); with
|
||||||
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
|
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
|
||||||
@@ -23,7 +25,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
|||||||
{ "jump_if_true", 20 }, -- 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", 20 }, -- 4
|
{ "jump_if_false", 22 }, -- 4
|
||||||
-- the Pokédex "new species" entry shows before the ask (predef
|
-- the Pokédex "new species" entry shows before the ask (predef
|
||||||
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
|
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
|
||||||
-- sets the owned bits so ShowPokedexData prints height/weight/text;
|
-- sets the owned bits so ShowPokedexData prints height/weight/text;
|
||||||
@@ -31,7 +33,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
|||||||
{ "push_screen", "DexEntryMenu",
|
{ "push_screen", "DexEntryMenu",
|
||||||
{ species = species, forceOwned = true } }, -- 5
|
{ species = species, forceOwned = true } }, -- 5
|
||||||
{ "ask", askText }, -- 6
|
{ "ask", askText }, -- 6
|
||||||
{ "jump_if_false", 21 }, -- 7
|
{ "jump_if_false", "end" }, -- 7
|
||||||
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
|
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
|
||||||
-- inside give_pokemon). Show the received text first so the
|
-- inside give_pokemon). Show the received text first so the
|
||||||
-- nickname prompt follows "you got X", matching Gen1.
|
-- nickname prompt follows "you got X", matching Gen1.
|
||||||
@@ -52,9 +54,16 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
|||||||
{ 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" } }, -- 17
|
or "BULBASAUR" } }, -- 17
|
||||||
{ "jump", 21 }, -- 18
|
{ "jump", "end" }, -- 18
|
||||||
{ "jump", 21 }, -- 19 (spacer)
|
{ "jump", "end" }, -- 19 (spacer)
|
||||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
|
-- a leftover ball after the player's pick: Oak turns to face the
|
||||||
|
-- player and reads the last-mon line instead of re-offering the
|
||||||
|
-- starter (scripts/OaksLab.asm OaksLabSelectedPokeBallScript ->
|
||||||
|
-- OaksLabLastMonScript; #601). The ROM's "#MON" ligature is spelled
|
||||||
|
-- out as Pokémon here.
|
||||||
|
{ "face_object", 5, "down" }, -- 20
|
||||||
|
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 21
|
||||||
|
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 22
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
-- Hand-ported from pret/pokeyellow scripts/OaksLab.asm.
|
||||||
|
-- Yellow: one Eevee ball on the table. Rival snatches it; Oak then
|
||||||
|
-- gives the player the wild Pikachu he caught earlier (STARTER_PIKACHU).
|
||||||
|
-- Object indices differ from Red (no Charmander/Squirtle/Bulbasaur balls):
|
||||||
|
-- 1 RIVAL (4,3), 2 EEVEE_POKE_BALL (7,3), 3 OAK1 (5,2),
|
||||||
|
-- 4-5 POKEDEX (2,1)/(3,1), 6 OAK2 (door), 7 GIRL, 8-9 SCIENTIST.
|
||||||
|
--
|
||||||
|
-- wRivalStarter rides in save.rivalStarter (RIVAL_STARTER_* 1 JOLTEON /
|
||||||
|
-- 2 FLAREON / 3 VAPOREON): JOLTEON baseline at the snatch
|
||||||
|
-- (OaksLabRivalTakesPokeballScript), FLAREON on a lab win / VAPOREON on
|
||||||
|
-- a lab loss (OaksLabRivalEndBattleScript), and Route 22's first battle
|
||||||
|
-- upgrades FLAREON back to JOLTEON (Route22Rival1AfterBattleScript).
|
||||||
|
|
||||||
|
local OAK1 = 3
|
||||||
|
local RIVAL = 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
talk = {
|
||||||
|
TEXT_OAKSLAB_OAK1 = {
|
||||||
|
{ "face_player" },
|
||||||
|
-- Yellow's OaksLabOak1Text leads with the dex-rating branch: once
|
||||||
|
-- EVENT_PALLET_AFTER_GETTING_POKEBALLS is set (converted saves) or
|
||||||
|
-- 2+ species are owned, Oak asks how the Pokédex is coming and
|
||||||
|
-- rates it (predef DisplayDexRating)
|
||||||
|
{ "check_flag", "EVENT_PALLET_AFTER_GETTING_POKEBALLS" },
|
||||||
|
{ "jump_if_true", "dex_rating" },
|
||||||
|
{ "check_dex_owned", 2 },
|
||||||
|
{ "jump_if_true", "dex_rating" },
|
||||||
|
{ "check_item", "POKE_BALL" },
|
||||||
|
{ "jump_if_true", "come_see" },
|
||||||
|
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
|
||||||
|
{ "jump_if_true", "give_balls" },
|
||||||
|
{ "check_flag", "EVENT_GOT_POKEDEX" },
|
||||||
|
{ "jump_if_true", "around_world" },
|
||||||
|
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
|
||||||
|
{ "jump_if_false", "pre_lab_battle" },
|
||||||
|
{ "check_item", "OAKS_PARCEL" },
|
||||||
|
{ "jump_if_false", "raise_young" },
|
||||||
|
-- .DeliverParcelText: parcel handover, then the Pokédex scene
|
||||||
|
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
|
||||||
|
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||||
|
{ "play_sound", "Get_Key_Item" },
|
||||||
|
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||||
|
{ "take_item", "OAKS_PARCEL", 1 },
|
||||||
|
{ "stop_music" },
|
||||||
|
{ "play_music", "Music_MeetRival" },
|
||||||
|
{ "show_text", "_OaksLabRivalGrampsText" },
|
||||||
|
{ "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
|
||||||
|
{ "place_npc", RIVAL, 4, 7, "up" },
|
||||||
|
{ "move_npc_to", RIVAL, 4, 3 },
|
||||||
|
{ "play_music", "Music_OaksLab" },
|
||||||
|
{ "face_object", RIVAL, "up" },
|
||||||
|
{ "face_object", OAK1, "down" },
|
||||||
|
-- Yellow opens with the rival bragging, not Red's "what did you
|
||||||
|
-- call me for" (OaksLabOakGivesPokedexScript text order)
|
||||||
|
{ "show_text", "_OaksLabRivalMyPokemonHasGrownStrongerText" },
|
||||||
|
{ "face_object", RIVAL, "up" },
|
||||||
|
{ "face_object", OAK1, "down" },
|
||||||
|
{ "show_text", "_OaksLabOakIHaveARequestText" },
|
||||||
|
{ "face_object", RIVAL, "up" },
|
||||||
|
{ "face_object", OAK1, "down" },
|
||||||
|
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||||
|
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||||
|
{ "play_sound", "Get_Key_Item" },
|
||||||
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||||
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||||
|
{ "face_object", RIVAL, "up" },
|
||||||
|
{ "face_object", OAK1, "down" },
|
||||||
|
{ "show_text", "_OaksLabOakThatWasMyDreamText" },
|
||||||
|
{ "face_object", RIVAL, "right" },
|
||||||
|
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
|
||||||
|
{ "set_flag", "EVENT_GOT_POKEDEX" },
|
||||||
|
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
|
||||||
|
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
|
||||||
|
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
|
||||||
|
{ "stop_music" },
|
||||||
|
{ "play_music", "Music_MeetRival" },
|
||||||
|
{ "move_npc_to", RIVAL, 4, 7 },
|
||||||
|
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" },
|
||||||
|
{ "play_music", "Music_OaksLab" },
|
||||||
|
{ "set_flag", "EVENT_1ST_ROUTE22_RIVAL_BATTLE" },
|
||||||
|
{ "clear_flag", "EVENT_2ND_ROUTE22_RIVAL_BATTLE" },
|
||||||
|
{ "set_flag", "EVENT_ROUTE22_RIVAL_WANTS_BATTLE" },
|
||||||
|
{ "show_object", "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
|
||||||
|
{ "label", "raise_young" },
|
||||||
|
-- Yellow: talk-to-it (starter Pikachu) instead of Red raise-young line.
|
||||||
|
{ "show_text", "_OaksLabOak1YouShouldTalkToIt" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
|
||||||
|
{ "label", "pre_lab_battle" },
|
||||||
|
{ "check_flag", "EVENT_GOT_STARTER" },
|
||||||
|
{ "jump_if_true", "can_fight" },
|
||||||
|
{ "show_text", "_OaksLabOak1GoAheadItsYours" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
{ "label", "can_fight" },
|
||||||
|
{ "show_text", "_OaksLabOak1YourPokemonCanFightText" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
|
||||||
|
{ "label", "around_world" },
|
||||||
|
{ "show_text", "_OaksLabOak1PokemonAroundTheWorldText" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
|
||||||
|
{ "label", "give_balls" },
|
||||||
|
{ "check_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||||
|
{ "jump_if_true", "come_see" },
|
||||||
|
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||||
|
{ "give_item", "POKE_BALL", 5, false },
|
||||||
|
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||||
|
{ "play_sound", "Get_Key_Item" },
|
||||||
|
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
|
||||||
|
{ "label", "come_see" },
|
||||||
|
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
|
||||||
|
{ "label", "dex_rating" },
|
||||||
|
{ "show_text", "_OaksLabOak1HowIsYourPokedexComingText" },
|
||||||
|
{ "dex_rating" },
|
||||||
|
},
|
||||||
|
|
||||||
|
-- OaksLabEeveePokeBallText / OaksLabRivalExclamationScript ->
|
||||||
|
-- OaksLabChoseStarterScript..OaksLabPlayerReceivesPikachuScript:
|
||||||
|
-- before Oak's choose speech the ball is just flavor; after it, the
|
||||||
|
-- rival "!"s, shoves the player off the table, snatches the ball,
|
||||||
|
-- then the player is walked over to Oak and handed Pikachu.
|
||||||
|
TEXT_OAKSLAB_EEVEE_POKE_BALL = function(game, ow, npc, done)
|
||||||
|
local flags = game.save.flags
|
||||||
|
if flags.EVENT_GOT_STARTER then
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not flags.EVENT_OAK_ASKED_TO_CHOOSE_MON then
|
||||||
|
ow.runner:run({
|
||||||
|
{ "show_text", "_OaksLabThatsAPokeball" },
|
||||||
|
}, { onDone = done })
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local px, py = ow.player.cellX, ow.player.cellY
|
||||||
|
local rows = {
|
||||||
|
-- OaksLabRivalExclamationScript: "!" over the rival
|
||||||
|
{ "emote", RIVAL, "shock" },
|
||||||
|
}
|
||||||
|
-- .RivalPushesPlayerAwayFromEeveeBall is DOWN then RIGHT x3 (the $07
|
||||||
|
-- bytes are Yellow's own step-right encoding, decoded by
|
||||||
|
-- engine/overworld/movement.asm Func_5288 -> Func_532b), and the
|
||||||
|
-- PAD_RIGHT x2 shove is NOT queued alongside it:
|
||||||
|
-- OaksLabRivalTakesPokeballScript .asm_1c564 polls every frame and
|
||||||
|
-- only simulates the pair once wNPCNumScriptedSteps reads 1 -- i.e.
|
||||||
|
-- as the rival begins the LAST byte, the step onto the tile the
|
||||||
|
-- player is standing on. Starting both on one row had Red stroll
|
||||||
|
-- off the Eevee while the rival was still at the top of the table
|
||||||
|
-- (#559).
|
||||||
|
if py == 4 then
|
||||||
|
rows[#rows + 1] = { "walk_npc", RIVAL, { "down", "right", "right" } }
|
||||||
|
-- this one runs concurrently with the shove below
|
||||||
|
rows[#rows + 1] = { "walk_npc", RIVAL, { "right" }, { wait = false } }
|
||||||
|
rows[#rows + 1] = { "face_player_dir", "left" }
|
||||||
|
rows[#rows + 1] = { "move_player", "right", 2 }
|
||||||
|
-- move_player blocks for both tiles, so the rival has already
|
||||||
|
-- landed on (7,4); this is just the beat before he turns up
|
||||||
|
rows[#rows + 1] = { "wait", 20 }
|
||||||
|
else
|
||||||
|
rows[#rows + 1] = { "move_npc_to", RIVAL, 7, 4 }
|
||||||
|
end
|
||||||
|
rows[#rows + 1] = { "face_object", RIVAL, "up" }
|
||||||
|
rows[#rows + 1] = { "hide_object", "OAKS_LAB", "OAKSLAB_EEVEE_POKE_BALL" }
|
||||||
|
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
|
||||||
|
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
|
||||||
|
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" }
|
||||||
|
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
|
||||||
|
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" }
|
||||||
|
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
|
||||||
|
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
|
||||||
|
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText5" }
|
||||||
|
-- OaksLabRLE_PlayerWalksToOak: the simulated-joypad buffer plays
|
||||||
|
-- its RLE list BACKWARDS (StartSimulatingJoypadStates consumes
|
||||||
|
-- from wSimulatedJoypadStatesEnd), so the real order is LEFT 1,
|
||||||
|
-- DOWN 1, LEFT 3, UP 2 -- from the shove spot (9,4) the player
|
||||||
|
-- rounds the BOTTOM of the table to (5,3), directly below Oak
|
||||||
|
if py == 4 then
|
||||||
|
rows[#rows + 1] = { "walk_npc", "player",
|
||||||
|
{ "left", "down", "left", "left", "left", "up", "up" } }
|
||||||
|
else
|
||||||
|
rows[#rows + 1] = { "walk_npc", "player", { "left" } }
|
||||||
|
end
|
||||||
|
rows[#rows + 1] = { "face_player_dir", "up" }
|
||||||
|
rows[#rows + 1] = { "face_object", OAK1, "down" }
|
||||||
|
-- OaksLabPlayerReceivedMonText: no nickname prompt -- the starter
|
||||||
|
-- Pikachu keeps its species name
|
||||||
|
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
|
||||||
|
rows[#rows + 1] = { "play_sound", "Get_Key_Item" }
|
||||||
|
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
|
||||||
|
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5, true }
|
||||||
|
rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" }
|
||||||
|
rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" }
|
||||||
|
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||||
|
end,
|
||||||
|
|
||||||
|
TEXT_OAKSLAB_RIVAL = {
|
||||||
|
{ "face_player" },
|
||||||
|
{ "check_flag", "EVENT_GOT_STARTER" },
|
||||||
|
{ "jump_if_false", "pre_starter" },
|
||||||
|
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
|
||||||
|
{ "label", "pre_starter" },
|
||||||
|
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB_2" },
|
||||||
|
{ "jump_if_false", "gramps_gone" },
|
||||||
|
{ "show_text", "_OaksLabRivalIllGetABetterPokemonThanYou" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
|
||||||
|
{ "label", "gramps_gone" },
|
||||||
|
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { save = game.save, game = game, overworld = ow }
|
||||||
|
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX1")
|
||||||
|
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_POKEDEX2")
|
||||||
|
end,
|
||||||
|
|
||||||
|
onStep = function(game, ow, x, y)
|
||||||
|
local flags = game.save.flags
|
||||||
|
-- OaksLabPlayerDontGoAwayScript: y==6 without the starter walks the
|
||||||
|
-- player back up a tile
|
||||||
|
if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER
|
||||||
|
and y >= 6 then
|
||||||
|
ow.runner:run({
|
||||||
|
{ "face_object", OAK1, "down" },
|
||||||
|
{ "face_object", RIVAL, "down" },
|
||||||
|
{ "show_text", "_OaksLabOakDontGoAwayYetText" },
|
||||||
|
{ "move_player", "up", 1 },
|
||||||
|
}, {})
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
-- OaksLabRivalChallengesPlayerScript..OaksLabPikachuDislikesPokeballsScript:
|
||||||
|
-- heading for the door with Pikachu starts the rival battle, his
|
||||||
|
-- exit walk, then Pikachu pops out of its ball.
|
||||||
|
if flags.EVENT_GOT_STARTER and not flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB
|
||||||
|
and y >= 6 then
|
||||||
|
local rival = ow:npcByIndex(RIVAL)
|
||||||
|
if not rival then return false end
|
||||||
|
local rows = {
|
||||||
|
{ "face_player_dir", "up" },
|
||||||
|
{ "stop_music" },
|
||||||
|
{ "play_music", "Music_MeetRival" },
|
||||||
|
{ "show_text", "_OaksLabRivalIllTakeYouOnText" },
|
||||||
|
}
|
||||||
|
-- FindPathToPlayer with the Y distance decremented: the rival
|
||||||
|
-- stops one tile above the player
|
||||||
|
local target
|
||||||
|
for _, c in ipairs({ { x, y - 1 }, { x - 1, y }, { x + 1, y },
|
||||||
|
{ x, y + 1 } }) do
|
||||||
|
if ow.map:inBounds(c[1], c[2]) and ow.map:isWalkableCell(c[1], c[2]) then
|
||||||
|
target = c
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if target then
|
||||||
|
table.insert(rows, { "move_npc_to", RIVAL, target[1], target[2] })
|
||||||
|
end
|
||||||
|
table.insert(rows, { "face_object", RIVAL,
|
||||||
|
target and target[2] < y and "down"
|
||||||
|
or target and target[2] > y and "up"
|
||||||
|
or target and target[1] < x and "right" or "left" })
|
||||||
|
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", 1 })
|
||||||
|
table.insert(rows, { "heal_party" })
|
||||||
|
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
|
||||||
|
-- OaksLabRivalEndBattleScript: his Eevee's future evolution is
|
||||||
|
-- decided here -- FLAREON if the player won, VAPOREON otherwise
|
||||||
|
table.insert(rows, { "check_battle_result", "win" })
|
||||||
|
table.insert(rows, { "jump_if_false", "lost_lab" })
|
||||||
|
table.insert(rows, { "set_field", "rivalStarter", 2 })
|
||||||
|
table.insert(rows, { "jump", "exit" })
|
||||||
|
table.insert(rows, { "label", "lost_lab" })
|
||||||
|
table.insert(rows, { "set_field", "rivalStarter", 3 })
|
||||||
|
table.insert(rows, { "label", "exit" })
|
||||||
|
-- OaksLabRivalStartsExitScript: parting shot, walk out past the
|
||||||
|
-- player, restore the lab theme
|
||||||
|
table.insert(rows, { "wait", 20 })
|
||||||
|
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||||
|
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
|
||||||
|
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||||
|
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||||
|
-- OaksLabPikachuEscapesPokeballScript: Pikachu hates its ball.
|
||||||
|
-- The overworld follower itself is still an open port
|
||||||
|
-- (docs/yellow-version.md runtime backlog); the story beat plays.
|
||||||
|
table.insert(rows, { "play_cry", "PIKACHU" })
|
||||||
|
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" })
|
||||||
|
table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" })
|
||||||
|
ow.runner:run(rows, { npc = rival })
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
}
|
||||||
+54
-8
@@ -19,19 +19,60 @@ local FEE = 500
|
|||||||
local BALLS = 30
|
local BALLS = 30
|
||||||
local STEPS = 502
|
local STEPS = 502
|
||||||
|
|
||||||
local function startGame(game, t, done)
|
local function startGame(game, t, done, balls, introText)
|
||||||
game.save.money = game.save.money - FEE
|
game.save.safari = { balls = balls or BALLS, steps = STEPS }
|
||||||
game.save.safari = { balls = BALLS, steps = STEPS }
|
game.save.safariNags = nil
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local paid = (t._SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText
|
local paid = introText
|
||||||
or "That'll be ¥500\nplease!\f{PLAYER} received\n30 SAFARI BALLs!")
|
or (t._SafariZoneGateSafariZoneWorker1ThatllBe500PleaseText
|
||||||
:gsub("{PLAYER}", game.save.player.name)
|
or "That'll be ¥500\nplease!\f{PLAYER} received\n30 SAFARI BALLs!")
|
||||||
|
:gsub("{NUM:[^}]*}", "500")
|
||||||
|
paid = paid:gsub("{PLAYER}", game.save.player.name)
|
||||||
local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText
|
local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText
|
||||||
or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!"
|
or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!"
|
||||||
local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!"
|
local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!"
|
||||||
game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, done))
|
game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, done))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Yellow's soft-lock fix (scripts/SafariZoneGate_2.asm): a player short of
|
||||||
|
-- the full fee still gets in.
|
||||||
|
-- 0 < money < 500: SafariZoneEntranceCalculateLowCostAdmission takes
|
||||||
|
-- everything and hands over min(money/23 + 1, 29) balls.
|
||||||
|
-- money == 0: SafariZoneEntranceGetLowCostAdmissionText nags four times
|
||||||
|
-- (LowCostText5/6/7/8), then relents -- free entry, one ball.
|
||||||
|
local function yellowLowCost(game, ow, t, done, back)
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
if game.save.money > 0 then
|
||||||
|
local balls = math.min(math.floor(game.save.money / 23) + 1, 29)
|
||||||
|
game.save.money = 0
|
||||||
|
local intro =
|
||||||
|
(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText
|
||||||
|
or "Oops! Not enough\nmoney!")
|
||||||
|
.. (t._SafariZoneLowCostText1
|
||||||
|
or "\fOh, all right, pay\nme what you have.")
|
||||||
|
.. "\f" .. (t._SafariZoneLowCostText2
|
||||||
|
or "But, I can't give\nyou all 30 BALLs.")
|
||||||
|
startGame(game, t, done, balls, intro)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local nag = game.save.safariNags or 0
|
||||||
|
game.save.safariNags = nag + 1
|
||||||
|
if nag >= 3 then
|
||||||
|
local intro =
|
||||||
|
(t._SafariZoneLowCostText8 or "Read my lips, NO!\nGet it?")
|
||||||
|
.. (t._SafariZoneLowCostText3
|
||||||
|
or "\fYou're persistent,\naren't you?\fOK, you can go in\nfor free, but\njust this once!")
|
||||||
|
startGame(game, t, done, 1, intro)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local nags = {
|
||||||
|
t._SafariZoneLowCostText5 or "I'm sorry, but you\nhave to pay to\nenter.",
|
||||||
|
t._SafariZoneLowCostText6 or "You can't enter\nwithout paying!",
|
||||||
|
t._SafariZoneLowCostText7 or "I said, no money,\nno entry!",
|
||||||
|
}
|
||||||
|
back(nags[nag + 1])
|
||||||
|
end
|
||||||
|
|
||||||
local function joinPrompt(game, ow, done)
|
local function joinPrompt(game, ow, done)
|
||||||
done = done or function() end
|
done = done or function() end
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
@@ -51,9 +92,14 @@ local function joinPrompt(game, ow, done)
|
|||||||
back(t._SafariZoneGateSafariZoneWorker1PleaseComeAgainText
|
back(t._SafariZoneGateSafariZoneWorker1PleaseComeAgainText
|
||||||
or "OK! Please come\nagain!")
|
or "OK! Please come\nagain!")
|
||||||
elseif game.save.money < FEE then
|
elseif game.save.money < FEE then
|
||||||
back(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText
|
if require("src.core.GameVersion").isYellow() then
|
||||||
or "Oops! Not enough\nmoney!")
|
yellowLowCost(game, ow, t, done, back)
|
||||||
|
else
|
||||||
|
back(t._SafariZoneGateSafariZoneWorker1NotEnoughMoneyText
|
||||||
|
or "Oops! Not enough\nmoney!")
|
||||||
|
end
|
||||||
else
|
else
|
||||||
|
game.save.money = game.save.money - FEE
|
||||||
startGame(game, t, done)
|
startGame(game, t, done)
|
||||||
end
|
end
|
||||||
end))
|
end))
|
||||||
|
|||||||
+92
-19
@@ -183,6 +183,7 @@ M.BILLS_HOUSE = {
|
|||||||
overworld = ow },
|
overworld = ow },
|
||||||
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||||
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||||
|
require("src.world.PikachuFollower").onBillEnteredMachine(game, ow)
|
||||||
done()
|
done()
|
||||||
end
|
end
|
||||||
if ow.player.facing == "down" then
|
if ow.player.facing == "down" then
|
||||||
@@ -273,6 +274,9 @@ M.BILLS_HOUSE = {
|
|||||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
||||||
end
|
end
|
||||||
|
if not flags.EVENT_MET_BILL_2 then
|
||||||
|
require("src.world.PikachuFollower").onBillsHouseEnter(game, ow)
|
||||||
|
end
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,7 +647,12 @@ M.WARDENS_HOUSE = {
|
|||||||
TEXT_WARDENSHOUSE_WARDEN = {
|
TEXT_WARDENSHOUSE_WARDEN = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
|
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
|
||||||
{ "jump_if_true", 13 }, -- 3
|
-- #535: previously jumped to the same silent-end target as the
|
||||||
|
-- give-then-thank fallthrough (row 13), so the warden said nothing
|
||||||
|
-- on every visit after the trade. pokered's .got_item branch
|
||||||
|
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText,
|
||||||
|
-- so route here to the new row 17 that does the same.
|
||||||
|
{ "jump_if_true", 17 }, -- 3
|
||||||
{ "check_item", "GOLD_TEETH" }, -- 4
|
{ "check_item", "GOLD_TEETH" }, -- 4
|
||||||
{ "jump_if_false", 15 }, -- 5
|
{ "jump_if_false", 15 }, -- 5
|
||||||
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
|
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
|
||||||
@@ -654,9 +663,15 @@ M.WARDENS_HOUSE = {
|
|||||||
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
|
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
|
||||||
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
|
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
|
||||||
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
|
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
|
||||||
{ "jump", 16 }, -- 13 (already got it)
|
{ "jump", 18 }, -- 13 (already got it this convo; jp .done)
|
||||||
{ "jump", 16 }, -- 14 (unused)
|
{ "jump", 18 }, -- 14 (unused)
|
||||||
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
|
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
|
||||||
|
{ "jump", 18 }, -- 16 (#535: skip the new explanation row below)
|
||||||
|
-- #535: pokered .got_item branch (scripts/WardensHouse.asm) --
|
||||||
|
-- printed on every subsequent talk once EVENT_GOT_HM04 is set.
|
||||||
|
-- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm):
|
||||||
|
-- HM04 teaches Strength, and hints at the Safari Zone secret house.
|
||||||
|
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 17
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -666,6 +681,48 @@ M.WARDENS_HOUSE = {
|
|||||||
-- generic OPP_GIOVANNI#2 battle)
|
-- generic OPP_GIOVANNI#2 battle)
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- SilphCo11FTeamRocketLeavesScript (scripts/SilphCo11F.asm) hides every
|
||||||
|
-- ROCKET and rocket-aligned SCIENTIST toggle on 2F-11F the moment Giovanni
|
||||||
|
-- falls; the item balls, the 2F/10F rescued workers and the 7F rival keep
|
||||||
|
-- theirs. Names and order are data/maps/toggleable_objects.asm's
|
||||||
|
-- TOGGLE_SILPH_CO_<floor>_n entries. Saffron City's half of the same
|
||||||
|
-- script lives in M.SAFFRON_CITY (story4.lua) (#392).
|
||||||
|
local SILPH_ROCKET_OBJECTS = {
|
||||||
|
{ "SILPH_CO_2F", { "SILPHCO2F_SCIENTIST1", "SILPHCO2F_SCIENTIST2",
|
||||||
|
"SILPHCO2F_ROCKET1", "SILPHCO2F_ROCKET2" } },
|
||||||
|
{ "SILPH_CO_3F", { "SILPHCO3F_ROCKET", "SILPHCO3F_SCIENTIST" } },
|
||||||
|
{ "SILPH_CO_4F", { "SILPHCO4F_ROCKET1", "SILPHCO4F_SCIENTIST",
|
||||||
|
"SILPHCO4F_ROCKET2" } },
|
||||||
|
{ "SILPH_CO_5F", { "SILPHCO5F_ROCKET1", "SILPHCO5F_SCIENTIST",
|
||||||
|
"SILPHCO5F_ROCKER", "SILPHCO5F_ROCKET2" } },
|
||||||
|
{ "SILPH_CO_6F", { "SILPHCO6F_ROCKET1", "SILPHCO6F_SCIENTIST",
|
||||||
|
"SILPHCO6F_ROCKET2" } },
|
||||||
|
{ "SILPH_CO_7F", { "SILPHCO7F_ROCKET1", "SILPHCO7F_SCIENTIST",
|
||||||
|
"SILPHCO7F_ROCKET2", "SILPHCO7F_ROCKET3" } },
|
||||||
|
{ "SILPH_CO_8F", { "SILPHCO8F_ROCKET1", "SILPHCO8F_SCIENTIST",
|
||||||
|
"SILPHCO8F_ROCKET2" } },
|
||||||
|
{ "SILPH_CO_9F", { "SILPHCO9F_ROCKET1", "SILPHCO9F_SCIENTIST",
|
||||||
|
"SILPHCO9F_ROCKET2" } },
|
||||||
|
{ "SILPH_CO_10F", { "SILPHCO10F_ROCKET", "SILPHCO10F_SCIENTIST" } },
|
||||||
|
{ "SILPH_CO_11F", { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1",
|
||||||
|
"SILPHCO11F_ROCKET2" } },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- hide_object writes save.objectToggles, so the single pass at the win
|
||||||
|
-- covers floors the player is not standing on; onlyMap is the per-floor
|
||||||
|
-- repair path below.
|
||||||
|
local function silphRocketsLeave(game, ow, onlyMap)
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { game = game, save = game.save, overworld = ow }
|
||||||
|
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
|
||||||
|
if not onlyMap or onlyMap == floor[1] then
|
||||||
|
for _, name in ipairs(floor[2]) do
|
||||||
|
Commands.hide_object(ctx, floor[1], name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
M.SILPH_CO_11F = {
|
M.SILPH_CO_11F = {
|
||||||
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
|
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
|
||||||
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
|
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
|
||||||
@@ -692,13 +749,11 @@ M.SILPH_CO_11F = {
|
|||||||
ow:scriptMove(gio, "down", 3, function()
|
ow:scriptMove(gio, "down", 3, function()
|
||||||
gio:facePlayer(ow.player)
|
gio:facePlayer(ow.player)
|
||||||
ow:engageTrainer(gio, function()
|
ow:engageTrainer(gio, function()
|
||||||
-- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor
|
-- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves
|
||||||
-- after the loss (the street rockets are handled by
|
-- after the loss (the street rockets are handled by
|
||||||
-- M.SAFFRON_CITY.onEnter in story4.lua).
|
-- M.SAFFRON_CITY.onEnter in story4.lua).
|
||||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||||
local Commands = require("src.script.Commands")
|
silphRocketsLeave(game, ow)
|
||||||
local ctx = { game = game, save = game.save, overworld = ow }
|
|
||||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
@@ -706,28 +761,46 @@ M.SILPH_CO_11F = {
|
|||||||
end,
|
end,
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||||
local Commands = require("src.script.Commands")
|
silphRocketsLeave(game, ow)
|
||||||
local ctx = { game = game, save = game.save, overworld = ow }
|
|
||||||
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
|
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
|
-- SilphCo11FSilphPresidentText branches on EVENT_GOT_MASTER_BALL only:
|
||||||
|
-- the teleport pads reach him without passing Giovanni's trigger, and
|
||||||
|
-- afterwards he describes the ball forever. The old rows fell off the
|
||||||
|
-- end of the script on both branches, so a second talk was silence
|
||||||
|
-- (#392).
|
||||||
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
|
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
|
||||||
{ "face_player" }, -- 1
|
{ "face_player" }, -- 1
|
||||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
|
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 2
|
||||||
{ "jump_if_false", 10 }, -- 3
|
{ "jump_if_true", 9 }, -- 3
|
||||||
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 4
|
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 4
|
||||||
{ "jump_if_true", 10 }, -- 5
|
|
||||||
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 6
|
|
||||||
-- give-then-print like scripts/SilphCo11F.asm
|
-- give-then-print like scripts/SilphCo11F.asm
|
||||||
{ "give_item", "MASTER_BALL", 1, false }, -- 7
|
{ "give_item", "MASTER_BALL", 1, false }, -- 5
|
||||||
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 8
|
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 6
|
||||||
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 9
|
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 7
|
||||||
{ "jump", 11 }, -- 10
|
{ "jump", "end" }, -- 8
|
||||||
|
{ "show_text",
|
||||||
|
"_SilphCo11FSilphPresidentMasterBallDescriptionText" }, -- 9
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- Floors 2F-10F carry no other onEnter, so this repairs saves that beat
|
||||||
|
-- Giovanni before the hide list existed (same shape as
|
||||||
|
-- M.POKEMON_TOWER_7F.onEnter); 11F hides its own inside the table above.
|
||||||
|
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
|
||||||
|
local mapId = floor[1]
|
||||||
|
if mapId ~= "SILPH_CO_11F" then
|
||||||
|
M[mapId] = M[mapId] or {}
|
||||||
|
M[mapId].onEnter = function(game, ow)
|
||||||
|
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||||
|
silphRocketsLeave(game, ow, mapId)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
-- 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
|
||||||
|
|||||||
+234
-101
@@ -1,6 +1,10 @@
|
|||||||
-- More hand-ported events: the Pallet Town intro, the thirsty Saffron
|
-- More hand-ported events: the Pallet Town intro, the thirsty Saffron
|
||||||
-- gate guards, the Bike Voucher chain, fossils and the day-care.
|
-- gate guards, the Bike Voucher chain, fossils and the day-care.
|
||||||
-- Registered via data/scripts/init.lua; each cites its pokered source.
|
-- Registered via data/scripts/init.lua; Red/Blue cite pokered, Yellow
|
||||||
|
-- cites pokeyellow (Pallet stop row, Oak spawn, walk RLE, and the
|
||||||
|
-- wild-Pikachu beat before the lab escort).
|
||||||
|
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -35,72 +39,79 @@ function escort.findPath(fromX, fromY, toX, toY)
|
|||||||
return path
|
return path
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PalletTownOakWalksToPlayerScript: Oak appears at his object spot
|
-- Red: Oak object (8,5) -> one tile below the player at y=1.
|
||||||
-- (8,5) and zigzags to one tile below the player (hNPCPlayerYDistance
|
-- Yellow: Oak object (10,4); stop fires at y=0 (pokeyellow PalletTown).
|
||||||
-- is pre-decremented before predef FindPathToPlayer).
|
|
||||||
function escort.oakApproach(playerX)
|
function escort.oakApproach(playerX)
|
||||||
|
if GameVersion.isYellow() then
|
||||||
|
return escort.findPath(10, 4, playerX, 1)
|
||||||
|
end
|
||||||
return escort.findPath(8, 5, playerX, 2)
|
return escort.findPath(8, 5, playerX, 2)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- RLEList_ProfOakWalkToLab (engine/overworld/auto_movement.asm):
|
-- RLEList_ProfOakWalkToLab (engine/overworld/auto_movement.asm).
|
||||||
-- DOWN x5, LEFT, DOWN x5, RIGHT x3, UP -- Oak's last step lands on the
|
-- Yellow differs: first DOWN is x6 (Oak starts one tile farther north).
|
||||||
-- lab door (12,11). (The trailing NPC_CHANGE_FACING is a march-in-
|
local function buildOakSteps()
|
||||||
-- place beat on the mat; here Oak just stands his final beat.)
|
if GameVersion.isYellow() then
|
||||||
escort.oakSteps = {
|
return {
|
||||||
"down", "down", "down", "down", "down",
|
"down", "down", "down", "down", "down", "down",
|
||||||
"left",
|
"left",
|
||||||
"down", "down", "down", "down", "down",
|
"down", "down", "down", "down", "down",
|
||||||
"right", "right", "right",
|
"right", "right", "right",
|
||||||
"up",
|
"up",
|
||||||
}
|
}
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
"down", "down", "down", "down", "down",
|
||||||
|
"left",
|
||||||
|
"down", "down", "down", "down", "down",
|
||||||
|
"right", "right", "right",
|
||||||
|
"up",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
-- RLEList_PlayerWalkToLab decodes to UP x2, RIGHT x3, DOWN x5, LEFT,
|
escort.oakSteps = buildOakSteps()
|
||||||
-- DOWN x6 and plays in REVERSE buffer order (wSimulatedJoypadStatesEnd
|
|
||||||
-- grows downward): DOWN x6, LEFT, DOWN x5, RIGHT x3, UP x2 -- Oak's
|
-- Player stays one step behind Oak (simplified reverse-RLE port).
|
||||||
-- exact path one step behind. The 17th press (the second UP) is eaten
|
|
||||||
-- by the door-warp frame (WarpFound clears hJoyHeld via EnterMap), so
|
|
||||||
-- only 16 real steps happen; the walk ends on the door at (12,11).
|
|
||||||
escort.playerSteps = { "down" }
|
escort.playerSteps = { "down" }
|
||||||
for _, d in ipairs(escort.oakSteps) do
|
for _, d in ipairs(escort.oakSteps) do
|
||||||
escort.playerSteps[#escort.playerSteps + 1] = d
|
escort.playerSteps[#escort.playerSteps + 1] = d
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function npcNamed(ow, name)
|
||||||
|
for _, n in ipairs(ow.npcs or {}) do
|
||||||
|
if n.def and n.def.name == name then return n end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
M.PALLET_TOWN = {
|
M.PALLET_TOWN = {
|
||||||
talk = require("data.scripts.pallet_town").talk,
|
talk = require("data.scripts.pallet_town").talk,
|
||||||
escort = escort,
|
escort = escort,
|
||||||
-- Oak stops you at the north row (PalletTownDefaultScript's
|
-- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4),
|
||||||
-- `wYCoord == 1` check), walks up from (8,5), and leads you to his
|
-- then a wild Pikachu battle before the lab escort (pokeyellow
|
||||||
-- lab with the player one step behind (scripts/PalletTown.asm +
|
-- PalletTownPikachuBattleScript).
|
||||||
-- PalletMovementScriptPointerTable in
|
|
||||||
-- engine/overworld/auto_movement.asm), then the lab walk-in and the
|
|
||||||
-- choose-mon exchange (scripts/OaksLab.asm OaksLabDefaultScript ..
|
|
||||||
-- OaksLabOakChooseMonSpeechScript).
|
|
||||||
onStep = function(game, ow, x, y)
|
onStep = function(game, ow, x, y)
|
||||||
if y ~= 1 or game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB
|
local yellow = GameVersion.isYellow()
|
||||||
|
local stopY = yellow and 0 or 1
|
||||||
|
if y ~= stopY or game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB
|
||||||
or game.save.flags.EVENT_GOT_STARTER then
|
or game.save.flags.EVENT_GOT_STARTER then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
local Commands = require("src.script.Commands")
|
local Commands = require("src.script.Commands")
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
local ctx = { save = game.save, game = game, overworld = ow }
|
local ctx = { save = game.save, game = game, overworld = ow }
|
||||||
|
|
||||||
-- PalletTownDefaultScript: stop the player, turn them around
|
-- Red turns the player down; Yellow faces up at the north exit.
|
||||||
-- (wPlayerMovingDirection = PLAYER_DIR_DOWN applies on the very
|
ow.player.facing = yellow and "up" or "down"
|
||||||
-- next frame, before the text box opens) and strike up the "oak
|
|
||||||
-- appears" theme (MUSIC_MEET_PROF_OAK)
|
|
||||||
ow.player.facing = "down"
|
|
||||||
Music.play(game.data, "Music_MeetProfOak")
|
Music.play(game.data, "Music_MeetProfOak")
|
||||||
|
|
||||||
-- DelayFrames-style hold: the world pauses (input stays locked)
|
|
||||||
-- for `frames` frames, then cb runs. Reuses the emote pause slot;
|
|
||||||
-- with an `npc` the "!" bubble draws above it (EmotionBubble).
|
|
||||||
local function hold(frames, npc, cb)
|
local function hold(frames, npc, cb)
|
||||||
ow.emote = { frames = frames, npc = npc, onDone = cb }
|
ow.emote = { frames = frames, npc = npc, onDone = cb }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- chain single-tile scriptMoves through a direction list
|
|
||||||
local function walkList(entity, steps, done)
|
local function walkList(entity, steps, done)
|
||||||
local i = 0
|
local i = 0
|
||||||
local function nextStep()
|
local function nextStep()
|
||||||
@@ -114,10 +125,8 @@ M.PALLET_TOWN = {
|
|||||||
nextStep()
|
nextStep()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---- Oak's Lab side (scripts/OaksLab.asm) ----------------------
|
-- OaksLabOakChooseMonSpeechScript. Yellow's OakChooseMon text is
|
||||||
|
-- the single-ball speech, not Red's "there are 3 POKéMON".
|
||||||
-- OaksLabOakChooseMonSpeechScript: the fed-up / choose-mon /
|
|
||||||
-- what-about-me / be-patient exchange, Delay3 between boxes
|
|
||||||
local function chooseMonSpeech()
|
local function chooseMonSpeech()
|
||||||
local function say(key, fb, next)
|
local function say(key, fb, next)
|
||||||
game.stack:push(TextBox.new(game, t[key] or fb, next))
|
game.stack:push(TextBox.new(game, t[key] or fb, next))
|
||||||
@@ -126,7 +135,9 @@ M.PALLET_TOWN = {
|
|||||||
"{RIVAL}: Gramps!\nI'm fed up with\nwaiting!", function()
|
"{RIVAL}: Gramps!\nI'm fed up with\nwaiting!", function()
|
||||||
hold(3, nil, function()
|
hold(3, nil, function()
|
||||||
say("_OaksLabOakChooseMonText",
|
say("_OaksLabOakChooseMonText",
|
||||||
"OAK: Here, {PLAYER}!\fThere are 3\nPOKéMON here!\fYou can have one!\nChoose!", function()
|
yellow
|
||||||
|
and "OAK: Look, {PLAYER}! Do\nyou see that ball\non the table?"
|
||||||
|
or "OAK: Here, {PLAYER}!\fThere are 3\nPOKéMON here!\fYou can have one!\nChoose!", function()
|
||||||
hold(3, nil, function()
|
hold(3, nil, function()
|
||||||
say("_OaksLabRivalWhatAboutMeText",
|
say("_OaksLabRivalWhatAboutMeText",
|
||||||
"{RIVAL}: Hey!\nGramps! What\nabout me?", function()
|
"{RIVAL}: Hey!\nGramps! What\nabout me?", function()
|
||||||
@@ -143,30 +154,18 @@ M.PALLET_TOWN = {
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- entering the lab: the door Oak (OAKSLAB_OAK2, (5,10)) walks up 3
|
-- Door Oak is OAKSLAB_OAK2: Red index 8, Yellow index 6 (one ball).
|
||||||
-- ahead of the player (OaksLabOakEntersLabScript OakEntryMovement),
|
|
||||||
-- swaps for the desk Oak (OAKSLAB_OAK1, (5,2)), then the player
|
|
||||||
-- walks up 8 from the mat (PlayerEntryMovementRLE) while the rival
|
|
||||||
-- and Oak turn with them (OaksLabPlayerEntersLabScript /
|
|
||||||
-- OaksLabFollowedOakScript)
|
|
||||||
local function labWalkIn()
|
local function labWalkIn()
|
||||||
local oak2 = ow:npcByIndex(8)
|
local oak2 = npcNamed(ow, "OAKSLAB_OAK2") or ow:npcByIndex(yellow and 6 or 8)
|
||||||
local function swapOaks()
|
local function swapOaks()
|
||||||
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
|
Commands.hide_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
|
||||||
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK1")
|
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK1")
|
||||||
hold(3, nil, function() -- Delay3
|
hold(3, nil, function()
|
||||||
Commands.face_object(ctx, 1, "down") -- rival watches you pass
|
Commands.face_object(ctx, 1, "down")
|
||||||
ow:scriptMove(ow.player, "up", 8, function()
|
ow:scriptMove(ow.player, "up", 8, function()
|
||||||
-- OaksLabFollowedOakScript: flags only after the walk-in, so a
|
|
||||||
-- stray step on the door mat can't fire the "don't go away"
|
|
||||||
-- push-up (oaks_lab.lua onStep) mid-cutscene. Outdoor escort
|
|
||||||
-- still re-arms on F1 mid-escort -- these flags stay clear
|
|
||||||
-- until the lab walk finishes.
|
|
||||||
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
|
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
|
||||||
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB_2 = true
|
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB_2 = true
|
||||||
Commands.face_object(ctx, 1, "up")
|
Commands.face_object(ctx, 1, "up")
|
||||||
-- res BIT_NO_MAP_MUSIC + PlayDefaultMusic: the lab theme
|
|
||||||
-- only starts once the walk-in is done
|
|
||||||
Music.playMap(game.data, "OAKS_LAB")
|
Music.playMap(game.data, "OAKS_LAB")
|
||||||
chooseMonSpeech()
|
chooseMonSpeech()
|
||||||
end)
|
end)
|
||||||
@@ -179,9 +178,6 @@ M.PALLET_TOWN = {
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PalletMovementScript_Done + the door warp: Oak is hidden as the
|
|
||||||
-- player steps into the doorway; PALLET_TOWN warp 3 -> OAKS_LAB
|
|
||||||
-- warp 2 = (5,11), with the door SFX (WarpFound -> SFX_GO_INSIDE)
|
|
||||||
local function enterLab()
|
local function enterLab()
|
||||||
Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
|
Commands.hide_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
|
||||||
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
|
Commands.show_object(ctx, "OAKS_LAB", "OAKSLAB_OAK2")
|
||||||
@@ -190,10 +186,6 @@ M.PALLET_TOWN = {
|
|||||||
{ keepMusic = true })
|
{ keepMusic = true })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PalletMovementScript_WalkToLab: Oak's NPC movement and the
|
|
||||||
-- player's simulated joypad run simultaneously, in lockstep; the
|
|
||||||
-- player retraces Oak's path one step behind and follows him into
|
|
||||||
-- the doorway on the final beat
|
|
||||||
local function walkToLab(oak)
|
local function walkToLab(oak)
|
||||||
local i = 0
|
local i = 0
|
||||||
local function tick()
|
local function tick()
|
||||||
@@ -206,9 +198,6 @@ M.PALLET_TOWN = {
|
|||||||
if oak and escort.oakSteps[i] then
|
if oak and escort.oakSteps[i] then
|
||||||
ow:scriptMove(oak, escort.oakSteps[i], 1)
|
ow:scriptMove(oak, escort.oakSteps[i], 1)
|
||||||
elseif oak then
|
elseif oak then
|
||||||
-- RLEList_ProfOakWalkToLab's trailing NPC_CHANGE_FACING beat:
|
|
||||||
-- Oak marches in place on the door mat while the player takes
|
|
||||||
-- the final step up behind him (movement.asm ChangeFacingDirection)
|
|
||||||
ow:marchInPlace(oak)
|
ow:marchInPlace(oak)
|
||||||
end
|
end
|
||||||
ow:scriptMove(ow.player, playerStep, 1, tick)
|
ow:scriptMove(ow.player, playerStep, 1, tick)
|
||||||
@@ -216,9 +205,6 @@ M.PALLET_TOWN = {
|
|||||||
tick()
|
tick()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PalletMovementScript_OakMoveLeft/_PlayerMoveLeft: from the right
|
|
||||||
-- tile (x == 11) Oak sidesteps left first, then the player follows
|
|
||||||
-- left (wNumStepsToTake = wXCoord - 10), and only then both walk
|
|
||||||
local function escortToLab(oak)
|
local function escortToLab(oak)
|
||||||
local numSteps = x - 10
|
local numSteps = x - 10
|
||||||
if oak and numSteps > 0 then
|
if oak and numSteps > 0 then
|
||||||
@@ -232,37 +218,65 @@ M.PALLET_TOWN = {
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---- Pallet Town side (scripts/PalletTown.asm) -----------------
|
-- After Oak reaches the player: Red goes straight to "It's unsafe!".
|
||||||
|
-- Yellow (PalletTownOakGreetsPlayerScript..AfterPikachuBattleScript):
|
||||||
|
-- ThatWasClose -> Oak faces the grass patch -> BATTLE_TYPE_PIKACHU
|
||||||
|
-- (the old-man-style simulated battle where PROF.OAK throws the ball
|
||||||
|
-- and always catches the lv5 Pikachu) -> Whew -> ComeWithMe.
|
||||||
|
local function afterOakArrives(oak)
|
||||||
|
if not yellow then
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._PalletTownOakItsUnsafeText
|
||||||
|
or "OAK: It's unsafe!\nWild POKéMON\nlive in tall grass!",
|
||||||
|
function() escortToLab(oak) end))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local function comeWithMe()
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._PalletTownOakComeWithMe
|
||||||
|
or "OAK: Here, come with\nme!",
|
||||||
|
function() escortToLab(oak) end))
|
||||||
|
end
|
||||||
|
local function afterPikaBattle()
|
||||||
|
if oak then oak.facing = "up" end
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._PalletTownOakWhewText or "OAK: Whew...",
|
||||||
|
comeWithMe))
|
||||||
|
end
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t._PalletTownOakThatWasCloseText
|
||||||
|
or "OAK: That was\nclose!\fWild POKéMON live\nin tall grass!",
|
||||||
|
function()
|
||||||
|
-- Oak turns toward the horizontally adjacent grass (left exit
|
||||||
|
-- looks right, right exit looks left -- the
|
||||||
|
-- EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN branch)
|
||||||
|
if oak then oak.facing = x == 10 and "right" or "left" end
|
||||||
|
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
||||||
|
battle:makeOldManDemo("PROF.OAK")
|
||||||
|
battle.onFinish = function()
|
||||||
|
afterPikaBattle()
|
||||||
|
end
|
||||||
|
game.stack:push(battle)
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
|
||||||
-- PalletTownOakWalksToPlayerScript: Oak appears at (8,5), faces up
|
|
||||||
-- (SetSpriteFacingDirectionAndDelay + Delay3), then zigzags to the
|
|
||||||
-- player; the "It's unsafe!" text follows and the escort begins
|
|
||||||
local function oakAppearsAndWalks()
|
local function oakAppearsAndWalks()
|
||||||
Commands.show_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
|
Commands.show_object(ctx, "PALLET_TOWN", "PALLETTOWN_OAK")
|
||||||
local oak = ow:npcByIndex(1)
|
local oak = npcNamed(ow, "PALLETTOWN_OAK") or ow:npcByIndex(1)
|
||||||
if oak then oak.facing = "up" end
|
if oak then oak.facing = "up" end
|
||||||
hold(6, nil, function()
|
hold(6, nil, function()
|
||||||
walkList(oak, escort.oakApproach(x), function()
|
walkList(oak, escort.oakApproach(x), function()
|
||||||
-- PalletTownOakNotSafeComeWithMeScript: the second text waits
|
afterOakArrives(oak)
|
||||||
-- for a button, then the escort starts
|
|
||||||
game.stack:push(TextBox.new(game,
|
|
||||||
t._PalletTownOakItsUnsafeText
|
|
||||||
or "OAK: It's unsafe!\nWild POKéMON\nlive in tall grass!",
|
|
||||||
function() escortToLab(oak) end))
|
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The "Hey! Wait!" box ends without a button wait (auto), then the
|
|
||||||
-- "!" bubble shows over the player WHILE the box is still on screen
|
|
||||||
-- (PalletTownOakText: DelayFrames 10 then EmotionBubble, box not yet
|
|
||||||
-- cleared). onOverlap sets the bubble during the box's last frames;
|
|
||||||
-- the box pops after `overlap`, and the bubble's 60-frame hold then
|
|
||||||
-- runs to Oak's appearance (the bubble is static while the box is up,
|
|
||||||
-- since the overworld pauses under it, so 10 overlap + 50 = 60).
|
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._PalletTownOakHeyWaitDontGoOutText or "OAK: Hey! Wait!\nDon't go out!",
|
t._PalletTownOakHeyWaitDontGoOutText or "OAK: Hey! Wait!\nDon't go out!",
|
||||||
nil, { auto = { delay = 10, overlap = 10, onOverlap = function()
|
nil, { auto = { delay = 10, overlap = 10, onOverlap = function()
|
||||||
|
-- .HeyWaitDontGoOutText turns the player to face down (toward
|
||||||
|
-- the approaching Oak) before the exclamation bubble
|
||||||
|
ow.player.facing = "down"
|
||||||
ow.emote = { npc = ow.player, frames = 50, onDone = oakAppearsAndWalks }
|
ow.emote = { npc = ow.player, frames = 50, onDone = oakAppearsAndWalks }
|
||||||
end } }))
|
end } }))
|
||||||
return true
|
return true
|
||||||
@@ -399,22 +413,141 @@ M.POKEMON_FAN_CLUB = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- BikeShopClerkText (scripts/BikeShop.asm) runs three ways: the BICYCLE is
|
||||||
|
-- already yours, you are carrying the BIKE VOUCHER, or you get the sales
|
||||||
|
-- pitch. The pitch draws its own window (TextBoxBorder hlcoord 0,0, b=4
|
||||||
|
-- c=15) holding BikeShopMenuText and BikeShopMenuPrice, and leaves it up
|
||||||
|
-- while the clerk keeps talking in the bottom box: the original never
|
||||||
|
-- erases it before TextScriptEnd (#568).
|
||||||
|
local BikeShopWindow = {}
|
||||||
|
BikeShopWindow.__index = BikeShopWindow
|
||||||
|
|
||||||
|
function BikeShopWindow.new(game, footer, onChoose)
|
||||||
|
local self = setmetatable({}, BikeShopWindow)
|
||||||
|
self.game = game
|
||||||
|
self.onChoose = onChoose
|
||||||
|
self.index = 1
|
||||||
|
self.active = true
|
||||||
|
-- BikeShopClerkDoYouLikeItText stays on screen under the window for as
|
||||||
|
-- long as the menu is up. The text box pushed on top types it out and
|
||||||
|
-- pops itself; this copy of its last page takes over from there, so the
|
||||||
|
-- bottom box never blanks between the pitch and the answer. Paginated
|
||||||
|
-- with the themed column budget so the copy breaks where the box did.
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local box = require("src.ui.Theme").textBox or {}
|
||||||
|
local pages = TextBox.paginate(TextBox.substitute(game, footer), box.maxCols)
|
||||||
|
self.footer = pages[#pages]
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function BikeShopWindow:update()
|
||||||
|
-- one answer only: the boxes pushed by onChoose sit on top of this
|
||||||
|
-- state, but a second A on the same frame must not fire it twice
|
||||||
|
if not self.active then return end
|
||||||
|
local input = self.game.input
|
||||||
|
-- HandleMenuInput with wMenuWrappingEnabled clear: the two rows clamp
|
||||||
|
if input:wasPressed("up") then
|
||||||
|
self.index = 1
|
||||||
|
elseif input:wasPressed("down") then
|
||||||
|
self.index = 2
|
||||||
|
elseif input:wasPressed("a") or input:wasPressed("b") then
|
||||||
|
local cancelled = input:wasPressed("b") -- bit B_PAD_B -> .cancel
|
||||||
|
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||||
|
self.active = false
|
||||||
|
self.onChoose(not cancelled and self.index == 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function BikeShopWindow:draw()
|
||||||
|
local Font = require("src.render.Font")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
local Theme = require("src.ui.Theme")
|
||||||
|
Font.drawBox(0, 0, 17, 6)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
local bike = self.game.data.items.BICYCLE
|
||||||
|
Font.draw(bike and bike.name or "BICYCLE", 16, 16) -- hlcoord 2, 2
|
||||||
|
Font.draw("¥1000000", 64, 24) -- hlcoord 8, 3
|
||||||
|
Font.draw(Strings("CANCEL"), 16, 32) -- `next` skips a row
|
||||||
|
-- wTopMenuItemX 1, wTopMenuItemY 2, rows two apart
|
||||||
|
Font.drawCode(Theme.cursor, 8, self.index == 1 and 16 or 32)
|
||||||
|
if self.footer then
|
||||||
|
-- same geometry TextBox resolves against, so a themed box matches
|
||||||
|
local box = Theme.textBox or {}
|
||||||
|
local tx, ty = box.tx or 0, box.ty or 12
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
Font.drawBox(tx, ty, box.tw or 20, box.th or 6)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
for i, line in ipairs(self.footer) do
|
||||||
|
Font.draw(line, (tx + 1) * 8, (ty + 2 * i) * 8)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
M.BIKE_SHOP = {
|
M.BIKE_SHOP = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_BIKESHOP_CLERK = function(game, ow, npc, done)
|
TEXT_BIKESHOP_CLERK = function(game, ow, npc, done)
|
||||||
|
local Bag = require("src.inventory.Bag")
|
||||||
|
local Flags = require("src.script.Flags")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
if (game.save.inventory.BICYCLE or 0) > 0 then
|
local t = game.data.text
|
||||||
game.stack:push(TextBox.new(game, "How's the\nBICYCLE treating\nyou?", done))
|
local function say(text, after, opts)
|
||||||
elseif (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
|
game.stack:push(TextBox.new(game, text, after, opts))
|
||||||
game.save.inventory.BIKE_VOUCHER = nil
|
|
||||||
game.save.inventory.BICYCLE = 1
|
|
||||||
game.stack:push(TextBox.new(game,
|
|
||||||
("Oh, that's a\nBIKE VOUCHER!\f%s exchanged\nit for a BICYCLE!")
|
|
||||||
:format(game.save.player.name), done))
|
|
||||||
else
|
|
||||||
game.stack:push(TextBox.new(game,
|
|
||||||
"A BICYCLE costs\n¥1000000. Sorry,\nno instalments!", done))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- CheckEvent EVENT_GOT_BICYCLE. Saves made before the clerk started
|
||||||
|
-- setting the event still have the bike in the bag, so either counts.
|
||||||
|
if (game.save.inventory.BICYCLE or 0) > 0
|
||||||
|
or Flags.get(game.save, "EVENT_GOT_BICYCLE") then
|
||||||
|
say(t._BikeShopClerkHowDoYouLikeYourBicycleText, done)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- .dontHaveBike: IsItemInBag BIKE_VOUCHER
|
||||||
|
if (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
|
||||||
|
say(t._BikeShopClerkOhThatsAVoucherText, function()
|
||||||
|
-- GiveItem's `jr nc, .BagFull`: the voucher is only spent once
|
||||||
|
-- the BICYCLE is actually in the bag
|
||||||
|
if not Bag.add(game.save, "BICYCLE", 1) then
|
||||||
|
say(t._BikeShopBagFullText, done)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
Bag.remove(game.save, "BIKE_VOUCHER", 1)
|
||||||
|
Flags.set(game.save, "EVENT_GOT_BICYCLE")
|
||||||
|
-- BikeShopExchangedVoucherText carries sound_get_key_item; the
|
||||||
|
-- map runs EnableAutoTextBoxDrawing, so the box still waits for
|
||||||
|
-- a button once the jingle has played (auto.wait, #247)
|
||||||
|
say(t._BikeShopExchangedVoucherText, done, {
|
||||||
|
auto = { wait = true, sound = function()
|
||||||
|
return require("src.core.Sound").play(game.data, "Get_Key_Item")
|
||||||
|
end },
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- .dontHaveVoucher: welcome, then the BICYCLE/CANCEL window
|
||||||
|
say(t._BikeShopClerkWelcomeText, function()
|
||||||
|
local pitch = t._BikeShopClerkDoYouLikeItText
|
||||||
|
game.stack:push(BikeShopWindow.new(game, pitch, function(bought)
|
||||||
|
local function comeAgain()
|
||||||
|
say(t._BikeShopComeAgainText, function()
|
||||||
|
game.stack:pop() -- the window, still up under the text
|
||||||
|
done()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
if bought then
|
||||||
|
-- a million is out of anyone's reach: BikeShopCantAffordText
|
||||||
|
say(t._BikeShopCantAffordText, comeAgain)
|
||||||
|
else
|
||||||
|
comeAgain()
|
||||||
|
end
|
||||||
|
end))
|
||||||
|
-- PrintText BikeShopClerkDoYouLikeItText, then straight into
|
||||||
|
-- HandleMenuInput: the box types out and hands over without
|
||||||
|
-- waiting, leaving the window's copy of the line on screen
|
||||||
|
say(pitch, nil, { auto = { delay = 0 } })
|
||||||
|
end)
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+180
-127
@@ -111,8 +111,9 @@ M.POKEMON_TOWER_5F = {
|
|||||||
--
|
--
|
||||||
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
|
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
|
||||||
-- Silph Scope check at the trigger -- the scope only decides whether the
|
-- Silph Scope check at the trigger -- the scope only decides whether the
|
||||||
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
|
-- disguise sticks (IsGhostBattle -> makeGhost: "too scared to move", balls
|
||||||
-- balls dodged). An earlier version of this port turned the player back
|
-- dodged) or comes off in the unveil (makeUnveiledGhost, #492). An earlier
|
||||||
|
-- version of this port turned the player back
|
||||||
-- without the scope and never opened the battle, which made 6F
|
-- without the scope and never opened the battle, which made 6F
|
||||||
-- impassable on any route that skips Rocket Hideout; vanilla lets the
|
-- impassable on any route that skips Rocket Hideout; vanilla lets the
|
||||||
-- battle open and a POKE_DOLL end it (see wBattleResult below).
|
-- battle open and a POKE_DOLL end it (see wBattleResult below).
|
||||||
@@ -128,7 +129,18 @@ M.POKEMON_TOWER_6F = {
|
|||||||
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", 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
|
-- ItemUseBall .notOldManBattle (item_effects.asm:166-175): a ball
|
||||||
|
-- thrown on POKEMON_TOWER_6F at the RESTLESS SOUL is dodged whether
|
||||||
|
-- or not the scope revealed it, so the "can't be caught" state rides
|
||||||
|
-- the battle instead of IsGhostBattle alone (#444)
|
||||||
|
battle.noCatch = true
|
||||||
|
-- InitWildBattle enters disguised for the RESTLESS SOUL either way
|
||||||
|
-- (core.asm:6698-6700). The scope does not skip the disguise, it
|
||||||
|
-- buys the unveil PrintBeginningBattleText .isMarowak plays over it
|
||||||
|
-- before the battle proceeds as an ordinary wild one (#492).
|
||||||
|
if game.save.inventory.SILPH_SCOPE then
|
||||||
|
battle:makeUnveiledGhost()
|
||||||
|
else
|
||||||
battle:makeGhost()
|
battle:makeGhost()
|
||||||
end
|
end
|
||||||
battle.onFinish = function(result)
|
battle.onFinish = function(result)
|
||||||
@@ -182,17 +194,16 @@ M.POKEMON_TOWER_6F = {
|
|||||||
-- preFrames: the shake's lead-in delays -- ShakeElevator's own Delay3s
|
-- preFrames: the shake's lead-in delays -- ShakeElevator's own Delay3s
|
||||||
-- come to 9 frames; Silph/Rocket's ...ShakeScript prefixes another
|
-- come to 9 frames; Silph/Rocket's ...ShakeScript prefixes another
|
||||||
-- Delay3 (12) while CeladonMartElevatorShakeScript farjps straight in
|
-- Delay3 (12) while CeladonMartElevatorShakeScript farjps straight in
|
||||||
|
-- The panel is a bg_event, not a map-entry script: data/maps/objects/
|
||||||
|
-- CeladonMartElevator.asm has `bg_event 3, 0, TEXT_CELADONMARTELEVATOR`
|
||||||
|
-- and CeladonMartElevatorText is what runs DisplayElevatorFloorMenu, so
|
||||||
|
-- the menu waits for the player to face the panel and press A (#395).
|
||||||
|
-- Map entry only stores the car's exit warps (CeladonMartElevator
|
||||||
|
-- StoreWarpEntriesScript), which is elevatorSeedExit below.
|
||||||
-- After the ride the original does NOT jump-cut to the floor: choosing a
|
-- After the ride the original does NOT jump-cut to the floor: choosing a
|
||||||
-- floor in engine/events/elevator.asm DisplayElevatorFloorMenu rewrites
|
-- floor rewrites the elevator car's own warp entries (wWarpEntries, via
|
||||||
-- the elevator car's own warp entries (wWarpEntries, via .UpdateWarp) to
|
-- .UpdateWarp) to the chosen floor's exit warp and returns control, and
|
||||||
-- the chosen floor's exit warp, then the player walks out of the car onto
|
-- the player walks out of the car onto that warp themselves.
|
||||||
-- that warp themselves (scripts/SilphCoElevator.asm etc.). Reproduce
|
|
||||||
-- that here: rewrite the car's exit warps, then drive a short scripted
|
|
||||||
-- walk-out onto an exit tile and take the (now rewritten) warp, reusing
|
|
||||||
-- ow:scriptMove / ow:takeWarp (the Oak-escort primitives).
|
|
||||||
local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
|
||||||
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
|
|
||||||
local WALK_ORDER = { "up", "down", "left", "right" }
|
|
||||||
|
|
||||||
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
|
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
|
||||||
-- door (warp id, map id). Shared generated map data, but the car's
|
-- door (warp id, map id). Shared generated map data, but the car's
|
||||||
@@ -205,46 +216,6 @@ local function elevatorSetExit(ow, floor)
|
|||||||
end
|
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),
|
|
||||||
-- else the nearest
|
|
||||||
local door
|
|
||||||
for _, w in ipairs(m.def.warps) do
|
|
||||||
if w.x == p.cellX and w.y == p.cellY then door = w break end
|
|
||||||
end
|
|
||||||
if not door then
|
|
||||||
local best
|
|
||||||
for _, w in ipairs(m.def.warps) do
|
|
||||||
local d = math.abs(w.x - p.cellX) + math.abs(w.y - p.cellY)
|
|
||||||
if not best or d < best then best, door = d, w end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
-- the car interior sits on the door's walkable side; "out" is the
|
|
||||||
-- doorway direction (the map edge for Silph/Celadon, the top doorway
|
|
||||||
-- for the Rocket car). Fixed direction order keeps the step
|
|
||||||
-- deterministic when a door tile has several walkable neighbours
|
|
||||||
-- (Silph/Celadon step up into the car, the Rocket car steps down).
|
|
||||||
local into
|
|
||||||
for _, dir in ipairs(WALK_ORDER) do
|
|
||||||
local v = WALK_DIRVEC[dir]
|
|
||||||
local nx, ny = door.x + v[1], door.y + v[2]
|
|
||||||
if m:inBounds(nx, ny) and m:isWalkableCell(nx, ny) then into = dir break end
|
|
||||||
end
|
|
||||||
into = into or "up"
|
|
||||||
local out = WALK_OPP[into]
|
|
||||||
local function leave()
|
|
||||||
ow:takeWarp(door) -- door SFX + warp to the rewritten floor target
|
|
||||||
end
|
|
||||||
-- step one tile into the car, then walk back through the doorway onto
|
|
||||||
-- the exit tile and take the warp: a visible walk-out on valid tiles
|
|
||||||
-- for either door orientation, instead of a jump cut
|
|
||||||
ow:scriptMove(p, into, 1, function()
|
|
||||||
ow:scriptMove(p, out, 1, leave)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function elevatorFloors(elevatorMapId, game)
|
local function elevatorFloors(elevatorMapId, game)
|
||||||
local floors = {}
|
local floors = {}
|
||||||
for mapId, def in pairs(game.data.maps) do
|
for mapId, def in pairs(game.data.maps) do
|
||||||
@@ -272,9 +243,9 @@ local function elevatorFloors(elevatorMapId, game)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function elevatorSeedExit(ow, floors, fromMapId)
|
local function elevatorSeedExit(ow, floors, fromMapId)
|
||||||
-- Seed a walk-out destination before the menu (or key-gate text):
|
-- Seed a walk-out destination on entry (before the panel is ever
|
||||||
-- entry floor when known, else the first listed floor (1F). Choosing
|
-- read): entry floor when known, else the first listed floor (1F).
|
||||||
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
|
-- Choosing a floor rewrites it again; B-cancel / no-key leave
|
||||||
-- keeps this seed so walking out of the car cannot hit a missing ROM
|
-- 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
|
-- placeholder (#123) or the car's static default floor (#90: Rocket
|
||||||
-- Hideout defaults to B1F even when entered from B2F/B4F).
|
-- Hideout defaults to B1F even when entered from B2F/B4F).
|
||||||
@@ -288,68 +259,68 @@ local function elevatorSeedExit(ow, floors, fromMapId)
|
|||||||
return exitFloor
|
return exitFloor
|
||||||
end
|
end
|
||||||
|
|
||||||
local function elevator(elevatorMapId, keyGate, preFrames)
|
local function elevator(elevatorMapId, panelText, keyGate, preFrames)
|
||||||
|
-- the panel bg_event: DisplayElevatorFloorMenu runs from this text
|
||||||
|
-- script, never from map entry (#395)
|
||||||
|
local function panel(game, ow, npc, done)
|
||||||
|
done = done or function() end
|
||||||
|
local floors = elevatorFloors(elevatorMapId, game)
|
||||||
|
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
|
||||||
|
-- a-key line and shows no floor menu
|
||||||
|
-- (scripts/RocketHideoutElevator.asm).
|
||||||
|
if keyGate and not game.save.inventory[keyGate.item] then
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
game.data.text[keyGate.text] or "It appears to\nneed a key.", done))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local items = {}
|
||||||
|
for _, f in ipairs(floors) do
|
||||||
|
table.insert(items, { label = f.token, value = f })
|
||||||
|
end
|
||||||
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
|
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
|
||||||
|
onChoose = function(item, list)
|
||||||
|
list:close()
|
||||||
|
-- the whole ShakeElevator ride runs in place -- music stop, 100
|
||||||
|
-- collision-thud scroll bounces, the PA chime -- and only then
|
||||||
|
-- does .UpdateWarp's rewrite land, with the player still stood
|
||||||
|
-- at the panel: they walk out to the car door themselves
|
||||||
|
local ElevatorShake = require("src.world.ElevatorShake")
|
||||||
|
game.stack:push(ElevatorShake.new(game, ow, {
|
||||||
|
preFrames = preFrames,
|
||||||
|
onDone = function()
|
||||||
|
elevatorSetExit(ow, item.value)
|
||||||
|
done()
|
||||||
|
end,
|
||||||
|
}))
|
||||||
|
end,
|
||||||
|
onCancel = function()
|
||||||
|
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
||||||
|
-- happens, the player just stays in the car (exit warps were
|
||||||
|
-- already seeded on entry)
|
||||||
|
done()
|
||||||
|
end,
|
||||||
|
}))
|
||||||
|
end
|
||||||
return {
|
return {
|
||||||
-- fromMapId: the floor the player just left (setMap passes it), so a
|
-- 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
|
-- 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
|
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
|
||||||
-- asserted and hard-crashed (#123).
|
-- asserted and hard-crashed (#123).
|
||||||
onEnter = function(game, ow, fromMapId)
|
onEnter = function(game, ow, fromMapId)
|
||||||
local floors = elevatorFloors(elevatorMapId, game)
|
elevatorSeedExit(ow, elevatorFloors(elevatorMapId, game), fromMapId)
|
||||||
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
|
|
||||||
local TextBox = require("src.render.TextBox")
|
|
||||||
game.stack:push(TextBox.new(game,
|
|
||||||
game.data.text[keyGate.text] or "It appears to\nneed a key."))
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local items = {}
|
|
||||||
for _, f in ipairs(floors) do
|
|
||||||
table.insert(items, { label = f.token, value = f })
|
|
||||||
end
|
|
||||||
local ListMenu = require("src.ui.ListMenu")
|
|
||||||
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
|
|
||||||
onChoose = function(item, list)
|
|
||||||
list:close()
|
|
||||||
-- the map-entry Transition (startWarpTo) calls onEnter from
|
|
||||||
-- its OWN midpoint callback, so this menu was pushed on top
|
|
||||||
-- of that still-active Transition; only the top state
|
|
||||||
-- updates, so it froze mid-fade instead of finishing. Left
|
|
||||||
-- alone it would resurface after the ride and play a stray
|
|
||||||
-- fade at the wrong time -- pop it now, before it can happen.
|
|
||||||
local Transition = require("src.render.Transition")
|
|
||||||
if getmetatable(game.stack:top()) == Transition then
|
|
||||||
game.stack:pop()
|
|
||||||
end
|
|
||||||
-- the whole ShakeElevator ride runs in place -- music stop,
|
|
||||||
-- 100 collision-thud scroll bounces, the PA chime -- and only
|
|
||||||
-- then does the player walk out of the car onto the chosen
|
|
||||||
-- floor (elevatorWalkOut rewrites the car's exit warps first)
|
|
||||||
local ElevatorShake = require("src.world.ElevatorShake")
|
|
||||||
game.stack:push(ElevatorShake.new(game, ow, {
|
|
||||||
preFrames = preFrames,
|
|
||||||
onDone = function()
|
|
||||||
elevatorWalkOut(ow, item.value)
|
|
||||||
end,
|
|
||||||
}))
|
|
||||||
end,
|
|
||||||
onCancel = function()
|
|
||||||
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
|
||||||
-- happens, the player just stays in the car (exit warps were
|
|
||||||
-- already seeded to the entry floor above)
|
|
||||||
end,
|
|
||||||
}))
|
|
||||||
end,
|
end,
|
||||||
|
talk = { [panelText] = panel },
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR")
|
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR",
|
||||||
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
|
"TEXT_SILPHCOELEVATOR_ELEVATOR")
|
||||||
|
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR",
|
||||||
|
"TEXT_CELADONMARTELEVATOR", nil, 9)
|
||||||
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
|
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
|
||||||
|
"TEXT_ROCKETHIDEOUTELEVATOR",
|
||||||
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
|
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
@@ -389,6 +360,38 @@ M.ROCKET_HIDEOUT_B4F = {
|
|||||||
end))
|
end))
|
||||||
end,
|
end,
|
||||||
|
|
||||||
|
-- Yellow swaps Rocket1/Rocket2 for Jessie & James and keeps a single
|
||||||
|
-- grunt, spelled ROCKETHIDEOUTB4F_ROCKET / TEXT_ROCKETHIDEOUTB4F_ROCKET
|
||||||
|
-- (pokeyellow/scripts/RocketHideoutB4F.asm), so the Red/Blue key above
|
||||||
|
-- never matched there and the LIFT KEY never dropped (#552, a
|
||||||
|
-- regression of #105). Yellow also moves the drop off the second
|
||||||
|
-- talk: RocketHideoutB4FRocketEndBattleText is text_promptbutton +
|
||||||
|
-- text_asm, so its SetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
|
||||||
|
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5 run the instant the battle ends.
|
||||||
|
TEXT_ROCKETHIDEOUTB4F_ROCKET = function(game, ow, npc, done)
|
||||||
|
if not ow:trainerDefeated(npc) then
|
||||||
|
ow:engageTrainer(npc, function()
|
||||||
|
-- engageTrainer records the win before it calls back, so this
|
||||||
|
-- is the end-battle text's SetEvent + ShowObject
|
||||||
|
if ow:trainerDefeated(npc)
|
||||||
|
and 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)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- RocketHideoutB4FRocketAfterBattleText: later talks only reprint
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
game.data.text._RocketHideoutB4FRocketAfterBattleText
|
||||||
|
or "Oh no! I dropped\nthe LIFT KEY!", done))
|
||||||
|
end,
|
||||||
|
|
||||||
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
|
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
|
||||||
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
|
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
|
||||||
-- owns both the engage and the BeatGiovanniScript aftermath.
|
-- owns both the engage and the BeatGiovanniScript aftermath.
|
||||||
@@ -538,45 +541,56 @@ M.GAME_CORNER = {
|
|||||||
end))
|
end))
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
|
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
|
||||||
|
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
|
||||||
|
-- object const and from every one of his text labels
|
||||||
|
-- (GameCornerClerkText, pokeyellow/scripts/GameCorner.asm) with an
|
||||||
|
-- identical body, so each line resolves under both spellings and the
|
||||||
|
-- handler is bound to both text ids just below (#552).
|
||||||
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")
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
|
local function line(suffix, fallback)
|
||||||
|
return t["_GameCornerClerk1" .. suffix]
|
||||||
|
or t["_GameCornerClerk" .. suffix]
|
||||||
|
or fallback
|
||||||
|
end
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
(t._GameCornerClerk1DoYouNeedSomeGameCoinsText
|
line("DoYouNeedSomeGameCoinsText",
|
||||||
or "Do you need some\ngame coins?\f¥1000 for 50."), function()
|
"Do you need some\ngame coins?\f¥1000 for 50."), function()
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
if not yes then
|
if not yes then
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._GameCornerClerk1PleaseComePlaySometimeText
|
line("PleaseComePlaySometimeText",
|
||||||
or "No? Please come\nplay sometime!", done))
|
"No? Please come\nplay sometime!"), done))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
|
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
|
||||||
-- the COIN CASE and room for at least 9 coins (Has9990Coins)
|
-- the COIN CASE and room for at least 9 coins (Has9990Coins)
|
||||||
if not game.save.inventory.COIN_CASE then
|
if not game.save.inventory.COIN_CASE then
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._GameCornerClerk1DontHaveCoinCaseText
|
line("DontHaveCoinCaseText",
|
||||||
or "You don't have a\nCOIN CASE!", done))
|
"You don't have a\nCOIN CASE!"), done))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if (game.save.coins or 0) >= 9990 then
|
if (game.save.coins or 0) >= 9990 then
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._GameCornerClerk1CoinCaseIsFullText
|
line("CoinCaseIsFullText",
|
||||||
or "Oops! Your COIN\nCASE is full.", done))
|
"Oops! Your COIN\nCASE is full."), done))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if game.save.money < 1000 then
|
if game.save.money < 1000 then
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
t._GameCornerClerk1CantAffordTheCoinsText
|
line("CantAffordTheCoinsText",
|
||||||
or "You can't afford\nthe coins!", done))
|
"You can't afford\nthe coins!"), done))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
game.save.money = game.save.money - 1000
|
game.save.money = game.save.money - 1000
|
||||||
game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
|
game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
(t._GameCornerClerk1ThanksHereAre50CoinsText
|
line("ThanksHereAre50CoinsText",
|
||||||
or "Thanks! Here are\nyour 50 coins!")
|
"Thanks! Here are\nyour 50 coins!")
|
||||||
.. ("\fCOINS: %d"):format(game.save.coins), done))
|
.. ("\fCOINS: %d"):format(game.save.coins), done))
|
||||||
end))
|
end))
|
||||||
end))
|
end))
|
||||||
@@ -584,6 +598,13 @@ M.GAME_CORNER = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- Yellow's coin clerk is GAMECORNER_CLERK / TEXT_GAMECORNER_CLERK where Red
|
||||||
|
-- and Blue spell him CLERK1, and the two text_asm bodies are identical, so
|
||||||
|
-- one handler answers both ids. Without the alias the Yellow clerk fell
|
||||||
|
-- through to his extracted offer line with no yes/no box behind it (#552).
|
||||||
|
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK =
|
||||||
|
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1
|
||||||
|
|
||||||
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
|
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
|
||||||
-- The six mon prizes differ between Red and Blue; the three TM prizes are
|
-- 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.
|
-- identical, so they are shared and appended to each version's mon list.
|
||||||
@@ -695,6 +716,16 @@ local DOCK_SHIP_BLOCKS = {
|
|||||||
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
|
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- her four hull columns bow-to-stern (upper-half / lower-half block ids)
|
||||||
|
-- and the open-water ids of the rows she sits in
|
||||||
|
local DOCK_SHIP_COLUMNS = {
|
||||||
|
{ bx = 5, top = 4, bottom = 8 },
|
||||||
|
{ bx = 6, top = 5, bottom = 9 },
|
||||||
|
{ bx = 7, top = 6, bottom = 10 },
|
||||||
|
{ bx = 8, top = 7, bottom = 11 },
|
||||||
|
}
|
||||||
|
local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13
|
||||||
|
|
||||||
M.VERMILION_DOCK = {
|
M.VERMILION_DOCK = {
|
||||||
onEnter = function(game, ow)
|
onEnter = function(game, ow)
|
||||||
local Flags = require("src.script.Flags")
|
local Flags = require("src.script.Flags")
|
||||||
@@ -729,17 +760,39 @@ M.VERMILION_DOCK = {
|
|||||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||||
end
|
end
|
||||||
puff(3, 15)
|
puff(3, 15)
|
||||||
|
-- VermilionDock_EraseSSAnne deliberately leaves the blocks under the
|
||||||
|
-- player alone ("south of the player and won't be redrawn"), so skip
|
||||||
|
-- his own block: he must not spend the walk-out standing on water
|
||||||
|
local pbx = math.floor(ow.player.cellX / 2)
|
||||||
|
local pby = math.floor(ow.player.cellY / 2)
|
||||||
local rows = {}
|
local rows = {}
|
||||||
rows[#rows + 1] = { "wait", 100 }
|
local function setBlock(bx, by, block)
|
||||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
if bx < 1 or bx > 8 then return end
|
||||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
if bx == pbx and by == pby then return end
|
||||||
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
|
rows[#rows + 1] = { "replace_block", bx, by, block }
|
||||||
end
|
end
|
||||||
rows[#rows + 1] = { "wait", 30 }
|
rows[#rows + 1] = { "wait", 120 }
|
||||||
|
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||||
|
-- .shift_columns_up slides her tile columns west behind a mid-frame
|
||||||
|
-- rSCX split; with no split scroll here she sails one block per beat
|
||||||
|
-- and the water closes in astern (#360)
|
||||||
|
for step = 1, 8 do
|
||||||
|
for _, col in ipairs(DOCK_SHIP_COLUMNS) do
|
||||||
|
setBlock(col.bx - step, 1, col.top)
|
||||||
|
setBlock(col.bx - step, 2, col.bottom)
|
||||||
|
end
|
||||||
|
setBlock(9 - step, 1, DOCK_WATER_TOP)
|
||||||
|
setBlock(9 - step, 2, DOCK_WATER_BOTTOM)
|
||||||
|
rows[#rows + 1] = { "wait", 20 }
|
||||||
|
end
|
||||||
|
-- the second horn as she clears the dock, then EraseSSAnne's 120
|
||||||
|
-- frames before the walk out
|
||||||
|
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||||
|
rows[#rows + 1] = { "wait", 120 }
|
||||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||||
-- keep Music_Surfing across the city warp (pokered stays on it
|
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
|
||||||
-- through the exit-ship walk)
|
-- cutscene, and VERMILION_CITY's own theme has to take over as the
|
||||||
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
|
-- player crosses in (EnterMap's PlayDefaultMusic)
|
||||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
||||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||||
ow:queueScript(rows)
|
ow:queueScript(rows)
|
||||||
|
|||||||
+32
-7
@@ -37,16 +37,15 @@ local function countOwned(save)
|
|||||||
return n
|
return n
|
||||||
end
|
end
|
||||||
|
|
||||||
local function oaksAide(threshold, itemId)
|
local function oaksAide(threshold, itemId, repeatText)
|
||||||
return function(game, ow, npc, done)
|
return function(game, ow, npc, done)
|
||||||
local t = text(game)
|
local t = text(game)
|
||||||
local flags = game.save.flags
|
local flags = game.save.flags
|
||||||
local itemName = game.data.items[itemId].name
|
local itemName = game.data.items[itemId].name
|
||||||
local flag = "EVENT_GOT_" .. itemId
|
local flag = "EVENT_GOT_" .. itemId
|
||||||
if flags[flag] then
|
if flags[flag] then
|
||||||
push(game, fill(t._OaksAideComeBackText or
|
push(game, t[repeatText] or
|
||||||
"I already gave\nyou the {RAM:}!",
|
fill("I already gave\nyou the {RAM:}!", { ram = itemName }), done)
|
||||||
{ num = threshold, ram = itemName }), done)
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
ask(game, fill(t._OaksAideHiText or
|
ask(game, fill(t._OaksAideHiText or
|
||||||
@@ -83,13 +82,16 @@ local function oaksAide(threshold, itemId)
|
|||||||
end
|
end
|
||||||
|
|
||||||
M.ROUTE_2_GATE = {
|
M.ROUTE_2_GATE = {
|
||||||
talk = { TEXT_ROUTE2GATE_OAKS_AIDE = oaksAide(10, "HM_FLASH") },
|
talk = { TEXT_ROUTE2GATE_OAKS_AIDE = oaksAide(10, "HM_FLASH",
|
||||||
|
"_Route2GateOaksAideFlashExplanationText") },
|
||||||
}
|
}
|
||||||
M.ROUTE_11_GATE_2F = {
|
M.ROUTE_11_GATE_2F = {
|
||||||
talk = { TEXT_ROUTE11GATE2F_OAKS_AIDE = oaksAide(30, "ITEMFINDER") },
|
talk = { TEXT_ROUTE11GATE2F_OAKS_AIDE = oaksAide(30, "ITEMFINDER",
|
||||||
|
"_Route11Gate2FOaksAideItemfinderDescriptionText") },
|
||||||
}
|
}
|
||||||
M.ROUTE_15_GATE_2F = {
|
M.ROUTE_15_GATE_2F = {
|
||||||
talk = { TEXT_ROUTE15GATE2F_OAKS_AIDE = oaksAide(50, "EXP_ALL") },
|
talk = { TEXT_ROUTE15GATE2F_OAKS_AIDE = oaksAide(50, "EXP_ALL",
|
||||||
|
"_Route15Gate2FOaksAideExpAllText") },
|
||||||
}
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
@@ -169,6 +171,28 @@ local function dojoBall(species, ownBall, otherBall, askKey)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- FightingDojoDefaultScript does not use the Master's facing direction for
|
||||||
|
-- this battle. It checks exactly the cell left of him, turns both sprites
|
||||||
|
-- toward one another, and then displays his trainer text. Keeping this out
|
||||||
|
-- of trainer sight also lets him keep his original downward-facing pose.
|
||||||
|
local function dojoMasterGate(game, ow, x, y)
|
||||||
|
if x ~= 4 or y ~= 3 or game.save.flags.EVENT_BEAT_KARATE_MASTER then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local master
|
||||||
|
for _, npc in ipairs(ow.npcs) do
|
||||||
|
if npc.def and npc.def.name == "FIGHTINGDOJO_KARATE_MASTER" then
|
||||||
|
master = npc
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not master or ow:trainerDefeated(master) then return false end
|
||||||
|
ow.player.facing = "right"
|
||||||
|
master:facePlayer(ow.player)
|
||||||
|
ow:engageTrainer(master)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
M.FIGHTING_DOJO = {
|
M.FIGHTING_DOJO = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL =
|
TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL =
|
||||||
@@ -195,6 +219,7 @@ M.FIGHTING_DOJO = {
|
|||||||
end
|
end
|
||||||
return false
|
return false
|
||||||
end,
|
end,
|
||||||
|
onStep = dojoMasterGate,
|
||||||
}
|
}
|
||||||
|
|
||||||
-- -------------------------------------------------------------------
|
-- -------------------------------------------------------------------
|
||||||
|
|||||||
+32
-4
@@ -135,6 +135,9 @@ M.SILPH_CO_2F = {
|
|||||||
talk = {
|
talk = {
|
||||||
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
||||||
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
|
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
|
||||||
|
-- the label carries no leading underscore: pokered keeps this one in
|
||||||
|
-- the script bank, not the far-text bank (#393)
|
||||||
|
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
|
||||||
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
|
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
|
||||||
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
||||||
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
||||||
@@ -787,8 +790,19 @@ local function bikeGateGuard(coords, stopText, explainText)
|
|||||||
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()
|
||||||
|
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript
|
||||||
|
-- (scripts/Route16Gate1F.asm, Route18Gate1F.asm) simulate one
|
||||||
|
-- PAD_RIGHT step after the refusal text, and only clear
|
||||||
|
-- wJoyIgnore / hand control back once that step finishes
|
||||||
|
-- (PlayerMovingRightScript). Without it the player was left
|
||||||
|
-- parked beside the guard's counter with no way past. #518
|
||||||
|
local function shoveRight()
|
||||||
|
ow:scriptMove(ow.player, "right", 1)
|
||||||
|
end
|
||||||
if dist > 0 then
|
if dist > 0 then
|
||||||
ow:scriptMove(ow.player, "up", dist)
|
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
||||||
|
else
|
||||||
|
shoveRight()
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
@@ -836,6 +850,19 @@ M.SILPH_CO_7F = {
|
|||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- SSAnne2FRivalAfterBattleScript's exit walk is keyed on the player's X,
|
||||||
|
-- not on where the rival stopped: from (37,8) the rival stands below him on
|
||||||
|
-- (36,8) and takes .RivalDownFourMovement straight down out of the room;
|
||||||
|
-- from (36,8) he stands above him on (36,7) and takes
|
||||||
|
-- .RivalWalkAroundPlayerMovement, which steps RIGHT and then falls through
|
||||||
|
-- into the same four DOWNs, so five downs in all (#360).
|
||||||
|
local function ssAnne2FRivalExitDirs(onLeft)
|
||||||
|
if onLeft then
|
||||||
|
return { "right", "down", "down", "down", "down", "down" }
|
||||||
|
end
|
||||||
|
return { "down", "down", "down", "down" }
|
||||||
|
end
|
||||||
|
|
||||||
-- S.S. Anne 2F rival ambush (scripts/SSAnne2F.asm; coords 36/37,8)
|
-- S.S. Anne 2F rival ambush (scripts/SSAnne2F.asm; coords 36/37,8)
|
||||||
M.SS_ANNE_2F = {
|
M.SS_ANNE_2F = {
|
||||||
onStep = function(game, ow, x, y)
|
onStep = function(game, ow, x, y)
|
||||||
@@ -848,11 +875,12 @@ M.SS_ANNE_2F = {
|
|||||||
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
|
||||||
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
{ "show_text", "_SSAnne2FRivalText" }, -- 4
|
||||||
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
|
||||||
{ "jump_if_false", 10 }, -- 6
|
{ "jump_if_false", 11 }, -- 6
|
||||||
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
|
||||||
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
|
||||||
{ "move_npc_to", 2, 36, 4 }, -- 9
|
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
|
||||||
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 10
|
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
|
||||||
|
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
|
||||||
}, onLeft and "up" or "left")
|
}, onLeft and "up" or "left")
|
||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,7 +173,46 @@ local function syncGymGatesAfterBattle(game, ow)
|
|||||||
applyGymGates(game, ow)
|
applyGymGates(game, ow)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Yellow's quiz-first rule (scripts/CinnabarGym.asm SuperNerd2..7): a
|
||||||
|
-- gate guardian refuses to battle until his quiz was attempted -- a
|
||||||
|
-- wrong answer sics him on you (the onInteract path below), a right one
|
||||||
|
-- opens his gate; walking up and talking first just gets the room's
|
||||||
|
-- CinnabarGymText_N lecture (Func_f2150's pointer table).
|
||||||
|
local function yellowQuizTalk(machineIndex)
|
||||||
|
return function(game, ow, npc, done)
|
||||||
|
local yellow = require("src.core.GameVersion").isYellow()
|
||||||
|
local defeated = ow:trainerDefeated(npc)
|
||||||
|
local gateOpen = game.save.flags[gymGateFlag(machineIndex)]
|
||||||
|
if yellow and not defeated and not gateOpen then
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local t = game.data.text
|
||||||
|
game.stack:push(TextBox.new(game,
|
||||||
|
t["_CinnabarGymText_" .. machineIndex]
|
||||||
|
or "You have to take\nthe quiz first!", done))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- gate open (or Red/Blue): the ordinary trainer engagement /
|
||||||
|
-- after-battle line, mirroring talkTo's generic trainer branch
|
||||||
|
npc:facePlayer(ow.player)
|
||||||
|
if not defeated then
|
||||||
|
ow:engageTrainer(npc, done)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local header = game.data:trainerHeader(ow.map.def.label, npc.def.index)
|
||||||
|
local after = header and header.after and game.data.text[header.after]
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
game.stack:push(TextBox.new(game, after or "...", done))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
M.CINNABAR_GYM = {
|
M.CINNABAR_GYM = {
|
||||||
|
talk = (function()
|
||||||
|
local talk = {}
|
||||||
|
for i = 1, 6 do
|
||||||
|
talk["TEXT_CINNABARGYM_SUPER_NERD" .. (i + 1)] = yellowQuizTalk(i)
|
||||||
|
end
|
||||||
|
return talk
|
||||||
|
end)(),
|
||||||
onEnter = applyGymGates,
|
onEnter = applyGymGates,
|
||||||
onVictory = syncGymGatesAfterBattle,
|
onVictory = syncGymGatesAfterBattle,
|
||||||
onInteract = function(game, ow, fx, fy)
|
onInteract = function(game, ow, fx, fy)
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
-- Summer Beach House (scripts/SummerBeachHouse.asm), Yellow's Route 19
|
||||||
|
-- surf shack. The Surfin' Dude only lets a party Pikachu that knows
|
||||||
|
-- SURF ride (IsSurfingPikachuInParty, home/map_objects.asm); saying yes
|
||||||
|
-- runs the Surfing Pikachu minigame (src/ui/SurfingMinigame.lua). The
|
||||||
|
-- corner printer shows/prints the high score once you have surfed this
|
||||||
|
-- visit (BIT_PIKACHU_MAP_SURF_SELECT is a per-map-load flag, so the
|
||||||
|
-- session markers live on the overworld state, not the save).
|
||||||
|
|
||||||
|
local function surfingPikachu(game)
|
||||||
|
for _, mon in ipairs(game.save.party or {}) do
|
||||||
|
if mon.species == "PIKACHU" then
|
||||||
|
for _, mv in ipairs(mon.moves or {}) do
|
||||||
|
if mv.id == "SURF" then return mon end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function push(game, text, done)
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
game.stack:push(TextBox.new(game, text, done))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the two-variant posters: the surf-capable line once a surfing
|
||||||
|
-- Pikachu is along, the plain one otherwise
|
||||||
|
local function poster(n)
|
||||||
|
return function(game, ow, npc, done)
|
||||||
|
local t = game.data.text
|
||||||
|
local key = ("_SummerBeachHousePoster%dText%d"):format(
|
||||||
|
n, surfingPikachu(game) and 1 or 2)
|
||||||
|
push(game, t[key] or "A surfing poster.", done)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
SUMMER_BEACH_HOUSE = {
|
||||||
|
talk = {
|
||||||
|
TEXT_SUMMERBEACHHOUSE_SURFINDUDE = function(game, ow, npc, done)
|
||||||
|
local t = game.data.text
|
||||||
|
if not surfingPikachu(game) then
|
||||||
|
push(game, t._SummerBeachHouseSurfinDudeText4
|
||||||
|
or "Dogs and burgers\non special today!", done)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- Text1 on the first ask each visit, the short Text3 after
|
||||||
|
local ask = ow.surfinDudeAsked
|
||||||
|
and (t._SummerBeachHouseSurfinDudeText3 or "Wanna go SURF?")
|
||||||
|
or (t._SummerBeachHouseSurfinDudeText1
|
||||||
|
or "Whoa!\nYour PIKACHU knows\nhow to SURF!\fGive it a go?")
|
||||||
|
ow.surfinDudeAsked = true
|
||||||
|
push(game, ask, function()
|
||||||
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
|
if not yes then
|
||||||
|
push(game, t._SummerBeachHouseSurfinDudeText2
|
||||||
|
or "Come SURF anytime,\nmy friend!", done)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local SurfingMinigame = require("src.ui.SurfingMinigame")
|
||||||
|
game.stack:push(SurfingMinigame.new(game, function()
|
||||||
|
ow.surfedThisVisit = true -- BIT_PIKACHU_MAP_SURF_SELECT
|
||||||
|
require("src.core.Music").playMap(game.data, ow.map.id)
|
||||||
|
done()
|
||||||
|
end))
|
||||||
|
end))
|
||||||
|
end)
|
||||||
|
end,
|
||||||
|
|
||||||
|
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
|
||||||
|
local t = game.data.text
|
||||||
|
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
|
||||||
|
function()
|
||||||
|
require("src.core.Sound").playCry(game.data, "PIKACHU")
|
||||||
|
done()
|
||||||
|
end)
|
||||||
|
end,
|
||||||
|
|
||||||
|
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
|
||||||
|
TEXT_SUMMERBEACHHOUSE_POSTER2 = poster(2),
|
||||||
|
TEXT_SUMMERBEACHHOUSE_POSTER3 = poster(3),
|
||||||
|
|
||||||
|
TEXT_SUMMERBEACHHOUSE_PRINTER = function(game, ow, npc, done)
|
||||||
|
local t = game.data.text
|
||||||
|
if not surfingPikachu(game) then
|
||||||
|
push(game, t._SummerBeachHousePrinterText1
|
||||||
|
or "It's some sort of\na machine...", done)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
push(game, t._SummerBeachHousePrinterText2
|
||||||
|
or "SUMMER BEACH HOUSE\nPRINTER, it says.", function()
|
||||||
|
if not ow.surfedThisVisit then
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
push(game, t._SummerBeachHousePrinterText3
|
||||||
|
or "The Hi-Score is\nshown.\fPRINT it out?", function()
|
||||||
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
|
if not yes then
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- PrintSurfingMinigameHighScore -> PNG stand-in
|
||||||
|
local Printer = require("src.core.Printer")
|
||||||
|
local Font = require("src.render.Font")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
local hi = game.save.surfingHighScore or 0
|
||||||
|
local name = game.save.player.name or "RED"
|
||||||
|
local saved, err = Printer.save("surf_hiscore", 160, 64,
|
||||||
|
function()
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, 160, 64)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
love.graphics.rectangle("line", 2.5, 2.5, 155, 59)
|
||||||
|
Font.draw(Strings("SUMMER BEACH HOUSE"), 8, 10)
|
||||||
|
Font.draw(Strings("SURFING Hi-Score"), 8, 24)
|
||||||
|
Font.draw(name, 8, 40)
|
||||||
|
Font.draw(Strings("%d pts", hi), 96, 40)
|
||||||
|
end)
|
||||||
|
push(game, saved
|
||||||
|
and Strings("Printed!\fSaved as\n%s\vin the save\nfolder.",
|
||||||
|
saved)
|
||||||
|
or Strings("Printer error!\n%s", tostring(err)), done)
|
||||||
|
end))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
-- Yellow's three Kanto-starter side-quests, hand-ported from
|
||||||
|
-- scripts/CeruleanMelaniesHouse.asm, scripts/Route24.asm
|
||||||
|
-- (Route24CooltrainerM4Text) and scripts/VermilionCity_2.asm
|
||||||
|
-- (VermilionCityPrintOfficerJennyText). Registered on top of the shared
|
||||||
|
-- Red map scripts by data/scripts/init.lua on a Yellow boot only, so the
|
||||||
|
-- table keys compose with story.lua / story4.lua's existing entries.
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
-- Melanie hands over her Bulbasaur once the starter Pikachu trusts you
|
||||||
|
-- (wPikachuHappiness >= 147).
|
||||||
|
M.CERULEAN_MELANIES_HOUSE = {
|
||||||
|
talk = {
|
||||||
|
TEXT_CERULEANMELANIESHOUSE_MELANIE = function(game, ow, npc, done)
|
||||||
|
local rows = { { "face_player" } }
|
||||||
|
if game.save.flags.EVENT_GOT_BULBASAUR_IN_CERULEAN then
|
||||||
|
rows[#rows + 1] = { "show_text", "MelanieText4" }
|
||||||
|
elseif (game.save.pikachuHappiness or 90) < 147 then
|
||||||
|
rows[#rows + 1] = { "show_text", "MelanieText1" }
|
||||||
|
else
|
||||||
|
rows[#rows + 1] = { "show_text", "MelanieText1" }
|
||||||
|
rows[#rows + 1] = { "ask", "MelanieText2" }
|
||||||
|
rows[#rows + 1] = { "jump_if_false", "declined" }
|
||||||
|
rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 }
|
||||||
|
rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full
|
||||||
|
-- Event and HideObject before MelanieText3: give_pokemon's nickname
|
||||||
|
-- prompt and the received text both yield, and a script that dies
|
||||||
|
-- in that window used to leave the BULBASAUR given with the event
|
||||||
|
-- clear, so Melanie offered a second one (#426). The flag is only
|
||||||
|
-- read at script entry, so the order is invisible in play.
|
||||||
|
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
|
||||||
|
rows[#rows + 1] = { "hide_object", "CERULEAN_MELANIES_HOUSE",
|
||||||
|
"CERULEANMELANIESHOUSE_BULBASAUR" }
|
||||||
|
rows[#rows + 1] = { "show_text", "MelanieText3" }
|
||||||
|
rows[#rows + 1] = { "jump", "end" }
|
||||||
|
rows[#rows + 1] = { "label", "declined" }
|
||||||
|
rows[#rows + 1] = { "show_text", "MelanieText5" }
|
||||||
|
end
|
||||||
|
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||||
|
end,
|
||||||
|
-- pet flavor: the text with the species' cry over it
|
||||||
|
TEXT_CERULEANMELANIESHOUSE_BULBASAUR = {
|
||||||
|
{ "play_cry", "BULBASAUR" },
|
||||||
|
{ "show_text", "MelanieBulbasaurText" },
|
||||||
|
},
|
||||||
|
TEXT_CERULEANMELANIESHOUSE_ODDISH = {
|
||||||
|
{ "play_cry", "ODDISH" },
|
||||||
|
{ "show_text", "MelanieOddishText" },
|
||||||
|
},
|
||||||
|
TEXT_CERULEANMELANIESHOUSE_SANDSHREW = {
|
||||||
|
{ "play_cry", "SANDSHREW" },
|
||||||
|
{ "show_text", "MelanieSandshrewText" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Damian gives away the Charmander he thinks is too weak (EVENT_54F).
|
||||||
|
M.ROUTE_24 = {
|
||||||
|
talk = {
|
||||||
|
TEXT_ROUTE24_COOLTRAINER_M4 = {
|
||||||
|
{ "face_player" },
|
||||||
|
{ "check_flag", "EVENT_54F" },
|
||||||
|
{ "jump_if_true", "after" },
|
||||||
|
{ "ask", "_Route24DamianText1" },
|
||||||
|
{ "jump_if_false", "declined" },
|
||||||
|
{ "give_pokemon", "CHARMANDER", 10 },
|
||||||
|
{ "jump_if_false", "end" },
|
||||||
|
-- SetEvent hoisted ahead of the received text (Route24.asm writes it
|
||||||
|
-- after Route24Text_515e3, where nothing in between can fail): the
|
||||||
|
-- nickname prompt and the text both yield, and a script death in that
|
||||||
|
-- window left the CHARMANDER given with EVENT_54F clear, so Damian
|
||||||
|
-- handed out a second one on the next talk (#426).
|
||||||
|
{ "set_flag", "EVENT_54F" },
|
||||||
|
{ "show_text", "_Route24DamianText2" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
{ "label", "declined" },
|
||||||
|
{ "show_text", "_Route24DamianText3" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
{ "label", "after" },
|
||||||
|
{ "show_text", "_Route24DamianText4" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Officer Jenny's Squirtle: kept until you carry the Thunder Badge.
|
||||||
|
M.VERMILION_CITY = {
|
||||||
|
talk = {
|
||||||
|
TEXT_VERMILIONCITY_OFFICER_JENNY = function(game, ow, npc, done)
|
||||||
|
local rows = { { "face_player" } }
|
||||||
|
if game.save.flags.EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY then
|
||||||
|
rows[#rows + 1] = { "show_text", "_OfficerJennyText5" }
|
||||||
|
elseif not (game.save.inventory
|
||||||
|
and game.save.inventory.THUNDERBADGE) then
|
||||||
|
rows[#rows + 1] = { "show_text", "_OfficerJennyText1" }
|
||||||
|
else
|
||||||
|
rows[#rows + 1] = { "ask", "_OfficerJennyText2" }
|
||||||
|
rows[#rows + 1] = { "jump_if_false", "declined" }
|
||||||
|
rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 }
|
||||||
|
rows[#rows + 1] = { "jump_if_false", "end" }
|
||||||
|
-- event before the received text, as above (#426)
|
||||||
|
rows[#rows + 1] = { "set_flag",
|
||||||
|
"EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }
|
||||||
|
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
|
||||||
|
rows[#rows + 1] = { "jump", "end" }
|
||||||
|
rows[#rows + 1] = { "label", "declined" }
|
||||||
|
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
|
||||||
|
end
|
||||||
|
ow.runner:run(rows, { npc = npc, onDone = done })
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
-- Jessie & James, Yellow's Team Rocket duo, at all four ambush sites:
|
||||||
|
-- Mt Moon B2F (scripts/MtMoonB2F.asm), Rocket Hideout B4F
|
||||||
|
-- (scripts/RocketHideoutB4F.asm), Pokemon Tower 7F
|
||||||
|
-- (scripts/PokemonTower7F.asm) and Silph Co 11F (scripts/SilphCo11F.asm).
|
||||||
|
-- Every site shares one shape: a coordinate trigger swaps the map theme
|
||||||
|
-- for Music_MeetJessieJames, the motto plays, the duo closes in, one
|
||||||
|
-- battle against the shared OPP_ROCKET party fights them both, and after
|
||||||
|
-- their parting lines they vanish together under a second sting of the
|
||||||
|
-- theme before the map theme resumes (PlayDefaultMusic ->
|
||||||
|
-- play_default_music).
|
||||||
|
--
|
||||||
|
-- Registered on top of the shared tables by data/scripts/init.lua on a
|
||||||
|
-- Yellow boot; MT_MOON_B2F's onStep chains story2's Super Nerd / fossil
|
||||||
|
-- trigger and SILPH_CO_11F's chains story's Giovanni trigger, since
|
||||||
|
-- non-talk hooks replace rather than merge.
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
-- Capture the FUNCTION, not the table: attachBase stores the module
|
||||||
|
-- table itself, so once this file's onStep is attached the table's slot
|
||||||
|
-- points back here -- delegating through the table would self-recurse.
|
||||||
|
local baseMtMoonStep = require("data.scripts.story2").MT_MOON_B2F.onStep
|
||||||
|
local baseSilph11Step = require("data.scripts.story").SILPH_CO_11F.onStep
|
||||||
|
|
||||||
|
M.MT_MOON_B2F = {
|
||||||
|
talk = {
|
||||||
|
TEXT_MTMOONB2F_JESSIE = {
|
||||||
|
{ "face_player" }, { "show_text", "_MtMoonJessieJamesText1" },
|
||||||
|
},
|
||||||
|
TEXT_MTMOONB2F_JAMES = {
|
||||||
|
{ "face_player" }, { "show_text", "_MtMoonJessieJamesText1" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
onStep = function(game, ow, x, y)
|
||||||
|
if baseMtMoonStep and baseMtMoonStep(game, ow, x, y) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local f = game.save.flags
|
||||||
|
if x ~= 3 or y ~= 5 then return false end
|
||||||
|
if f.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES then return false end
|
||||||
|
if not (f.EVENT_GOT_DOME_FOSSIL or f.EVENT_GOT_HELIX_FOSSIL) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
ow.runner:run({
|
||||||
|
{ "stop_music" },
|
||||||
|
{ "play_music", "Music_MeetJessieJames" },
|
||||||
|
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
|
||||||
|
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
||||||
|
{ "show_text", "_MtMoonJessieJamesText1" },
|
||||||
|
{ "face_player_dir", "up" },
|
||||||
|
{ "emote", "player", "shock", 30 },
|
||||||
|
-- MtMoonB2FScript_49e15 simulates PAD_UP for one player step, then
|
||||||
|
-- Script6/Script9 walk Jessie (object 2, MovementData_f9e65: six
|
||||||
|
-- $06) and James (object 6, f9e66: five $06); both objects' movement
|
||||||
|
-- byte 2 is LEFT, so .determineDirection makes those LEFT steps --
|
||||||
|
-- Jessie (9,3)->(3,3) above the player at (3,4), James (9,4)->(4,4)
|
||||||
|
-- beside him. #423
|
||||||
|
{ "walk_npc", "player", { "up" } },
|
||||||
|
{ "walk_npc", 2, { "left", "left", "left", "left", "left", "left" } },
|
||||||
|
{ "face_object", 2, "down" },
|
||||||
|
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
|
||||||
|
{ "face_object", 6, "left" },
|
||||||
|
{ "show_text", "_MtMoonJessieJamesText2" },
|
||||||
|
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
|
||||||
|
{ "check_battle_result", "win" },
|
||||||
|
{ "jump_if_false", "end" },
|
||||||
|
{ "show_text", "_MtMoonJessieJamesText3" },
|
||||||
|
{ "show_text", "_MtMoonJessieJamesText4" },
|
||||||
|
{ "stop_music" },
|
||||||
|
{ "play_music", "Music_MeetJessieJames" },
|
||||||
|
{ "fade", "out" },
|
||||||
|
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
|
||||||
|
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
|
||||||
|
{ "fade", "in" },
|
||||||
|
{ "play_default_music" },
|
||||||
|
{ "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" },
|
||||||
|
}, {})
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
-- Rocket Hideout B4F (RocketHideoutB4FScript_455a5..Script13): the
|
||||||
|
-- motto plays from off-screen FIRST, then the duo pops in at (25,10) /
|
||||||
|
-- (24,10) and whichever of them shares the player's column ($18=24 or
|
||||||
|
-- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three
|
||||||
|
-- tiles down to loom over the player while the other steps one. A loss
|
||||||
|
-- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the
|
||||||
|
-- trigger re-arms clean.
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
M.ROCKET_HIDEOUT_B4F = {
|
||||||
|
talk = {
|
||||||
|
TEXT_ROCKETHIDEOUTB4F_JESSIE = {
|
||||||
|
{ "face_player" }, { "show_text", "_RocketHideoutJessieJamesText1" },
|
||||||
|
},
|
||||||
|
TEXT_ROCKETHIDEOUTB4F_JAMES = {
|
||||||
|
{ "face_player" }, { "show_text", "_RocketHideoutJessieJamesText1" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
onStep = function(game, ow, x, y)
|
||||||
|
local f = game.save.flags
|
||||||
|
if y ~= 14 or (x ~= 24 and x ~= 25) then return false end
|
||||||
|
if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end
|
||||||
|
-- ON_LEFT: player under James's column (25); movement data pairs
|
||||||
|
-- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the
|
||||||
|
-- column-mate walks 3, the other 1.
|
||||||
|
local onLeft = (x == 25)
|
||||||
|
ow.runner:run({
|
||||||
|
{ "stop_music" },
|
||||||
|
{ "play_music", "Music_MeetJessieJames" },
|
||||||
|
{ "show_text", "_RocketHideoutJessieJamesText1" },
|
||||||
|
{ "face_player_dir", "up" },
|
||||||
|
{ "emote", "player", "shock", 30 },
|
||||||
|
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||||
|
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||||
|
-- James (object 2) then Jessie (object 3), Script4..Script9 order
|
||||||
|
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
|
||||||
|
{ "face_object", 2, onLeft and "down" or "left" },
|
||||||
|
{ "walk_npc", 3, onLeft and { "down" } or { "down", "down", "down" } },
|
||||||
|
{ "face_object", 3, onLeft and "right" or "down" },
|
||||||
|
{ "show_text", "_RocketHideoutJessieJamesText2" },
|
||||||
|
{ "start_battle", "trainer", "OPP_ROCKET", 43 },
|
||||||
|
{ "check_battle_result", "win" },
|
||||||
|
{ "jump_if_false", "lost" },
|
||||||
|
{ "show_text", "_RocketHideoutJessieJamesText3" },
|
||||||
|
{ "show_text", "_RocketHideoutJessieJamesText4" },
|
||||||
|
{ "stop_music" },
|
||||||
|
{ "play_music", "Music_MeetJessieJames" },
|
||||||
|
{ "fade", "out" },
|
||||||
|
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||||
|
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||||
|
{ "fade", "in" },
|
||||||
|
{ "play_default_music" },
|
||||||
|
{ "set_flag", "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES" },
|
||||||
|
{ "jump", "end" },
|
||||||
|
{ "label", "lost" },
|
||||||
|
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||||
|
{ "hide_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||||
|
}, {})
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
-- Pokemon Tower 7F (PokemonTower7FScript_60d2a..Script10): same beat
|
||||||
|
-- one floor below Fuji, except the duo pops in BEFORE the motto and
|
||||||
|
-- Jessie ($a=10 column) leads the walk-down; ON_LEFT ($b=11) hands the
|
||||||
|
-- three-tile walk to James instead. On a loss vanilla only resets the
|
||||||
|
-- script counter (the blackout warp reloads the map anyway).
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
M.POKEMON_TOWER_7F = {
|
||||||
|
talk = {
|
||||||
|
TEXT_POKEMONTOWER7F_JESSIE = {
|
||||||
|
{ "face_player" }, { "show_text", "_PokemonTowerJessieJamesText1" },
|
||||||
|
},
|
||||||
|
TEXT_POKEMONTOWER7F_JAMES = {
|
||||||
|
{ "face_player" }, { "show_text", "_PokemonTowerJessieJamesText1" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
onStep = function(game, ow, x, y)
|
||||||
|
local f = game.save.flags
|
||||||
|
if y ~= 12 or (x ~= 10 and x ~= 11) then return false end
|
||||||
|
if f.EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES then return false end
|
||||||
|
local onLeft = (x == 11) -- EVENT_POKEMONTOWER_7_JESSIE_JAMES_ON_LEFT
|
||||||
|
ow.runner:run({
|
||||||
|
{ "stop_music" },
|
||||||
|
{ "play_music", "Music_MeetJessieJames" },
|
||||||
|
{ "show_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JESSIE" },
|
||||||
|
{ "show_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JAMES" },
|
||||||
|
{ "show_text", "_PokemonTowerJessieJamesText1" },
|
||||||
|
{ "face_player_dir", "up" },
|
||||||
|
{ "emote", "player", "shock", 30 },
|
||||||
|
-- Jessie (object 1) then James (object 2), Script1..Script6 order
|
||||||
|
{ "walk_npc", 1, onLeft and { "down" } or { "down", "down", "down" } },
|
||||||
|
{ "face_object", 1, onLeft and "right" or "down" },
|
||||||
|
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
|
||||||
|
{ "face_object", 2, onLeft and "down" or "left" },
|
||||||
|
{ "show_text", "_PokemonTowerJessieJamesText2" },
|
||||||
|
{ "start_battle", "trainer", "OPP_ROCKET", 44 },
|
||||||
|
{ "check_battle_result", "win" },
|
||||||
|
{ "jump_if_false", "end" },
|
||||||
|
{ "show_text", "_PokemonTowerJessieJamesText3" },
|
||||||
|
{ "show_text", "_PokemonTowerJessieJamesText4" },
|
||||||
|
{ "stop_music" },
|
||||||
|
{ "play_music", "Music_MeetJessieJames" },
|
||||||
|
{ "fade", "out" },
|
||||||
|
{ "hide_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JESSIE" },
|
||||||
|
{ "hide_object", "POKEMON_TOWER_7F", "POKEMONTOWER7F_JAMES" },
|
||||||
|
{ "fade", "in" },
|
||||||
|
{ "play_default_music" },
|
||||||
|
{ "set_flag", "EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES" },
|
||||||
|
}, {})
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
-- Silph Co 11F (SilphCo11FScript_6229c..Script14): the only site where
|
||||||
|
-- the duo starts VISIBLE (toggleable_objects.asm keeps SILPHCO11F_JAMES
|
||||||
|
-- / _JESSIE ON), flanking Giovanni at (2,8)/(3,8), so they are talkable
|
||||||
|
-- before the ambush (SilphCo11FJessieJamesText = the full motto). The
|
||||||
|
-- trigger is the top row (y=3, x<4); EVENT_780/EVENT_781 pick one of
|
||||||
|
-- three approach paths (SilphCo11FMovementData_622f5..62311, $5=up
|
||||||
|
-- $6=left) that route James then Jessie up to the player without ever
|
||||||
|
-- crossing the player's tile. Their duo text lives in
|
||||||
|
-- text/SilphCo10F.asm (_SilphCoJessieJamesText*).
|
||||||
|
-- -------------------------------------------------------------------
|
||||||
|
|
||||||
|
M.SILPH_CO_11F = {
|
||||||
|
talk = {
|
||||||
|
TEXT_SILPHCO11F_JESSIE = {
|
||||||
|
{ "face_player" }, { "show_text", "_SilphCoJessieJamesText1" },
|
||||||
|
},
|
||||||
|
TEXT_SILPHCO11F_JAMES = {
|
||||||
|
{ "face_player" }, { "show_text", "_SilphCoJessieJamesText1" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
onStep = function(game, ow, x, y)
|
||||||
|
if baseSilph11Step and baseSilph11Step(game, ow, x, y) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
local f = game.save.flags
|
||||||
|
if y ~= 3 or x > 3 then return false end
|
||||||
|
if f.EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES then return false end
|
||||||
|
-- x==3 -> base path, x==2 -> EVENT_780 variant, x<=1 -> EVENT_781
|
||||||
|
local jamesDirs, jamesFace, jessieDirs, jessieFace
|
||||||
|
if x == 3 then
|
||||||
|
jamesDirs, jamesFace = { "up", "up", "up", "up", "up" }, "right"
|
||||||
|
jessieDirs, jessieFace = { "up", "up", "up", "up" }, "up"
|
||||||
|
elseif x == 2 then
|
||||||
|
jamesDirs, jamesFace = { "up", "up", "up", "up" }, "up"
|
||||||
|
jessieDirs, jessieFace = { "up", "up", "up", "up", "up" }, "left"
|
||||||
|
else
|
||||||
|
jamesDirs = { "up", "up", "left", "up", "up" }
|
||||||
|
jamesFace = "up"
|
||||||
|
jessieDirs = { "up", "up", "up", "left", "up", "up" }
|
||||||
|
jessieFace = "left"
|
||||||
|
end
|
||||||
|
ow.runner:run({
|
||||||
|
{ "stop_music" },
|
||||||
|
{ "play_music", "Music_MeetJessieJames" },
|
||||||
|
{ "show_text", "_SilphCoJessieJamesText1" },
|
||||||
|
{ "face_player_dir", "down" },
|
||||||
|
{ "emote", "player", "shock", 30 },
|
||||||
|
-- James (object 4) then Jessie (object 6), Script5..Script10 order
|
||||||
|
{ "walk_npc", 4, jamesDirs },
|
||||||
|
{ "face_object", 4, jamesFace },
|
||||||
|
{ "walk_npc", 6, jessieDirs },
|
||||||
|
{ "face_object", 6, jessieFace },
|
||||||
|
{ "show_text", "_SilphCoJessieJamesText2" },
|
||||||
|
{ "start_battle", "trainer", "OPP_ROCKET", 45 },
|
||||||
|
{ "check_battle_result", "win" },
|
||||||
|
{ "jump_if_false", "end" },
|
||||||
|
{ "show_text", "_SilphCoJessieJamesText3" },
|
||||||
|
{ "show_text", "_SilphCoJessieJamesText4" },
|
||||||
|
{ "stop_music" },
|
||||||
|
{ "play_music", "Music_MeetJessieJames" },
|
||||||
|
{ "fade", "out" },
|
||||||
|
{ "hide_object", "SILPH_CO_11F", "SILPHCO11F_JAMES" },
|
||||||
|
{ "hide_object", "SILPH_CO_11F", "SILPHCO11F_JESSIE" },
|
||||||
|
{ "fade", "in" },
|
||||||
|
{ "play_default_music" },
|
||||||
|
{ "set_flag", "EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES" },
|
||||||
|
}, {})
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -65,6 +65,13 @@ display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
|
|||||||
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
|
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
|
||||||
put the row back.
|
put the row back.
|
||||||
|
|
||||||
|
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
|
||||||
|
to AUTO, which reads this device as an ARM Linux handheld and resolves to
|
||||||
|
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
|
||||||
|
so the overworld runs smoothly on the H700 out of the box. Bump it to
|
||||||
|
BALANCED or HIGH from OPTIONS if you want the extras and your device keeps
|
||||||
|
up; see [Performance tier](new-features.md#performance-tier-low-end-devices).
|
||||||
|
|
||||||
The pack bundles the LÖVE 11.5 aarch64 runtime from
|
The pack bundles the LÖVE 11.5 aarch64 runtime from
|
||||||
[PortMaster](https://portmaster.games/), so the device does not need a
|
[PortMaster](https://portmaster.games/), so the device does not need a
|
||||||
separate `love_11.5` runtime download on first launch. The launcher resolves
|
separate `love_11.5` runtime download on first launch. The launcher resolves
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
# Building a Blue Version of the port
|
|
||||||
|
|
||||||
pokered builds Red and Blue from one source tree: the Makefile
|
|
||||||
assembles everything twice with `rgbasm -D _RED` or `-D _BLUE`, and
|
|
||||||
every in-game difference sits in an `IF DEF(_RED)` / `IF DEF(_BLUE)`
|
|
||||||
block. Our extraction pipeline resolves those conditionals the same
|
|
||||||
way (`tools/extract/util.py` `ASM_DEFINES`), so most of a Blue build
|
|
||||||
falls out of re-running the extractors. Only three things are
|
|
||||||
hand-ported on the Lua side and need swapping by hand.
|
|
||||||
|
|
||||||
## What differs between Red and Blue
|
|
||||||
|
|
||||||
| Where (pokered) | What |
|
|
||||||
| --- | --- |
|
|
||||||
| `data/wild/maps/*.asm` (34 files) | Version-exclusive encounters (Red: Ekans, Oddish, Growlithe, Mankey, Scyther, Electabuzz, Blue: Sandshrew, Bellsprout, Vulpix, Meowth, Pinsir, Magmar) |
|
|
||||||
| `data/pokemon/title_mons.asm` | The 16 title-screen Pokémon |
|
|
||||||
| `engine/movie/title.asm` + `gfx/version.asm` | The "Red Version" / "Blue Version" ribbon |
|
|
||||||
| `constants/player_constants.asm` | Preset names (Red: RED/ASH/JACK + BLUE/GARY/JOHN) |
|
|
||||||
| `data/sgb/sgb_palettes.asm`, `sgb_border.asm` | Super Game Boy palettes and border |
|
|
||||||
| `data/events/prizes.asm`, `prize_mon_levels.asm` | Game Corner prize mons, costs and levels |
|
|
||||||
| `engine/movie/intro.asm`, `data/credits/credits_text.asm`, `engine/slots/slot_machine.asm`, `engine/battle/animations.asm`, `audio/sfx/save_3.asm` | Small gated tweaks (credits say "BLUE VERSION STAFF", etc.) |
|
|
||||||
|
|
||||||
## Step 1, flip the extraction define
|
|
||||||
|
|
||||||
`tools/extract/util.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
ASM_DEFINES = {"_RED"} # -> {"_BLUE"}
|
|
||||||
```
|
|
||||||
|
|
||||||
Then regenerate everything (same as scripts/setup.sh does):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd tools
|
|
||||||
../.venv/bin/python3 build_data.py \
|
|
||||||
--pokered /Users/bryanbassett/Documents/development/pokered \
|
|
||||||
--out ../data/generated --assets ../assets/generated
|
|
||||||
```
|
|
||||||
|
|
||||||
This alone switches the wild encounters, preset names, SGB palettes
|
|
||||||
and credits text. **Caveat:** `parse_preset_names` sanity-checks in
|
|
||||||
`tools/extract/field.py:1193` expect "RED" in the player presets,
|
|
||||||
relax that check for a Blue build (Blue's presets are BLUE/GARY/JOHN
|
|
||||||
for the player and RED/ASH/JACK for the rival).
|
|
||||||
|
|
||||||
`tools/extract/palettes.py` uses its own raw reader with hardcoded
|
|
||||||
`IF DEF(_BLUE)` skipping (around line 44), invert that too, or port
|
|
||||||
it to `read_asm` so `ASM_DEFINES` covers it.
|
|
||||||
|
|
||||||
## Step 2, title screen (hand-ported)
|
|
||||||
|
|
||||||
`src/ui/TitleState.lua`:
|
|
||||||
|
|
||||||
1. **Ribbon art.** The extractor writes
|
|
||||||
`assets/generated/title/red_version.png`; add `blue_version.png`
|
|
||||||
to `gfx.extract_title` in `tools/extract/gfx.py` (source:
|
|
||||||
`gfx/title/blue_version.png`, 64×8). In `TitleState:draw()`, the
|
|
||||||
Red strip needs two quads (tiles 0–1 "Red", skip, tiles 5–9
|
|
||||||
"Version", `title.asm` `VersionOnTitleScreenText`). Blue's strip
|
|
||||||
prints its tiles contiguously (`db $61..$68`), so draw the whole
|
|
||||||
64×8 image at px (56, 64) and verify with the title driver
|
|
||||||
screenshot.
|
|
||||||
|
|
||||||
2. **Title mons.** Replace `CYCLE_SPECIES` with Blue's list from
|
|
||||||
`data/pokemon/title_mons.asm`:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local CYCLE_SPECIES = {
|
|
||||||
"SQUIRTLE", "CHARMANDER", "BULBASAUR", "MANKEY", "HITMONLEE",
|
|
||||||
"VULPIX", "CHANSEY", "AERODACTYL", "JOLTEON", "SNORLAX",
|
|
||||||
"GLOOM", "POLIWAG", "DODUO", "PORYGON", "GENGAR", "RAICHU",
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 3, Game Corner prizes (hand-ported)
|
|
||||||
|
|
||||||
`data/scripts/story3.lua` (~line 209) carries the Red prize tables.
|
|
||||||
Blue's values (`prizes.asm` + `prize_mon_levels.asm`):
|
|
||||||
|
|
||||||
| Prize | Cost | Level |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| ABRA | 120 | 6 |
|
|
||||||
| CLEFAIRY | 750 | 12 |
|
|
||||||
| NIDORINO | 1200 | 17 |
|
|
||||||
| PINSIR | 2500 | 20 |
|
|
||||||
| DRATINI | 4600 | 24 |
|
|
||||||
| PORYGON | 6500 | 18 |
|
|
||||||
|
|
||||||
(TM prizes are identical in both versions.)
|
|
||||||
|
|
||||||
## Step 4, verify
|
|
||||||
|
|
||||||
```sh
|
|
||||||
luajit tests/run_tests.lua
|
|
||||||
```
|
|
||||||
|
|
||||||
Plus two spot checks:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# every grass table must have exactly 10 slots (a conditional-parsing
|
|
||||||
# regression shows up as 19–20 slots)
|
|
||||||
luajit -e 'local e=dofile("data/generated/encounters.lua")
|
|
||||||
for m,d in pairs(e) do if type(d)=="table" and d.grass and #d.grass.slots>0
|
|
||||||
and #d.grass.slots~=10 then print("BAD",m,#d.grass.slots) end end'
|
|
||||||
|
|
||||||
# a Blue exclusive should now appear (and Growlithe should not)
|
|
||||||
grep -c VULPIX data/generated/encounters.lua
|
|
||||||
grep -c GROWLITHE data/generated/encounters.lua
|
|
||||||
```
|
|
||||||
|
|
||||||
Title screenshot: run the driver in
|
|
||||||
`tests/drivers/` style (`SHOT_DIR=... POKEPORT_DRIVER=... love .`) and
|
|
||||||
eyeball the ribbon, title mon, and copyright row.
|
|
||||||
|
|
||||||
## What you get for free / what to skip
|
|
||||||
|
|
||||||
- Free after re-extraction: encounters (incl. Super Rod groups), SGB
|
|
||||||
palettes, preset names, credits text, the gated sfx/animation
|
|
||||||
tweaks.
|
|
||||||
- Trades, gift Pokémon, story scripts, gym data: identical in Western
|
|
||||||
Red/Blue, nothing to touch.
|
|
||||||
- Save files: a Red save loads fine, but dex AREA nests and new
|
|
||||||
encounters will be Blue's. Trainer parties are identical.
|
|
||||||
|
|
||||||
## Making it a runtime toggle instead
|
|
||||||
|
|
||||||
If you want one build with both versions, extraction would need to
|
|
||||||
emit both branches keyed by version (e.g. `slots` / `slotsBlue`) and
|
|
||||||
the three hand-ported spots would read a `save.version` or
|
|
||||||
`conf.lua` flag. That is a bigger change than the rebuild above,
|
|
||||||
the flip-and-regenerate route needs no engine changes at all.
|
|
||||||
@@ -12,7 +12,7 @@ this port.
|
|||||||
|
|
||||||
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) |
|
| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
|
||||||
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
|
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
|
||||||
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
|
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
|
||||||
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
|
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
|
||||||
@@ -38,6 +38,10 @@ this port.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
- Mods may patch `constants.bagSize` through the public content registry. The
|
||||||
|
native `save.lua` format keeps every existing item when the configured
|
||||||
|
limit changes; exporting to a cartridge `.sav` still writes only the first
|
||||||
|
20 bag slots because the original SRAM layout has no room for more.
|
||||||
- PC Box **overflow handling** was deliberately changed even though the
|
- PC Box **overflow handling** was deliberately changed even though the
|
||||||
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
|
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
|
||||||
or blocks the deposit," this port spills into the next box with room.
|
or blocks the deposit," this port spills into the next box with room.
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Build & install on your iPhone — step by step
|
||||||
|
|
||||||
|
Don't want to install Xcode? Download the release IPA and sideload it with
|
||||||
|
AltStore instead — see [ios-sideload.md](ios-sideload.md).
|
||||||
|
|
||||||
|
This guide assumes **zero** programming experience. Follow it top to
|
||||||
|
bottom and you'll have the game running on your own iPhone in roughly an
|
||||||
|
hour (most of it is waiting for downloads).
|
||||||
|
|
||||||
|
## What you need
|
||||||
|
|
||||||
|
- A **Mac** (any Apple-silicon or recent Intel Mac on macOS 14 or newer)
|
||||||
|
- An **iPhone** and its **charging cable**
|
||||||
|
- A free **Apple ID** (the same account you use for the App Store)
|
||||||
|
- Your **own, legally obtained** Pokémon Red or Blue ROM file (a 1 MB
|
||||||
|
`.gb` file). This project ships no game data — the app rebuilds
|
||||||
|
everything from *your* cartridge dump and verifies it before use.
|
||||||
|
- About **15 GB free disk space** (Xcode is enormous; the game itself is
|
||||||
|
tiny)
|
||||||
|
|
||||||
|
> **Free vs. paid Apple account:** a free Apple ID works. The only catch:
|
||||||
|
> apps signed with a free account stop launching after **7 days** — just
|
||||||
|
> re-run the install command (step 5) to re-sign; your save data is kept.
|
||||||
|
> A paid Apple Developer account ($99/yr) extends that to a year.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Install Xcode (once)
|
||||||
|
|
||||||
|
1. On the Mac, open the **App Store**, search **Xcode**, click **Get**.
|
||||||
|
It's a ~10 GB download — go make tea.
|
||||||
|
2. Open Xcode once. Accept the license. If it offers to install extra
|
||||||
|
components or the **iOS platform**, say yes and let it finish.
|
||||||
|
|
||||||
|
## Step 2 — Sign Xcode into your Apple ID (once)
|
||||||
|
|
||||||
|
1. In Xcode's menu bar: **Xcode → Settings → Accounts**.
|
||||||
|
2. Click the **+** in the bottom-left → **Apple Account** → sign in.
|
||||||
|
3. Close the window. (This quietly creates the "signing certificate" the
|
||||||
|
build uses — you never have to touch it again.)
|
||||||
|
|
||||||
|
## Step 3 — Get this project onto the Mac
|
||||||
|
|
||||||
|
If you received it as a folder, put it somewhere easy like your home
|
||||||
|
folder. If it's on GitHub, click the green **Code** button → **Download
|
||||||
|
ZIP**, then double-click the zip to unpack it.
|
||||||
|
|
||||||
|
## Step 4 — Build it (one command)
|
||||||
|
|
||||||
|
1. Open the **Terminal** app (press ⌘-space, type `terminal`, press
|
||||||
|
return).
|
||||||
|
2. Type `cd ` (c, d, space — don't press return yet), then **drag the
|
||||||
|
project folder** from Finder onto the Terminal window — it fills in
|
||||||
|
the path — and press **return**.
|
||||||
|
3. Paste this and press return:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/build_ios.sh --fetch
|
||||||
|
```
|
||||||
|
|
||||||
|
The first run downloads the LÖVE engine and compiles everything
|
||||||
|
(5–15 minutes). Lines of build output scrolling by is normal. You're
|
||||||
|
done when you see **`==> done`**.
|
||||||
|
|
||||||
|
## Step 5 — Put it on your iPhone
|
||||||
|
|
||||||
|
1. Plug the iPhone into the Mac with the cable. **Unlock it** and keep it
|
||||||
|
unlocked. If it asks **"Trust This Computer?" → Trust**.
|
||||||
|
2. Paste this and press return:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/build_ios.sh --device --install
|
||||||
|
```
|
||||||
|
|
||||||
|
The script finds your signing identity and your phone by itself. If it
|
||||||
|
complains, it tells you exactly what to fix (usually: the phone was
|
||||||
|
locked — unlock and re-run).
|
||||||
|
|
||||||
|
3. First time only, the iPhone will want two approvals:
|
||||||
|
- **Developer Mode**: Settings → **Privacy & Security** → scroll to
|
||||||
|
**Developer Mode** → turn on → restart the phone → confirm.
|
||||||
|
- **Trust the developer**: Settings → **General** → **VPN & Device
|
||||||
|
Management** → tap the entry under *Developer App* → **Trust**.
|
||||||
|
|
||||||
|
Then re-run the command in step 5.2 if the install had failed.
|
||||||
|
|
||||||
|
## Step 6 — Give it your ROM and play
|
||||||
|
|
||||||
|
1. Get your `.gb` file onto the phone — AirDrop it to yourself, or save
|
||||||
|
it in iCloud Drive / Files.
|
||||||
|
2. Open the app. On the **RED** tab, tap **Import ROM** and pick your
|
||||||
|
`.gb` file in the file browser that appears. (Alternative: in the
|
||||||
|
**Files** app, drop the `.gb` into **On My iPhone → the game's
|
||||||
|
folder** and just reopen the app — it imports automatically.)
|
||||||
|
3. Import takes ~10 seconds, the button turns into **Play Red** — tap it.
|
||||||
|
|
||||||
|
## Optional goodies
|
||||||
|
|
||||||
|
- **Mods**: launcher → **MODS** tab → **Import mod .zip**.
|
||||||
|
- **Save import/export**: buttons on each game's tab, using the normal
|
||||||
|
iOS file picker.
|
||||||
|
|
||||||
|
## When things go wrong
|
||||||
|
|
||||||
|
| Symptom | Fix |
|
||||||
|
|---|---|
|
||||||
|
| `xcodebuild not found` | Xcode isn't installed or wasn't opened once — do Step 1 |
|
||||||
|
| `no Apple signing identity found` | Do Step 2, then re-run |
|
||||||
|
| `no iPhone/iPad found` | Cable in? Phone unlocked? Tapped "Trust"? |
|
||||||
|
| Install fails with "locked" | Unlock the phone, keep it unlocked, re-run |
|
||||||
|
| App icon appears then won't open | Do the two approvals in Step 5.3 |
|
||||||
|
| App stops launching after a week | Free-account 7-day limit — re-run Step 5.2 |
|
||||||
|
| "That ROM could not be imported" | The file isn't a canonical 1 MB US Red/Blue dump — the importer checks its fingerprint |
|
||||||
|
|
||||||
|
Every build/install command is safe to re-run; your saves live on the
|
||||||
|
phone and survive reinstalls.
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Sideload the iOS build with AltStore
|
||||||
|
|
||||||
|
Every GitHub Release ships an IPA (`gen1recomp-*-ios.ipa`). Install it on
|
||||||
|
your iPhone or iPad with [AltStore Classic](https://altstore.io/) — AltStore
|
||||||
|
re-signs the app with **your** free Apple ID so you do not need a Mac or
|
||||||
|
Xcode.
|
||||||
|
|
||||||
|
## 1. Install AltStore
|
||||||
|
|
||||||
|
Follow the official guide for your computer:
|
||||||
|
|
||||||
|
- [How to Install (Windows)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-windows)
|
||||||
|
- [How to Install (macOS)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-macos)
|
||||||
|
|
||||||
|
You will install **AltServer** on the computer, then use it to put AltStore
|
||||||
|
on the phone. What AltServer is and why it needs to stay running:
|
||||||
|
|
||||||
|
- [AltServer](https://faq.altstore.io/altstore-classic/altserver)
|
||||||
|
|
||||||
|
Stuck? Start here:
|
||||||
|
|
||||||
|
- [Troubleshooting Guide](https://faq.altstore.io/altstore-classic/troubleshooting-guide)
|
||||||
|
|
||||||
|
## 2. Install the game
|
||||||
|
|
||||||
|
1. Download `gen1recomp-*-ios.ipa` from
|
||||||
|
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
|
||||||
|
2. Open **AltStore** on the phone (AltServer must be running on the same
|
||||||
|
Wi‑Fi, or keep the phone plugged into the computer).
|
||||||
|
3. Tap **My Apps → +** (or share the IPA into AltStore) and pick the file.
|
||||||
|
4. Sign in with your Apple ID when prompted. Wait for the install to finish.
|
||||||
|
5. On first launch: Settings → **Privacy & Security → Developer Mode** (iOS
|
||||||
|
16+), and Settings → **General → VPN & Device Management** → Trust your
|
||||||
|
Apple ID if asked.
|
||||||
|
|
||||||
|
Then open the app, import your own legal `.gb` ROM on the Red/Blue tab, and
|
||||||
|
play.
|
||||||
|
|
||||||
|
## Refresh / 7-day limit
|
||||||
|
|
||||||
|
With a free Apple ID, sideloaded apps stop launching after **7 days**. Keep
|
||||||
|
AltServer running so AltStore can refresh them, or open AltStore and refresh
|
||||||
|
manually before they expire. Saves on the phone are kept across refreshes.
|
||||||
|
|
||||||
|
## Prefer building it yourself?
|
||||||
|
|
||||||
|
Building from source on a Mac (no AltStore) is covered in
|
||||||
|
[ios-install.md](ios-install.md).
|
||||||
@@ -202,3 +202,64 @@ column, slot card below Play, when the window is too narrow for both
|
|||||||
(wheel, or drag on touch/desktop) clamped to their own content extent,
|
(wheel, or drag on touch/desktop) clamped to their own content extent,
|
||||||
recomputed every draw. The tab bar labels only the active chip so it stays
|
recomputed every draw. The tab bar labels only the active chip so it stays
|
||||||
narrow-safe, and content caps out at `~1440 * s` wide, centered.
|
narrow-safe, and content caps out at `~1440 * s` wide, centered.
|
||||||
|
|
||||||
|
The desktop window has a floor of 480x360 (`conf.lua` `minwidth`/`minheight`),
|
||||||
|
under which the cards stop being readable at all. Mobile ignores it: those
|
||||||
|
windows are fullscreen.
|
||||||
|
|
||||||
|
### Page scroll
|
||||||
|
|
||||||
|
Two columns fit any window the launcher is likely to open in; one stacked
|
||||||
|
column does not. On a phone-shaped window the ROM card, SAVE FILES, Play and
|
||||||
|
SAVE SLOT together run past the bottom, and a footer pinned to the window
|
||||||
|
bottom painted over them with the overflow unreachable.
|
||||||
|
|
||||||
|
So the whole column under the tab bar -- panel, updater banner, footer --
|
||||||
|
scrolls as one page whenever it is taller than the room below the tab bar:
|
||||||
|
|
||||||
|
- The strip, logo and tab bar stay pinned, so navigation is always on screen.
|
||||||
|
Everything else draws at `contentTop - pageScroll` inside a scissor, and the
|
||||||
|
footer is laid out downward from `footerTop` right after the content instead
|
||||||
|
of upward from the window bottom.
|
||||||
|
- `RomImporter.pageScrollFor(naturalH, viewportH, scroll)` is the whole
|
||||||
|
decision, pure and pinned by `tests/engine/launcher_page_scroll.lua`. A
|
||||||
|
window that grows back drags the offset down with it, so the page can never
|
||||||
|
stay parked past its own end.
|
||||||
|
- The panels report their natural height as they draw (`_drawGamePanel` and
|
||||||
|
`_drawModsPanel` return it), so the decision reads the previous frame's
|
||||||
|
measurement -- the same one-frame settle the two lists already rely on.
|
||||||
|
- **One scroll axis at a time.** While the page scrolls, the panels draw
|
||||||
|
`paged`: the slot and mod lists take their natural height, keep no inner
|
||||||
|
scroll region and report a max of 0, so the wheel, the right stick and a drag
|
||||||
|
all move the page and never fight a list for the same gesture. Two-column
|
||||||
|
layouts do not overflow, `paged` stays false, and every one of these behaves
|
||||||
|
exactly as it did before.
|
||||||
|
- Hit testing follows the clip: `inside` (clicks) and `_ptIn` (hover) reject a
|
||||||
|
rect that scrolled out of the viewport, so a control that slid under the tab
|
||||||
|
bar cannot be clicked through it. Tab chips carry `pinned = true` and are
|
||||||
|
exempt. `pageScroll` resets on a tab change, each tab being a different
|
||||||
|
length.
|
||||||
|
- A press on empty background pans the page, resolved in `_updateSlotDrag` like
|
||||||
|
every other drag here.
|
||||||
|
|
||||||
|
### Dragging on Android
|
||||||
|
|
||||||
|
The launcher is handed no move events on any platform: `main.lua` forwards
|
||||||
|
neither `touchmoved` nor `mousemoved` while it is up, which is why every drag
|
||||||
|
here is resolved by polling inside `draw` instead. Desktop polls the mouse;
|
||||||
|
Android used to poll nothing at all ("no reliable pointer polling" meant its
|
||||||
|
mouse emulation), so it had no scroll gesture whatsoever -- fine while every
|
||||||
|
scroll region was an inner list with a wheel alternative, useless the moment
|
||||||
|
the page itself became the thing that scrolls, since a phone is exactly where
|
||||||
|
it overflows.
|
||||||
|
|
||||||
|
`love.touch` is pollable, so `_pointerHold` reads the first active touch there
|
||||||
|
and hands `_updateSlotDrag` the same (held, y) pair the mouse gives on desktop.
|
||||||
|
Consequences:
|
||||||
|
|
||||||
|
- Slot rows and mod toggles ARM on press and commit on release on Android too,
|
||||||
|
matching desktop, so a swipe that starts on a card scrolls instead of
|
||||||
|
selecting the row it started on.
|
||||||
|
- `touchPollable` (set once in `new`) gates all of it. Where `love.touch` is
|
||||||
|
missing, every Android path is exactly what it was: act on press, never arm,
|
||||||
|
no drag.
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ Regenerate the reference straight into a wiki checkout:
|
|||||||
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
|
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Editing maps in Tiled
|
||||||
|
|
||||||
|
Maps are data, not assets, so they can be authored in a real map editor and
|
||||||
|
exported as a mod. `tools/tiled_export.py` builds a
|
||||||
|
[Tiled](https://www.mapeditor.org) workspace out of the imported ROM cache:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/tiled_export.py # -> build/tiled/ (gitignored)
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `build/tiled/gen1.tiled-project`, edit any of the 222 maps (or
|
||||||
|
`kanto.world` for the stitched overworld), and export with the
|
||||||
|
`gen1-mod-export` extension — one map file, or a whole loadable mod folder.
|
||||||
|
An edited vanilla map becomes a `mod.content.maps:patch` carrying only the
|
||||||
|
fields that moved; a new map becomes a `:register`. See
|
||||||
|
`docs/new-features.md` and the extension's own README.
|
||||||
|
|
||||||
## Rendering pipelines
|
## Rendering pipelines
|
||||||
|
|
||||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||||
@@ -150,5 +167,38 @@ The console understands these verbs (anything else is evaluated as Lua):
|
|||||||
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
|
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
|
||||||
- `help` — list the verbs.
|
- `help` — list the verbs.
|
||||||
|
|
||||||
|
## Tool input and title-menu hooks
|
||||||
|
|
||||||
|
Tool mods that need to act once per game logic tick can wrap `input.step`.
|
||||||
|
It runs immediately before queued button edges are promoted, so input added by
|
||||||
|
the wrapper is visible during that same fixed step. The callback receives
|
||||||
|
`(next, game, dt)` and must call `next(game, dt)`.
|
||||||
|
|
||||||
|
`ui.title_menu.items` receives `(next, game, items)` and follows the same
|
||||||
|
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
|
||||||
|
for a tool to offer a fresh-session action before gameplay begins.
|
||||||
|
|
||||||
|
Ephemeral tools can wrap `save.write(next, game)` and return `false` to veto a
|
||||||
|
progress write before world state is captured or any bytes reach disk.
|
||||||
|
|
||||||
|
`render.hud` receives `(next, game, viewport)` after the finished game frame is
|
||||||
|
composited and before touch controls draw. The window-space viewport contains
|
||||||
|
`width`, `height`, `gameX`, `gameY`, `gameWidth`, `gameHeight`, `scale`, `dpiX`,
|
||||||
|
and `dpiY`, so a tool can use the letterbox margins without drawing over the
|
||||||
|
playfield or pushing an updating game state.
|
||||||
|
|
||||||
|
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
|
||||||
|
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
|
||||||
|
the mod full control of the window, while calling `next` runs the engine's
|
||||||
|
normal single-window composite so the mod can decorate around it. `ctx` carries
|
||||||
|
the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
|
||||||
|
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
|
||||||
|
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
|
||||||
|
palette-correct blit of either canvas into an arbitrary screen rect, and the
|
||||||
|
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`)
|
||||||
|
for driving a second physical display. This is what lets a mod lay the two
|
||||||
|
passes out as two stacked Game Boy screens, or push one onto a second screen,
|
||||||
|
without the engine knowing the layout.
|
||||||
|
|
||||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||||
that reach outside their permission set.
|
that reach outside their permission set.
|
||||||
|
|||||||
+129
-7
@@ -86,14 +86,20 @@ Game Boy equivalent:
|
|||||||
## Colors mode
|
## Colors mode
|
||||||
|
|
||||||
The `2` key (and the Options menu COLORS row) cycles the display mode
|
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||||
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**.
|
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**
|
||||||
|
(on Blue the first slot labels **OG BLUE**; on Yellow, **OG YELLOW**).
|
||||||
The first three are the real colorizations; the rest are DMG-shade novelties:
|
The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||||
|
|
||||||
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
|
- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart --
|
||||||
red BG palette + one green OBJ palette, every map, no per-map variation
|
one global BG palette + one OBJ palette, every map, no per-map variation
|
||||||
(Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally).
|
(Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally).
|
||||||
The player/NPCs stay green over the red terrain via the OBP bake +
|
The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP
|
||||||
post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`).
|
bake + post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`, or Blue's blue/pink
|
||||||
|
pair).
|
||||||
|
- **OG YELLOW** (Yellow playthrough, same `ogred` save id): Pokemon Yellow's
|
||||||
|
authentic GBC look from `CGBBasePalettes` (`data/palettes_yellow.lua`,
|
||||||
|
sourced from pret/pokeyellow). Per-map / per-species colors, not a single
|
||||||
|
boot-ROM ramp -- Yellow was CGB-enhanced.
|
||||||
- **SGB** (default): the per-map Super Game Boy region palettes
|
- **SGB** (default): the per-map Super Game Boy region palettes
|
||||||
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
||||||
real SGB. (This is the mode formerly mislabeled "GBC".)
|
real SGB. (This is the mode formerly mislabeled "GBC".)
|
||||||
@@ -137,6 +143,41 @@ effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
|
|||||||
its launcher because the device reports `"Linux"` while its GPU is in the
|
its launcher because the device reports `"Linux"` while its GPU is in the
|
||||||
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
|
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
|
||||||
|
|
||||||
|
## Performance tier (low-end devices)
|
||||||
|
|
||||||
|
The Options **PERFORMANCE** row scales the port's optional presentation
|
||||||
|
extras down for weaker hardware. The extras it governs are the three
|
||||||
|
heaviest things the port adds on top of the original -- the whole-screen 3D
|
||||||
|
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
|
||||||
|
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
|
||||||
|
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
|
||||||
|
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
|
||||||
|
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
|
||||||
|
only in how much eye-candy the renderer is allowed to do.
|
||||||
|
|
||||||
|
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
|
||||||
|
| ------------ | ---- | ------ | ----------- | ----------------- |
|
||||||
|
| **HIGH** | on | on | on | none |
|
||||||
|
| **BALANCED** | off | off | on | none |
|
||||||
|
| **LOW** | off | off | off | 60 |
|
||||||
|
| **AUTO** | picks a default from the device (below) |||
|
||||||
|
|
||||||
|
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
|
||||||
|
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
|
||||||
|
desktops to **BALANCED**, and everything else -- a normal desktop, and
|
||||||
|
every existing `options.lua` that predates this option -- to **HIGH**,
|
||||||
|
so the common case is unchanged. See `src/core/Performance.detect`.
|
||||||
|
- AUTO only chooses the *default*; all four tiers are selectable, so a
|
||||||
|
wrong guess is one row away from being overridden.
|
||||||
|
- The clamps are applied **live** against your stored options and never
|
||||||
|
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
|
||||||
|
FX / ZOOM without forgetting them -- raising the tier restores exactly
|
||||||
|
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
|
||||||
|
saved choice on a clamped tier: it's your preference, waiting for a tier
|
||||||
|
that can afford it.)
|
||||||
|
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
|
||||||
|
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
|
||||||
|
|
||||||
## Peer-to-peer link play (lua-enet)
|
## Peer-to-peer link play (lua-enet)
|
||||||
|
|
||||||
Trades and link battles connect two copies of the game directly over
|
Trades and link battles connect two copies of the game directly over
|
||||||
@@ -151,6 +192,13 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
|
|||||||
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
||||||
also exercises real UDP pairing.
|
also exercises real UDP pairing.
|
||||||
|
|
||||||
|
Red, Blue, and Yellow copies link with each other, as the real cable
|
||||||
|
does. The compatibility fingerprint hashes only data a link mode can
|
||||||
|
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
|
||||||
|
only R/B/Y link-surface difference) no longer read as different games
|
||||||
|
(issue #511). Moving the fingerprint is a link parity change: builds
|
||||||
|
from before this fix will refuse to pair with builds after it.
|
||||||
|
|
||||||
## Fair play in link and online matches
|
## Fair play in link and online matches
|
||||||
|
|
||||||
A link session is decided by the battle and nothing else, so for its
|
A link session is decided by the battle and nothing else, so for its
|
||||||
@@ -193,6 +241,11 @@ flushes the live options. Old saves that still embed an `options` table are
|
|||||||
migrated once into `options.lua` on load.
|
migrated once into `options.lua` on load.
|
||||||
|
|
||||||
- Music / SFX volume
|
- Music / SFX volume
|
||||||
|
- PIKACHU VOL (0-7, Yellow only): trims Pikachu's PCM voice clips under the
|
||||||
|
SFX level, so the follower's constant chatter, the title-screen cry and
|
||||||
|
every in-battle "Pika!" can be pulled down (or muted at 0) without
|
||||||
|
quieting the rest of the sound effects. The row is hidden on Red/Blue,
|
||||||
|
which have no voice clips.
|
||||||
- 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)
|
||||||
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
||||||
@@ -259,6 +312,15 @@ stick push hides it, the next screen touch brings it back, and unplugging
|
|||||||
the last controller restores it immediately. Layout re-derives from the
|
the last controller restores it immediately. Layout re-derives from the
|
||||||
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||||
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||||
|
|
||||||
|
The launcher's **Touch Controls** button opens a drag editor: move each
|
||||||
|
button freely, **Disable** to hide the overlay permanently (for
|
||||||
|
controllers / emulation handhelds -- distinct from the temporary
|
||||||
|
gamepad auto-hide), **Reset** for defaults, **Done** to save into
|
||||||
|
`options.lua` as normalized window fractions so rotation keeps the
|
||||||
|
relative placement. In-game, Options → **TOUCH PAD** toggles the same
|
||||||
|
on/off flag without leaving a play session.
|
||||||
|
|
||||||
## Translation support
|
## Translation support
|
||||||
|
|
||||||
Every string the player can read is now reachable from a mod, so a
|
Every string the player can read is now reachable from a mod, so a
|
||||||
@@ -318,7 +380,8 @@ semantics - so the two windows read as one app. Six tabs:
|
|||||||
party dock, so deposit and withdraw live in one place. Empty slots are
|
party dock, so deposit and withdraw live in one place. Empty slots are
|
||||||
clickable and create a mon there.
|
clickable and create a mon there.
|
||||||
- **Items**: money, a searchable item picker (replacing the arrows that
|
- **Items**: money, a searchable item picker (replacing the arrows that
|
||||||
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
|
cycled one id at a time through ~250 items), the configurable bag (20 slots
|
||||||
|
by default), PC storage
|
||||||
with no slot cap, and the eight badges as toggle chips.
|
with no slot cap, and the eight badges as toggle chips.
|
||||||
- **Events**: flags, defeated trainers, taken items and per-map object
|
- **Events**: flags, defeated trainers, taken items and per-map object
|
||||||
toggles, with a real filter field and a two-column paged grid.
|
toggles, with a real filter field and a two-column paged grid.
|
||||||
@@ -338,3 +401,62 @@ second, relabelling itself to `Confirm?` in between.
|
|||||||
|
|
||||||
A validation pill in the tab rail mirrors what the running game would
|
A validation pill in the tab rail mirrors what the running game would
|
||||||
quarantine on load; clicking it jumps to the tab holding the first problem.
|
quarantine on load; clicking it jumps to the tab holding the first problem.
|
||||||
|
|
||||||
|
## Tiled map editing (mod authoring)
|
||||||
|
|
||||||
|
`tools/tiled_export.py` turns the imported ROM cache into a Tiled workspace,
|
||||||
|
so maps can be edited in a real map editor and exported back out as a mod.
|
||||||
|
It has its own document: docs/tiled-map-editing.md.
|
||||||
|
|
||||||
|
## Pokédex diploma (both versions)
|
||||||
|
|
||||||
|
The Celadon Mansion 3F game designer shows the dex-completion diploma
|
||||||
|
once 150 species are owned. On Yellow, the graphic artist next to him
|
||||||
|
then offers to print it, saving the certificate as a PNG under `prints/`
|
||||||
|
in the save directory, and Bill's PC gains Yellow's PRINT BOX item which
|
||||||
|
exports the current box list the same way.
|
||||||
|
|
||||||
|
## Pokédex printing (Yellow)
|
||||||
|
|
||||||
|
Yellow's Game Boy Printer PRNT option in the Pokédex side menu is stood in
|
||||||
|
for by an image export: choosing PRNT renders the mon's entry page (sprite,
|
||||||
|
kind, number, height/weight, dex text) to a PNG at 4x scale under
|
||||||
|
`prints/` in the save directory, then reports the filename in a dialog.
|
||||||
|
No printer hardware or link cable emulation involved; the file is the
|
||||||
|
printout.
|
||||||
|
|
||||||
|
## Find Mods (community mod indexes)
|
||||||
|
|
||||||
|
A FIND MODS tab sits beside MODS in the launcher and browses a published
|
||||||
|
mod index: a metadata-only feed listing mods that live in their authors'
|
||||||
|
own repositories. No index ships with the launcher and none is ever added
|
||||||
|
automatically, so the tab opens on an "Add an index" prompt until you name
|
||||||
|
one; paste an index URL or its `owner/repo` and it is remembered in
|
||||||
|
`options.lua`. More than one index can be added, and the listings merge.
|
||||||
|
|
||||||
|
## Soft reset (all versions)
|
||||||
|
|
||||||
|
Holding A, B, START and SELECT together restarts the game the way flicking
|
||||||
|
a Game Boy's power switch did, dropping straight back to the title screen.
|
||||||
|
It works from anywhere, including mid-battle, which the QUIT entry on the
|
||||||
|
start menu cannot do: the original combo is how stationary and gift
|
||||||
|
Pokemon get their stats rerolled without sitting through a full relaunch.
|
||||||
|
Unsaved progress is discarded, exactly as on hardware.
|
||||||
|
|
||||||
|
As on the original, the four buttons have to stay held for 16 straight
|
||||||
|
polls (better than a quarter of a second) and any direction in the mix
|
||||||
|
cancels it, so it is hard to hit by accident -- including on the on-screen
|
||||||
|
touch controls, where it would take four fingers held on four separate
|
||||||
|
controls.
|
||||||
|
|
||||||
|
## Controls rebinding (CONTROLS screen)
|
||||||
|
|
||||||
|
OPTIONS -> CONTROLS lists every Game Boy button with its current keyboard
|
||||||
|
key and controller button side by side (Z/A). Press A on a row, then press
|
||||||
|
and release the key or pad button you want; the rebind commits on the
|
||||||
|
release. If that input already belongs to another row, the two rows swap,
|
||||||
|
so no button is ever stranded without an input and no input ever serves
|
||||||
|
two buttons. Holding a second key or pad button while the first is still
|
||||||
|
down backs out of the capture without touching a keyboard; Escape still
|
||||||
|
cancels too. SELECT clears one row back to its default, and START resets
|
||||||
|
every binding after a confirmation.
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Tiled map editing (mod authoring)
|
||||||
|
|
||||||
|
`tools/tiled_export.py` turns the imported ROM cache into a
|
||||||
|
[Tiled](https://www.mapeditor.org) workspace, so maps can be edited in a
|
||||||
|
real map editor and exported back out as a mod. The original had no map
|
||||||
|
editor at all; the port's own map data is plain Lua, which is what makes
|
||||||
|
this a data path rather than an asset path.
|
||||||
|
|
||||||
|
Editing is done in our own Tiled build,
|
||||||
|
[bryanthaboi/tiled_gen1recomp](https://github.com/bryanthaboi/tiled_gen1recomp/releases),
|
||||||
|
which ships the `gen1-mod-export` extension the workspace relies on. Grab it
|
||||||
|
from that repo's releases; upstream Tiled opens the workspace but cannot
|
||||||
|
export a mod out of it.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/tiled_export.py # -> build/tiled/ (gitignored)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `build/tiled/gen1.tiled-project` in that build of Tiled.
|
||||||
|
|
||||||
|
- **The overworld is one surface.** All 222 maps become `maps/*.tmj`, and
|
||||||
|
`kanto.world` places the 36 connected overworld maps at their real
|
||||||
|
connection offsets. That world is pre-loaded (seeded into the workspace's
|
||||||
|
Tiled session), so opening any one overworld map draws its neighbors around
|
||||||
|
it and you scroll and edit straight across the seams. Everything else is a
|
||||||
|
double-click away in Tiled's project panel.
|
||||||
|
- **Extending Kanto wires both ends.** A connection lives on both maps, so
|
||||||
|
hooking a new map onto a base map also emits the return connection as a
|
||||||
|
patch on that base map, keeping its other directions intact. The return
|
||||||
|
offset is derived, not guessed: all 78 vanilla reciprocal pairs satisfy
|
||||||
|
`back.offset == -offset`.
|
||||||
|
- **A Tiled tile is a gen1 block.** Each of the 24 tilesets becomes a Tiled
|
||||||
|
tileset whose tiles are its 32x32 blocks, composited from the 8x8 sheet,
|
||||||
|
so a tile layer *is* the map's `blocks` array. Warps, signs and objects
|
||||||
|
sit on the 16px cell grid in object layers, which is the grid the engine
|
||||||
|
addresses them on.
|
||||||
|
- **Collision is visible.** View > Show Tile Collision Shapes draws the real
|
||||||
|
walkability: a rectangle covers each cell whose feet tile is not in the
|
||||||
|
tileset's `walkable` list, which is the rule `src/world/Map.lua` applies.
|
||||||
|
- **Maps are shown in their real colors.** Each map is atlased in the SGB
|
||||||
|
palette it renders with, so Cerulean is blue and Lavender is purple in the
|
||||||
|
editor exactly as in game. Vanilla resolves that through a cascade with
|
||||||
|
interiors inheriting the last outdoor map, so the workspace mirrors the
|
||||||
|
cascade and walks the warp graph to colour interiors. Changing a map's
|
||||||
|
`palette` exports `palette = "..."` on the record, which beats the cascade,
|
||||||
|
and the editor offers the real palette names as a dropdown.
|
||||||
|
- **New blocks and new tilesets.** `blocksets/*.tmj` show a tileset's blocks
|
||||||
|
as raw 8x8 tiles, four by four, so new blocks can be composed there;
|
||||||
|
per-tile flags on `tilesets/tiles_*.tsj` become `walkable`, `waterTiles`,
|
||||||
|
`doorTiles` and the rest.
|
||||||
|
- **Export is a diff, not a fork of the data.** The `gen1-mod-export`
|
||||||
|
extension (shipped in `tiled_gen1recomp`) writes either one map file or a whole
|
||||||
|
loadable mod folder. An edited vanilla map diffs against the imported data
|
||||||
|
and emits `mod.content.maps:patch` carrying *only* the fields that moved, so
|
||||||
|
a mod covers the parts it changes and leaves the rest to the base game; a
|
||||||
|
new map gets `:register` at an index of 1000 or above. An unchanged map
|
||||||
|
exports nothing at all. Exports pass `tools/modkit.py validate` and `lint`.
|
||||||
|
- **Or the whole record, on request.** Ticking `exactExport` on a map switches
|
||||||
|
it to `mod.content.maps:override`, pinning the map to exactly what the
|
||||||
|
editor shows. It is off by default because an override wins outright over
|
||||||
|
any other mod patching that map, where a patch composes.
|
||||||
|
|
||||||
|
No ROM-derived art travels into an exported mod: a tileset still drawing on
|
||||||
|
the player's own imported sheet references that path rather than shipping the
|
||||||
|
pixels, and only a sheet the author supplied is copied in.
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||||
|
|
||||||
local Game, EditorApp, Importer
|
local Game, EditorApp, Importer, TouchEditor
|
||||||
|
|
||||||
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
||||||
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
||||||
@@ -134,11 +134,31 @@ function closeEditor()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------ touch controls editor
|
||||||
|
-- Suspends the launcher while the player drags on-screen buttons / toggles
|
||||||
|
-- the overlay off (#327). No ROM cache needed -- options.lua only.
|
||||||
|
local touchEditorHost
|
||||||
|
local closeTouchControlsEditor -- forward declaration
|
||||||
|
|
||||||
|
local function openTouchControlsEditor()
|
||||||
|
touchEditorHost = Importer
|
||||||
|
Importer = nil
|
||||||
|
TouchEditor = require("src.ui.TouchControlsEditor")
|
||||||
|
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
|
||||||
|
end
|
||||||
|
|
||||||
|
function closeTouchControlsEditor()
|
||||||
|
if TouchEditor and TouchEditor.unload then TouchEditor.unload() end
|
||||||
|
TouchEditor = nil
|
||||||
|
Importer = touchEditorHost
|
||||||
|
touchEditorHost = nil
|
||||||
|
end
|
||||||
|
|
||||||
local function bootGame(version)
|
local function bootGame(version)
|
||||||
-- The launcher hands us the chosen game (Red / Blue); scripted and headless
|
-- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and
|
||||||
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
|
-- headless runs fall back to POKEPORT_VERSION, then Red. Set the active
|
||||||
-- overlay its extracted cache BEFORE anything requires generated data, so
|
-- version and overlay its extracted cache BEFORE anything requires generated
|
||||||
-- data/generated + assets/generated resolve to that version's files.
|
-- data, so data/generated + assets/generated resolve to that version's files.
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
|
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
|
||||||
require("src.import.CacheFs").mountVersion(GameVersion.get())
|
require("src.import.CacheFs").mountVersion(GameVersion.get())
|
||||||
@@ -231,19 +251,25 @@ function love.load(args)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Interactive: the launcher always runs. Red and Blue are each live: a
|
-- Interactive: the launcher always runs. Red, Blue, and Yellow are each
|
||||||
-- column shows Play when that game's ROM is already imported, or Choose ROM
|
-- live: a column shows Play when that game's ROM is already imported, or
|
||||||
-- / drag-drop when it is not (Yellow is still a placeholder). Any dropped
|
-- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its
|
||||||
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
|
-- SHA-1 (GameVersion.forSha1); pressing Play boots that game. Edit on a
|
||||||
-- Edit on a save row opens the bundled editor on that slot (openEditor).
|
-- save row opens the bundled editor on that slot (openEditor).
|
||||||
Importer = RomImporter.new(function(version)
|
Importer = RomImporter.new(function(version)
|
||||||
Importer = nil
|
Importer = nil
|
||||||
bootGame(version)
|
bootGame(version)
|
||||||
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
|
end, {
|
||||||
|
launcher = true,
|
||||||
|
forceImport = forceImport,
|
||||||
|
onEditSave = openEditor,
|
||||||
|
onEditTouchControls = openTouchControlsEditor,
|
||||||
|
})
|
||||||
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 TouchEditor then return TouchEditor.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
|
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
|
||||||
@@ -283,6 +309,7 @@ end
|
|||||||
|
|
||||||
function love.draw()
|
function love.draw()
|
||||||
if editorMode then return EditorApp.draw() end
|
if editorMode then return EditorApp.draw() end
|
||||||
|
if TouchEditor then return TouchEditor.draw() end
|
||||||
if Importer then return Importer:draw() end
|
if Importer then return Importer:draw() end
|
||||||
|
|
||||||
Game:draw()
|
Game:draw()
|
||||||
@@ -303,36 +330,61 @@ end
|
|||||||
|
|
||||||
function love.keypressed(key, scancode, isrepeat)
|
function love.keypressed(key, scancode, isrepeat)
|
||||||
if editorMode then return EditorApp.keypressed(key) end
|
if editorMode then return EditorApp.keypressed(key) end
|
||||||
|
if TouchEditor then return TouchEditor.keypressed(key) end
|
||||||
if Importer then return Importer:keypressed(key) end
|
if Importer then return Importer:keypressed(key) end
|
||||||
Game:keypressed(key)
|
Game:keypressed(key)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.keyreleased(key)
|
function love.keyreleased(key)
|
||||||
if editorMode then return end
|
if editorMode or TouchEditor then return end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
Game:keyreleased(key)
|
Game:keyreleased(key)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.gamepadpressed(joystick, button)
|
function love.gamepadpressed(joystick, button)
|
||||||
if editorMode then return end
|
if editorMode or TouchEditor then return end
|
||||||
if Importer then return Importer:gamepadpressed(joystick, button) end
|
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||||
Game:gamepadpressed(joystick, button)
|
Game:gamepadpressed(joystick, button)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.gamepadreleased(joystick, button)
|
function love.gamepadreleased(joystick, button)
|
||||||
if editorMode then return end
|
if editorMode or TouchEditor then return end
|
||||||
if Importer then return Importer:gamepadreleased(joystick, button) end
|
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||||
Game:gamepadreleased(joystick, button)
|
Game:gamepadreleased(joystick, button)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.gamepadaxis(joystick, axis, value)
|
function love.gamepadaxis(joystick, axis, value)
|
||||||
if editorMode then return end
|
if editorMode or TouchEditor then return end
|
||||||
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||||
Game:gamepadaxis(joystick, axis, value)
|
Game:gamepadaxis(joystick, axis, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function love.joystickpressed(joystick, button)
|
||||||
|
if editorMode or TouchEditor then return end
|
||||||
|
if Importer then return Importer:joystickpressed(joystick, button) end
|
||||||
|
Game:joystickpressed(joystick, button)
|
||||||
|
end
|
||||||
|
|
||||||
|
function love.joystickreleased(joystick, button)
|
||||||
|
if editorMode or TouchEditor then return end
|
||||||
|
if Importer then return Importer:joystickreleased(joystick, button) end
|
||||||
|
Game:joystickreleased(joystick, button)
|
||||||
|
end
|
||||||
|
|
||||||
|
function love.joystickaxis(joystick, axis, value)
|
||||||
|
if editorMode or TouchEditor then return end
|
||||||
|
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
||||||
|
Game:joystickaxis(joystick, axis, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
function love.joystickhat(joystick, hat, direction)
|
||||||
|
if editorMode or TouchEditor then return end
|
||||||
|
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
||||||
|
Game:joystickhat(joystick, hat, direction)
|
||||||
|
end
|
||||||
|
|
||||||
function love.joystickremoved(joystick)
|
function love.joystickremoved(joystick)
|
||||||
if editorMode then return end
|
if editorMode or TouchEditor then return end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
Game:joystickremoved(joystick)
|
Game:joystickremoved(joystick)
|
||||||
end
|
end
|
||||||
@@ -341,7 +393,7 @@ end
|
|||||||
-- direction's key-up can be delivered to the OS instead of the game while
|
-- 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.
|
-- unfocused, so reset input on either transition rather than trust it.
|
||||||
function love.focus(f)
|
function love.focus(f)
|
||||||
if editorMode then return end
|
if editorMode or TouchEditor then return end
|
||||||
if Importer then
|
if Importer then
|
||||||
if Importer.focus then Importer:focus(f) end
|
if Importer.focus then Importer:focus(f) end
|
||||||
return
|
return
|
||||||
@@ -351,25 +403,50 @@ end
|
|||||||
|
|
||||||
-- v is true when the window becomes visible again, false on minimize.
|
-- v is true when the window becomes visible again, false on minimize.
|
||||||
function love.visible(v)
|
function love.visible(v)
|
||||||
if editorMode then return end
|
if editorMode or TouchEditor then return end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
Game:visible(v)
|
Game:visible(v)
|
||||||
end
|
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 TouchEditor then
|
||||||
|
-- iOS synthesizes mousepressed for the primary touch (same as the
|
||||||
|
-- launcher); Android drives the editor through love.touch directly.
|
||||||
|
if love.system.getOS() == "iOS" then return end
|
||||||
|
return TouchEditor.touchpressed(id, x, y)
|
||||||
|
end
|
||||||
|
if Importer then
|
||||||
|
-- iOS: LÖVE already synthesizes a mousepressed for the primary touch,
|
||||||
|
-- and love.mousepressed below forwards that to the Importer, so
|
||||||
|
-- forwarding here too fires every launcher button twice per tap. The
|
||||||
|
-- resulting double-present was fatal for the document picker: the
|
||||||
|
-- second sheet stole the first one's weakly-held delegate, so picking
|
||||||
|
-- a file silently did nothing. Android keeps the forward for upstream
|
||||||
|
-- parity (its SAF picker is a separate activity and tolerates the
|
||||||
|
-- re-launch).
|
||||||
|
if love.system.getOS() == "iOS" then return end
|
||||||
|
return Importer:mousepressed(x, y, 1)
|
||||||
|
end
|
||||||
Game:touchpressed(id, x, y)
|
Game:touchpressed(id, x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.touchmoved(id, x, y, dx, dy, pressure)
|
function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||||
if editorMode then return end
|
if editorMode then return end
|
||||||
|
if TouchEditor then
|
||||||
|
if love.system.getOS() == "iOS" then return end
|
||||||
|
return TouchEditor.touchmoved(id, x, y)
|
||||||
|
end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
Game:touchmoved(id, x, y)
|
Game:touchmoved(id, x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.touchreleased(id, x, y, dx, dy, pressure)
|
function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||||
if editorMode then return end
|
if editorMode then return end
|
||||||
|
if TouchEditor then
|
||||||
|
if love.system.getOS() == "iOS" then return end
|
||||||
|
return TouchEditor.touchreleased(id, x, y)
|
||||||
|
end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
Game:touchreleased(id, x, y)
|
Game:touchreleased(id, x, y)
|
||||||
end
|
end
|
||||||
@@ -379,12 +456,36 @@ function love.wheelmoved(x, y)
|
|||||||
if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end
|
if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if TouchEditor then return end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
Game:wheelmoved(x, y)
|
Game:wheelmoved(x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.mousepressed(x, y, button)
|
function love.mousepressed(x, y, button, istouch)
|
||||||
if Importer then return Importer:mousepressed(x, y, button) end
|
if TouchEditor then
|
||||||
|
-- Android primary touch already arrived via love.touchpressed; a second
|
||||||
|
-- mouse path would double-fire Done / begin a second drag.
|
||||||
|
if love.system.getOS() == "Android" then return end
|
||||||
|
return TouchEditor.mousepressed(x, y, button)
|
||||||
|
end
|
||||||
|
if Importer then
|
||||||
|
-- The same double-fire TouchEditor guards against, which the launcher was
|
||||||
|
-- missing: love.touchpressed above forwards the primary touch to the
|
||||||
|
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that
|
||||||
|
-- same touch, so one tap ran every launcher button twice. On Import that
|
||||||
|
-- meant two choose() calls and two stacked SAF picker activities: the
|
||||||
|
-- player picked their ROM, the top picker closed, and the second was still
|
||||||
|
-- underneath asking for it again, which is the "import the file twice"
|
||||||
|
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
|
||||||
|
-- USB mouse) working, which an Android-wide return would have broken.
|
||||||
|
--
|
||||||
|
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
|
||||||
|
-- returns early on iOS and never forwards, so there the synthesized mouse
|
||||||
|
-- press is the ONLY event the launcher gets. Filtering istouch on both
|
||||||
|
-- killed every tap on iOS outright.
|
||||||
|
if istouch and love.system.getOS() == "Android" then return end
|
||||||
|
return Importer:mousepressed(x, y, button)
|
||||||
|
end
|
||||||
if editorMode and EditorApp.mousepressed then
|
if editorMode and EditorApp.mousepressed then
|
||||||
return EditorApp.mousepressed(x, y, button)
|
return EditorApp.mousepressed(x, y, button)
|
||||||
end
|
end
|
||||||
@@ -394,6 +495,10 @@ function love.mousepressed(x, y, button)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function love.mousereleased(x, y, button)
|
function love.mousereleased(x, y, button)
|
||||||
|
if TouchEditor then
|
||||||
|
if love.system.getOS() == "Android" then return end
|
||||||
|
return TouchEditor.mousereleased(x, y, button)
|
||||||
|
end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
if editorMode and EditorApp.mousereleased then
|
if editorMode and EditorApp.mousereleased then
|
||||||
return EditorApp.mousereleased(x, y, button)
|
return EditorApp.mousereleased(x, y, button)
|
||||||
@@ -404,6 +509,10 @@ function love.mousereleased(x, y, button)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function love.mousemoved(x, y)
|
function love.mousemoved(x, y)
|
||||||
|
if TouchEditor then
|
||||||
|
if love.system.getOS() == "Android" then return end
|
||||||
|
return TouchEditor.mousemoved(x, y)
|
||||||
|
end
|
||||||
if editorMode or Importer then return end
|
if editorMode or Importer then return end
|
||||||
if mouseTouch and Game and love.mouse.isDown(1) then
|
if mouseTouch and Game and love.mouse.isDown(1) then
|
||||||
Game:touchmoved("mouse", x, y)
|
Game:touchmoved("mouse", x, y)
|
||||||
@@ -411,6 +520,7 @@ function love.mousemoved(x, y)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function love.textinput(text)
|
function love.textinput(text)
|
||||||
|
if TouchEditor then return end
|
||||||
if Importer then return Importer:textinput(text) end
|
if Importer then return Importer:textinput(text) end
|
||||||
if editorMode and EditorApp.textinput then
|
if editorMode and EditorApp.textinput then
|
||||||
return EditorApp.textinput(text)
|
return EditorApp.textinput(text)
|
||||||
@@ -424,6 +534,16 @@ function love.quit()
|
|||||||
pcall(function()
|
pcall(function()
|
||||||
require("src.core.DiscordPresence").shutdown()
|
require("src.core.DiscordPresence").shutdown()
|
||||||
end)
|
end)
|
||||||
|
-- LOVE waits for every live love.thread before the process exits, and both
|
||||||
|
-- background workers idle in a loop that only a "quit" command breaks, so
|
||||||
|
-- without this the process outlived the window and the next launch re-entered
|
||||||
|
-- the dead one instead of starting fresh (#339)
|
||||||
|
if package.loaded["src.core.ChipAudio"] then
|
||||||
|
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||||
|
end
|
||||||
|
if package.loaded["src.update.Check"] then
|
||||||
|
pcall(package.loaded["src.update.Check"].shutdown)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.filedropped(file)
|
function love.filedropped(file)
|
||||||
@@ -461,6 +581,13 @@ function love.run()
|
|||||||
for name, a, b, c, d, e, f in love.event.poll() do
|
for name, a, b, c, d, e, f in love.event.poll() do
|
||||||
if name == "quit" then
|
if name == "quit" then
|
||||||
if not love.quit or not love.quit() then
|
if not love.quit or not love.quit() then
|
||||||
|
-- Android keeps the process and its task alive after LOVE's own
|
||||||
|
-- teardown, so the relaunched task re-enters an activity whose
|
||||||
|
-- native main already returned; end the process outright once the
|
||||||
|
-- love.quit hook has run (#339)
|
||||||
|
if love.system and love.system.getOS() == "Android" then
|
||||||
|
os.exit(a or 0)
|
||||||
|
end
|
||||||
return a or 0
|
return a or 0
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+28
-2
@@ -50,6 +50,29 @@ chosen file under the app save directory as `picked_rom.gb`,
|
|||||||
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
|
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
|
||||||
itself remains data-free (no embedded ROM or generated cache).
|
itself remains data-free (no embedded ROM or generated cache).
|
||||||
|
|
||||||
|
### Step bridge (Pokéwalker mod)
|
||||||
|
|
||||||
|
`love.system.syncHealthSteps()` → `GameActivity.syncHealthSteps` (same
|
||||||
|
JNI route as the picker: `common/android.cpp` →
|
||||||
|
`modules/system/System.cpp` → `wrap_System.cpp`). The Java side does a
|
||||||
|
one-shot read of the hardware `TYPE_STEP_COUNTER` sensor (cumulative
|
||||||
|
since boot, counted by the OS whether or not any app runs), anchors the
|
||||||
|
reading in `SharedPreferences` so a walk is never credited twice
|
||||||
|
(a reading below the anchor means the phone rebooted → re-anchor without
|
||||||
|
crediting), and stages the delta as `steps_pending.json` in the save
|
||||||
|
identity dir — the same contract as the iOS `GRHealthBridge`. Nothing in
|
||||||
|
the base game calls it; the consumer is the
|
||||||
|
[Pokéwalker mod](https://github.com/mresnick67/Gen1ReComp-Pokewalker),
|
||||||
|
installed as a mod `.zip` at runtime (its SYNC STEPS option defaults
|
||||||
|
off).
|
||||||
|
|
||||||
|
Android 10+ gates the sensor behind the `ACTIVITY_RECOGNITION` runtime
|
||||||
|
permission (declared in `app/src/main/AndroidManifest.xml`; keep it out
|
||||||
|
of the build script's permission trim). The first
|
||||||
|
`syncHealthSteps()` call shows the system prompt; on grant the sensor
|
||||||
|
read runs immediately (`onRequestPermissionsResult`,
|
||||||
|
`STEP_PERMISSION_REQUEST_CODE`).
|
||||||
|
|
||||||
### SDK / NDK
|
### SDK / NDK
|
||||||
|
|
||||||
love-android 11.5a expects:
|
love-android 11.5a expects:
|
||||||
@@ -70,7 +93,10 @@ The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
|
|||||||
### Payload path
|
### Payload path
|
||||||
|
|
||||||
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
|
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
|
||||||
`data/`, `assets/`, and `tools/rom_manifest.json`. Generated game data,
|
`data/`, `assets/`, and the Red, Blue, and Yellow ROM manifests. The Android
|
||||||
|
packer verifies the Yellow manifest before it packages; if a partial source
|
||||||
|
export omitted it, it restores the file from this checkout's Git data and then
|
||||||
|
falls back to the project's GitHub copy. Generated game data,
|
||||||
scripts, tests, and mobile build sources are excluded.
|
scripts, tests, and mobile build sources are excluded.
|
||||||
|
|
||||||
## Branding (applied by the build script)
|
## Branding (applied by the build script)
|
||||||
@@ -79,7 +105,7 @@ 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` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
|
||||||
| `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 |
|
| `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 |
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
the link screen shows as "(Operation not permitted)" (issue #287).
|
the link screen shows as "(Operation not permitted)" (issue #287).
|
||||||
scripts/build_android.sh must not strip this again. -->
|
scripts/build_android.sh must not strip this again. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
|
||||||
|
counter, which Android 10+ gates behind this runtime permission.
|
||||||
|
Requested only on the first sync call (the Pokéwalker mod's SYNC
|
||||||
|
STEPS option); scripts/build_android.sh must not strip it. -->
|
||||||
|
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||||
<!-- OpenGL ES 2.0 -->
|
<!-- OpenGL ES 2.0 -->
|
||||||
<uses-feature android:glEsVersion="0x00020000" />
|
<uses-feature android:glEsVersion="0x00020000" />
|
||||||
<!-- Touchscreen support -->
|
<!-- Touchscreen support -->
|
||||||
|
|||||||
@@ -219,6 +219,40 @@ bool showCreateDocument(const char *suggestedName)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool syncHealthSteps()
|
||||||
|
{
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity, "syncHealthSteps", "()Z");
|
||||||
|
jboolean result = env->CallStaticBooleanMethod(activity, method);
|
||||||
|
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool restartApp()
|
||||||
|
{
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
|
||||||
|
// Old APK / new liblove skew: fail soft so HostShell.restart can fall
|
||||||
|
// back to a clean quit instead of aborting on a missing method (#575).
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity, "restartApp", "()Z");
|
||||||
|
if (method == nullptr)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Does not return on success: the Java side exits the process.
|
||||||
|
jboolean result = env->CallStaticBooleanMethod(activity, method);
|
||||||
|
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Helper functions for the filesystem module
|
* Helper functions for the filesystem module
|
||||||
*/
|
*/
|
||||||
@@ -851,4 +885,51 @@ const char *getArg0()
|
|||||||
} // android
|
} // android
|
||||||
} // love
|
} // love
|
||||||
|
|
||||||
|
extern "C" __attribute__((visibility("default")))
|
||||||
|
int love_android_secondary_ready()
|
||||||
|
{
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
jmethodID m = env->GetStaticMethodID(activity, "hasSecondaryDisplay", "()Z");
|
||||||
|
jboolean ready = JNI_FALSE;
|
||||||
|
if (m)
|
||||||
|
ready = env->CallStaticBooleanMethod(activity, m);
|
||||||
|
else
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return ready ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" __attribute__((visibility("default")))
|
||||||
|
void love_android_push_secondary(const void *rgba, int w, int h)
|
||||||
|
{
|
||||||
|
if (!rgba || w <= 0 || h <= 0)
|
||||||
|
return;
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
jmethodID m = env->GetStaticMethodID(activity, "updateSecondaryFrame", "(Ljava/nio/ByteBuffer;II)V");
|
||||||
|
if (m)
|
||||||
|
{
|
||||||
|
jobject buf = env->NewDirectByteBuffer((void*) rgba, (jlong) w * (jlong) h * 4);
|
||||||
|
env->CallStaticVoidMethod(activity, m, buf, w, h);
|
||||||
|
env->DeleteLocalRef(buf);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" __attribute__((visibility("default")))
|
||||||
|
void love_android_secondary_enable(int on)
|
||||||
|
{
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
jmethodID m = env->GetStaticMethodID(activity, "setSecondaryEnabled", "(Z)V");
|
||||||
|
if (m)
|
||||||
|
env->CallStaticVoidMethod(activity, m, on ? JNI_TRUE : JNI_FALSE);
|
||||||
|
else
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
}
|
||||||
|
|
||||||
#endif // LOVE_ANDROID
|
#endif // LOVE_ANDROID
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ bool showFilePicker(const char *destFilename = nullptr);
|
|||||||
**/
|
**/
|
||||||
bool showCreateDocument(const char *suggestedName = nullptr);
|
bool showCreateDocument(const char *suggestedName = nullptr);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pokéwalker step bridge: asks GameActivity to read the hardware step
|
||||||
|
* counter and stage steps_pending.json in the save identity dir (see
|
||||||
|
* GameActivity.syncHealthSteps). Returns whether a sync could start.
|
||||||
|
*/
|
||||||
|
bool syncHealthSteps();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full process relaunch (GameActivity.restartApp): schedules the app's
|
||||||
|
* launch intent and kills the process, because the in-process
|
||||||
|
* quit("restart") loop double-inits physfs and crashes (#575). On success
|
||||||
|
* the process dies inside the Java call and this never returns; false
|
||||||
|
* means the relaunch could not be scheduled.
|
||||||
|
**/
|
||||||
|
bool restartApp();
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Helper functions for the filesystem module
|
* Helper functions for the filesystem module
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -212,6 +212,24 @@ bool System::createFile(const char *suggestedName) const
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool System::syncHealthSteps() const
|
||||||
|
{
|
||||||
|
#ifdef LOVE_ANDROID
|
||||||
|
return love::android::syncHealthSteps();
|
||||||
|
#else
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool System::restartApp() const
|
||||||
|
{
|
||||||
|
#ifdef LOVE_ANDROID
|
||||||
|
return love::android::restartApp();
|
||||||
|
#else
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
bool System::hasBackgroundMusic() const
|
bool System::hasBackgroundMusic() const
|
||||||
{
|
{
|
||||||
#if defined(LOVE_ANDROID)
|
#if defined(LOVE_ANDROID)
|
||||||
|
|||||||
@@ -127,6 +127,20 @@ public:
|
|||||||
**/
|
**/
|
||||||
virtual bool createFile(const char *suggestedName = nullptr) const;
|
virtual bool createFile(const char *suggestedName = nullptr) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pokéwalker: stage pending real-world steps (steps_pending.json in the
|
||||||
|
* save dir) from the platform step source. Android-only; false elsewhere.
|
||||||
|
*/
|
||||||
|
virtual bool syncHealthSteps() const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relaunches the whole app with a fresh process (Android only; false
|
||||||
|
* elsewhere). The in-process love.event.quit("restart") double-inits
|
||||||
|
* physfs on Android and crashes, so src/core/HostShell.lua calls this
|
||||||
|
* instead (#575). Does not return on success -- the process exits.
|
||||||
|
**/
|
||||||
|
virtual bool restartApp() 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.
|
||||||
|
|||||||
@@ -109,6 +109,20 @@ int w_createFile(lua_State *L)
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int w_syncHealthSteps(lua_State *L)
|
||||||
|
{
|
||||||
|
luax_pushboolean(L, instance()->syncHealthSteps());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int w_restartApp(lua_State *L)
|
||||||
|
{
|
||||||
|
// Does not return on success: GameActivity.restartApp exits the process
|
||||||
|
// after scheduling the relaunch (#575).
|
||||||
|
luax_pushboolean(L, instance()->restartApp());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
int w_hasBackgroundMusic(lua_State *L)
|
int w_hasBackgroundMusic(lua_State *L)
|
||||||
{
|
{
|
||||||
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
||||||
@@ -126,6 +140,8 @@ static const luaL_Reg functions[] =
|
|||||||
{ "vibrate", w_vibrate },
|
{ "vibrate", w_vibrate },
|
||||||
{ "pickFile", w_pickFile },
|
{ "pickFile", w_pickFile },
|
||||||
{ "createFile", w_createFile },
|
{ "createFile", w_createFile },
|
||||||
|
{ "syncHealthSteps", w_syncHealthSteps },
|
||||||
|
{ "restartApp", w_restartApp },
|
||||||
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
||||||
{ 0, 0 }
|
{ 0, 0 }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,16 +37,26 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import android.Manifest;
|
import android.Manifest;
|
||||||
|
import android.app.AlarmManager;
|
||||||
import android.app.AlertDialog;
|
import android.app.AlertDialog;
|
||||||
|
import android.app.PendingIntent;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.DialogInterface;
|
import android.content.DialogInterface;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.content.pm.ActivityInfo;
|
||||||
import android.content.pm.ApplicationInfo;
|
import android.content.pm.ApplicationInfo;
|
||||||
import android.content.res.AssetManager;
|
import android.content.res.AssetManager;
|
||||||
|
import android.hardware.Sensor;
|
||||||
|
import android.hardware.SensorEvent;
|
||||||
|
import android.hardware.SensorEventListener;
|
||||||
|
import android.hardware.SensorManager;
|
||||||
import android.media.AudioManager;
|
import android.media.AudioManager;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Environment;
|
import android.os.Environment;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
import android.os.Vibrator;
|
import android.os.Vibrator;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.util.DisplayMetrics;
|
import android.util.DisplayMetrics;
|
||||||
@@ -66,6 +76,8 @@ public class GameActivity extends SDLActivity {
|
|||||||
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
|
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
|
||||||
public static final int FILE_PICKER_REQUEST_CODE = 4;
|
public static final int FILE_PICKER_REQUEST_CODE = 4;
|
||||||
public static final int FILE_CREATE_REQUEST_CODE = 5;
|
public static final int FILE_CREATE_REQUEST_CODE = 5;
|
||||||
|
public static final int STEP_PERMISSION_REQUEST_CODE = 6;
|
||||||
|
public static final int RESTART_REQUEST_CODE = 7;
|
||||||
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
|
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
|
||||||
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
|
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
|
||||||
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
|
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
|
||||||
@@ -78,8 +90,31 @@ public class GameActivity extends SDLActivity {
|
|||||||
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
|
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
|
||||||
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
|
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
|
||||||
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
|
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
|
||||||
|
// Written when a SAF pick cannot be read at all, with the destination
|
||||||
|
// basename as its body, so RomImporter:focus can say so in the launcher
|
||||||
|
// instead of leaving the player on "No ROM imported" (issue #442).
|
||||||
|
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
|
||||||
|
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
|
||||||
|
// consumed by the Pokéwalker mod, same contract as the iOS
|
||||||
|
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
|
||||||
|
// (cumulative since boot, counted by the OS whether or not any app is
|
||||||
|
// running), anchored in SharedPreferences so a walk is never credited
|
||||||
|
// twice.
|
||||||
|
private static final String PENDING_STEPS_FILENAME = "steps_pending.json";
|
||||||
|
private static final String STEP_PREFS = "pokewalker_steps";
|
||||||
|
private static final String STEP_PREF_ANCHOR = "anchor";
|
||||||
|
private static final String STEP_PREF_ANCHOR_WALLTIME = "anchor_walltime";
|
||||||
|
private static final long STEP_MAX_PER_SYNC = 50000;
|
||||||
// Destination basename for the in-flight SAF pick (set by showFilePicker).
|
// Destination basename for the in-flight SAF pick (set by showFilePicker).
|
||||||
|
// Saved/restored across instance state: the picker is a separate activity
|
||||||
|
// and Android may destroy this one while it is up (memory pressure, or
|
||||||
|
// "Don't keep activities"). A recreated instance still receives
|
||||||
|
// onActivityResult, so without this a mod or save pick came back with the
|
||||||
|
// field reset and was filed as picked_rom.gb, which Lua then rejected as a
|
||||||
|
// bad ROM instead of installing it (#553).
|
||||||
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
||||||
|
private static final String STATE_PENDING_PICK = "pendingPickFilename";
|
||||||
|
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
|
||||||
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
||||||
private String pendingCreateSuggestedName = "export.sav";
|
private String pendingCreateSuggestedName = "export.sav";
|
||||||
private static boolean immersiveActive = false;
|
private static boolean immersiveActive = false;
|
||||||
@@ -144,6 +179,14 @@ public class GameActivity extends SDLActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
if (savedInstanceState != null) {
|
||||||
|
// Restore the in-flight SAF destinations, so a pick that returns to
|
||||||
|
// a recreated activity still lands under the basename it asked for.
|
||||||
|
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
|
||||||
|
if (pick != null) pendingPickFilename = pick;
|
||||||
|
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
|
||||||
|
if (create != null) pendingCreateSuggestedName = create;
|
||||||
|
}
|
||||||
metrics = getResources().getDisplayMetrics();
|
metrics = getResources().getDisplayMetrics();
|
||||||
|
|
||||||
// Set low-latency audio values
|
// Set low-latency audio values
|
||||||
@@ -291,12 +334,62 @@ public class GameActivity extends SDLActivity {
|
|||||||
Log.d("GameActivity", "Cancelling vibration");
|
Log.d("GameActivity", "Cancelling vibration");
|
||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
}
|
}
|
||||||
|
teardownSecondaryDisplay();
|
||||||
super.onPause();
|
super.onPause();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onResume() {
|
public void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
|
setupSecondaryDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDL decides the activity's requested orientation at window creation
|
||||||
|
* (SDLActivity.setOrientationBis). With a resizable window and no
|
||||||
|
* SDL_HINT_ORIENTATIONS -- exactly what conf.lua produces on Android --
|
||||||
|
* it asks for SCREEN_ORIENTATION_FULL_SENSOR, and that request overrides
|
||||||
|
* the android:screenOrientation="fullUser" set in the manifest. The
|
||||||
|
* *_SENSOR constants follow the accelerometer even when the player has
|
||||||
|
* turned auto-rotate off, so the game kept rotating on a device whose
|
||||||
|
* rotation was locked.
|
||||||
|
*
|
||||||
|
* Remap SDL's choice onto the matching *_USER constant, which allows the
|
||||||
|
* same orientations but defers to the system rotation setting. Applied
|
||||||
|
* after super so SDL keeps deciding *which* orientations the window may
|
||||||
|
* take; this only changes who breaks the tie, the sensor or the player.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void setOrientationBis(int w, int h, boolean resizable, String hint) {
|
||||||
|
super.setOrientationBis(w, h, resizable, hint);
|
||||||
|
|
||||||
|
// The *_USER constants only exist from API 18; below that the sensor
|
||||||
|
// ones are all there is, so leave SDL's request alone.
|
||||||
|
if (android.os.Build.VERSION.SDK_INT < 18) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int requested = getRequestedOrientation();
|
||||||
|
int userRequested;
|
||||||
|
switch (requested) {
|
||||||
|
case ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR:
|
||||||
|
userRequested = ActivityInfo.SCREEN_ORIENTATION_FULL_USER;
|
||||||
|
break;
|
||||||
|
case ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE:
|
||||||
|
userRequested = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE;
|
||||||
|
break;
|
||||||
|
case ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT:
|
||||||
|
userRequested = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// SENSOR / plain LANDSCAPE / PORTRAIT etc: either already
|
||||||
|
// explicit or never produced by setOrientationBis.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d("GameActivity", "requestedOrientation " + requested + " -> " + userRequested
|
||||||
|
+ " (honour the device rotation lock)");
|
||||||
|
setRequestedOrientation(userRequested);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
@@ -357,15 +450,23 @@ public class GameActivity extends SDLActivity {
|
|||||||
* Shows the system document picker (Storage Access Framework) so the
|
* Shows the system document picker (Storage Access Framework) so the
|
||||||
* player can pick a ROM / mod / save from anywhere (Downloads, Drive,
|
* player can pick a ROM / mod / save from anywhere (Downloads, Drive,
|
||||||
* etc.) without needing to know where the app's external files folder
|
* etc.) without needing to know where the app's external files folder
|
||||||
* is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
|
* is. The picked file (if any) arrives later in onActivityResult, not
|
||||||
* arrives later in onActivityResult, not synchronously here.
|
* synchronously here.
|
||||||
|
*
|
||||||
|
* API 21+ uses ACTION_OPEN_DOCUMENT; API 16-20 uses an ACTION_GET_CONTENT
|
||||||
|
* chooser instead. Below 19 OPEN_DOCUMENT does not exist, and on 19/20
|
||||||
|
* the stock DocumentsUI is unreliable -- it launches and then hands back
|
||||||
|
* RESULT_CANCELED with no data, which onActivityResult cannot tell apart
|
||||||
|
* from the player cancelling (#584). GET_CONTENT lets any installed file
|
||||||
|
* manager serve the pick, and both intents return the same content:// or
|
||||||
|
* file:// URI shapes, so the result path in onActivityResult stays
|
||||||
|
* picker-agnostic and unchanged.
|
||||||
*
|
*
|
||||||
* @param destFilename basename under the app save identity (e.g.
|
* @param destFilename basename under the app save identity (e.g.
|
||||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||||
*/
|
*/
|
||||||
@Keep
|
@Keep
|
||||||
public static boolean showFilePicker(String destFilename) {
|
public static boolean showFilePicker(String destFilename) {
|
||||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null) return false;
|
if (self == null) return false;
|
||||||
if (destFilename == null || destFilename.length() == 0) {
|
if (destFilename == null || destFilename.length() == 0) {
|
||||||
@@ -379,11 +480,26 @@ public class GameActivity extends SDLActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.pendingPickFilename = destFilename;
|
self.pendingPickFilename = destFilename;
|
||||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
if (android.os.Build.VERSION.SDK_INT >= 21) {
|
||||||
|
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||||
|
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||||
|
intent.setType("*/*");
|
||||||
|
try {
|
||||||
|
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
||||||
|
return true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Some OEM / TV builds ship without DocumentsUI; fall through
|
||||||
|
// to the GET_CONTENT chooser below instead of giving up (#584).
|
||||||
|
Log.d("GameActivity", "could not open document picker: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||||
intent.setType("*/*");
|
intent.setType("*/*");
|
||||||
try {
|
try {
|
||||||
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
self.startActivityForResult(
|
||||||
|
Intent.createChooser(intent, "Choose a file"),
|
||||||
|
FILE_PICKER_REQUEST_CODE);
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.d("GameActivity", "could not open file picker: " + e.getMessage());
|
Log.d("GameActivity", "could not open file picker: " + e.getMessage());
|
||||||
@@ -409,14 +525,60 @@ public class GameActivity extends SDLActivity {
|
|||||||
return showFilePicker(PICKED_SAVE_FILENAME);
|
return showFilePicker(PICKED_SAVE_FILENAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relaunches the whole app for love.system.restartApp, used by
|
||||||
|
* src/core/HostShell.lua when a mod toggle needs a cold boot (#575).
|
||||||
|
* love.event.quit("restart") re-runs LOVE's boot inside the same
|
||||||
|
* process, and the second love.filesystem.init throws once physfs
|
||||||
|
* failed to deinit ("already initialized"), killing the app. Instead
|
||||||
|
* we hand our launch intent to AlarmManager and then exit the process:
|
||||||
|
* the alarm lives in system_server, so it survives our death and
|
||||||
|
* cannot race the exit the way a plain startActivity right before
|
||||||
|
* Runtime.exit can on some OEMs, and the dead process guarantees no
|
||||||
|
* native (physfs / SDL / JNI) state leaks into the fresh run.
|
||||||
|
*/
|
||||||
|
@Keep
|
||||||
|
public static boolean restartApp() {
|
||||||
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
|
if (self == null) return false;
|
||||||
|
try {
|
||||||
|
Context context = self.getApplicationContext();
|
||||||
|
Intent intent = context.getPackageManager()
|
||||||
|
.getLaunchIntentForPackage(context.getPackageName());
|
||||||
|
if (intent == null) return false;
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
int pendingFlags = PendingIntent.FLAG_CANCEL_CURRENT;
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 23) {
|
||||||
|
// Mandatory mutability flag on API 31+; harmless from 23 up.
|
||||||
|
pendingFlags |= PendingIntent.FLAG_IMMUTABLE;
|
||||||
|
}
|
||||||
|
PendingIntent pending = PendingIntent.getActivity(
|
||||||
|
context, RESTART_REQUEST_CODE, intent, pendingFlags);
|
||||||
|
AlarmManager alarm = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
|
||||||
|
if (alarm == null) return false;
|
||||||
|
alarm.set(AlarmManager.RTC, System.currentTimeMillis() + 250, pending);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "could not schedule restart: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Runtime.getRuntime().exit(0);
|
||||||
|
return true; // unreachable, but keeps the JNI signature honest
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
||||||
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
||||||
* etc. Suggested name is the dialog's default filename.
|
* etc. Suggested name is the dialog's default filename.
|
||||||
|
*
|
||||||
|
* CREATE_DOCUMENT does not exist below API 19 and has no pre-SAF
|
||||||
|
* equivalent, so unlike showFilePicker this stays 19+ (#584); the false
|
||||||
|
* return degrades on the Lua side (RomImporter export) to "Exported
|
||||||
|
* inside the app folder", which is the correct pre-KitKat behavior.
|
||||||
*/
|
*/
|
||||||
@Keep
|
@Keep
|
||||||
public static boolean showCreateDocument(String suggestedName) {
|
public static boolean showCreateDocument(String suggestedName) {
|
||||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||||
|
// (see showFilePicker for why the import side got a pre-19 path)
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null) return false;
|
if (self == null) return false;
|
||||||
if (suggestedName == null || suggestedName.length() == 0) {
|
if (suggestedName == null || suggestedName.length() == 0) {
|
||||||
@@ -452,6 +614,171 @@ public class GameActivity extends SDLActivity {
|
|||||||
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
||||||
|
private void writeSaveDirFlag(String name, String body) {
|
||||||
|
try {
|
||||||
|
FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false);
|
||||||
|
fos.write(body.getBytes());
|
||||||
|
fos.close();
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step sync, called from Lua as love.system.syncHealthSteps()
|
||||||
|
* (see modules/system/wrap_System.cpp). Asynchronous like the picker:
|
||||||
|
* returns whether a sync could be started; the result lands later as
|
||||||
|
* steps_pending.json in the save identity dir, where the Pokéwalker
|
||||||
|
* mod's poll consumes it.
|
||||||
|
*
|
||||||
|
* Android 10+ gates the step counter behind the ACTIVITY_RECOGNITION
|
||||||
|
* runtime permission; the first call shows the system prompt and a later
|
||||||
|
* sync (the mod retries on save load / option change) delivers.
|
||||||
|
*/
|
||||||
|
@Keep
|
||||||
|
public static boolean syncHealthSteps() {
|
||||||
|
final GameActivity self = (GameActivity) mSingleton;
|
||||||
|
if (self == null) return false;
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 29
|
||||||
|
&& ActivityCompat.checkSelfPermission(self,
|
||||||
|
Manifest.permission.ACTIVITY_RECOGNITION)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
self.runOnUiThread(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
ActivityCompat.requestPermissions(self,
|
||||||
|
new String[]{Manifest.permission.ACTIVITY_RECOGNITION},
|
||||||
|
STEP_PERMISSION_REQUEST_CODE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
self.startStepSensorRead();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot read of the cumulative hardware step counter. The sensor
|
||||||
|
* usually reports its cached value moments after registration; some
|
||||||
|
* devices hold the event until the next physical step, so the listener
|
||||||
|
* is given 20 seconds before being torn down (the next sync retries).
|
||||||
|
*/
|
||||||
|
private void startStepSensorRead() {
|
||||||
|
final SensorManager manager =
|
||||||
|
(SensorManager) getSystemService(Context.SENSOR_SERVICE);
|
||||||
|
if (manager == null) return;
|
||||||
|
Sensor counter = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
|
||||||
|
if (counter == null) {
|
||||||
|
Log.d("GameActivity", "no step counter sensor on this device");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final SensorEventListener listener = new SensorEventListener() {
|
||||||
|
private boolean delivered = false;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSensorChanged(SensorEvent event) {
|
||||||
|
if (delivered || event.values.length == 0) return;
|
||||||
|
delivered = true;
|
||||||
|
manager.unregisterListener(this);
|
||||||
|
deliverSteps((long) event.values[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onAccuracyChanged(Sensor sensor, int accuracy) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!manager.registerListener(listener, counter,
|
||||||
|
SensorManager.SENSOR_DELAY_NORMAL)) {
|
||||||
|
Log.d("GameActivity", "step counter listener registration failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
// No-op if the listener already delivered and unregistered.
|
||||||
|
manager.unregisterListener(listener);
|
||||||
|
}
|
||||||
|
}, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a cumulative counter reading into pending steps. The counter
|
||||||
|
* resets to zero on reboot: a reading below the stored anchor re-anchors
|
||||||
|
* without crediting (steps walked between the reboot and this sync are
|
||||||
|
* lost, which errs on the honest side).
|
||||||
|
*/
|
||||||
|
private void deliverSteps(long counterNow) {
|
||||||
|
SharedPreferences prefs = getSharedPreferences(STEP_PREFS, MODE_PRIVATE);
|
||||||
|
long anchor = prefs.getLong(STEP_PREF_ANCHOR, -1);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (anchor < 0 || counterNow < anchor) {
|
||||||
|
prefs.edit()
|
||||||
|
.putLong(STEP_PREF_ANCHOR, counterNow)
|
||||||
|
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
|
||||||
|
.apply();
|
||||||
|
Log.d("GameActivity", "step anchor set at " + counterNow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long steps = Math.min(counterNow - anchor, STEP_MAX_PER_SYNC);
|
||||||
|
long fromWalltime = prefs.getLong(STEP_PREF_ANCHOR_WALLTIME, now);
|
||||||
|
if (steps <= 0) return;
|
||||||
|
prefs.edit()
|
||||||
|
.putLong(STEP_PREF_ANCHOR, counterNow)
|
||||||
|
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
|
||||||
|
.apply();
|
||||||
|
|
||||||
|
File dir = saveIdentityDir();
|
||||||
|
if (!dir.isDirectory() && !dir.mkdirs()) {
|
||||||
|
Log.d("GameActivity", "cannot create save dir for steps: " + dir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
File pending = new File(dir, PENDING_STEPS_FILENAME);
|
||||||
|
long total = steps;
|
||||||
|
// Merge with an unconsumed earlier delivery so steps are never lost
|
||||||
|
// (same contract as the iOS bridge).
|
||||||
|
if (pending.isFile()) {
|
||||||
|
try {
|
||||||
|
byte[] raw = new byte[(int) Math.min(pending.length(), 4096)];
|
||||||
|
FileInputStream in = new FileInputStream(pending);
|
||||||
|
int read = in.read(raw);
|
||||||
|
in.close();
|
||||||
|
if (read > 0) {
|
||||||
|
org.json.JSONObject old =
|
||||||
|
new org.json.JSONObject(new String(raw, 0, read, "UTF-8"));
|
||||||
|
total += Math.max(0, old.optLong("steps", 0));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "ignoring unreadable pending steps: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
org.json.JSONObject payload = new org.json.JSONObject();
|
||||||
|
payload.put("steps", total);
|
||||||
|
payload.put("from", isoTime(fromWalltime));
|
||||||
|
payload.put("to", isoTime(now));
|
||||||
|
File tmp = new File(dir, PENDING_STEPS_FILENAME + ".tmp");
|
||||||
|
FileOutputStream out = new FileOutputStream(tmp);
|
||||||
|
out.write(payload.toString().getBytes("UTF-8"));
|
||||||
|
out.close();
|
||||||
|
if (!tmp.renameTo(pending)) {
|
||||||
|
tmp.delete();
|
||||||
|
Log.d("GameActivity", "could not publish pending steps");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.d("GameActivity", total + " steps pending for the Pokewalker mod");
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "could not write pending steps: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String isoTime(long millis) {
|
||||||
|
java.text.SimpleDateFormat format =
|
||||||
|
new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US);
|
||||||
|
format.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
|
||||||
|
return format.format(new java.util.Date(millis));
|
||||||
|
}
|
||||||
|
|
||||||
private boolean copyFileToUri(File source, Uri destUri) {
|
private boolean copyFileToUri(File source, Uri destUri) {
|
||||||
InputStream in = null;
|
InputStream in = null;
|
||||||
OutputStream out = null;
|
OutputStream out = null;
|
||||||
@@ -475,6 +802,13 @@ public class GameActivity extends SDLActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onSaveInstanceState(Bundle outState) {
|
||||||
|
super.onSaveInstanceState(outState);
|
||||||
|
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
|
||||||
|
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||||
super.onActivityResult(requestCode, resultCode, data);
|
super.onActivityResult(requestCode, resultCode, data);
|
||||||
@@ -491,14 +825,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
Uri uri = data.getData();
|
Uri uri = data.getData();
|
||||||
if (copyFileToUri(source, uri)) {
|
if (copyFileToUri(source, uri)) {
|
||||||
// Signal Lua on next focus that the SAF export finished.
|
// Signal Lua on next focus that the SAF export finished.
|
||||||
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
|
writeSaveDirFlag(EXPORT_DONE_FILENAME, "ok");
|
||||||
try {
|
|
||||||
FileOutputStream fos = new FileOutputStream(flag, false);
|
|
||||||
fos.write("ok".getBytes());
|
|
||||||
fos.close();
|
|
||||||
} catch (IOException e) {
|
|
||||||
Log.d("GameActivity", "could not write export_done flag: " + e.getMessage());
|
|
||||||
}
|
|
||||||
// Keep pending_export.sav so a retry still works; Lua may remove it.
|
// Keep pending_export.sav so a retry still works; Lua may remove it.
|
||||||
} else {
|
} else {
|
||||||
Log.d("GameActivity", "could not write export to " + uri);
|
Log.d("GameActivity", "could not write export to " + uri);
|
||||||
@@ -521,19 +848,36 @@ public class GameActivity extends SDLActivity {
|
|||||||
? pendingPickFilename : PICKED_ROM_FILENAME;
|
? pendingPickFilename : PICKED_ROM_FILENAME;
|
||||||
File destFile = new File(destDir, destName);
|
File destFile = new File(destDir, destName);
|
||||||
|
|
||||||
InputStream source;
|
// ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
|
||||||
|
// some OEM shells (ColorOS) offer third-party file managers in a
|
||||||
|
// chooser, and those hand back either a provider URI this app has no
|
||||||
|
// grant for (SecurityException / FileNotFoundException) or a bare
|
||||||
|
// file:// path (unreadable without storage permission on targetSdk 34).
|
||||||
|
// Try the resolver, then the path, then tell Lua why nothing imported.
|
||||||
|
InputStream source = null;
|
||||||
try {
|
try {
|
||||||
source = getContentResolver().openInputStream(uri);
|
source = getContentResolver().openInputStream(uri);
|
||||||
} catch (FileNotFoundException e) {
|
} catch (Exception e) {
|
||||||
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
|
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
|
||||||
return;
|
}
|
||||||
|
if (source == null && "file".equals(uri.getScheme()) && uri.getPath() != null) {
|
||||||
|
try {
|
||||||
|
source = new FileInputStream(uri.getPath());
|
||||||
|
} catch (FileNotFoundException e) {
|
||||||
|
Log.d("GameActivity", "could not open picked path: " + e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (source == null) {
|
if (source == null) {
|
||||||
Log.d("GameActivity", "ContentResolver returned no stream for picked file");
|
Log.d("GameActivity", "no readable stream for picked file " + uri);
|
||||||
|
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!copyAssetFile(source, destFile.getPath())) {
|
if (!copyAssetFile(source, destFile.getPath())) {
|
||||||
Log.d("GameActivity", "could not copy picked file to " + destFile);
|
Log.d("GameActivity", "could not copy picked file to " + destFile);
|
||||||
|
// A truncated pick would only fail verification later, so drop it
|
||||||
|
// and report instead.
|
||||||
|
destFile.delete();
|
||||||
|
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,6 +1009,17 @@ public class GameActivity extends SDLActivity {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case STEP_PERMISSION_REQUEST_CODE: {
|
||||||
|
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
Log.d("GameActivity", "Step permission granted");
|
||||||
|
// Deliver right away so the sync the player just
|
||||||
|
// opted into doesn't wait for the next launch.
|
||||||
|
startStepSensorRead();
|
||||||
|
} else {
|
||||||
|
Log.d("GameActivity", "Did not get step permission.");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||||
}
|
}
|
||||||
@@ -847,4 +1202,182 @@ public class GameActivity extends SDLActivity {
|
|||||||
return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
|
return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
|
||||||
|
// physical display. Driven from the engine through love_android_secondary_*
|
||||||
|
// in src/jni/love/src/common/android.cpp.
|
||||||
|
private static volatile SecondaryPresentation secondaryPresentation;
|
||||||
|
private static volatile boolean secondaryEnabled = false;
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
public static void setSecondaryEnabled(final boolean on) {
|
||||||
|
secondaryEnabled = on;
|
||||||
|
final GameActivity self = (GameActivity) mSingleton;
|
||||||
|
if (self == null) return;
|
||||||
|
self.runOnUiThread(new Runnable() {
|
||||||
|
@Override public void run() {
|
||||||
|
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setupSecondaryDisplay() {
|
||||||
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
|
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
|
||||||
|
try {
|
||||||
|
android.hardware.display.DisplayManager dm =
|
||||||
|
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
|
||||||
|
if (dm == null) return;
|
||||||
|
Display chosen = null;
|
||||||
|
for (Display d : dm.getDisplays()) {
|
||||||
|
android.graphics.Point size = new android.graphics.Point();
|
||||||
|
d.getRealSize(size);
|
||||||
|
Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName()
|
||||||
|
+ " size=" + size.x + "x" + size.y);
|
||||||
|
if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) {
|
||||||
|
chosen = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chosen == null) {
|
||||||
|
Display[] pres =
|
||||||
|
dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
|
||||||
|
if (pres != null && pres.length > 0) chosen = pres[0];
|
||||||
|
}
|
||||||
|
if (chosen == null) {
|
||||||
|
Log.d("GameActivity", "no secondary display found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
|
||||||
|
p.show();
|
||||||
|
secondaryPresentation = p;
|
||||||
|
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.d("GameActivity", "secondary display setup failed: " + t);
|
||||||
|
secondaryPresentation = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void teardownSecondaryDisplay() {
|
||||||
|
SecondaryPresentation p = secondaryPresentation;
|
||||||
|
secondaryPresentation = null;
|
||||||
|
if (p != null) {
|
||||||
|
try { p.dismiss(); } catch (Throwable t) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
public static boolean hasSecondaryDisplay() {
|
||||||
|
return secondaryPresentation != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||||
|
SecondaryPresentation p = secondaryPresentation;
|
||||||
|
if (p != null && buf != null && w > 0 && h > 0) {
|
||||||
|
p.updateFrame(buf, w, h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class SecondaryPresentation extends android.app.Presentation {
|
||||||
|
private final FrameView frameView;
|
||||||
|
|
||||||
|
SecondaryPresentation(Context context, Display display) {
|
||||||
|
super(context, display);
|
||||||
|
frameView = new FrameView(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
android.view.Window w = getWindow();
|
||||||
|
if (w != null) {
|
||||||
|
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||||
|
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||||
|
WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||||
|
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
|
||||||
|
w.setLayout(WindowManager.LayoutParams.MATCH_PARENT,
|
||||||
|
WindowManager.LayoutParams.MATCH_PARENT);
|
||||||
|
}
|
||||||
|
setContentView(frameView);
|
||||||
|
applyImmersive();
|
||||||
|
frameView.post(new Runnable() {
|
||||||
|
@Override public void run() { applyImmersive(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onWindowFocusChanged(boolean hasFocus) {
|
||||||
|
super.onWindowFocusChanged(hasFocus);
|
||||||
|
if (hasFocus) applyImmersive();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyImmersive() {
|
||||||
|
android.view.Window w = getWindow();
|
||||||
|
if (w == null) return;
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 30) {
|
||||||
|
w.setDecorFitsSystemWindows(false);
|
||||||
|
android.view.WindowInsetsController c = w.getInsetsController();
|
||||||
|
if (c != null) {
|
||||||
|
c.hide(android.view.WindowInsets.Type.systemBars());
|
||||||
|
c.setSystemBarsBehavior(
|
||||||
|
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
w.getDecorView().setSystemUiVisibility(
|
||||||
|
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||||
|
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||||
|
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||||
|
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||||
|
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||||
|
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||||
|
frameView.updateFrame(buf, w, h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class FrameView extends View {
|
||||||
|
private android.graphics.Bitmap bitmap;
|
||||||
|
private final android.graphics.Rect dst = new android.graphics.Rect();
|
||||||
|
private final android.graphics.Paint paint = new android.graphics.Paint();
|
||||||
|
private final Object lock = new Object();
|
||||||
|
private int fw, fh;
|
||||||
|
|
||||||
|
FrameView(Context context) {
|
||||||
|
super(context);
|
||||||
|
paint.setFilterBitmap(false);
|
||||||
|
paint.setAntiAlias(false);
|
||||||
|
setBackgroundColor(0xFF000000);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||||
|
synchronized (lock) {
|
||||||
|
if (bitmap == null || fw != w || fh != h) {
|
||||||
|
if (bitmap != null) bitmap.recycle();
|
||||||
|
bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888);
|
||||||
|
fw = w; fh = h;
|
||||||
|
}
|
||||||
|
buf.rewind();
|
||||||
|
bitmap.copyPixelsFromBuffer(buf);
|
||||||
|
}
|
||||||
|
postInvalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(android.graphics.Canvas canvas) {
|
||||||
|
synchronized (lock) {
|
||||||
|
if (bitmap == null || fw == 0 || fh == 0) return;
|
||||||
|
int vw = getWidth(), vh = getHeight();
|
||||||
|
int s = Math.min(vw / fw, vh / fh);
|
||||||
|
if (s < 1) s = 1;
|
||||||
|
int dw = fw * s, dh = fh * s;
|
||||||
|
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
|
||||||
|
dst.set(dx, dy, dx + dw, dy + dh);
|
||||||
|
canvas.drawColor(0xFF000000);
|
||||||
|
canvas.drawBitmap(bitmap, null, dst, paint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
11.5
|
12.0
|
||||||
|
|||||||
+36
-37
@@ -1,19 +1,36 @@
|
|||||||
# iOS build (LÖVE 11.5)
|
# iOS build (LÖVE 12.0)
|
||||||
|
|
||||||
macOS + Xcode only. Pins the official **LÖVE 11.5** iOS Xcode tree
|
> **Native ROM/mod/save import.** The iOS build ships a Swift
|
||||||
(`love-11.5-ios-source.zip` from [love2d/love releases](https://github.com/love2d/love/releases/tag/11.5)),
|
> document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`)
|
||||||
matching `conf.lua`'s `t.version = "11.5"`.
|
> that `patch_love_src.py` wires into the LÖVE tree on every build:
|
||||||
|
>
|
||||||
|
> - `love.system.pickFile("rom"|"mod"|"sav")` and `love.system.createFile`
|
||||||
|
> are exposed to Lua on iOS (same contract as love-android's SAF picker:
|
||||||
|
> picks land in the save dir as `picked_rom.gb` / `picked_mod.zip` /
|
||||||
|
> `picked_save.sav`; exports signal via `export_done.flag`).
|
||||||
|
> - The Info.plist overlay enables `UIFileSharingEnabled` +
|
||||||
|
> `LSSupportsOpeningDocumentsInPlace`, and `GRBootstrap.m` sweeps
|
||||||
|
> `.gb/.gbc/.zip/.sav` files dropped in Documents (Files app / Finder)
|
||||||
|
> into the LÖVE save dir on every activation — drop a ROM, open the app,
|
||||||
|
> and it imports with no taps.
|
||||||
|
> - `src/import/RomImporter.lua` treats iOS as a mobile platform and polls
|
||||||
|
> for picker results (iOS pickers are in-process modals, so Android's
|
||||||
|
> refocus rescan never fires).
|
||||||
|
>
|
||||||
|
> The note below about a missing "UIDocumentPicker handoff" is
|
||||||
|
> resolved by this bridge.
|
||||||
|
|
||||||
There is no separate `love2d/love-ios` GitHub repo for 11.5; the release zip
|
macOS + Xcode only. Fetches the **LÖVE 12.0** source tree and matching Apple
|
||||||
**is** the vendored iOS project (Xcode project under
|
dependencies from the official [LÖVE source](https://github.com/love2d/love)
|
||||||
`love-src/platform/xcode/love.xcodeproj`, target `love-ios`).
|
and [Apple dependencies](https://github.com/love2d/love-apple-dependencies)
|
||||||
|
repositories. `conf.lua` declares LÖVE 12.0 on iOS and 11.5 elsewhere.
|
||||||
|
|
||||||
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `11.5`.
|
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `12.0`.
|
||||||
|
|
||||||
## Quick start (simulator)
|
## Quick start (simulator)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Fetch LÖVE 11.5 iOS sources (once) + build for Simulator
|
# Fetch LÖVE 12.0 iOS sources and dependencies (once) + build for Simulator
|
||||||
scripts/build_ios.sh --fetch
|
scripts/build_ios.sh --fetch
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -60,10 +77,10 @@ Manual out-of-band steps:
|
|||||||
|
|
||||||
| Path | Role |
|
| Path | Role |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `LOVE_VERSION` | Engine pin (`11.5`) |
|
| `LOVE_VERSION` | Engine pin (`12.0`) |
|
||||||
| `overlays/love-ios.plist` | Portrait-only Info.plist + display name **Pokemon Red** (copied over the upstream plist every build) |
|
| `overlays/love-ios.plist` | Portrait-only Info.plist + display name **Pokemon Red** (copied over the upstream plist every build) |
|
||||||
| `love-src/` | Downloaded `love-11.5-ios-source` tree (**gitignored**, do not commit) |
|
| `love-src/` | Downloaded LÖVE 12.0 source tree (**gitignored**, do not commit) |
|
||||||
| `cache/` | Downloaded zips (**gitignored**) |
|
| `cache/` | Temporary source and dependency checkout data (**gitignored**) |
|
||||||
| `build/` | `xcodebuild` derived data (**gitignored**) |
|
| `build/` | `xcodebuild` derived data (**gitignored**) |
|
||||||
|
|
||||||
Game payload lands at:
|
Game payload lands at:
|
||||||
@@ -74,28 +91,10 @@ and is fused into the built `.app` (LÖVE auto-runs any bundled `*.love`).
|
|||||||
|
|
||||||
## Apple libraries dependency
|
## Apple libraries dependency
|
||||||
|
|
||||||
The official `love-11.5-ios-source.zip` already ships prebuilt iOS
|
`scripts/build_ios.sh --fetch` retrieves the matching iOS libraries and the
|
||||||
xcframeworks under `platform/xcode/ios/libraries/` (SDL2, LuaJIT, freetype,
|
SDL3 framework from
|
||||||
ogg, vorbis, theora, modplug).
|
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies).
|
||||||
|
Re-run it if either dependency directory is absent.
|
||||||
If that folder is missing or incomplete (e.g. you cloned sources without
|
|
||||||
libs), download the matching prebuilts and install them:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fL -o mobile/ios/cache/love-11.5-apple-libraries.zip \
|
|
||||||
https://github.com/love2d/love/releases/download/11.5/love-11.5-apple-libraries.zip
|
|
||||||
unzip -q mobile/ios/cache/love-11.5-apple-libraries.zip -d mobile/ios/cache
|
|
||||||
rm -rf mobile/ios/love-src/platform/xcode/ios/libraries
|
|
||||||
cp -R mobile/ios/cache/love-apple-dependencies/iOS/libraries \
|
|
||||||
mobile/ios/love-src/platform/xcode/ios/libraries
|
|
||||||
```
|
|
||||||
|
|
||||||
`scripts/build_ios.sh` checks for `libraries/SDL2.xcframework` and fails with
|
|
||||||
these instructions if it is absent.
|
|
||||||
|
|
||||||
Upstream also documents
|
|
||||||
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies)
|
|
||||||
as an alternate source of the same libraries.
|
|
||||||
|
|
||||||
## App identity
|
## App identity
|
||||||
|
|
||||||
@@ -114,7 +113,7 @@ so refreshing `love-src/` does not lose branding.
|
|||||||
| Flag | Meaning |
|
| Flag | Meaning |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| *(default)* | Simulator, Debug, no signing |
|
| *(default)* | Simulator, Debug, no signing |
|
||||||
| `--fetch` | Download/extract `love-11.5-ios-source.zip` if `love-src/` is missing |
|
| `--fetch` | Fetch the LÖVE 12.0 source tree and Apple dependencies if `love-src/` is missing |
|
||||||
| `--device` | Build against `iphoneos` instead of `iphonesimulator` |
|
| `--device` | Build against `iphoneos` instead of `iphonesimulator` |
|
||||||
| `--release` | `Release` configuration instead of `Debug` |
|
| `--release` | `Release` configuration instead of `Debug` |
|
||||||
| `--package-only` | Zip `game.love` + apply plist overlay; skip `xcodebuild` |
|
| `--package-only` | Zip `game.love` + apply plist overlay; skip `xcodebuild` |
|
||||||
@@ -127,5 +126,5 @@ Also: `scripts/build.sh ios` delegates here (`--release` is forwarded).
|
|||||||
- iOS platform installed in Xcode (Settings → Platforms). `xcodebuild -showsdks`
|
- iOS platform installed in Xcode (Settings → Platforms). `xcodebuild -showsdks`
|
||||||
should list `iphonesimulator` / `iphoneos`. A partial install can fail IB/xib
|
should list `iphonesimulator` / `iphoneos`. A partial install can fail IB/xib
|
||||||
compiles with `iOS … Platform Not Installed` even when the SDK name appears.
|
compiles with `iOS … Platform Not Installed` even when the SDK name appears.
|
||||||
- `love-src/` present (`--fetch` or manual unzip of `love-11.5-ios-source.zip`)
|
- `love-src/` present (`--fetch`)
|
||||||
- iOS libraries under `love-src/platform/xcode/ios/libraries/` (see above)
|
- iOS libraries under `love-src/platform/xcode/ios/libraries/` and SDL3 under `love-src/platform/xcode/shared/Frameworks/`
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// gen1recomp iOS native bridge bootstrap.
|
||||||
|
//
|
||||||
|
// Runs the Files-app inbox sweep (GRPickerBridge.sweepInbox) every time the
|
||||||
|
// app becomes active, so ROMs/mods/saves dropped into the app's Documents
|
||||||
|
// folder land in the LÖVE save directory before the Lua importer rescans.
|
||||||
|
// Registered from a constructor so no LÖVE/SDL source needs to know about it.
|
||||||
|
|
||||||
|
#import <UIKit/UIKit.h>
|
||||||
|
|
||||||
|
__attribute__((constructor))
|
||||||
|
static void GRBootstrapInstall(void)
|
||||||
|
{
|
||||||
|
[[NSNotificationCenter defaultCenter]
|
||||||
|
addObserverForName:UIApplicationDidBecomeActiveNotification
|
||||||
|
object:nil
|
||||||
|
queue:[NSOperationQueue mainQueue]
|
||||||
|
usingBlock:^(NSNotification *note) {
|
||||||
|
Class bridge = NSClassFromString(@"GRPickerBridge");
|
||||||
|
if ([bridge respondsToSelector:@selector(sweepInbox)]) {
|
||||||
|
[bridge performSelector:@selector(sweepInbox)];
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// gen1recomp iOS native bridge: Apple Health step sync (Pokéwalker mode).
|
||||||
|
//
|
||||||
|
// Reached from liblove's wrap_System.cpp through the Objective-C runtime as
|
||||||
|
// love.system.syncHealthSteps() — see mobile/ios/patch_love_src.py. The Lua
|
||||||
|
// side (mods/pokewalker) opts in, calls sync, and later consumes
|
||||||
|
// steps_pending.json from the LÖVE save directory:
|
||||||
|
//
|
||||||
|
// { "steps": 4312, "from": "<ISO8601>", "to": "<ISO8601>" }
|
||||||
|
//
|
||||||
|
// Steps are counted from a persisted anchor (last successful sync; first
|
||||||
|
// run starts at midnight today) so the same walk is never credited twice.
|
||||||
|
// An unconsumed pending file is merged, not overwritten, so steps survive
|
||||||
|
// the player quitting before entering the overworld.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import HealthKit
|
||||||
|
|
||||||
|
@objc(GRHealthBridge)
|
||||||
|
public final class GRHealthBridge: NSObject {
|
||||||
|
|
||||||
|
private static let store = HKHealthStore()
|
||||||
|
private static let anchorKey = "GRHealthLastSyncDate"
|
||||||
|
private static let pendingName = "steps_pending.json"
|
||||||
|
// Sanity clamp per sync: guards against absurd backlogs (device clock
|
||||||
|
// changes, months-old anchors) turning into instant level-100 parties.
|
||||||
|
private static let maxStepsPerSync = 50_000
|
||||||
|
|
||||||
|
/// love.system.syncHealthSteps() -> bool ("sync started").
|
||||||
|
/// Requests read authorization on first use (system sheet appears over
|
||||||
|
/// the game), then asynchronously writes the pending-steps file.
|
||||||
|
@objc(syncStepsWithCommand:saveDir:)
|
||||||
|
public static func syncSteps(command: UnsafePointer<CChar>?,
|
||||||
|
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||||
|
guard HKHealthStore.isHealthDataAvailable(),
|
||||||
|
let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)
|
||||||
|
else { return false }
|
||||||
|
let dir = saveDir.map { String(cString: $0) } ?? ""
|
||||||
|
guard !dir.isEmpty else { return false }
|
||||||
|
|
||||||
|
store.requestAuthorization(toShare: nil, read: [stepType]) { _, error in
|
||||||
|
if let error = error {
|
||||||
|
NSLog("GRHealthBridge: authorization error: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Read authorization is intentionally opaque (a denied query
|
||||||
|
// just returns no samples), so always run the query.
|
||||||
|
runQuery(stepType: stepType, dir: dir)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Query
|
||||||
|
|
||||||
|
private static func runQuery(stepType: HKQuantityType, dir: String) {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let now = Date()
|
||||||
|
let anchor = (defaults.object(forKey: anchorKey) as? Date)
|
||||||
|
?? Calendar.current.startOfDay(for: now)
|
||||||
|
guard anchor < now else { return }
|
||||||
|
|
||||||
|
let predicate = HKQuery.predicateForSamples(withStart: anchor, end: now,
|
||||||
|
options: .strictStartDate)
|
||||||
|
let query = HKStatisticsQuery(quantityType: stepType,
|
||||||
|
quantitySamplePredicate: predicate,
|
||||||
|
options: .cumulativeSum) { _, stats, error in
|
||||||
|
if let error = error {
|
||||||
|
// Typical on first run before the user answers the sheet,
|
||||||
|
// or when access is denied; the anchor is left untouched so
|
||||||
|
// the next sync retries the same window.
|
||||||
|
NSLog("GRHealthBridge: step query error: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let steps = Int(stats?.sumQuantity()?.doubleValue(for: .count()) ?? 0)
|
||||||
|
defaults.set(now, forKey: anchorKey)
|
||||||
|
deliver(steps: min(steps, maxStepsPerSync),
|
||||||
|
from: anchor, to: now, dir: dir)
|
||||||
|
}
|
||||||
|
store.execute(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pending file
|
||||||
|
|
||||||
|
private static func deliver(steps: Int, from: Date, to: Date, dir: String) {
|
||||||
|
let url = URL(fileURLWithPath: dir).appendingPathComponent(pendingName)
|
||||||
|
|
||||||
|
// Merge with an unconsumed earlier delivery so steps are never lost.
|
||||||
|
var total = steps
|
||||||
|
var fromDate = from
|
||||||
|
if let data = try? Data(contentsOf: url),
|
||||||
|
let old = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
|
||||||
|
total += (old["steps"] as? Int) ?? 0
|
||||||
|
if let oldFrom = old["from"] as? String,
|
||||||
|
let parsed = isoFormatter.date(from: oldFrom), parsed < fromDate {
|
||||||
|
fromDate = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard total > 0 else { return }
|
||||||
|
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"steps": total,
|
||||||
|
"from": isoFormatter.string(from: fromDate),
|
||||||
|
"to": isoFormatter.string(from: to),
|
||||||
|
]
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: URL(fileURLWithPath: dir, isDirectory: true),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
NSLog("GRHealthBridge: %d steps pending", total)
|
||||||
|
} catch {
|
||||||
|
NSLog("GRHealthBridge: could not write pending steps: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let isoFormatter: ISO8601DateFormatter = {
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime]
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
// gen1recomp iOS native bridge: document picker + Files-app inbox sweep.
|
||||||
|
//
|
||||||
|
// liblove's wrap_System.cpp calls into this class through the Objective-C
|
||||||
|
// runtime (objc_getClass / objc_msgSend), so nothing here may be renamed
|
||||||
|
// without updating that patch (see mobile/ios/patch_love_src.py).
|
||||||
|
//
|
||||||
|
// Contract (mirrors love-android's GameActivity.showFilePicker):
|
||||||
|
// love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into
|
||||||
|
// the LÖVE save directory as picked_rom.gb / picked_mod.zip /
|
||||||
|
// picked_save.sav; RomImporter's pending-file scan consumes it.
|
||||||
|
// love.system.createFile(name) -> exports save dir's pending_export.sav
|
||||||
|
// through the system picker, then writes export_done.flag.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
@objc(GRPickerBridge)
|
||||||
|
public final class GRPickerBridge: NSObject {
|
||||||
|
|
||||||
|
// Every live picker keeps its own delegate here
|
||||||
|
// (UIDocumentPickerViewController holds its delegate weakly). A single
|
||||||
|
// "current delegate" slot would break under double activation: the
|
||||||
|
// engine's touch handling can fire a button twice (touch + synthesized
|
||||||
|
// mouse), the second present would replace the first picker's delegate,
|
||||||
|
// and the sheet the user actually sees would then pick into nil —
|
||||||
|
// silently doing nothing.
|
||||||
|
private static var liveDelegates: [PickerDelegate] = []
|
||||||
|
|
||||||
|
// conf.lua t.identity — where LÖVE puts the fused save directory on iOS
|
||||||
|
// (<sandbox>/Library/Application Support/<identity>).
|
||||||
|
private static let loveIdentity = "pokemon-love2d"
|
||||||
|
|
||||||
|
// MARK: - Entry points called from liblove (C strings on purpose)
|
||||||
|
|
||||||
|
@objc(presentPickerWithKind:saveDir:)
|
||||||
|
public static func presentPicker(kind: UnsafePointer<CChar>?,
|
||||||
|
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||||
|
let kindStr = kind.map { String(cString: $0) } ?? "rom"
|
||||||
|
guard let dir = resolvedSaveDir(saveDir) else { return false }
|
||||||
|
|
||||||
|
let destName: String
|
||||||
|
var types: [UTType] = []
|
||||||
|
switch kindStr {
|
||||||
|
case "mod":
|
||||||
|
destName = "picked_mod.zip"
|
||||||
|
types = [.zip]
|
||||||
|
case "sav":
|
||||||
|
destName = "picked_save.sav"
|
||||||
|
default:
|
||||||
|
destName = "picked_rom.gb"
|
||||||
|
for ext in ["gb", "gbc"] {
|
||||||
|
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering
|
||||||
|
// .data as well keeps every real file selectable. The importer
|
||||||
|
// validates by size + SHA-1, so a wrong pick is rejected safely.
|
||||||
|
types.append(.data)
|
||||||
|
if !types.contains(.item) { types.append(.item) }
|
||||||
|
|
||||||
|
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
|
||||||
|
asCopy: true)
|
||||||
|
picker.allowsMultipleSelection = false
|
||||||
|
let delegate = PickerDelegate { urls in
|
||||||
|
guard let src = urls.first else { return }
|
||||||
|
copyItem(at: src, into: dir, named: destName)
|
||||||
|
}
|
||||||
|
return present(picker, with: delegate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc(presentExportWithName:saveDir:)
|
||||||
|
public static func presentExport(name: UnsafePointer<CChar>?,
|
||||||
|
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||||
|
let suggested = name.map { String(cString: $0) } ?? "export.sav"
|
||||||
|
guard let dir = resolvedSaveDir(saveDir) else { return false }
|
||||||
|
let staged = dir.appendingPathComponent("pending_export.sav")
|
||||||
|
guard FileManager.default.fileExists(atPath: staged.path) else { return false }
|
||||||
|
|
||||||
|
// Stage under the suggested name so the picker's filename field is
|
||||||
|
// prefilled; forExporting moves/copies it to the user's destination.
|
||||||
|
let tmp = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(suggested)
|
||||||
|
try? FileManager.default.removeItem(at: tmp)
|
||||||
|
do {
|
||||||
|
try FileManager.default.copyItem(at: staged, to: tmp)
|
||||||
|
} catch {
|
||||||
|
NSLog("GRPickerBridge: staging export failed: \(error)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let picker = UIDocumentPickerViewController(forExporting: [tmp], asCopy: true)
|
||||||
|
let delegate = PickerDelegate { urls in
|
||||||
|
guard !urls.isEmpty else { return }
|
||||||
|
// Same completion signal love-android's GameActivity writes;
|
||||||
|
// RomImporter:focus consumes it and clears pending_export.sav.
|
||||||
|
let flag = dir.appendingPathComponent("export_done.flag")
|
||||||
|
try? "ok".data(using: .utf8)?.write(to: flag)
|
||||||
|
}
|
||||||
|
return present(picker, with: delegate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moves ROM/mod/save files the user dropped into the app's Documents
|
||||||
|
// folder (Files app / Finder file sharing) into the LÖVE save directory,
|
||||||
|
// where the importer's pending-file scan looks. Called on every
|
||||||
|
// UIApplicationDidBecomeActive (see GRBootstrap.m).
|
||||||
|
@objc public static func sweepInbox() {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first,
|
||||||
|
let appSupport = fm.urls(for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask).first else { return }
|
||||||
|
let saveDir = appSupport.appendingPathComponent(loveIdentity, isDirectory: true)
|
||||||
|
let wanted: Set<String> = ["gb", "gbc", "zip", "sav"]
|
||||||
|
guard let items = try? fm.contentsOfDirectory(at: docs,
|
||||||
|
includingPropertiesForKeys: nil) else { return }
|
||||||
|
for url in items where wanted.contains(url.pathExtension.lowercased()) {
|
||||||
|
ensureDirectory(saveDir)
|
||||||
|
let dest = saveDir.appendingPathComponent(url.lastPathComponent)
|
||||||
|
try? fm.removeItem(at: dest)
|
||||||
|
do {
|
||||||
|
try fm.moveItem(at: url, to: dest)
|
||||||
|
NSLog("GRPickerBridge: swept %@ into save dir", url.lastPathComponent)
|
||||||
|
} catch {
|
||||||
|
NSLog("GRPickerBridge: sweep failed for \(url.lastPathComponent): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private static func resolvedSaveDir(_ cstr: UnsafePointer<CChar>?) -> URL? {
|
||||||
|
var dir = cstr.map { String(cString: $0) } ?? ""
|
||||||
|
if dir.isEmpty {
|
||||||
|
guard let appSupport = FileManager.default
|
||||||
|
.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||||
|
else { return nil }
|
||||||
|
dir = appSupport.appendingPathComponent(loveIdentity).path
|
||||||
|
}
|
||||||
|
let url = URL(fileURLWithPath: dir, isDirectory: true)
|
||||||
|
ensureDirectory(url)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func ensureDirectory(_ url: URL) {
|
||||||
|
try? FileManager.default.createDirectory(at: url,
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func copyItem(at src: URL, into dir: URL, named name: String) {
|
||||||
|
let scoped = src.startAccessingSecurityScopedResource()
|
||||||
|
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
||||||
|
ensureDirectory(dir)
|
||||||
|
let dest = dir.appendingPathComponent(name)
|
||||||
|
try? FileManager.default.removeItem(at: dest)
|
||||||
|
do {
|
||||||
|
try FileManager.default.copyItem(at: src, to: dest)
|
||||||
|
NSLog("GRPickerBridge: delivered %@", name)
|
||||||
|
} catch {
|
||||||
|
NSLog("GRPickerBridge: copy failed: \(error)")
|
||||||
|
// Surface the failure in-game: the Lua pick poll turns this
|
||||||
|
// into an on-screen notice instead of a silent no-op.
|
||||||
|
let report = "Could not copy \(src.lastPathComponent): " +
|
||||||
|
error.localizedDescription
|
||||||
|
try? report.data(using: .utf8)?
|
||||||
|
.write(to: dir.appendingPathComponent("pick_error.txt"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func present(_ picker: UIDocumentPickerViewController,
|
||||||
|
with delegate: PickerDelegate) -> Bool {
|
||||||
|
let doPresent = { () -> Bool in
|
||||||
|
// Double activation (touch + synthesized mouse) arrives within
|
||||||
|
// one frame — before UIKit even exposes the first sheet via
|
||||||
|
// presentedViewController — so gate on our own delegate list.
|
||||||
|
// Without this the stacked present makes the picker auto-dismiss
|
||||||
|
// with zero documents (observed as didPickDocumentsAt 0 urls)
|
||||||
|
// and the user's pick silently does nothing.
|
||||||
|
guard liveDelegates.isEmpty else {
|
||||||
|
NSLog("GRPickerBridge: picker already active; ignoring re-present")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard var top = UIApplication.shared.windows
|
||||||
|
.first(where: { $0.isKeyWindow })?.rootViewController
|
||||||
|
else { return false }
|
||||||
|
while let presented = top.presentedViewController { top = presented }
|
||||||
|
picker.delegate = delegate
|
||||||
|
liveDelegates.append(delegate)
|
||||||
|
delegate.onFinish = { [weak delegate] in
|
||||||
|
liveDelegates.removeAll { $0 === delegate }
|
||||||
|
}
|
||||||
|
top.present(picker, animated: true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if Thread.isMainThread { return doPresent() }
|
||||||
|
var ok = false
|
||||||
|
DispatchQueue.main.sync { ok = doPresent() }
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class PickerDelegate: NSObject, UIDocumentPickerDelegate {
|
||||||
|
private let onPick: ([URL]) -> Void
|
||||||
|
var onFinish: (() -> Void)?
|
||||||
|
init(onPick: @escaping ([URL]) -> Void) { self.onPick = onPick }
|
||||||
|
|
||||||
|
func documentPicker(_ controller: UIDocumentPickerViewController,
|
||||||
|
didPickDocumentsAt urls: [URL]) {
|
||||||
|
NSLog("GRPickerBridge: didPickDocumentsAt %d url(s)", urls.count)
|
||||||
|
onPick(urls)
|
||||||
|
onFinish?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
|
||||||
|
// No file was written; the Lua side's pending-file poll simply
|
||||||
|
// never finds anything.
|
||||||
|
NSLog("GRPickerBridge: picker cancelled")
|
||||||
|
onFinish?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- gen1recomp iOS: read-only step counts for the Pokéwalker mod
|
||||||
|
(GRHealthBridge.swift / mods/pokewalker). -->
|
||||||
|
<key>com.apple.developer.healthkit</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>en</string>
|
<string>en</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Pokemon Red</string>
|
<string>gen1recomp</string>
|
||||||
<key>CFBundleDocumentTypes</key>
|
<key>CFBundleDocumentTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
@@ -49,10 +49,6 @@
|
|||||||
<false/>
|
<false/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string>Launch Screen</string>
|
<string>Launch Screen</string>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
|
||||||
<array>
|
|
||||||
<string>opengles-2</string>
|
|
||||||
</array>
|
|
||||||
<key>UIStatusBarHidden</key>
|
<key>UIStatusBarHidden</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||||
@@ -100,5 +96,16 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
<!-- gen1recomp iOS: expose the app's Documents folder in the Files app /
|
||||||
|
Finder so ROMs, mod .zips, and .sav files can be dropped in without
|
||||||
|
the picker; GRBootstrap sweeps them into the LÖVE save dir. -->
|
||||||
|
<key>UIFileSharingEnabled</key>
|
||||||
|
<true/>
|
||||||
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
<true/>
|
||||||
|
<!-- gen1recomp iOS: Pokéwalker mod reads step counts (opt-in, in the
|
||||||
|
in-game mod manager) and converts them to Pokémon EXP. -->
|
||||||
|
<key>NSHealthShareUsageDescription</key>
|
||||||
|
<string>The Pokéwalker mod reads your step count to give your Pokémon party experience points for real-world walking.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 12.0
|
||||||
|
source tree (mobile/ios/love-src/). Idempotent AND re-appliable: the first
|
||||||
|
run stashes a pristine `.orig` copy of every file it rewrites, and later
|
||||||
|
runs always start over from that copy — so editing the patch content here
|
||||||
|
just works on the next build, no manual restore needed.
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
1. Copies mobile/ios/native/ (GRPickerBridge.swift, GRHealthBridge.swift,
|
||||||
|
GRBootstrap.m) and the HealthKit entitlements into the LÖVE tree.
|
||||||
|
2. Patches liblove's wrap_System.cpp to expose love.system.pickFile,
|
||||||
|
love.system.createFile, and love.system.syncHealthSteps on iOS (each
|
||||||
|
calls a GR*Bridge Swift class through the Objective-C runtime, so
|
||||||
|
liblove never links against Swift directly).
|
||||||
|
3. Patches love.xcodeproj so the love-ios app target compiles the native
|
||||||
|
files (Swift 5, iOS 14 deployment for UTType/forExporting APIs).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
IOS_DIR = Path(__file__).resolve().parent
|
||||||
|
LOVE_SRC = IOS_DIR / "love-src"
|
||||||
|
NATIVE_SRC = IOS_DIR / "native"
|
||||||
|
NATIVE_DST = LOVE_SRC / "platform" / "xcode" / "ios" / "native"
|
||||||
|
WRAP_SYSTEM = LOVE_SRC / "src" / "modules" / "system" / "wrap_System.cpp"
|
||||||
|
PBXPROJ = LOVE_SRC / "platform" / "xcode" / "love.xcodeproj" / "project.pbxproj"
|
||||||
|
ENTITLEMENTS_SRC = IOS_DIR / "overlays" / "love-ios.entitlements"
|
||||||
|
|
||||||
|
NATIVE_FILES = ("GRPickerBridge.swift", "GRHealthBridge.swift", "GRBootstrap.m")
|
||||||
|
|
||||||
|
MARKER = "gen1recomp iOS picker bridge"
|
||||||
|
|
||||||
|
# Headers must land outside `namespace love { namespace system {`.
|
||||||
|
WRAP_INCLUDES = """
|
||||||
|
// %s: headers for the native-bridge functions below.
|
||||||
|
#ifdef LOVE_IOS
|
||||||
|
#include <objc/runtime.h>
|
||||||
|
#include <objc/message.h>
|
||||||
|
#include <string>
|
||||||
|
#include "filesystem/Filesystem.h"
|
||||||
|
#endif
|
||||||
|
""" % MARKER
|
||||||
|
|
||||||
|
WRAP_FUNCS = """
|
||||||
|
// --- %s -------------------------------------------------
|
||||||
|
// love.system.pickFile / createFile / syncHealthSteps for iOS. pickFile and
|
||||||
|
// createFile mirror the love-android extension this project's importer
|
||||||
|
// already targets; syncHealthSteps feeds the Pokéwalker mod. Implemented in
|
||||||
|
// Swift (GR*Bridge classes, love-ios app target); reached via the ObjC
|
||||||
|
// runtime so liblove itself needs no Swift interop.
|
||||||
|
#ifdef LOVE_IOS
|
||||||
|
static const char *gr_saveDirectory()
|
||||||
|
{
|
||||||
|
static std::string saveDirectory;
|
||||||
|
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
|
||||||
|
if (fs == nullptr)
|
||||||
|
return "";
|
||||||
|
saveDirectory = fs->getSaveDirectory();
|
||||||
|
return saveDirectory.c_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
static int gr_callBridge(lua_State *L, const char *className,
|
||||||
|
const char *selector, const char *arg)
|
||||||
|
{
|
||||||
|
Class cls = objc_getClass(className);
|
||||||
|
if (cls == nullptr)
|
||||||
|
{
|
||||||
|
lua_pushboolean(L, 0);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
typedef signed char (*GRMsg)(Class, SEL, const char *, const char *);
|
||||||
|
signed char ok = ((GRMsg)objc_msgSend)(cls, sel_registerName(selector),
|
||||||
|
arg, gr_saveDirectory());
|
||||||
|
lua_pushboolean(L, ok != 0);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int w_pickFile(lua_State *L)
|
||||||
|
{
|
||||||
|
const char *kind = luaL_optstring(L, 1, "rom");
|
||||||
|
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
int w_createFile(lua_State *L)
|
||||||
|
{
|
||||||
|
const char *name = luaL_optstring(L, 1, "export.sav");
|
||||||
|
return gr_callBridge(L, "GRPickerBridge", "presentExportWithName:saveDir:", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
int w_syncHealthSteps(lua_State *L)
|
||||||
|
{
|
||||||
|
return gr_callBridge(L, "GRHealthBridge", "syncStepsWithCommand:saveDir:", "sync");
|
||||||
|
}
|
||||||
|
#endif // LOVE_IOS
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
""" % MARKER
|
||||||
|
|
||||||
|
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
||||||
|
{ "pickFile", w_pickFile },
|
||||||
|
{ "createFile", w_createFile },
|
||||||
|
{ "syncHealthSteps", w_syncHealthSteps },
|
||||||
|
#endif
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Deterministic 24-hex-digit object IDs, chosen not to collide with the
|
||||||
|
# upstream project (grep-verified against love-11.5's pbxproj).
|
||||||
|
ID_FILE_PICKER = "6E1AC0DE0001000000000001"
|
||||||
|
ID_FILE_OBJC = "6E1AC0DE0001000000000002"
|
||||||
|
ID_FILE_HEALTH = "6E1AC0DE0001000000000003"
|
||||||
|
ID_BUILD_PICKER = "6E1AC0DE0002000000000001"
|
||||||
|
ID_BUILD_OBJC = "6E1AC0DE0002000000000002"
|
||||||
|
ID_BUILD_HEALTH = "6E1AC0DE0002000000000003"
|
||||||
|
SOURCES_PHASE_ID = "FA0B7F021A95AAF3000E1D17" # love-ios Sources phase
|
||||||
|
IOS_APP_CONFIG_IDS = (
|
||||||
|
"FA0B7F261A95AAF4000E1D17", # Debug
|
||||||
|
"FA0B7F271A95AAF4000E1D17", # Release
|
||||||
|
"FA0B7F281A95AAF4000E1D17", # Distribution
|
||||||
|
)
|
||||||
|
|
||||||
|
PBX_SOURCES = (
|
||||||
|
("GRPickerBridge.swift", ID_FILE_PICKER, ID_BUILD_PICKER, "sourcecode.swift"),
|
||||||
|
("GRHealthBridge.swift", ID_FILE_HEALTH, ID_BUILD_HEALTH, "sourcecode.swift"),
|
||||||
|
("GRBootstrap.m", ID_FILE_OBJC, ID_BUILD_OBJC, "sourcecode.c.objc"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fail(msg):
|
||||||
|
print(f"patch_love_src: error: {msg}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def pristine(path: Path) -> str:
|
||||||
|
"""Text of `path` before any of our patching: backed by a `.orig` stash.
|
||||||
|
|
||||||
|
The stash is only trusted if it is itself unpatched; that protects
|
||||||
|
against a stash accidentally taken after an earlier patch run.
|
||||||
|
"""
|
||||||
|
orig = path.with_suffix(path.suffix + ".orig")
|
||||||
|
if orig.is_file():
|
||||||
|
text = orig.read_text()
|
||||||
|
if MARKER not in text and ID_FILE_PICKER not in text:
|
||||||
|
return text
|
||||||
|
text = path.read_text()
|
||||||
|
if MARKER in text or ID_FILE_PICKER in text:
|
||||||
|
fail(f"{path} is already patched and no pristine .orig stash exists;\n"
|
||||||
|
f" delete {LOVE_SRC} and re-run scripts/build_ios.sh --fetch")
|
||||||
|
orig.write_text(text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def copy_native_files():
|
||||||
|
NATIVE_DST.mkdir(parents=True, exist_ok=True)
|
||||||
|
for name in NATIVE_FILES:
|
||||||
|
src = NATIVE_SRC / name
|
||||||
|
if not src.is_file():
|
||||||
|
fail(f"missing {src}")
|
||||||
|
shutil.copy2(src, NATIVE_DST / name)
|
||||||
|
if not ENTITLEMENTS_SRC.is_file():
|
||||||
|
fail(f"missing {ENTITLEMENTS_SRC}")
|
||||||
|
shutil.copy2(ENTITLEMENTS_SRC, NATIVE_DST / "love-ios.entitlements")
|
||||||
|
print(f"patch_love_src: native files -> {NATIVE_DST}")
|
||||||
|
|
||||||
|
|
||||||
|
def patch_wrap_system():
|
||||||
|
text = pristine(WRAP_SYSTEM)
|
||||||
|
include_anchor = '#include "sdl/System.h"\n'
|
||||||
|
if include_anchor not in text:
|
||||||
|
fail(f"include anchor not found in {WRAP_SYSTEM}")
|
||||||
|
text = text.replace(include_anchor, include_anchor + WRAP_INCLUDES, 1)
|
||||||
|
anchor = "static const luaL_Reg functions[] ="
|
||||||
|
if anchor not in text:
|
||||||
|
fail(f"anchor not found in {WRAP_SYSTEM}")
|
||||||
|
text = text.replace(anchor, WRAP_FUNCS + anchor, 1)
|
||||||
|
reg_anchor = '\t{ "vibrate", w_vibrate },\n'
|
||||||
|
if reg_anchor not in text:
|
||||||
|
fail(f"registration anchor not found in {WRAP_SYSTEM}")
|
||||||
|
text = text.replace(reg_anchor, reg_anchor + WRAP_REGISTRATION, 1)
|
||||||
|
WRAP_SYSTEM.write_text(text)
|
||||||
|
print("patch_love_src: wrap_System.cpp patched "
|
||||||
|
"(pickFile/createFile/syncHealthSteps)")
|
||||||
|
|
||||||
|
|
||||||
|
def patch_pbxproj():
|
||||||
|
text = pristine(PBXPROJ)
|
||||||
|
|
||||||
|
build_files = "".join(
|
||||||
|
f"\t\t{build_id} /* {name} in Sources */ = "
|
||||||
|
f"{{isa = PBXBuildFile; fileRef = {file_id} /* {name} */; }};\n"
|
||||||
|
for name, file_id, build_id, _ in PBX_SOURCES
|
||||||
|
)
|
||||||
|
anchor = "/* Begin PBXBuildFile section */\n"
|
||||||
|
if anchor not in text:
|
||||||
|
fail("PBXBuildFile section not found")
|
||||||
|
text = text.replace(anchor, anchor + build_files, 1)
|
||||||
|
|
||||||
|
file_refs = "".join(
|
||||||
|
f"\t\t{file_id} /* {name} */ = "
|
||||||
|
f"{{isa = PBXFileReference; lastKnownFileType = {ftype}; "
|
||||||
|
f"name = {name}; path = ios/native/{name}; "
|
||||||
|
f"sourceTree = SOURCE_ROOT; }};\n"
|
||||||
|
for name, file_id, _, ftype in PBX_SOURCES
|
||||||
|
)
|
||||||
|
anchor = "/* Begin PBXFileReference section */\n"
|
||||||
|
if anchor not in text:
|
||||||
|
fail("PBXFileReference section not found")
|
||||||
|
text = text.replace(anchor, anchor + file_refs, 1)
|
||||||
|
|
||||||
|
# Add the files to the love-ios Sources phase.
|
||||||
|
phase_re = re.compile(
|
||||||
|
re.escape(SOURCES_PHASE_ID)
|
||||||
|
+ r" /\* Sources \*/ = \{.*?files = \(\n", re.S)
|
||||||
|
m = phase_re.search(text)
|
||||||
|
if not m:
|
||||||
|
fail("love-ios Sources phase not found")
|
||||||
|
insertion = "".join(
|
||||||
|
f"\t\t\t\t{build_id} /* {name} in Sources */,\n"
|
||||||
|
for name, _, build_id, _ in PBX_SOURCES
|
||||||
|
)
|
||||||
|
text = text[: m.end()] + insertion + text[m.end():]
|
||||||
|
|
||||||
|
# Swift + modern deployment target + HealthKit entitlements on the
|
||||||
|
# love-ios app target only (UTType and forExporting need iOS 14;
|
||||||
|
# liblove stays as upstream).
|
||||||
|
for config_id in IOS_APP_CONFIG_IDS:
|
||||||
|
cfg_re = re.compile(
|
||||||
|
re.escape(config_id) + r" /\* \w+ \*/ = \{.*?buildSettings = \{\n",
|
||||||
|
re.S)
|
||||||
|
m = cfg_re.search(text)
|
||||||
|
if not m:
|
||||||
|
fail(f"build configuration {config_id} not found")
|
||||||
|
settings = (
|
||||||
|
"\t\t\t\tSWIFT_VERSION = 5.0;\n"
|
||||||
|
"\t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 14.0;\n"
|
||||||
|
'\t\t\t\tCODE_SIGN_ENTITLEMENTS = "ios/native/love-ios.entitlements";\n'
|
||||||
|
)
|
||||||
|
text = text[: m.end()] + settings + text[m.end():]
|
||||||
|
|
||||||
|
PBXPROJ.write_text(text)
|
||||||
|
print("patch_love_src: love.xcodeproj patched (native sources + Swift + entitlements)")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not LOVE_SRC.is_dir():
|
||||||
|
fail("love-src/ missing; run scripts/build_ios.sh --fetch first")
|
||||||
|
copy_native_files()
|
||||||
|
patch_wrap_system()
|
||||||
|
patch_pbxproj()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Nuzlocke
|
||||||
|
|
||||||
|
An enforced Gen 1 Nuzlocke.
|
||||||
|
|
||||||
|
Oak configures Slow Start, whether duplicate evolutionary families are skipped
|
||||||
|
or consume an area's encounter, and whether Safari maps are separate areas.
|
||||||
|
|
||||||
|
After Slow Start, the mod enforces mandatory nicknames for starters, gifts,
|
||||||
|
and catches; one capture per area; no duplicate evolutionary families; and
|
||||||
|
permanent death. A party Pokémon says that it died and is removed immediately.
|
||||||
|
If the final party member dies, the game runs the credits to THE END and then
|
||||||
|
deletes the active save.
|
||||||
|
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
-- Nuzlocke rules. Uses the current engine's internal seams while the
|
||||||
|
-- equivalent public API hooks are being added.
|
||||||
|
return function(mod)
|
||||||
|
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
|
||||||
|
steps = next(steps, speech)
|
||||||
|
mod.ui.insertStepAfter(steps, "oak_welcome", {
|
||||||
|
id = "nuzlocke_intro", kind = "say", pic = "oak",
|
||||||
|
text = "A Nuzlocke is a\npromise.\fEvery loss is\npermanent.",
|
||||||
|
})
|
||||||
|
mod.ui.insertStepAfter(steps, "nuzlocke_intro", {
|
||||||
|
id = "nuzlocke_slow_start", kind = "yesno", pic = "oak",
|
||||||
|
saveKey = "slow_start", defaultNo = true,
|
||||||
|
text = "Use SLOW START?\nRules start with\nPOKé BALLS.",
|
||||||
|
})
|
||||||
|
mod.ui.insertStepAfter(steps, "nuzlocke_slow_start", {
|
||||||
|
id = "nuzlocke_dupes", kind = "choice", pic = "oak",
|
||||||
|
saveKey = "dupes_mode", text = "When you meet a\nknown family?",
|
||||||
|
choices = { "SKIP", "LOSE" }, values = { "skip", "strict" },
|
||||||
|
})
|
||||||
|
mod.ui.insertStepAfter(steps, "nuzlocke_dupes", {
|
||||||
|
id = "nuzlocke_safari", kind = "yesno", pic = "oak",
|
||||||
|
saveKey = "safari_sectors", text = "Separate SAFARI\nsectors?",
|
||||||
|
})
|
||||||
|
mod.ui.insertStepAfter(steps, "nuzlocke_safari", {
|
||||||
|
id = "nuzlocke_close", kind = "say", pic = "oak",
|
||||||
|
text = "Give every friend\na name. Keep them\nsafe. Good luck!",
|
||||||
|
})
|
||||||
|
return steps
|
||||||
|
end)
|
||||||
|
|
||||||
|
mod.events:on("intro.oak_speech.answered", function(ev)
|
||||||
|
if ev.saveKey then mod.save:set(ev.saveKey, ev.value) end
|
||||||
|
end)
|
||||||
|
|
||||||
|
local function active(game, battle)
|
||||||
|
if not (game and game.save) or (battle and (battle.demo or battle.ghost)) then return false end
|
||||||
|
if not mod.save:get("slow_start", false) then return true end
|
||||||
|
if mod.save:get("balls_unlocked", false) then return true end
|
||||||
|
for id, count in pairs(game.save.inventory or {}) do
|
||||||
|
if count > 0 and game.data.items[id] and game.data.items[id].ball then
|
||||||
|
mod.save:set("balls_unlocked", true)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function areaKey(game, battle)
|
||||||
|
if battle and battle.safari and not mod.save:get("safari_sectors", false) then
|
||||||
|
return "SAFARI_ZONE"
|
||||||
|
end
|
||||||
|
return (game.overworld and game.overworld.map and game.overworld.map.id)
|
||||||
|
or (game.save.player and game.save.player.map) or "UNKNOWN"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function family(data, species)
|
||||||
|
local found, pending = {}, { species }
|
||||||
|
while #pending > 0 do
|
||||||
|
local id = table.remove(pending)
|
||||||
|
if not found[id] then
|
||||||
|
found[id] = true
|
||||||
|
for _, evo in ipairs((data.pokemon[id] or {}).evolutions or {}) do pending[#pending + 1] = evo.species end
|
||||||
|
for parent, def in pairs(data.pokemon or {}) do
|
||||||
|
for _, evo in ipairs(def.evolutions or {}) do
|
||||||
|
if evo.species == id then pending[#pending + 1] = parent end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return found
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ownsFamily(game, species)
|
||||||
|
local members = family(game.data, species)
|
||||||
|
local function owns(mon) return mon and members[mon.species] end
|
||||||
|
for _, mon in ipairs(game.save.party or {}) do if owns(mon) then return true end end
|
||||||
|
for _, box in ipairs(game.save.boxes or {}) do
|
||||||
|
for _, mon in ipairs(box) do if owns(mon) then return true end end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function caughtAreas()
|
||||||
|
local areas = mod.save:get("caught_areas")
|
||||||
|
if type(areas) ~= "table" then areas = {}; mod.save:set("caught_areas", areas) end
|
||||||
|
return areas
|
||||||
|
end
|
||||||
|
|
||||||
|
local function denied(game, battle, species)
|
||||||
|
if not active(game, battle) then return nil end
|
||||||
|
if caughtAreas()[areaKey(game, battle)] then return "area" end
|
||||||
|
if ownsFamily(game, species) then return "dupes" end
|
||||||
|
end
|
||||||
|
|
||||||
|
mod.events:on("pokemon.caught", function(ev)
|
||||||
|
-- A successful capture proves that Slow Start has ended even when it was
|
||||||
|
-- the last ball in the bag.
|
||||||
|
if mod.save:get("slow_start", false) then mod.save:set("balls_unlocked", true) end
|
||||||
|
if active(ev.game, ev.battle) then
|
||||||
|
caughtAreas()[areaKey(ev.game, ev.battle)] = ev.species
|
||||||
|
mod.save:set("caught_areas", caughtAreas())
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
mod.events:on("game.ready", function()
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local Party = require("src.pokemon.Party")
|
||||||
|
local Boxes = require("src.pokemon.Boxes")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Screens = require("src.ui.Screens")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
local Bag = require("src.inventory.Bag")
|
||||||
|
|
||||||
|
BattleState.askNicknameUI = function(self, mon)
|
||||||
|
self.lockedBall, self.blankForAskName = nil, false
|
||||||
|
return self:buildScreen("NamingScreen", {
|
||||||
|
title = Strings("NICKNAME?"), maxLen = 10,
|
||||||
|
onDone = function(name) mon.nickname = name or "A" end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Gifts and starters use the same mandatory naming screen.
|
||||||
|
Commands.give_pokemon = function(ctx, species, level)
|
||||||
|
local gift = { ctx = ctx, species = species, level = level }
|
||||||
|
if ctx.game.mods then ctx.game.mods.events:emit("pokemon.before_give", gift) end
|
||||||
|
local mon = Pokemon.new(ctx.game.data, gift.species, gift.level)
|
||||||
|
ctx.game.stringBuffer, ctx.pendingPokemonName = ctx.game.data.pokemon[gift.species].name or gift.species, gift.species
|
||||||
|
BattleState.stampOT(ctx.save, mon)
|
||||||
|
local inParty = Party.add(ctx.save.party, mon)
|
||||||
|
local boxNum = inParty and nil or Boxes.deposit(ctx.save, mon)
|
||||||
|
if not inParty and not boxNum then ctx.lastCheck = false; return end
|
||||||
|
if ctx.save.pokedex then ctx.save.pokedex.seen[gift.species], ctx.save.pokedex.owned[gift.species] = true, true end
|
||||||
|
ctx.lastCheck, ctx.addedToParty, ctx.boxNum = true, inParty, boxNum
|
||||||
|
if ctx.runner then
|
||||||
|
Screens.push(ctx.game, "NamingScreen", {
|
||||||
|
title = Strings("NICKNAME?"), maxLen = 10,
|
||||||
|
onDone = function(name) mon.nickname = name or "A"; ctx.runner:resume() end,
|
||||||
|
})
|
||||||
|
ctx.runner:yield()
|
||||||
|
else mon.nickname = "A" end
|
||||||
|
if boxNum then
|
||||||
|
ctx.game.boxMonNicks, ctx.game.stringBuffer = mon.nickname, tostring(boxNum)
|
||||||
|
if ctx.runner then Commands.show_text(ctx, "_SentToBoxText") end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local vanillaThrowBall = BattleState.throwBall
|
||||||
|
BattleState.throwBall = function(self, ball)
|
||||||
|
local reason = denied(self.game, self, self.enemy and self.enemy.mon.species)
|
||||||
|
if reason then
|
||||||
|
if reason == "dupes" and mod.save:get("dupes_mode", "skip") == "strict" then
|
||||||
|
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
|
||||||
|
mod.save:set("caught_areas", caughtAreas())
|
||||||
|
end
|
||||||
|
Bag.add(self.game.save, ball, 1, self.game.data)
|
||||||
|
self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
|
||||||
|
or "You already have\nthis POKéMON family!")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
return vanillaThrowBall(self, ball)
|
||||||
|
end
|
||||||
|
|
||||||
|
local vanillaOnFaint = BattleState.onFaint
|
||||||
|
BattleState.onFaint = function(self, battler)
|
||||||
|
if not (battler.isPlayer and active(self.game, self)) then return vanillaOnFaint(self, battler) end
|
||||||
|
if battler.faintQueued then return end
|
||||||
|
battler.faintQueued = true
|
||||||
|
if self.participants then self.participants[battler.mon] = nil end
|
||||||
|
Runtime.emit("battle.fainted", { battle = self, battler = battler })
|
||||||
|
for i, mon in ipairs(self.game.save.party) do
|
||||||
|
if mon == battler.mon then table.remove(self.game.save.party, i); break end
|
||||||
|
end
|
||||||
|
self:actNext(function()
|
||||||
|
battler.fainted = true
|
||||||
|
require("src.core.Sound").playCry(self.data, battler.mon.species)
|
||||||
|
require("src.core.Sound").play(self.data, "Faint_Fall")
|
||||||
|
self.fx = self.fx or {}; self.fx.faint = { battler = battler, frames = 30 }
|
||||||
|
end)
|
||||||
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
|
table.insert(self.queue, self.nextInsert, { wait = 30 })
|
||||||
|
self:sayNext(Strings("%s\ndied!", battler.name))
|
||||||
|
self:act(function() self:playerMonFainted() end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local vanillaPlayerFainted = BattleState.playerMonFainted
|
||||||
|
BattleState.playerMonFainted = function(self)
|
||||||
|
if active(self.game, self) and not Party.firstHealthy(self.game.save.party) then
|
||||||
|
self.nuzlockeGameOver, self.result, self.afterQueue = true, "nuzlocke_game_over", "finish"
|
||||||
|
self:sayNext(Strings("All of your\nPOKéMON are dead..."))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
return vanillaPlayerFainted(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
local vanillaFinish = BattleState.finish
|
||||||
|
BattleState.finish = function(self)
|
||||||
|
if not self.nuzlockeGameOver then return vanillaFinish(self) end
|
||||||
|
self.nuzlockeGameOver = nil
|
||||||
|
self.game.stack:pop()
|
||||||
|
Runtime.emit("battle.ended", { battle = self, result = "nuzlocke_game_over" })
|
||||||
|
-- Game over has no victory lap: delete the slot, then use Credits only
|
||||||
|
-- as its existing THE END renderer / A-or-B wait screen.
|
||||||
|
local version = GameVersion.get()
|
||||||
|
local slot = SaveData.activeSlot(version)
|
||||||
|
if slot then SaveData.deleteSlot(version, slot)
|
||||||
|
elseif love and love.filesystem then
|
||||||
|
local main = SaveData.saveFilename(version)
|
||||||
|
love.filesystem.remove(main); love.filesystem.remove(main .. ".bak"); love.filesystem.remove(main .. ".tmp")
|
||||||
|
end
|
||||||
|
local ending = Screens.push(self.game, "Credits", function()
|
||||||
|
require("src.core.Music").stop()
|
||||||
|
while self.game.stack:top() do self.game.stack:pop() end
|
||||||
|
Screens.push(self.game, "IntroMovie", function()
|
||||||
|
if self.game.makeTitleState then self.game.stack:push(self.game:makeTitleState()) end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
ending.phase, ending.timer = "end_wait", 0
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"id": "nuzlocke",
|
||||||
|
"name": "Nuzlocke",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"api": 2,
|
||||||
|
"entry": "main.lua",
|
||||||
|
"profile": "content",
|
||||||
|
"category": "GAMEPLAY",
|
||||||
|
"game_version": ">=0.0.0-dev <1.0.0",
|
||||||
|
"priority": 100,
|
||||||
|
"permissions": [
|
||||||
|
"engine_internals"
|
||||||
|
],
|
||||||
|
"dependencies": [],
|
||||||
|
"optional_dependencies": [],
|
||||||
|
"conflicts": [],
|
||||||
|
"description": "A configurable Gen 1 Nuzlocke with permanent death and area catches.",
|
||||||
|
"github": "bryanthaboi/nuzlocke"
|
||||||
|
}
|
||||||
+17
-3
@@ -35,6 +35,7 @@ TARGET="all"
|
|||||||
NOTARY_PROFILE="notary-profile"
|
NOTARY_PROFILE="notary-profile"
|
||||||
NOTARIZE=true
|
NOTARIZE=true
|
||||||
IOS_RELEASE=false
|
IOS_RELEASE=false
|
||||||
|
IOS_IPA=false
|
||||||
|
|
||||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
@@ -48,6 +49,7 @@ while [ $# -gt 0 ]; do
|
|||||||
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
||||||
--no-notarize) NOTARIZE=false ;;
|
--no-notarize) NOTARIZE=false ;;
|
||||||
--release) IOS_RELEASE=true ;;
|
--release) IOS_RELEASE=true ;;
|
||||||
|
--ipa) IOS_IPA=true ;;
|
||||||
*) fail "unknown argument: $1" ;;
|
*) fail "unknown argument: $1" ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
@@ -66,17 +68,23 @@ 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/save-editor \
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
|
tools/rom_manifest_yellow.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
if unzip -Z1 "$LOVE_FILE" \
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
fail "game.love unexpectedly contains generated ROM data"
|
fail "game.love unexpectedly contains generated ROM data"
|
||||||
fi
|
fi
|
||||||
# The editor is only reachable if its entry point and both module directories
|
# The editor is only reachable if its entry point and both module directories
|
||||||
# made it in; a silent miss would ship a launcher whose Edit button crashes.
|
# made it in, and every version's import manifest has to ship or that game's
|
||||||
|
# ROM import fails in the built app (dev reads them off the source tree, so
|
||||||
|
# the miss only ever shows up in a build -- the Yellow manifest shipped this
|
||||||
|
# way once).
|
||||||
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||||
tools/save-editor/panels/Party.lua; do
|
tools/save-editor/panels/Party.lua \
|
||||||
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
|
tools/rom_manifest_yellow.json; do
|
||||||
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|
||||||
|| fail "game.love is missing $required (save editor would not load)"
|
|| fail "game.love is missing $required"
|
||||||
done
|
done
|
||||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
||||||
|
|
||||||
@@ -292,6 +300,9 @@ build_android() {
|
|||||||
if [ "$VERSION_EXPLICIT" = true ]; then
|
if [ "$VERSION_EXPLICIT" = true ]; then
|
||||||
args+=(--version "$VERSION")
|
args+=(--version "$VERSION")
|
||||||
fi
|
fi
|
||||||
|
if [ "$IOS_IPA" = true ]; then
|
||||||
|
args+=(--ipa)
|
||||||
|
fi
|
||||||
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
|
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,6 +313,9 @@ build_ios() {
|
|||||||
if [ "$IOS_RELEASE" = true ]; then
|
if [ "$IOS_RELEASE" = true ]; then
|
||||||
args+=(--release)
|
args+=(--release)
|
||||||
fi
|
fi
|
||||||
|
if [ "$VERSION_EXPLICIT" = true ]; then
|
||||||
|
args+=(--version "$VERSION")
|
||||||
|
fi
|
||||||
"$ROOT/scripts/build_ios.sh" ${args[@]+"${args[@]}"}
|
"$ROOT/scripts/build_ios.sh" ${args[@]+"${args[@]}"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ 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"
|
||||||
|
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
||||||
|
# A fresh source checkout normally supplies this through Git. This URL is
|
||||||
|
# deliberately only a last resort for incomplete source exports: the manifest
|
||||||
|
# contains extraction metadata, never a ROM or extracted game data.
|
||||||
|
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
||||||
|
|
||||||
VERSION=""
|
VERSION=""
|
||||||
PACKAGE_ONLY=false
|
PACKAGE_ONLY=false
|
||||||
@@ -72,6 +77,59 @@ if [ ! -d "$ANDROID_DIR/love/src/jni/love/src" ]; then
|
|||||||
Re-clone or 'git checkout -- mobile/android'. See mobile/ANDROID.md."
|
Re-clone or 'git checkout -- mobile/android'. See mobile/ANDROID.md."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ------------------------------------------------------- Yellow import metadata
|
||||||
|
# Android packages game.love itself rather than reusing scripts/build.sh's
|
||||||
|
# archive. Keep a partial source export from silently shipping an APK that can
|
||||||
|
# list Yellow but cannot import it. Prefer the exact manifest from this
|
||||||
|
# checkout's Git object database; only then fall back to the public repository.
|
||||||
|
yellow_manifest_is_valid() {
|
||||||
|
local path="$1"
|
||||||
|
python3 - "$path" <<'PY'
|
||||||
|
import json, pathlib, sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
raise SystemExit(0 if manifest.get("romSha1") ==
|
||||||
|
"cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1" else 1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_yellow_manifest() {
|
||||||
|
local manifest="$ROOT/$YELLOW_MANIFEST_RELATIVE"
|
||||||
|
local staged
|
||||||
|
staged="$(mktemp)"
|
||||||
|
|
||||||
|
if yellow_manifest_is_valid "$manifest"; then
|
||||||
|
rm -f "$staged"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "Yellow import manifest is missing or invalid; recovering it before packaging"
|
||||||
|
if git -C "$ROOT" show "HEAD:$YELLOW_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
|
||||||
|
&& yellow_manifest_is_valid "$staged"; then
|
||||||
|
mkdir -p "$(dirname "$manifest")"
|
||||||
|
mv "$staged" "$manifest"
|
||||||
|
say "restored Yellow import manifest from this checkout's Git data"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v curl >/dev/null 2>&1 \
|
||||||
|
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||||
|
--output "$staged" "$YELLOW_MANIFEST_URL" \
|
||||||
|
&& yellow_manifest_is_valid "$staged"; then
|
||||||
|
mkdir -p "$(dirname "$manifest")"
|
||||||
|
mv "$staged" "$manifest"
|
||||||
|
say "downloaded Yellow import manifest from the project repository"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$staged"
|
||||||
|
fail "Yellow import manifest is unavailable. Git recovery failed and could not download $YELLOW_MANIFEST_URL"
|
||||||
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- branding
|
# --------------------------------------------------------------- branding
|
||||||
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
||||||
# Manifest still gets permission trims. Re-applied every build so refreshing
|
# Manifest still gets permission trims. Re-applied every build so refreshing
|
||||||
@@ -135,21 +193,33 @@ PY
|
|||||||
# --------------------------------------------------------------- game.love
|
# --------------------------------------------------------------- game.love
|
||||||
pack_game_love() {
|
pack_game_love() {
|
||||||
say "packing game.love for love-android embed flavor"
|
say "packing game.love for love-android embed flavor"
|
||||||
|
ensure_yellow_manifest
|
||||||
mkdir -p "$EMBED_ASSETS"
|
mkdir -p "$EMBED_ASSETS"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||||
# row opens it in-process, so it must be inside the archive (see build.sh).
|
# row opens it in-process, so it must be inside the archive (see build.sh).
|
||||||
|
# Deliberately NO fused mods: a mod inside game.love sits in the read-only
|
||||||
|
# APK, so the mod manager's Delete can't remove it and it reappears every
|
||||||
|
# launch. Pokewalker ships as an importable .zip instead, which gives it
|
||||||
|
# a real install/upgrade/delete lifecycle.
|
||||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||||
main.lua conf.lua src data assets tools/save-editor \
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
|
tools/rom_manifest_yellow.json \
|
||||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||||
-x 'data/generated/*' -x 'assets/generated/*')
|
-x 'data/generated/*' -x 'assets/generated/*')
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
if unzip -Z1 "$LOVE_FILE" \
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
fail "game.love unexpectedly contains generated ROM data"
|
fail "game.love unexpectedly contains generated ROM data"
|
||||||
fi
|
fi
|
||||||
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
# Do not pipe unzip straight into grep here: on a large archive grep can
|
||||||
|
# finish early and make unzip report SIGPIPE under `set -o pipefail`.
|
||||||
|
local archive_entries
|
||||||
|
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
|
||||||
|
grep -qx 'tools/save-editor/App.lua' <<< "$archive_entries" \
|
||||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||||
|
grep -qx "$YELLOW_MANIFEST_RELATIVE" <<< "$archive_entries" \
|
||||||
|
|| fail "game.love is missing the Yellow ROM import manifest"
|
||||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||||
|
|
||||||
# This script packs its own game.love (it does not reuse build.sh's), so it
|
# This script packs its own game.love (it does not reuse build.sh's), so it
|
||||||
@@ -224,10 +294,32 @@ require_android_sdk() {
|
|||||||
# --------------------------------------------------------------- gradle
|
# --------------------------------------------------------------- gradle
|
||||||
run_gradle() {
|
run_gradle() {
|
||||||
local task="assembleEmbedNoRecordDebug"
|
local task="assembleEmbedNoRecordDebug"
|
||||||
say "building APK ($task)"
|
local build_dir="$ANDROID_DIR"
|
||||||
|
|
||||||
|
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
|
||||||
|
# the project path ("Your APP_BUILD_SCRIPT points to an unknown file").
|
||||||
|
# When this checkout lives at a spaced path (e.g. "~/xCode Projects/..."),
|
||||||
|
# shadow the android tree to a space-free location and build there; the
|
||||||
|
# shadow persists across runs so gradle/ndk builds stay incremental.
|
||||||
|
case "$ANDROID_DIR" in
|
||||||
|
*" "*)
|
||||||
|
build_dir="${TMPDIR:-/tmp}/gen1recomp-android-shadow"
|
||||||
|
say "path contains spaces (ndk-build cannot handle them);"
|
||||||
|
say "shadow-building in: $build_dir"
|
||||||
|
mkdir -p "$build_dir"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude=".gradle" --exclude="app/build" --exclude="love/build" \
|
||||||
|
--exclude="local.properties" \
|
||||||
|
"$ANDROID_DIR/" "$build_dir/"
|
||||||
|
if [ -f "$ANDROID_DIR/local.properties" ]; then
|
||||||
|
cp "$ANDROID_DIR/local.properties" "$build_dir/local.properties"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
say "building APK ($task)"
|
||||||
if ! (
|
if ! (
|
||||||
cd "$ANDROID_DIR"
|
cd "$build_dir"
|
||||||
./gradlew --no-daemon "$task"
|
./gradlew --no-daemon "$task"
|
||||||
); then
|
); then
|
||||||
fail "gradle $task failed.
|
fail "gradle $task failed.
|
||||||
@@ -236,7 +328,7 @@ 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="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
|
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
|
||||||
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 {} \;
|
||||||
|
|||||||
+361
-70
@@ -1,21 +1,26 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 11.5's
|
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 12.0's
|
||||||
# official iOS Xcode project (love-11.5-ios-source.zip).
|
# iOS Xcode project.
|
||||||
#
|
#
|
||||||
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--package-only]
|
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--install]
|
||||||
|
# [--version X.Y.Z] [--package-only]
|
||||||
#
|
#
|
||||||
# (default) Simulator Debug (CODE_SIGNING_ALLOWED=NO)
|
# (default) Simulator Debug (ad-hoc signed)
|
||||||
# --device iphoneos SDK (needs signing / DEVELOPMENT_TEAM)
|
# --device iphoneos SDK; signing team auto-detected from the
|
||||||
|
# keychain when DEVELOPMENT_TEAM is not set
|
||||||
|
# --install after a --device build, install the app onto the
|
||||||
|
# first connected iPhone/iPad (unlock it first)
|
||||||
# --release Release configuration
|
# --release Release configuration
|
||||||
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
|
# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION
|
||||||
|
# --fetch Fetch LÖVE 12.0 sources and Apple dependencies into mobile/ios/love-src/
|
||||||
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
|
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# - macOS + Xcode (xcodebuild)
|
# - macOS + Xcode (xcodebuild)
|
||||||
# - 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/
|
|
||||||
#
|
#
|
||||||
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
|
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
|
||||||
|
# dist/ios/gen1recomp.ipa (device builds only)
|
||||||
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
|
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -35,17 +40,36 @@ LIBS_DIR="$XCODE_DIR/ios/libraries"
|
|||||||
|
|
||||||
APP_NAME="gen1recomp"
|
APP_NAME="gen1recomp"
|
||||||
DISPLAY_NAME="gen1recomp"
|
DISPLAY_NAME="gen1recomp"
|
||||||
BUNDLE_ID="com.theboisclub.pokemonred"
|
# Bundle ID resolution, most specific wins:
|
||||||
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
|
# 1. GEN1_BUNDLE_ID env var
|
||||||
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
|
# 2. mobile/ios/bundle_id.local (one line, gitignored — pins YOUR install
|
||||||
APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip"
|
# so rebuilds keep updating the same app on your phone)
|
||||||
IOS_SOURCE_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${IOS_SOURCE_ZIP}"
|
# 3. device builds: com.gen1recomp.t<your team id> — explicit App IDs are
|
||||||
APPLE_LIBS_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${APPLE_LIBS_ZIP}"
|
# globally unique across ALL Apple accounts (and required once
|
||||||
|
# capabilities like HealthKit are involved), so a per-team default
|
||||||
|
# lets anyone build without colliding with someone else's app
|
||||||
|
# 4. simulator: the project default (no App ID registration involved)
|
||||||
|
BUNDLE_ID="${GEN1_BUNDLE_ID:-com.theboisclub.gen1recomp}"
|
||||||
|
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
|
||||||
|
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
|
||||||
|
fi
|
||||||
|
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 12.0)"
|
||||||
|
LOVE_SOURCE_REF="${LOVE_SOURCE_REF:-main}"
|
||||||
|
APPLE_DEPENDENCIES_REF="${APPLE_DEPENDENCIES_REF:-main}"
|
||||||
|
LOVE_SOURCE_REPO="https://github.com/love2d/love.git"
|
||||||
|
APPLE_DEPENDENCIES_REPO="https://github.com/love2d/love-apple-dependencies.git"
|
||||||
|
|
||||||
FETCH=false
|
FETCH=false
|
||||||
DEVICE=false
|
DEVICE=false
|
||||||
RELEASE=false
|
RELEASE=false
|
||||||
PACKAGE_ONLY=false
|
PACKAGE_ONLY=false
|
||||||
|
INSTALL=false
|
||||||
|
CREATE_IPA=false
|
||||||
|
# Last resort for an incomplete source export, mirroring build_android.sh.
|
||||||
|
MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}"
|
||||||
|
MANIFESTS=""
|
||||||
|
|
||||||
|
VERSION=""
|
||||||
|
|
||||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
@@ -57,15 +81,69 @@ while [ $# -gt 0 ]; do
|
|||||||
--device) DEVICE=true ;;
|
--device) DEVICE=true ;;
|
||||||
--release) RELEASE=true ;;
|
--release) RELEASE=true ;;
|
||||||
--package-only) PACKAGE_ONLY=true ;;
|
--package-only) PACKAGE_ONLY=true ;;
|
||||||
|
--install) INSTALL=true ;;
|
||||||
|
--ipa) CREATE_IPA=true ;;
|
||||||
|
--version) VERSION="$2"; shift ;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
sed -n '2,22p' "$0"
|
sed -n '2,24p' "$0"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*) fail "unknown argument: $1 (try --fetch, --device, --release, or --package-only)" ;;
|
*) fail "unknown argument: $1 (try --fetch, --device, --release, --version, --install, or --package-only)" ;;
|
||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if $CREATE_IPA; then
|
||||||
|
DEVICE=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- signing identity
|
||||||
|
# Auto-detect the Apple Development team when the caller didn't set one.
|
||||||
|
# Prefer a *valid* identity from `find-identity` (the parenthetical there is
|
||||||
|
# the cert id, not the team), then read that cert's OU. Scanning every
|
||||||
|
# "Apple Development" certificate picks expired personal/work certs first.
|
||||||
|
detect_team() {
|
||||||
|
local cn
|
||||||
|
cn="$(security find-identity -v -p codesigning 2>/dev/null \
|
||||||
|
| sed -n -E 's/.*"Apple Development: ([^"]+)".*/\1/p' \
|
||||||
|
| head -1)"
|
||||||
|
[ -n "$cn" ] || return 1
|
||||||
|
security find-certificate -c "Apple Development: $cn" -p 2>/dev/null \
|
||||||
|
| openssl x509 -noout -subject 2>/dev/null \
|
||||||
|
| sed -n 's/.*OU *= *\([A-Z0-9]*\).*/\1/p' \
|
||||||
|
| head -1
|
||||||
|
}
|
||||||
|
if $DEVICE && [ -z "${DEVELOPMENT_TEAM:-}" ]; then
|
||||||
|
DEVELOPMENT_TEAM="$(detect_team || true)"
|
||||||
|
if [ -n "$DEVELOPMENT_TEAM" ]; then
|
||||||
|
say "signing team auto-detected from keychain: $DEVELOPMENT_TEAM"
|
||||||
|
else
|
||||||
|
fail "no Apple signing identity found.
|
||||||
|
Open Xcode -> Settings -> Accounts, press +, and sign in with your
|
||||||
|
Apple ID (a free account works). That creates the certificate this
|
||||||
|
script signs with. Then re-run this command."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -z "$BUNDLE_ID" ]; then
|
||||||
|
if $DEVICE; then
|
||||||
|
BUNDLE_ID="com.gen1recomp.t$(printf '%s' "$DEVELOPMENT_TEAM" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
else
|
||||||
|
BUNDLE_ID="com.theboisclub.pokemonred"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# --------------------------------------------------------------- host checks
|
# --------------------------------------------------------------- host checks
|
||||||
if [ "$(uname -s)" != "Darwin" ]; then
|
if [ "$(uname -s)" != "Darwin" ]; then
|
||||||
fail "iOS builds require macOS (Darwin). This host is $(uname -s).
|
fail "iOS builds require macOS (Darwin). This host is $(uname -s).
|
||||||
@@ -81,25 +159,19 @@ fi
|
|||||||
# --------------------------------------------------------------- fetch love-src
|
# --------------------------------------------------------------- fetch love-src
|
||||||
fetch_love_ios() {
|
fetch_love_ios() {
|
||||||
mkdir -p "$CACHE"
|
mkdir -p "$CACHE"
|
||||||
local zip_path="$CACHE/$IOS_SOURCE_ZIP"
|
|
||||||
if [ ! -f "$zip_path" ]; then
|
|
||||||
say "downloading $IOS_SOURCE_ZIP (LÖVE $LOVE_VERSION iOS sources)"
|
|
||||||
curl -fL --progress-bar "$IOS_SOURCE_URL" -o "$zip_path" \
|
|
||||||
|| fail "download failed: $IOS_SOURCE_URL"
|
|
||||||
else
|
|
||||||
say "using cached $zip_path"
|
|
||||||
fi
|
|
||||||
|
|
||||||
say "extracting into $LOVE_SRC"
|
|
||||||
rm -rf "$LOVE_SRC"
|
|
||||||
local tmp
|
local tmp
|
||||||
tmp="$(mktemp -d "$CACHE/extract.XXXXXX")"
|
tmp="$(mktemp -d "$CACHE/extract.XXXXXX")"
|
||||||
unzip -q "$zip_path" -d "$tmp"
|
say "fetching LÖVE $LOVE_VERSION sources ($LOVE_SOURCE_REF)"
|
||||||
# Zip root is love-<version>-ios-source/
|
git clone --depth 1 --branch "$LOVE_SOURCE_REF" "$LOVE_SOURCE_REPO" "$tmp/love" \
|
||||||
local extracted
|
|| fail "failed to fetch LÖVE sources from $LOVE_SOURCE_REPO"
|
||||||
extracted="$(find "$tmp" -maxdepth 1 -mindepth 1 -type d ! -name '__MACOSX' | head -1)"
|
say "fetching Apple dependencies ($APPLE_DEPENDENCIES_REF)"
|
||||||
[ -n "$extracted" ] || fail "unexpected layout inside $IOS_SOURCE_ZIP"
|
git clone --depth 1 --branch "$APPLE_DEPENDENCIES_REF" "$APPLE_DEPENDENCIES_REPO" "$tmp/dependencies" \
|
||||||
mv "$extracted" "$LOVE_SRC"
|
|| fail "failed to fetch Apple dependencies from $APPLE_DEPENDENCIES_REPO"
|
||||||
|
rm -rf "$LOVE_SRC"
|
||||||
|
mv "$tmp/love" "$LOVE_SRC"
|
||||||
|
mkdir -p "$LIBS_DIR" "$XCODE_DIR/shared"
|
||||||
|
cp -R "$tmp/dependencies/iOS/libraries/." "$LIBS_DIR"
|
||||||
|
cp -R "$tmp/dependencies/shared/." "$XCODE_DIR/shared"
|
||||||
rm -rf "$tmp"
|
rm -rf "$tmp"
|
||||||
say "love-src ready (LÖVE $LOVE_VERSION)"
|
say "love-src ready (LÖVE $LOVE_VERSION)"
|
||||||
}
|
}
|
||||||
@@ -109,18 +181,12 @@ if [ ! -d "$XCODE_DIR/love.xcodeproj" ]; then
|
|||||||
fetch_love_ios
|
fetch_love_ios
|
||||||
else
|
else
|
||||||
fail "LÖVE $LOVE_VERSION iOS sources not found at mobile/ios/love-src/.
|
fail "LÖVE $LOVE_VERSION iOS sources not found at mobile/ios/love-src/.
|
||||||
Fetch them (documented download of love-${LOVE_VERSION}-ios-source.zip):
|
Fetch them:
|
||||||
scripts/build_ios.sh --fetch
|
scripts/build_ios.sh --fetch
|
||||||
Or manually:
|
|
||||||
mkdir -p mobile/ios/cache
|
|
||||||
curl -fL -o mobile/ios/cache/$IOS_SOURCE_ZIP \\
|
|
||||||
$IOS_SOURCE_URL
|
|
||||||
unzip -q mobile/ios/cache/$IOS_SOURCE_ZIP -d mobile/ios/cache
|
|
||||||
mv mobile/ios/cache/love-${LOVE_VERSION}-ios-source mobile/ios/love-src
|
|
||||||
See mobile/ios/README.md."
|
See mobile/ios/README.md."
|
||||||
fi
|
fi
|
||||||
elif $FETCH; then
|
elif $FETCH; then
|
||||||
say "love-src already present; skipping download (delete mobile/ios/love-src to refresh)"
|
say "love-src already present; skipping fetch (delete mobile/ios/love-src to refresh)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
[ -d "$XCODE_DIR/love.xcodeproj" ] \
|
[ -d "$XCODE_DIR/love.xcodeproj" ] \
|
||||||
@@ -128,25 +194,16 @@ fi
|
|||||||
|
|
||||||
# --------------------------------------------------------------- apple libraries
|
# --------------------------------------------------------------- apple libraries
|
||||||
require_ios_libraries() {
|
require_ios_libraries() {
|
||||||
if [ -d "$LIBS_DIR/SDL2.xcframework" ]; then
|
if [ -d "$LIBS_DIR/SDL2.xcframework" ] && [ -d "$XCODE_DIR/shared/Frameworks/SDL3.xcframework" ]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
fail "prebuilt iOS libraries missing at:
|
fail "prebuilt iOS libraries missing at:
|
||||||
$LIBS_DIR
|
$LIBS_DIR
|
||||||
love-ios expects SDL2.xcframework (and friends) there.
|
and shared/Frameworks.
|
||||||
|
|
||||||
The official love-${LOVE_VERSION}-ios-source.zip normally includes them.
|
Re-fetch the LÖVE $LOVE_VERSION source tree and its Apple dependencies:
|
||||||
If they are absent, install love-${LOVE_VERSION}-apple-libraries.zip:
|
|
||||||
|
|
||||||
mkdir -p mobile/ios/cache
|
scripts/build_ios.sh --fetch"
|
||||||
curl -fL -o mobile/ios/cache/$APPLE_LIBS_ZIP \\
|
|
||||||
$APPLE_LIBS_URL
|
|
||||||
unzip -q mobile/ios/cache/$APPLE_LIBS_ZIP -d mobile/ios/cache
|
|
||||||
rm -rf mobile/ios/love-src/platform/xcode/ios/libraries
|
|
||||||
cp -R mobile/ios/cache/love-apple-dependencies/iOS/libraries \\
|
|
||||||
mobile/ios/love-src/platform/xcode/ios/libraries
|
|
||||||
|
|
||||||
See mobile/ios/README.md (Apple libraries dependency)."
|
|
||||||
}
|
}
|
||||||
|
|
||||||
require_ios_libraries
|
require_ios_libraries
|
||||||
@@ -159,24 +216,130 @@ apply_ios_branding() {
|
|||||||
cp "$OVERLAY_PLIST" "$dest"
|
cp "$OVERLAY_PLIST" "$dest"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apply_ios_icon() {
|
||||||
|
local source="$ROOT/assets/logo/logo.png"
|
||||||
|
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
|
||||||
|
[ -f "$source" ] || fail "missing iOS icon source: $source"
|
||||||
|
[ -d "$target" ] || fail "missing iOS app icon set: $target"
|
||||||
|
local entry name size
|
||||||
|
while IFS=: read -r name size; do
|
||||||
|
sips -z "$size" "$size" "$source" --out "$target/$name" >/dev/null
|
||||||
|
done <<'EOF'
|
||||||
|
icon-1024pt@1x.png:1024
|
||||||
|
icon-29pt@1x.png:29
|
||||||
|
icon-29pt@2x.png:58
|
||||||
|
icon-29pt@3x.png:87
|
||||||
|
icon-40pt@1x.png:40
|
||||||
|
icon-40pt@2x.png:80
|
||||||
|
icon-40pt@3x.png:120
|
||||||
|
icon-60pt@2x.png:120
|
||||||
|
icon-60pt@3x.png:180
|
||||||
|
icon-76pt@1x.png:76
|
||||||
|
icon-76pt@2x.png:152
|
||||||
|
icon-83.5pt@2x.png:167
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- game.love
|
# --------------------------------------------------------------- game.love
|
||||||
|
# Every version's import manifest has to ship or that game's ROM import fails in
|
||||||
|
# the built app: decodeManifest (src/import/RomImporter.lua) errors outright when
|
||||||
|
# one is absent, and dev reads them off the source tree, so the miss only ever
|
||||||
|
# shows up in a build. iOS shipped without the Yellow one in 0.1.45 to 0.1.47
|
||||||
|
# for exactly that reason.
|
||||||
|
#
|
||||||
|
# The list is READ OUT OF src/core/GameVersion.lua rather than hand-kept here, so
|
||||||
|
# a fourth version cannot silently ship without its manifest, and a missing file
|
||||||
|
# is recovered from Git or the project repo the same way build_android.sh already
|
||||||
|
# recovers Yellow's. Recovery is a last resort for an incomplete source export:
|
||||||
|
# a manifest carries extraction metadata only, never a ROM or game data.
|
||||||
|
manifest_paths() {
|
||||||
|
python3 - "$ROOT/src/core/GameVersion.lua" <<'PY'
|
||||||
|
import re, sys
|
||||||
|
src = open(sys.argv[1]).read()
|
||||||
|
print(" ".join(dict.fromkeys(re.findall(r'manifest\s*=\s*"([^"]+)"', src))))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest_is_valid() {
|
||||||
|
python3 - "$1" <<'PY'
|
||||||
|
import json, pathlib, sys
|
||||||
|
try:
|
||||||
|
m = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise SystemExit(1)
|
||||||
|
sha = m.get("romSha1")
|
||||||
|
raise SystemExit(0 if isinstance(sha, str) and len(sha) == 40 else 1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_manifests() {
|
||||||
|
MANIFESTS="$(manifest_paths)"
|
||||||
|
[ -n "$MANIFESTS" ] \
|
||||||
|
|| fail "could not read any manifest path out of src/core/GameVersion.lua"
|
||||||
|
local rel staged
|
||||||
|
for rel in $MANIFESTS; do
|
||||||
|
if manifest_is_valid "$ROOT/$rel"; then continue; fi
|
||||||
|
warn "$rel is missing or invalid; recovering it before packaging"
|
||||||
|
staged="$(mktemp)"
|
||||||
|
if git -C "$ROOT" show "HEAD:$rel" > "$staged" 2>/dev/null \
|
||||||
|
&& manifest_is_valid "$staged"; then
|
||||||
|
mkdir -p "$ROOT/$(dirname "$rel")"
|
||||||
|
mv "$staged" "$ROOT/$rel"
|
||||||
|
say "restored $rel from this checkout's Git data"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if command -v curl >/dev/null 2>&1 \
|
||||||
|
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||||
|
--output "$staged" "$MANIFEST_BASE_URL/$rel" \
|
||||||
|
&& manifest_is_valid "$staged"; then
|
||||||
|
mkdir -p "$ROOT/$(dirname "$rel")"
|
||||||
|
mv "$staged" "$ROOT/$rel"
|
||||||
|
say "downloaded $rel from the project repository"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
rm -f "$staged"
|
||||||
|
fail "$rel is unavailable: Git recovery failed and $MANIFEST_BASE_URL/$rel could not be downloaded"
|
||||||
|
done
|
||||||
|
say "import manifests: $MANIFESTS"
|
||||||
|
}
|
||||||
|
|
||||||
pack_game_love() {
|
pack_game_love() {
|
||||||
say "packing game.love for love-ios resources"
|
say "packing game.love for love-ios resources"
|
||||||
mkdir -p "$RESOURCES_DIR"
|
mkdir -p "$RESOURCES_DIR"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
# Same payload as scripts/build.sh / build_android.sh: game sources plus
|
# Same payload as scripts/build.sh / build_android.sh: game sources plus
|
||||||
# tools/save-editor, which the launcher's Edit button opens in-process.
|
# tools/save-editor, which the launcher's Edit button opens in-process.
|
||||||
|
# Deliberately NO fused mods: a mod inside game.love sits in the
|
||||||
|
# read-only app bundle, so the mod manager's Delete can't remove it and
|
||||||
|
# it reappears every launch. Mods install as .zips at runtime instead
|
||||||
|
# (launcher -> MODS -> Import mod .zip), the same lifecycle as every
|
||||||
|
# other platform.
|
||||||
|
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
|
||||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||||
main.lua conf.lua src data assets tools/save-editor \
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
$MANIFESTS \
|
||||||
-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/*')
|
||||||
|
# NOTE: grep -q here would race pipefail — it exits on first match, unzip
|
||||||
|
# dies of SIGPIPE (141), and the pipeline "fails" nondeterministically.
|
||||||
|
# >/dev/null keeps grep reading the whole stream instead.
|
||||||
if unzip -Z1 "$LOVE_FILE" \
|
if unzip -Z1 "$LOVE_FILE" \
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
| grep -E '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' >/dev/null; then
|
||||||
fail "game.love unexpectedly contains generated ROM data"
|
fail "game.love unexpectedly contains generated ROM data"
|
||||||
fi
|
fi
|
||||||
unzip -Z1 "$LOVE_FILE" | grep -qx 'tools/save-editor/App.lua' \
|
# Same required-file gate as scripts/build.sh and scripts/build_android.sh.
|
||||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
# iOS only checked App.lua, which is why the Yellow manifest shipped missing
|
||||||
|
# in 0.1.45 through 0.1.47: decodeManifest (src/import/RomImporter.lua) errors
|
||||||
|
# outright when a version's manifest is absent, so Import ROM on Yellow died
|
||||||
|
# in the built app while dev, which reads the source tree, stayed green.
|
||||||
|
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
|
||||||
|
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
|
||||||
|
for required in src/update/Boot.lua tools/save-editor/App.lua \
|
||||||
|
tools/save-editor/Kit.lua tools/save-editor/panels/Party.lua \
|
||||||
|
$MANIFESTS; do
|
||||||
|
printf '%s\n' "$archive_entries" | grep -qx "$required" \
|
||||||
|
|| fail "game.love is missing $required"
|
||||||
|
done
|
||||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +434,43 @@ PY
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- xcodebuild
|
# --------------------------------------------------------------- xcodebuild
|
||||||
|
# love.system.pickFile and createFile are a native bridge compiled in by
|
||||||
|
# mobile/ios/patch_love_src.py, not part of LÖVE. A build that skipped the
|
||||||
|
# patch still links and still runs, then finds the field nil the moment anyone
|
||||||
|
# taps Import ROM (#482). #539 made that degrade to the copy-into-Files flow
|
||||||
|
# rather than crash, which is the right floor, but a build with no picker at all
|
||||||
|
# is a silent downgrade, so fail here instead of shipping one.
|
||||||
|
#
|
||||||
|
# Checked against the built binary rather than the source, because patching
|
||||||
|
# love-src proves nothing about what Xcode actually compiled: the shipped
|
||||||
|
# 0.1.45/0.1.46/0.1.47 IPAs all DO carry the bridge, so the reports that blamed
|
||||||
|
# a missing patch step were self-built IPAs, exactly the case this catches.
|
||||||
|
verify_native_bridge() {
|
||||||
|
local app="$1"
|
||||||
|
local exe bin missing=""
|
||||||
|
exe="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \
|
||||||
|
"$app/Info.plist" 2>/dev/null || true)"
|
||||||
|
bin="$app/${exe:-love}"
|
||||||
|
[ -f "$bin" ] || bin="$app/love"
|
||||||
|
if [ ! -f "$bin" ]; then
|
||||||
|
warn "no executable inside $(basename "$app"); skipping native bridge check"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# grep -q here would race pipefail the same way pack_game_love documents:
|
||||||
|
# it exits on first match, strings dies of SIGPIPE, the pipeline "fails"
|
||||||
|
# nondeterministically. >/dev/null keeps grep reading the whole stream.
|
||||||
|
for sym in pickFile createFile; do
|
||||||
|
strings -a "$bin" | grep -x "$sym" >/dev/null || missing="$missing $sym"
|
||||||
|
done
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
fail "built app has no native bridge (missing:$missing).
|
||||||
|
Import ROM would fall back to copy-into-Files instead of opening the picker.
|
||||||
|
mobile/ios/patch_love_src.py did not take. Re-run:
|
||||||
|
scripts/build_ios.sh --fetch && scripts/build_ios.sh"
|
||||||
|
fi
|
||||||
|
say "native bridge present (pickFile, createFile)"
|
||||||
|
}
|
||||||
|
|
||||||
run_xcodebuild() {
|
run_xcodebuild() {
|
||||||
local config sdk destination
|
local config sdk destination
|
||||||
if $RELEASE; then
|
if $RELEASE; then
|
||||||
@@ -291,6 +491,15 @@ run_xcodebuild() {
|
|||||||
|
|
||||||
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
|
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
|
||||||
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
|
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
|
||||||
|
# Always stamp both: the overlay plist expands $(MARKETING_VERSION) /
|
||||||
|
# $(CURRENT_PROJECT_VERSION), and love-ios has no project-level defaults.
|
||||||
|
local marketing_version="$LOVE_VERSION"
|
||||||
|
local project_version="1"
|
||||||
|
if [ -n "$VERSION" ]; then
|
||||||
|
marketing_version="$VERSION"
|
||||||
|
project_version="$VERSION_CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
local args=(
|
local args=(
|
||||||
-project "$PROJECT"
|
-project "$PROJECT"
|
||||||
-target love-ios
|
-target love-ios
|
||||||
@@ -300,18 +509,26 @@ run_xcodebuild() {
|
|||||||
SYMROOT="$BUILD_DIR/Build/Products"
|
SYMROOT="$BUILD_DIR/Build/Products"
|
||||||
OBJROOT="$BUILD_DIR/Build/Intermediates"
|
OBJROOT="$BUILD_DIR/Build/Intermediates"
|
||||||
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
|
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
|
||||||
PRODUCT_NAME="$APP_NAME"
|
MARKETING_VERSION="$marketing_version"
|
||||||
MARKETING_VERSION="$LOVE_VERSION"
|
CURRENT_PROJECT_VERSION="$project_version"
|
||||||
ONLY_ACTIVE_ARCH=NO
|
ONLY_ACTIVE_ARCH=NO
|
||||||
)
|
)
|
||||||
|
|
||||||
if ! $DEVICE; then
|
if ! $DEVICE; then
|
||||||
# Simulator: no signing required
|
# Simulator: ad-hoc signing (no certificate needed). A plain unsigned
|
||||||
args+=(CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=)
|
# build would drop the entitlements file, and HealthKit refuses to run
|
||||||
|
# without the com.apple.developer.healthkit entitlement even in the
|
||||||
|
# simulator.
|
||||||
|
args+=(CODE_SIGNING_ALLOWED=YES CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY=-)
|
||||||
else
|
else
|
||||||
warn "device build: configure signing in Xcode or set DEVELOPMENT_TEAM / CODE_SIGN_IDENTITY"
|
warn "device build: configure signing in Xcode or set DEVELOPMENT_TEAM / CODE_SIGN_IDENTITY"
|
||||||
if [ -n "${DEVELOPMENT_TEAM:-}" ]; then
|
if [ -n "${DEVELOPMENT_TEAM:-}" ]; then
|
||||||
args+=(DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM")
|
# Automatic signing + provisioning updates lets xcodebuild register the
|
||||||
|
# bundle ID / create a development profile from the CLI, so a device
|
||||||
|
# build works without ever opening the project in Xcode.
|
||||||
|
args+=(DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM"
|
||||||
|
CODE_SIGN_STYLE=Automatic
|
||||||
|
-allowProvisioningUpdates)
|
||||||
fi
|
fi
|
||||||
if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then
|
if [ -n "${CODE_SIGN_IDENTITY:-}" ]; then
|
||||||
args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY")
|
args+=(CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY")
|
||||||
@@ -326,11 +543,27 @@ run_xcodebuild() {
|
|||||||
|
|
||||||
say "xcodebuild love-ios ($config / $sdk)"
|
say "xcodebuild love-ios ($config / $sdk)"
|
||||||
set +e
|
set +e
|
||||||
(
|
local xc_status
|
||||||
cd "$XCODE_DIR"
|
if command -v xcbeautify >/dev/null 2>&1; then
|
||||||
xcodebuild "${args[@]}"
|
(
|
||||||
)
|
cd "$XCODE_DIR"
|
||||||
local xc_status=$?
|
xcodebuild "${args[@]}"
|
||||||
|
) 2>&1 | xcbeautify
|
||||||
|
local pipeline_status=("${PIPESTATUS[@]}")
|
||||||
|
local xcode_status=${pipeline_status[0]}
|
||||||
|
local beautify_status=${pipeline_status[1]}
|
||||||
|
if [ "$xcode_status" -ne 0 ]; then
|
||||||
|
xc_status=$xcode_status
|
||||||
|
else
|
||||||
|
xc_status=$beautify_status
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
(
|
||||||
|
cd "$XCODE_DIR"
|
||||||
|
xcodebuild "${args[@]}"
|
||||||
|
)
|
||||||
|
xc_status=$?
|
||||||
|
fi
|
||||||
set -e
|
set -e
|
||||||
if [ "$xc_status" -ne 0 ]; then
|
if [ "$xc_status" -ne 0 ]; then
|
||||||
fail "xcodebuild failed (exit $xc_status).
|
fail "xcodebuild failed (exit $xc_status).
|
||||||
@@ -361,23 +594,81 @@ run_xcodebuild() {
|
|||||||
cp "$LOVE_FILE" "$app/game.love"
|
cp "$LOVE_FILE" "$app/game.love"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
verify_native_bridge "$app"
|
||||||
|
|
||||||
local dist_dir="$DIST/${config}-${sdk}"
|
local dist_dir="$DIST/${config}-${sdk}"
|
||||||
rm -rf "$dist_dir"
|
rm -rf "$dist_dir"
|
||||||
mkdir -p "$dist_dir"
|
mkdir -p "$dist_dir"
|
||||||
cp -R "$app" "$dist_dir/"
|
cp -R "$app" "$dist_dir/$APP_NAME.app"
|
||||||
say "copied to $dist_dir/$(basename "$app")"
|
say "copied to $dist_dir/$APP_NAME.app"
|
||||||
|
|
||||||
|
if $DEVICE; then
|
||||||
|
package_ipa "$dist_dir/$APP_NAME.app"
|
||||||
|
fi
|
||||||
|
|
||||||
say "iOS app: $app"
|
say "iOS app: $app"
|
||||||
say "bundle id: $BUNDLE_ID display: $DISPLAY_NAME"
|
say "bundle id: $BUNDLE_ID display: $DISPLAY_NAME"
|
||||||
if $DEVICE; then
|
if $DEVICE; then
|
||||||
warn "signing/provisioning is manual, see mobile/ios/README.md"
|
if $INSTALL; then
|
||||||
|
install_to_device "$app"
|
||||||
|
else
|
||||||
|
say "install with: scripts/build_ios.sh --device --install (iPhone plugged in + unlocked)"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
say "simulator tip: xcrun simctl install booted \"$app\""
|
say "simulator tip: xcrun simctl install booted \"$app\""
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Pack Payload/<app>.app into dist/ios/gen1recomp.ipa for release / sideload tools.
|
||||||
|
package_ipa() {
|
||||||
|
local app="$1"
|
||||||
|
local ipa="$DIST/$APP_NAME.ipa"
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp -d "$DIST/ipa.XXXXXX")"
|
||||||
|
mkdir -p "$tmp/Payload"
|
||||||
|
cp -R "$app" "$tmp/Payload/$(basename "$app")"
|
||||||
|
rm -f "$ipa"
|
||||||
|
(cd "$tmp" && zip -q -r "$ipa" Payload)
|
||||||
|
rm -rf "$tmp"
|
||||||
|
say "ipa: $ipa ($(du -h "$ipa" | cut -f1))"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ device install
|
||||||
|
# Installs the freshly built .app onto the first connected iPhone/iPad via
|
||||||
|
# devicectl. The phone must be paired (plugged in at least once + "Trust
|
||||||
|
# This Computer") and UNLOCKED during the install.
|
||||||
|
install_to_device() {
|
||||||
|
local app="$1"
|
||||||
|
local line udid
|
||||||
|
line="$(xcrun devicectl list devices 2>/dev/null \
|
||||||
|
| grep -E 'iPhone|iPad' | grep -v 'Watch' | head -1 || true)"
|
||||||
|
udid="$(printf '%s' "$line" \
|
||||||
|
| grep -Eo '[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}' \
|
||||||
|
| head -1 || true)"
|
||||||
|
if [ -z "$udid" ]; then
|
||||||
|
fail "no iPhone/iPad found.
|
||||||
|
Plug the phone in with a cable, unlock it, tap 'Trust This Computer'
|
||||||
|
if asked, then re-run: scripts/build_ios.sh --device --install"
|
||||||
|
fi
|
||||||
|
say "installing onto: $(printf '%s' "$line" | sed 's/ .*//') ($udid)"
|
||||||
|
if xcrun devicectl device install app --device "$udid" "$app"; then
|
||||||
|
say "installed. On the phone: tap the new app on your Home Screen."
|
||||||
|
say "first launch may ask you to enable Developer Mode (Settings ->"
|
||||||
|
say "Privacy & Security -> Developer Mode) and to trust the developer"
|
||||||
|
say "(Settings -> General -> VPN & Device Management)."
|
||||||
|
else
|
||||||
|
fail "install failed. Most common cause: the phone was locked.
|
||||||
|
Unlock it, keep it plugged in, and re-run:
|
||||||
|
scripts/build_ios.sh --device --install"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- main
|
# --------------------------------------------------------------- main
|
||||||
apply_ios_branding
|
apply_ios_branding
|
||||||
|
apply_ios_icon
|
||||||
|
say "applying iOS native bridge patches (picker/Files support)"
|
||||||
|
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
|
||||||
|
ensure_manifests
|
||||||
pack_game_love
|
pack_game_love
|
||||||
ensure_game_love_in_xcode
|
ensure_game_love_in_xcode
|
||||||
|
|
||||||
|
|||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Static analysis for the engine Lua (config: .luacheckrc).
|
||||||
|
#
|
||||||
|
# Complements scripts/test.sh: the tests prove behavior, luacheck catches the
|
||||||
|
# defects that never run in a green test -- undefined globals/locals (the
|
||||||
|
# class that hid a music.volume crash: a hook read a `state` that was still
|
||||||
|
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
|
||||||
|
# cosmetic categories the codebase lives with, so what prints is worth a look.
|
||||||
|
#
|
||||||
|
# scripts/lint.sh lint src/
|
||||||
|
# scripts/lint.sh src tools lint specific paths
|
||||||
|
#
|
||||||
|
# Install once with: luarocks install luacheck
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
if ! command -v luacheck >/dev/null 2>&1; then
|
||||||
|
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
luacheck "${@:-src}"
|
||||||
+7
-4
@@ -1,11 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build game data from a user-provided Pokemon Red ROM and install LÖVE.
|
# Build game data from a user-provided Pokemon Red/Blue/Yellow ROM and install LÖVE.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# scripts/setup.sh --rom /path/to/pokemon-red.gb
|
# scripts/setup.sh --rom /path/to/pokemon-red.gb
|
||||||
|
# scripts/setup.sh --rom /path/to/pokemon-yellow.gbc
|
||||||
# ROM_PATH=/path/to/pokemon-red.gb scripts/setup.sh
|
# ROM_PATH=/path/to/pokemon-red.gb scripts/setup.sh
|
||||||
#
|
#
|
||||||
# With no explicit path, the first *.gb file in the project root is used.
|
# With no explicit path, the first *.gb / *.gbc file in the project root is used.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -34,15 +35,17 @@ command -v python3 >/dev/null 2>&1 \
|
|||||||
|| fail "Python 3 is required to decode the ROM"
|
|| fail "Python 3 is required to decode the ROM"
|
||||||
|
|
||||||
if [ -z "$ROM" ]; then
|
if [ -z "$ROM" ]; then
|
||||||
for candidate in "$ROOT"/*.gb; do
|
shopt -s nullglob
|
||||||
|
for candidate in "$ROOT"/*.gb "$ROOT"/*.gbc; do
|
||||||
if [ -f "$candidate" ]; then
|
if [ -f "$candidate" ]; then
|
||||||
ROM="$candidate"
|
ROM="$candidate"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
shopt -u nullglob
|
||||||
fi
|
fi
|
||||||
[ -n "$ROM" ] && [ -f "$ROM" ] \
|
[ -n "$ROM" ] && [ -f "$ROM" ] \
|
||||||
|| fail "Pokemon Red ROM not found. Put your .gb file in $ROOT or pass --rom /path/to/file.gb"
|
|| fail "Pokemon ROM not found. Put a .gb or .gbc in $ROOT or pass --rom /path/to/file"
|
||||||
|
|
||||||
if [ ! -x "$VENV/bin/python3" ]; then
|
if [ ! -x "$VENV/bin/python3" ]; then
|
||||||
say "creating Python environment"
|
say "creating Python environment"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ local NOTES = {
|
|||||||
-- mirrors ChipAudio's snapTicks so authored drums land on the same sample
|
-- mirrors ChipAudio's snapTicks so authored drums land on the same sample
|
||||||
-- grid as the ROM's own drum tables
|
-- grid as the ROM's own drum tables
|
||||||
local function snapTicks(ticks)
|
local function snapTicks(ticks)
|
||||||
return math.floor((ticks * 735 + 256) / 512)
|
return math.floor((ticks * 1470 + 256) / 512)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------- validation
|
-- ------- validation
|
||||||
|
|||||||
+486
-113
@@ -4,7 +4,7 @@
|
|||||||
-- Flow: intro -> menu (FIGHT/PKMN/ITEM/RUN) -> move select -> turn
|
-- Flow: intro -> menu (FIGHT/PKMN/ITEM/RUN) -> move select -> turn
|
||||||
-- resolution (a queue of messages/actions/UI pushes) -> back to menu,
|
-- resolution (a queue of messages/actions/UI pushes) -> back to menu,
|
||||||
-- until one side is out, then finish. Pops itself and calls
|
-- until one side is out, then finish. Pops itself and calls
|
||||||
-- onFinish("win"|"lose"|"run"|"caught"|"skipped").
|
-- onFinish("win"|"lose"|"run"|"caught").
|
||||||
--
|
--
|
||||||
-- The Gen 1 move-effect pipeline (multi-hit, charge, trapping, thrash,
|
-- The Gen 1 move-effect pipeline (multi-hit, charge, trapping, thrash,
|
||||||
-- bide, recharge, confusion, screens, substitute, transform, ...) is
|
-- bide, recharge, confusion, screens, substitute, transform, ...) is
|
||||||
@@ -38,15 +38,16 @@ BattleState.letterboxWhite = true
|
|||||||
|
|
||||||
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
|
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
|
||||||
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
|
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
|
||||||
-- differs; every battler, queue and animation below is shared. The wide
|
-- differs; every battler, queue and animation below is shared. Menus and
|
||||||
-- layout is live only while this battle is the state being drawn on top --
|
-- prompts pushed during a wide battle keep its wide canvas, while drawing
|
||||||
-- a party menu or bag pushed over it is a 160x144 screen, so the surface
|
-- their classic 160px UI centred within it (Game:draw).
|
||||||
-- goes back with it and the battle underneath is not drawn at all.
|
function BattleState:isWideBattleLayout()
|
||||||
function BattleState:wideLayout()
|
|
||||||
local options = self.game and self.game.save and self.game.save.options
|
local options = self.game and self.game.save and self.game.save.options
|
||||||
if not options or options.battleLayout ~= "wide" then return false end
|
return options and options.battleLayout == "wide" or false
|
||||||
local stack = self.game.stack
|
end
|
||||||
return (stack and stack.top and stack:top()) == self
|
|
||||||
|
function BattleState:wideLayout()
|
||||||
|
return self:isWideBattleLayout()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Renderer:setUISize asks the top state for its surface before anything draws
|
-- Renderer:setUISize asks the top state for its surface before anything draws
|
||||||
@@ -187,6 +188,36 @@ local function namedPalette(data, name)
|
|||||||
return { name = key, colors = colors }
|
return { name = key, colors = colors }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Custom trainer portraits can opt into the same Advanced OBJ palette source
|
||||||
|
-- as their overworld walker. Vanilla trainers preserve the hardware-faithful
|
||||||
|
-- MEWMON fallback used during the battle introduction.
|
||||||
|
function BattleState.trainerPalette(data, trainer)
|
||||||
|
local source = trainer and trainer.paletteSource
|
||||||
|
if source then
|
||||||
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
local colors, group = PaletteFX.spriteObp({ paletteSource = source }, trainer.id)
|
||||||
|
if colors then
|
||||||
|
return { name = "trainer:" .. source .. ":" .. tostring(group), colors = colors }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return namedPalette(data, "MEWMON")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Yellow only: ROCKET with wTrainerNo >= $2a is Jessie & James, who share
|
||||||
|
-- the class and the name "ROCKET" but battle behind their own pic
|
||||||
|
-- (home/trainers2.asm IsFightingJessieJames). picJessieJames exists only
|
||||||
|
-- in a Yellow cache extracted after #439, so an older cache keeps the
|
||||||
|
-- grunt pic until it is re-imported.
|
||||||
|
function BattleState.trainerPicPath(data, trainer, oppClass, partyIndex)
|
||||||
|
if oppClass == "OPP_ROCKET" and (partyIndex or 1) >= 42
|
||||||
|
and trainer and trainer.picJessieJames then
|
||||||
|
return trainer.picJessieJames
|
||||||
|
end
|
||||||
|
if trainer and trainer.pic then return trainer.pic end
|
||||||
|
local base = trainer and trainer.basePic and data.trainers[trainer.basePic]
|
||||||
|
return base and base.pic or nil
|
||||||
|
end
|
||||||
|
|
||||||
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
|
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
|
||||||
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB
|
-- SetAnimationBGPalette effects remap the four BG shades; on the SGB
|
||||||
-- the colorizer then colors the REMAPPED shade, so a faded pic shows
|
-- the colorizer then colors the REMAPPED shade, so a faded pic shows
|
||||||
@@ -260,10 +291,17 @@ function BattleState:picImage(img)
|
|||||||
or PaletteFX.mode == "classic"
|
or PaletteFX.mode == "classic"
|
||||||
if self.grayPics or mono then return grayImage(img) end
|
if self.grayPics or mono then return grayImage(img) end
|
||||||
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go
|
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go
|
||||||
-- dark with the HP bars while the blackout text is up (#292). Below the
|
-- dark with the HP bars while the blackout text is up (#292). The intro
|
||||||
-- mono check on purpose: the forced-mono modes re-threshold the whole
|
-- silhouette slide (SlidePlayerAndEnemySilhouettesOnScreen) darkens the
|
||||||
-- frame downstream, and the DMG had no SGB darkening to begin with.
|
-- same way: the original slides both pics in under the %11100100
|
||||||
if self.blackedOut then return blackImage(self.data, img) end
|
-- silhouette palette and only runs SET_PAL_BATTLE once they have landed,
|
||||||
|
-- so a still-sliding pic reads as a black silhouette, exactly like the
|
||||||
|
-- evolution movie's PAL_BLACK (#577). Below the mono check on purpose:
|
||||||
|
-- the forced-mono modes re-threshold the whole frame downstream, and the
|
||||||
|
-- DMG had no SGB darkening to begin with.
|
||||||
|
if self.blackedOut or (self.introSlide or 0) > 0 then
|
||||||
|
return blackImage(self.data, img)
|
||||||
|
end
|
||||||
return fadeImage(img, self:activeBgp())
|
return fadeImage(img, self:activeBgp())
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -607,22 +645,70 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
|||||||
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
|
-- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes
|
||||||
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
|
-- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE
|
||||||
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
|
-- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle)
|
||||||
self.trainerPic = getImage(self.trainer.pic, namedPalette(game.data, "MEWMON"))
|
self.trainerPic = getImage(
|
||||||
|
BattleState.trainerPicPath(game.data, self.trainer, oppClass, partyIndex),
|
||||||
|
BattleState.trainerPalette(game.data, self.trainer))
|
||||||
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
|
self.introText = Strings("%s wants\nto fight!", self.trainer.name)
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The disguise itself. InitWildBattle .isGhost (engine/battle/core.asm)
|
||||||
|
-- swaps only the pic and the nick -- wEnemyMonSpecies2 still holds the real
|
||||||
|
-- species, so the SGB palette stays the disguised mon's. ghostReal keeps
|
||||||
|
-- what LoadEnemyMonData puts back when the scope unveils.
|
||||||
|
local function disguiseAsGhost(self)
|
||||||
|
self.ghostReal = { name = self.enemy.name, sprite = self.enemy.sprite }
|
||||||
|
self.enemy.name = "GHOST"
|
||||||
|
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
|
||||||
|
monPalette(self.data, self.enemy.mon.species))
|
||||||
|
self.introText = Strings("The GHOST\nappeared!")
|
||||||
|
end
|
||||||
|
|
||||||
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
|
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
|
||||||
-- the enemy is "GHOST", you're too scared to attack, and balls fail.
|
-- the enemy is "GHOST", you're too scared to attack, and balls fail.
|
||||||
function BattleState:makeGhost()
|
function BattleState:makeGhost()
|
||||||
self.ghost = true
|
self.ghost = true
|
||||||
self.enemy.name = "GHOST"
|
disguiseAsGhost(self)
|
||||||
-- the ghost keeps the disguised mon's SGB palette: InitWildBattle
|
end
|
||||||
-- swaps only the pic, wEnemyMonSpecies2 still holds the real species
|
|
||||||
-- (engine/battle/core.asm InitWildBattle .isGhost)
|
-- The RESTLESS SOUL with the SILPH SCOPE in the bag. Mechanically this is
|
||||||
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
|
-- not a ghost battle at all -- IsGhostBattle (core.asm) returns false the
|
||||||
monPalette(self.data, self.enemy.mon.species))
|
-- moment the scope is in the bag, so the mon attacks, is attacked and rolls
|
||||||
self.introText = Strings("The GHOST\nappeared!")
|
-- for flight normally -- but InitWildBattle still enters disguised: its
|
||||||
|
-- .isGhost branch fires on wCurOpponent == RESTLESS_SOUL as well. Taking
|
||||||
|
-- the disguise off is PrintBeginningBattleText .isMarowak's job
|
||||||
|
-- (engine/battle/common_text.asm), which enter() queues through
|
||||||
|
-- queueScopeReveal (#492).
|
||||||
|
function BattleState:makeUnveiledGhost()
|
||||||
|
self.scopeReveal = true
|
||||||
|
disguiseAsGhost(self)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- MarowakAnim (engine/battle/ghost_marowak_anim.asm) counted in frames: the
|
||||||
|
-- ghost pic is copied into OAM and flashed 8 times by xor-ing rOBP1 with
|
||||||
|
-- $80 (10 frames a side), the BG pic underneath is swapped to the RESTLESS
|
||||||
|
-- SOUL, rOBP1 is shifted left two bits every 10 frames until the ghost has
|
||||||
|
-- faded into the paper (3 shifts), and the Marowak copy fades back in the
|
||||||
|
-- same way (4 shifts). Alpha over the paper is what those OBP shifts look
|
||||||
|
-- like on screen.
|
||||||
|
BattleState.GHOST_FLASH_FRAMES = 80
|
||||||
|
BattleState.GHOST_FADE_OUT_FRAMES = 30
|
||||||
|
BattleState.GHOST_FADE_IN_FRAMES = 40
|
||||||
|
BattleState.GHOST_REVEAL_FRAMES = 150
|
||||||
|
|
||||||
|
-- The rows .isMarowak prints over the disguise, after the "GHOST appeared!"
|
||||||
|
-- box enter() has already queued: the unveil line, MarowakAnim, then "Wild
|
||||||
|
-- MAROWAK appeared!" under the restored name. The fall-through into
|
||||||
|
-- .playSFX at the end is the same SFX_TRAINER_APPEARED the port does not
|
||||||
|
-- play for trainer intros either, so it is left out here too.
|
||||||
|
function BattleState:queueScopeReveal()
|
||||||
|
local unveiled = self.data.text and self.data.text._UnveiledGhostText
|
||||||
|
self:say(unveiled
|
||||||
|
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
|
||||||
|
self:act(function() self.ghostReveal = { t = 0 } end)
|
||||||
|
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
|
||||||
|
self:say(Strings("Wild %s\nappeared!",
|
||||||
|
self.ghostReal and self.ghostReal.name or self.enemy.name))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN,
|
-- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN,
|
||||||
@@ -630,8 +716,28 @@ end
|
|||||||
-- player mon; the battle menu appears under the OLD MAN's name and a
|
-- player mon; the battle menu appears under the OLD MAN's name and a
|
||||||
-- scripted cursor hovers FIGHT, hops to ITEM and forces the item menu
|
-- scripted cursor hovers FIGHT, hops to ITEM and forces the item menu
|
||||||
-- (one POKé BALL x50). The throw always catches; nothing is kept.
|
-- (one POKé BALL x50). The throw always catches; nothing is kept.
|
||||||
function BattleState:makeOldManDemo()
|
-- Yellow's Pallet intro (BATTLE_TYPE_PIKACHU) is the same simulated
|
||||||
|
-- script under "PROF.OAK" (pokeyellow core.asm .profOakName), so the
|
||||||
|
-- displayed thrower name is a parameter.
|
||||||
|
function BattleState:makeOldManDemo(name)
|
||||||
self.demo = true
|
self.demo = true
|
||||||
|
self.demoName = name or "OLD MAN"
|
||||||
|
-- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType:
|
||||||
|
-- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU
|
||||||
|
-- gets .profOakName + ProfOakPicBack (pokeyellow core.asm). The thrower
|
||||||
|
-- name the caller passes IS that distinction here, so it picks the pic too
|
||||||
|
-- -- data/scripts/story2.lua is the only site that names PROF.OAK (#557).
|
||||||
|
self.oakDemo = name == "PROF.OAK"
|
||||||
|
-- Yellow's Pallet intro runs this before the player owns any mon
|
||||||
|
-- (BATTLE_TYPE_PIKACHU precedes the lab gift), so newWild flagged the
|
||||||
|
-- battle dead for lack of a party. The demo never sends out, draws, or
|
||||||
|
-- acts with the player side; a hidden placeholder battler keeps the
|
||||||
|
-- shared battle phases nil-safe.
|
||||||
|
if not self.player then
|
||||||
|
self.dead = false
|
||||||
|
self.player = makeBattler(self.game.data,
|
||||||
|
Pokemon.new(self.game.data, self.enemy.mon.species, 5), true)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Safari Zone battles (engine/battle/core.asm safari sections +
|
-- Safari Zone battles (engine/battle/core.asm safari sections +
|
||||||
@@ -725,10 +831,17 @@ function BattleState:buildScreen(id, ...)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- insert a wait for the HP bars to finish draining (UpdateHPBar):
|
-- insert a wait for the HP bars to finish draining (UpdateHPBar):
|
||||||
-- the queue holds until every battler's displayed HP catches up
|
-- the queue holds until every battler's displayed HP catches up.
|
||||||
function BattleState:drainNext()
|
-- `stopAt` pins how far that battler's bar may drain on this row. A
|
||||||
|
-- multi-hit move takes every strike off the model while the turn is still
|
||||||
|
-- being queued, so an unpinned row would drain straight to the
|
||||||
|
-- post-last-hit HP and the later strikes would animate nothing (#394);
|
||||||
|
-- ApplyDamageToEnemyPokemon runs UpdateHPBar2 once per strike inside the
|
||||||
|
-- wNumAttacksLeft loop (engine/battle/core.asm:4727).
|
||||||
|
function BattleState:drainNext(battler, stopAt)
|
||||||
self.nextInsert = (self.nextInsert or 0) + 1
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
table.insert(self.queue, self.nextInsert, { drain = true })
|
table.insert(self.queue, self.nextInsert,
|
||||||
|
{ drain = true, battler = battler, stopAt = stopAt })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar):
|
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar):
|
||||||
@@ -737,14 +850,22 @@ end
|
|||||||
function BattleState:stepHPDrain()
|
function BattleState:stepHPDrain()
|
||||||
local busy = false
|
local busy = false
|
||||||
for _, b in ipairs({ self.player, self.enemy }) do
|
for _, b in ipairs({ self.player, self.enemy }) do
|
||||||
if b and b.shownHP and b.shownHP ~= b.mon.hp then
|
if b and b.shownHP then
|
||||||
local step = math.max(1, b.mon.stats.hp) / 96
|
-- drainFloor is the stop the running row carries (see drainNext)
|
||||||
if b.shownHP > b.mon.hp then
|
local goal = b.mon.hp
|
||||||
b.shownHP = math.max(b.mon.hp, b.shownHP - step)
|
if b.drainFloor and b.drainFloor > goal
|
||||||
else
|
and b.shownHP >= b.drainFloor then
|
||||||
b.shownHP = math.min(b.mon.hp, b.shownHP + step)
|
goal = b.drainFloor
|
||||||
|
end
|
||||||
|
if b.shownHP ~= goal then
|
||||||
|
local step = math.max(1, b.mon.stats.hp) / 96
|
||||||
|
if b.shownHP > goal then
|
||||||
|
b.shownHP = math.max(goal, b.shownHP - step)
|
||||||
|
else
|
||||||
|
b.shownHP = math.min(goal, b.shownHP + step)
|
||||||
|
end
|
||||||
|
busy = busy or b.shownHP ~= goal
|
||||||
end
|
end
|
||||||
busy = busy or b.shownHP ~= b.mon.hp
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return busy
|
return busy
|
||||||
@@ -822,6 +943,8 @@ function BattleState:updateQueue()
|
|||||||
if self.draining then
|
if self.draining then
|
||||||
if self:stepHPDrain() then return true end
|
if self:stepHPDrain() then return true end
|
||||||
self.draining = nil
|
self.draining = nil
|
||||||
|
if self.player then self.player.drainFloor = nil end
|
||||||
|
if self.enemy then self.enemy.drainFloor = nil end
|
||||||
end
|
end
|
||||||
-- a move animation holds the queue until it finishes; its screen
|
-- a move animation holds the queue until it finishes; its screen
|
||||||
-- effects (SE_*) and per-row sounds route into the fx layer as they
|
-- effects (SE_*) and per-row sounds route into the fx layer as they
|
||||||
@@ -860,6 +983,7 @@ function BattleState:updateQueue()
|
|||||||
end
|
end
|
||||||
if item.drain then
|
if item.drain then
|
||||||
self.draining = true
|
self.draining = true
|
||||||
|
if item.battler then item.battler.drainFloor = item.stopAt end
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
if item.wait then
|
if item.wait then
|
||||||
@@ -1046,6 +1170,10 @@ function BattleState:computeMusicKind()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- init_battle.asm: challenging a gym leader (wGymLeaderNo, the badge
|
||||||
|
-- fights only -- not Lance or the Champion) bumps the companion's
|
||||||
|
-- happiness the moment the battle starts
|
||||||
|
self.isGymLeader = isBoss
|
||||||
if self.kind == "trainer" and self.trainer
|
if self.kind == "trainer" and self.trainer
|
||||||
and self.trainer.id == "OPP_RIVAL3" then
|
and self.trainer.id == "OPP_RIVAL3" then
|
||||||
return "final"
|
return "final"
|
||||||
@@ -1078,22 +1206,53 @@ function BattleState:battleKind()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:enter()
|
function BattleState:enter()
|
||||||
if self.dead then
|
-- Out of useable POKéMON before the battle even starts. pokered does not
|
||||||
|
-- skip the battle: .checkAnyPartyAlive (engine/battle/core.asm:158-162)
|
||||||
|
-- runs right after the intro and jumps to HandlePlayerBlackOut, so the
|
||||||
|
-- player blacks out and afterBattle's "lose" path revives the party at the
|
||||||
|
-- last heal point. Handing the map back with a 0 HP party instead bricked
|
||||||
|
-- the save: every later encounter aborted here too and sighted trainers
|
||||||
|
-- re-engaged forever (#425). self.dead alone is not the test --
|
||||||
|
-- makeOldManDemo and LinkBattle install a player battler after the
|
||||||
|
-- constructor flagged the battle dead.
|
||||||
|
if self.dead and not self.player then
|
||||||
|
local name = self.game.save.player.name
|
||||||
|
self.result = "lose"
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
Runtime.emit("battle.ended", { battle = self, result = "skipped" })
|
require("src.core.Music").restoreMap(self.data)
|
||||||
if self.onFinish then self.onFinish("skipped") end
|
Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true })
|
||||||
|
local onFinish = self.onFinish
|
||||||
|
local function blackedOut()
|
||||||
|
if onFinish then onFinish("lose") end
|
||||||
|
end
|
||||||
|
-- the Oak's Lab starter rival returns above PlayerBlackedOutText2 and
|
||||||
|
-- afterBattle keeps the player in the lab, so print nothing there
|
||||||
|
if BattleState.isOaksLabStarterRival(self) then return blackedOut() end
|
||||||
|
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
||||||
|
-- playerMonFainted queues on the battle screen; there is no battle
|
||||||
|
-- screen to queue them on here, so they print over the map.
|
||||||
|
self.game.stack:push(require("src.render.TextBox").new(self.game,
|
||||||
|
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
|
||||||
|
.. Strings("%s blacked\nout!", name), blackedOut))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local Music = require("src.core.Music")
|
local Music = require("src.core.Music")
|
||||||
self.musicKind = self:computeMusicKind()
|
self.musicKind = self:computeMusicKind()
|
||||||
|
if self.isGymLeader then
|
||||||
|
require("src.world.PikachuFollower")
|
||||||
|
.modifyHappiness(self.game.save, "GYMLEADER")
|
||||||
|
end
|
||||||
-- normally already playing: the transition wipe starts the theme
|
-- normally already playing: the transition wipe starts the theme
|
||||||
-- (audio/play_battle_music.asm runs before the transition, and
|
-- (audio/play_battle_music.asm runs before the transition, and
|
||||||
-- Music.play no-ops on the same song); this covers battles pushed
|
-- Music.play no-ops on the same song); this covers battles pushed
|
||||||
-- without a transition (link battles, scripted pushes)
|
-- without a transition (link battles, scripted pushes)
|
||||||
Music.playBattle(self.data, self.musicKind)
|
Music.playBattle(self.data, self.musicKind)
|
||||||
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
|
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
|
||||||
-- sides slide in; the trainer pics stay up until the send-outs
|
-- sides slide in as black silhouettes. The original scrolls SCX from
|
||||||
self.introSlide = 40
|
-- $90 to 0 two pixels per frame (72 frames); the port covers the full
|
||||||
|
-- 160px screen width, so 2px/frame is an 80-frame slide (slide offset is
|
||||||
|
-- introSlide*2 below). The trainer pics stay up until the send-outs.
|
||||||
|
self.introSlide = 80
|
||||||
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
|
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
|
||||||
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
|
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
|
||||||
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
||||||
@@ -1117,7 +1276,8 @@ function BattleState:enter()
|
|||||||
-- fade, the ground-padding measurement and battle_sprite_scales.
|
-- fade, the ground-padding measurement and battle_sprite_scales.
|
||||||
local backPath, backTrueColor =
|
local backPath, backTrueColor =
|
||||||
require("src.pokemon.Sprites").playerPath(self.data, "back",
|
require("src.pokemon.Sprites").playerPath(self.data, "back",
|
||||||
{ kind = "battle", demo = self.demo, battle = self })
|
{ kind = "battle", demo = self.demo, oakDemo = self.oakDemo,
|
||||||
|
battle = self })
|
||||||
self.playerBackPic = getImage(backPath,
|
self.playerBackPic = getImage(backPath,
|
||||||
namedPalette(self.data, "MEWMON"), backTrueColor)
|
namedPalette(self.data, "MEWMON"), backTrueColor)
|
||||||
self.showPlayerBack = self.playerBackPic ~= nil
|
self.showPlayerBack = self.playerBackPic ~= nil
|
||||||
@@ -1132,11 +1292,16 @@ function BattleState:enter()
|
|||||||
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
|
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
|
||||||
-- sounds with the "Wild X appeared!" box instead of waiting on the A
|
-- sounds with the "Wild X appeared!" box instead of waiting on the A
|
||||||
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
|
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
|
||||||
-- ghost gets no cry at all (common_text.asm:43-48).
|
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the
|
||||||
if self.kind ~= "trainer" and self.kind ~= "link" and not self.ghost then
|
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
|
||||||
|
if self.kind ~= "trainer" and self.kind ~= "link"
|
||||||
|
and not self.ghost and not self.scopeReveal then
|
||||||
queueEnemyCry()
|
queueEnemyCry()
|
||||||
end
|
end
|
||||||
self:say(self.introText)
|
self:say(self.introText)
|
||||||
|
-- the unveil rides on that same box, before _InitBattleCommon clears the
|
||||||
|
-- intro chrome below (#492)
|
||||||
|
if self.scopeReveal then self:queueScopeReveal() end
|
||||||
-- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is
|
-- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is
|
||||||
-- dismissed both HUD blocks are cleared and ClearSprites drops the
|
-- dismissed both HUD blocks are cleared and ClearSprites drops the
|
||||||
-- pokeball OAM, so the intro chrome never returns for the rest of the
|
-- pokeball OAM, so the intro chrome never returns for the rest of the
|
||||||
@@ -1357,6 +1522,7 @@ function BattleState:update(dt)
|
|||||||
for _, b in ipairs({ self.player, self.enemy }) do
|
for _, b in ipairs({ self.player, self.enemy }) do
|
||||||
if b then
|
if b then
|
||||||
if b.shownHP then b.shownHP = b.mon.hp end
|
if b.shownHP then b.shownHP = b.mon.hp end
|
||||||
|
b.drainFloor = nil
|
||||||
b.shownStatus = b.mon.status
|
b.shownStatus = b.mon.status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1409,10 +1575,14 @@ function BattleState:update(dt)
|
|||||||
end
|
end
|
||||||
local col = (self.menuIndex - 1) % 2
|
local col = (self.menuIndex - 1) % 2
|
||||||
local row = math.floor((self.menuIndex - 1) / 2)
|
local row = math.floor((self.menuIndex - 1) / 2)
|
||||||
if input:wasPressed("left") or input:wasPressed("right") then
|
if input:wasPressed("left") then
|
||||||
col = 1 - col
|
col = math.max(0, col - 1)
|
||||||
elseif input:wasPressed("up") or input:wasPressed("down") then
|
elseif input:wasPressed("right") then
|
||||||
row = 1 - row
|
col = math.min(1, col + 1)
|
||||||
|
elseif input:wasPressed("up") then
|
||||||
|
row = math.max(0, row - 1)
|
||||||
|
elseif input:wasPressed("down") then
|
||||||
|
row = math.min(1, row + 1)
|
||||||
end
|
end
|
||||||
self.menuIndex = row * 2 + col + 1
|
self.menuIndex = row * 2 + col + 1
|
||||||
if input:wasPressed("a") then
|
if input:wasPressed("a") then
|
||||||
@@ -1441,10 +1611,14 @@ function BattleState:update(dt)
|
|||||||
end
|
end
|
||||||
local col = (self.menuIndex - 1) % 2
|
local col = (self.menuIndex - 1) % 2
|
||||||
local row = math.floor((self.menuIndex - 1) / 2)
|
local row = math.floor((self.menuIndex - 1) / 2)
|
||||||
if input:wasPressed("left") or input:wasPressed("right") then
|
if input:wasPressed("left") then
|
||||||
col = 1 - col
|
col = math.max(0, col - 1)
|
||||||
elseif input:wasPressed("up") or input:wasPressed("down") then
|
elseif input:wasPressed("right") then
|
||||||
row = 1 - row
|
col = math.min(1, col + 1)
|
||||||
|
elseif input:wasPressed("up") then
|
||||||
|
row = math.max(0, row - 1)
|
||||||
|
elseif input:wasPressed("down") then
|
||||||
|
row = math.min(1, row + 1)
|
||||||
end
|
end
|
||||||
self.menuIndex = row * 2 + col + 1
|
self.menuIndex = row * 2 + col + 1
|
||||||
if input:wasPressed("a") then
|
if input:wasPressed("a") then
|
||||||
@@ -1711,7 +1885,7 @@ function BattleState:oldManThrow()
|
|||||||
self.phase = "messages"
|
self.phase = "messages"
|
||||||
self.afterQueue = "finish"
|
self.afterQueue = "finish"
|
||||||
self.result = "run" -- nothing is kept; wBattleResult only ends the demo
|
self.result = "run" -- nothing is kept; wBattleResult only ends the demo
|
||||||
self:say(Strings("OLD MAN used\nPOKé BALL!"))
|
self:say(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
|
||||||
self:act(function()
|
self:act(function()
|
||||||
require("src.core.Sound").play(self.data, "Ball_Toss")
|
require("src.core.Sound").play(self.data, "Ball_Toss")
|
||||||
-- ItemUseBall's beat before the toss chain (like throwBall)
|
-- ItemUseBall's beat before the toss chain (like throwBall)
|
||||||
@@ -1914,6 +2088,15 @@ function BattleState:resolveSwitch(newMon)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:endOfTurn()
|
function BattleState:endOfTurn()
|
||||||
|
-- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed
|
||||||
|
-- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual
|
||||||
|
-- sweep and the trapping-counter release are skipped on the turn a
|
||||||
|
-- Teleport escape (or a win/loss/capture) settles it (#441). The
|
||||||
|
-- turn_ended hook still fires: mods count turns, not residuals.
|
||||||
|
if self.result then
|
||||||
|
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 })
|
||||||
|
return
|
||||||
|
end
|
||||||
-- sideToxic mirrors w*ToxicCounter: it advances only while the
|
-- sideToxic mirrors w*ToxicCounter: it advances only while the
|
||||||
-- battler's badly-poisoned flag (toxicCounter) is set, an item/AI
|
-- battler's badly-poisoned flag (toxicCounter) is set, an item/AI
|
||||||
-- cure clears the flag but NOT the side counter, and a fresh Toxic
|
-- cure clears the flag but NOT the side counter, and a fresh Toxic
|
||||||
@@ -2131,6 +2314,31 @@ local function startPicKind(pf, kind)
|
|||||||
pf.hidden = nil
|
pf.hidden = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- PredefShakeScreenHorizontally (engine/gfx/screen_effects.asm): the window
|
||||||
|
-- jumps right by b for 5 frames then home for 4, b counting down to 1.
|
||||||
|
-- b = 8 for SE_SHAKE_SCREEN and the heavy applying-attack shake, b = 2 for
|
||||||
|
-- the light one.
|
||||||
|
local function fastShakeProg(b)
|
||||||
|
local prog = {}
|
||||||
|
for i = b, 1, -1 do
|
||||||
|
prog[#prog + 1] = { dx = i, frames = 5 }
|
||||||
|
prog[#prog + 1] = { dx = 0, frames = 4 }
|
||||||
|
end
|
||||||
|
return prog
|
||||||
|
end
|
||||||
|
|
||||||
|
-- AnimationShakeScreenHorizontallySlow (engine/battle/animations.asm:526):
|
||||||
|
-- rWX creeps 1px right every 2 frames b times, then back down to 0, c times
|
||||||
|
-- over. Silent -- this is the non-damaging move's feedback.
|
||||||
|
local function slowShakeProg(b, c)
|
||||||
|
local prog = {}
|
||||||
|
for _ = 1, c do
|
||||||
|
for i = 1, b do prog[#prog + 1] = { dx = i, frames = 2 } end
|
||||||
|
for i = b - 1, 0, -1 do prog[#prog + 1] = { dx = i, frames = 2 } end
|
||||||
|
end
|
||||||
|
return prog
|
||||||
|
end
|
||||||
|
|
||||||
-- Route one AnimPlayer event into the fx layer. Frame counts and
|
-- Route one AnimPlayer event into the fx layer. Frame counts and
|
||||||
-- amplitudes are the routines' own (engine/battle/animations.asm;
|
-- amplitudes are the routines' own (engine/battle/animations.asm;
|
||||||
-- shakes: engine/gfx/screen_effects.asm).
|
-- shakes: engine/gfx/screen_effects.asm).
|
||||||
@@ -2172,14 +2380,7 @@ function BattleState:applyAnimEffect(ev)
|
|||||||
|
|
||||||
-- ---------------------------------------------- screen shakes
|
-- ---------------------------------------------- screen shakes
|
||||||
elseif e == "SE_SHAKE_SCREEN" then
|
elseif e == "SE_SHAKE_SCREEN" then
|
||||||
-- PredefShakeScreenHorizontally b=8: the window jumps right by b
|
fx.shakeProg = fastShakeProg(8)
|
||||||
-- for 5 frames then home for 4, b counting down 8..1
|
|
||||||
local prog = {}
|
|
||||||
for b = 8, 1, -1 do
|
|
||||||
prog[#prog + 1] = { dx = b, frames = 5 }
|
|
||||||
prog[#prog + 1] = { dx = 0, frames = 4 }
|
|
||||||
end
|
|
||||||
fx.shakeProg = prog
|
|
||||||
elseif e == "SE_ROCK_SLIDE_SHAKE" then
|
elseif e == "SE_ROCK_SLIDE_SHAKE" then
|
||||||
-- DoRockSlideSpecialEffects: 1px horizontal then vertical rumble
|
-- DoRockSlideSpecialEffects: 1px horizontal then vertical rumble
|
||||||
fx.shakeProg = { { dx = 1, frames = 5 }, { dx = 0, frames = 4 },
|
fx.shakeProg = { { dx = 1, frames = 5 }, { dx = 0, frames = 4 },
|
||||||
@@ -2276,34 +2477,77 @@ function BattleState:applyAnimEffect(ev)
|
|||||||
-- battler.substituteHP is set (MoveEffects raises it with the move)
|
-- battler.substituteHP is set (MoveEffects raises it with the move)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The target's post-animation hit feedback (PlayApplyingAttackAnimation,
|
-- The post-animation applying-attack feedback (PlayApplyingAttackAnimation
|
||||||
-- engine/battle/animations.asm:475): the player's damaging moves blink
|
-- -> AnimationTypePointerTable, engine/battle/animations.asm:475-524).
|
||||||
-- the ENEMY pic; the enemy's damaging moves shake the screen vertically
|
-- hit.animType is wAnimationType, 1..6:
|
||||||
-- (ShakeScreenVertically -> PredefShakeScreenVertically b=8: the window
|
-- 1 enemy damaging, no added effect ShakeScreenVertically (b=8)
|
||||||
-- drops by b for 3 frames then home for 3, b counting down) -- the
|
-- 2 enemy damaging, added effect fast horizontal shake, b=8
|
||||||
-- player's pic never blinks. Damage sound with either. A hold keeps
|
-- 3 enemy non-damaging slow horizontal shake, b=6, c=2
|
||||||
|
-- 4 player damaging, no added effect BlinkEnemyMonSprite
|
||||||
|
-- 5 player damaging, added effect fast horizontal shake, b=2
|
||||||
|
-- 6 player non-damaging slow horizontal shake, b=3, c=2
|
||||||
|
-- Types 3 and 6 are silent; the rest open with PlayApplyingAttackSound,
|
||||||
|
-- which is the damage sound hit.sfx already carries. Only 1 and 4 were
|
||||||
|
-- implemented, so every move with an added effect blinked (or shook
|
||||||
|
-- vertically) instead of shaking sideways and every status move showed
|
||||||
|
-- nothing at all -- Bubblebeam, Confusion, Hypnosis (#354). A hold keeps
|
||||||
-- the queue still until the effect finishes.
|
-- the queue still until the effect finishes.
|
||||||
function BattleState:applyHitFx(hit)
|
function BattleState:applyHitFx(hit)
|
||||||
if hit.blink then
|
self.fx = self.fx or {}
|
||||||
self.fx = self.fx or {}
|
-- rows queued before animType existed carry only the blink target
|
||||||
if hit.blink.isPlayer then
|
local t = hit.animType
|
||||||
local prog = {}
|
if not t and hit.blink then t = hit.blink.isPlayer and 1 or 4 end
|
||||||
for b = 8, 1, -1 do
|
|
||||||
prog[#prog + 1] = { dy = b, frames = 3 }
|
|
||||||
prog[#prog + 1] = { dy = 0, frames = 3 }
|
|
||||||
end
|
|
||||||
self.fx.shakeProg = prog
|
|
||||||
self.waitFrames = 48 -- the predef blocks until the shake settles
|
|
||||||
else
|
|
||||||
self.fx.blink = { target = hit.blink, frames = 20 }
|
|
||||||
self.waitFrames = 20
|
|
||||||
end
|
|
||||||
end
|
|
||||||
if hit.sfx then
|
if hit.sfx then
|
||||||
require("src.core.Sound").play(self.data, hit.sfx)
|
require("src.core.Sound").play(self.data, hit.sfx)
|
||||||
end
|
end
|
||||||
|
if not t or not self:animationsOn() then return end
|
||||||
|
if t == 1 then
|
||||||
|
-- PredefShakeScreenVertically b=8: the window drops by b for 3 frames
|
||||||
|
-- then home for 3, b counting down
|
||||||
|
local prog = {}
|
||||||
|
for b = 8, 1, -1 do
|
||||||
|
prog[#prog + 1] = { dy = b, frames = 3 }
|
||||||
|
prog[#prog + 1] = { dy = 0, frames = 3 }
|
||||||
|
end
|
||||||
|
self.fx.shakeProg = prog
|
||||||
|
self.waitFrames = 48 -- the predef blocks until the shake settles
|
||||||
|
elseif t == 2 then
|
||||||
|
self.fx.shakeProg = fastShakeProg(8)
|
||||||
|
self.waitFrames = 72
|
||||||
|
elseif t == 3 then
|
||||||
|
self.fx.shakeProg = slowShakeProg(6, 2)
|
||||||
|
self.waitFrames = 48
|
||||||
|
elseif t == 4 then
|
||||||
|
if hit.blink then
|
||||||
|
self.fx.blink = { target = hit.blink, frames = 20 }
|
||||||
|
self.waitFrames = 20
|
||||||
|
end
|
||||||
|
elseif t == 5 then
|
||||||
|
self.fx.shakeProg = fastShakeProg(2)
|
||||||
|
self.waitFrames = 18
|
||||||
|
elseif t == 6 then
|
||||||
|
self.fx.shakeProg = slowShakeProg(3, 2)
|
||||||
|
self.waitFrames = 24
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Primary status effects whose pokered handler ends in
|
||||||
|
-- PlayCurrentMoveAnimation2 (engine/battle/effects.asm:1448), which sets
|
||||||
|
-- wAnimationType 6 on the player's turn and 3 on the enemy's: sleep,
|
||||||
|
-- poison, confuse, disable and the primary stat-down effects. Every other
|
||||||
|
-- primary effect goes through PlayCurrentMoveAnimation and leaves the type
|
||||||
|
-- at 0 (no applying animation): paralysis (FreezeBurnParalyzeEffect),
|
||||||
|
-- leech seed, the stat-UP effects, Splash. Side-effect stat drops are
|
||||||
|
-- skipped too -- UpdateLoweredStatDone bails out for them because the
|
||||||
|
-- damaging move's own type 2/5 shake already played.
|
||||||
|
local SLOW_SHAKE_EFFECTS = {
|
||||||
|
SLEEP_EFFECT = true, POISON_EFFECT = true, CONFUSION_EFFECT = true,
|
||||||
|
DISABLE_EFFECT = true,
|
||||||
|
ATTACK_DOWN1_EFFECT = true, DEFENSE_DOWN1_EFFECT = true,
|
||||||
|
DEFENSE_DOWN2_EFFECT = true, SPEED_DOWN1_EFFECT = true,
|
||||||
|
ACCURACY_DOWN1_EFFECT = true,
|
||||||
|
}
|
||||||
|
|
||||||
-- AnimateSendingOutMon (core.asm:6801-6838): the mon grows out of the
|
-- AnimateSendingOutMon (core.asm:6801-6838): the mon grows out of the
|
||||||
-- ball -- a 3-frame ball beat, 4 frames of the pic at 3/7 scale (a 3x3
|
-- ball -- a 3-frame ball beat, 4 frames of the pic at 3/7 scale (a 3x3
|
||||||
-- block of its 7x7 tiles), 5 frames at 5/7 (5x5), then full size.
|
-- block of its 7x7 tiles), 5 frames at 5/7 (5x5), then full size.
|
||||||
@@ -2487,6 +2731,35 @@ function BattleState:updateFx()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- The Silph Scope unveil (MarowakAnim; queueScopeReveal parks a wait row
|
||||||
|
-- of GHOST_REVEAL_FRAMES over it): the ghost flashes, fades out, the pic
|
||||||
|
-- and the nick swap back the way LoadEnemyMonData restores them, and the
|
||||||
|
-- real mon fades in (#492).
|
||||||
|
local gr = self.ghostReveal
|
||||||
|
if gr and self.enemy then
|
||||||
|
gr.t = gr.t + 1
|
||||||
|
local pf = self:picFxFor(self.enemy)
|
||||||
|
local flashEnd = BattleState.GHOST_FLASH_FRAMES
|
||||||
|
local outEnd = flashEnd + BattleState.GHOST_FADE_OUT_FRAMES
|
||||||
|
if gr.t <= flashEnd then
|
||||||
|
pf.fade = (math.floor((gr.t - 1) / 10) % 2 == 1) and 0.5 or 1
|
||||||
|
elseif gr.t <= outEnd then
|
||||||
|
pf.fade = 1 - math.ceil((gr.t - flashEnd) / 10) / 3
|
||||||
|
else
|
||||||
|
if not gr.swapped then
|
||||||
|
gr.swapped = true
|
||||||
|
local real = self.ghostReal
|
||||||
|
if real then
|
||||||
|
self.enemy.name = real.name or self.enemy.name
|
||||||
|
self.enemy.sprite = real.sprite or self.enemy.sprite
|
||||||
|
end
|
||||||
|
end
|
||||||
|
pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4)
|
||||||
|
end
|
||||||
|
if gr.t >= BattleState.GHOST_REVEAL_FRAMES then
|
||||||
|
self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil
|
||||||
|
end
|
||||||
|
end
|
||||||
-- the send-out grow-in (AnimateSendingOutMon): 3+4+5 frames, then
|
-- the send-out grow-in (AnimateSendingOutMon): 3+4+5 frames, then
|
||||||
-- the pic draws at full size again
|
-- the pic draws at full size again
|
||||||
if self.growIn then
|
if self.growIn then
|
||||||
@@ -2501,10 +2774,24 @@ function BattleState:updateFx()
|
|||||||
-- so a sounding siren rides out the next hit's HP drain instead of
|
-- so a sounding siren rides out the next hit's HP drain instead of
|
||||||
-- dropping out mid-announcement (#293)
|
-- dropping out mid-announcement (#293)
|
||||||
self.lowHealthAlarmOn = self:lowHealthAlarmActive()
|
self.lowHealthAlarmOn = self:lowHealthAlarmActive()
|
||||||
if self.lowHealthAlarmOn then
|
-- battle.low_health_alarm: on/off toggle for the siren loop, ctx.on
|
||||||
Sound.startLoop(self.data, "Low_Health_Alarm")
|
-- mirrors self.lowHealthAlarmOn. Vanilla just starts/stops the loop
|
||||||
|
-- each frame; a mod can wrap this to reshape the toggle (e.g. force
|
||||||
|
-- ctx.on false after some budget) before letting vanilla act on it.
|
||||||
|
if Runtime.wantsHook("battle.low_health_alarm") then
|
||||||
|
Runtime.call("battle.low_health_alarm", function(ctx)
|
||||||
|
if ctx.on then
|
||||||
|
Sound.startLoop(ctx.battle.data, "Low_Health_Alarm")
|
||||||
|
else
|
||||||
|
Sound.stopLoop("Low_Health_Alarm")
|
||||||
|
end
|
||||||
|
end, { on = self.lowHealthAlarmOn, battle = self })
|
||||||
else
|
else
|
||||||
Sound.stopLoop("Low_Health_Alarm")
|
if self.lowHealthAlarmOn then
|
||||||
|
Sound.startLoop(self.data, "Low_Health_Alarm")
|
||||||
|
else
|
||||||
|
Sound.stopLoop("Low_Health_Alarm")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2523,6 +2810,12 @@ function BattleState:syncShownStatus()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:executeAction(user, target, action)
|
function BattleState:executeAction(user, target, action)
|
||||||
|
-- MainInBattleLoop reads wEscapedFromBattle right after Execute*Move and
|
||||||
|
-- rets (core.asm:417-421, 456-460): a Teleport/Roar/Whirlwind escape ends
|
||||||
|
-- the turn where it lands and the second mover never moves. self.result
|
||||||
|
-- is only ever set once the battle is over (run/win/lose/caught), and the
|
||||||
|
-- faint cases are already covered by the HP guard below (#441)
|
||||||
|
if self.result then return end
|
||||||
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
|
if user.mon.hp <= 0 or target.mon.hp <= 0 then return end
|
||||||
if not action then return end
|
if not action then return end
|
||||||
|
|
||||||
@@ -2887,6 +3180,8 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
|||||||
-- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim)
|
-- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim)
|
||||||
if primaryEffectFailed(msgs) then
|
if primaryEffectFailed(msgs) then
|
||||||
self:cancelMoveAnim()
|
self:cancelMoveAnim()
|
||||||
|
elseif SLOW_SHAKE_EFFECTS[move.effect] and self.moveAnimRow then
|
||||||
|
self.moveAnimRow.hit = { animType = user.isPlayer and 6 or 3 }
|
||||||
end
|
end
|
||||||
for _, m in ipairs(msgs) do
|
for _, m in ipairs(msgs) do
|
||||||
self:sayNext(m)
|
self:sayNext(m)
|
||||||
@@ -2940,6 +3235,10 @@ function BattleState:continueBide(user, target)
|
|||||||
self:sayNext(Strings("But, it failed!"))
|
self:sayNext(Strings("But, it failed!"))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
-- .UnleashEnergy (core.asm:3501-3529) re-points wPlayerMoveNum at BIDE
|
||||||
|
-- and rejoins HandleIfPlayerMoveMissed, so BIDE's own animation plays
|
||||||
|
-- here, after UnleashedEnergyText and before the damage (#375)
|
||||||
|
self:animNext("BIDE", user.isPlayer)
|
||||||
self:applyDamage(target, dmg)
|
self:applyDamage(target, dmg)
|
||||||
if target.mon.hp <= 0 then self:onFaint(target) end
|
if target.mon.hp <= 0 then self:onFaint(target) end
|
||||||
end
|
end
|
||||||
@@ -2964,7 +3263,7 @@ function BattleState:applyDamage(target, dmg)
|
|||||||
end
|
end
|
||||||
local dealt = math.min(dmg, target.mon.hp)
|
local dealt = math.min(dmg, target.mon.hp)
|
||||||
target.mon.hp = target.mon.hp - dealt
|
target.mon.hp = target.mon.hp - dealt
|
||||||
if dealt > 0 then self:drainNext() end -- animate the bar down
|
if dealt > 0 then self:drainNext(target, target.mon.hp) end -- animate the bar down
|
||||||
if target.bideTurns then
|
if target.bideTurns then
|
||||||
target.bideDamage = (target.bideDamage or 0) + dealt
|
target.bideDamage = (target.bideDamage or 0) + dealt
|
||||||
end
|
end
|
||||||
@@ -2986,6 +3285,17 @@ function BattleState:onFaint(battler)
|
|||||||
self.participants[battler.mon] = nil
|
self.participants[battler.mon] = nil
|
||||||
end
|
end
|
||||||
Runtime.emit("battle.fainted", { battle = self, battler = battler })
|
Runtime.emit("battle.fainted", { battle = self, battler = battler })
|
||||||
|
if battler.isPlayer then
|
||||||
|
-- HandlePlayerMonFainted (core.asm:1070-1085): the companion loses
|
||||||
|
-- happiness on its own faint; an enemy 30+ levels above it makes
|
||||||
|
-- that the CARELESSTRAINER hit instead
|
||||||
|
local enemyLevel = self.enemy and self.enemy.mon
|
||||||
|
and self.enemy.mon.level or 0
|
||||||
|
local reason = (enemyLevel - (battler.mon.level or 0)) >= 30
|
||||||
|
and "CARELESSTRAINER" or "FAINTED"
|
||||||
|
require("src.world.PikachuFollower")
|
||||||
|
.modifyHappiness(self.game.save, reason, battler.mon)
|
||||||
|
end
|
||||||
-- the faint slide + cry ride the queue (after the move animation and
|
-- the 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
|
||||||
-- faint text via a queued hold
|
-- faint text via a queued hold
|
||||||
@@ -3017,7 +3327,10 @@ function BattleState:onFaint(battler)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:enemyMonFainted()
|
-- Exp for the defeated enemy, shared by the faint path (enemyMonFainted)
|
||||||
|
-- and, when a mod's battle.catch_exp hook says so, the catch path
|
||||||
|
-- (storeCaughtMon).
|
||||||
|
function BattleState:awardExp()
|
||||||
-- 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
|
||||||
@@ -3070,6 +3383,9 @@ function BattleState:enemyMonFainted()
|
|||||||
-- the move-learn checks (experience.asm:245-256)
|
-- the move-learn checks (experience.asm:245-256)
|
||||||
local game = self.game
|
local game = self.game
|
||||||
for _, lv in ipairs(levels) do
|
for _, lv in ipairs(levels) do
|
||||||
|
-- experience.asm:248 fires per grew-level text
|
||||||
|
require("src.world.PikachuFollower")
|
||||||
|
.modifyHappiness(game.save, "LEVELUP", mon)
|
||||||
self:sayNext(Strings("%s grew\nto level %d!", name, lv))
|
self:sayNext(Strings("%s grew\nto level %d!", name, lv))
|
||||||
self:uiNext(function()
|
self:uiNext(function()
|
||||||
require("src.core.Sound").play(game.data, "Level_Up")
|
require("src.core.Sound").play(game.data, "Level_Up")
|
||||||
@@ -3092,27 +3408,44 @@ function BattleState:enemyMonFainted()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- with EXP.ALL, participants split half the exp and the other half
|
-- battle.exp_award: the participant/EXP.ALL split, factored out so a
|
||||||
-- is divided among the whole party (engine/battle/experience.asm)
|
-- mod can replace it wholesale (e.g. a flat undivided share to every
|
||||||
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
|
-- non-fainted party mon) without re-deriving participants/alive.
|
||||||
for _, mon in ipairs(alive) do
|
-- ctx.applyShare(mon, split, announce) is the same helper vanilla uses.
|
||||||
applyShare(mon, participants * (expAll and 2 or 1), true)
|
local function vanillaExpAward(ctx)
|
||||||
end
|
-- with EXP.ALL, participants split half the exp and the other half
|
||||||
if expAll then
|
-- is divided among the whole party (engine/battle/experience.asm)
|
||||||
-- the second GainExperience pass sets the gain flags for the WHOLE
|
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
|
||||||
-- party, so DivideExpDataByNumMonsGainingExp divides the already
|
for _, mon in ipairs(ctx.alive) do
|
||||||
-- halved-and-participant-divided exp again by the party count, and
|
ctx.applyShare(mon, ctx.participants * (expAll and 2 or 1), true)
|
||||||
-- .partyMonLoop still skips fainted mons (core.asm:818-858 +
|
end
|
||||||
-- experience.asm:9-13); each mon gets its own GainedText with the
|
if expAll then
|
||||||
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
|
-- the second GainExperience pass sets the gain flags for the WHOLE
|
||||||
-- summary line
|
-- party, so DivideExpDataByNumMonsGainingExp divides the already
|
||||||
for _, mon in ipairs(self.game.save.party) do
|
-- halved-and-participant-divided exp again by the party count, and
|
||||||
if mon.hp > 0 then
|
-- .partyMonLoop still skips fainted mons (core.asm:818-858 +
|
||||||
applyShare(mon, math.max(1, participants) * #self.game.save.party * 2, "expAll")
|
-- experience.asm:9-13); each mon gets its own GainedText with the
|
||||||
|
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
|
||||||
|
-- summary line
|
||||||
|
for _, mon in ipairs(self.game.save.party) do
|
||||||
|
if mon.hp > 0 then
|
||||||
|
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
local awardCtx = { battle = self, participants = participants, alive = alive,
|
||||||
|
applyShare = applyShare }
|
||||||
|
if Runtime.wantsHook("battle.exp_award") then
|
||||||
|
Runtime.call("battle.exp_award", vanillaExpAward, awardCtx)
|
||||||
|
else
|
||||||
|
vanillaExpAward(awardCtx)
|
||||||
|
end
|
||||||
self.participants = {}
|
self.participants = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
function BattleState:enemyMonFainted()
|
||||||
|
self:awardExp()
|
||||||
|
|
||||||
if self.kind == "trainer" then
|
if self.kind == "trainer" then
|
||||||
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
|
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
|
||||||
@@ -3151,10 +3484,12 @@ function BattleState:enemyMonFainted()
|
|||||||
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
|
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
|
||||||
local shiftSwitchMon = nil
|
local shiftSwitchMon = nil
|
||||||
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
|
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
|
||||||
-- _TrainerAboutToUseText: "X is" / "about to use" then cont nick,
|
-- _TrainerAboutToUseText (data/text/text_2.asm): "X is" / "about to
|
||||||
-- then para "Will PLAYER" / "change POKéMON?" with YES/NO.
|
-- use" then a CONT to the nick, not a fresh box -- the box scrolls
|
||||||
self:say(Strings("%s is\nabout to use", self.trainer.name))
|
-- "X is" off so "about to use" stays above the name, instead of the
|
||||||
self:say(Strings("%s!", nextName))
|
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
|
||||||
|
-- "change POKéMON?" with YES/NO.
|
||||||
|
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
||||||
self:sayChoice(
|
self:sayChoice(
|
||||||
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
||||||
function(yes)
|
function(yes)
|
||||||
@@ -3272,8 +3607,18 @@ function BattleState:enemyMonFainted()
|
|||||||
-- PrintEndBattleText prints one text box; a `para` (\f) inside it
|
-- PrintEndBattleText prints one text box; a `para` (\f) inside it
|
||||||
-- starts a fresh page, which is a message row of its own here (five
|
-- starts a fresh page, which is a message row of its own here (five
|
||||||
-- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText)
|
-- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText)
|
||||||
|
-- TrainerEndBattleText (home/trainers.asm) prints _TrainerNameText
|
||||||
|
-- (wNameBuffer then ": ", data/text/text_1.asm) and only then the
|
||||||
|
-- saved pointer, so the loss line opens with the trainer class tag on
|
||||||
|
-- its first line (RIVAL1/2/3 tag with the rival's name:
|
||||||
|
-- TrainerNamePointers aims those entries at wTrainerName). The tag
|
||||||
|
-- prints once, so a `para` page carries no second copy (#566).
|
||||||
|
local tag = self.trainer and self.trainer.name
|
||||||
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
||||||
if page ~= "" then self:sayNext(page) end
|
if page ~= "" then
|
||||||
|
self:sayNext(tag and (tag .. ": " .. page) or page)
|
||||||
|
tag = nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
|
self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
|
||||||
@@ -3673,6 +4018,12 @@ function BattleState:storeCaughtMon()
|
|||||||
-- (item_effects.asm:472-501), regenerating its move list from the
|
-- (item_effects.asm:472-501), regenerating its move list from the
|
||||||
-- base data -- a Mimic'd slot never leaves the battle with it
|
-- base data -- a Mimic'd slot never leaves the battle with it
|
||||||
self:restoreMimicked(self.enemy)
|
self:restoreMimicked(self.enemy)
|
||||||
|
-- battle.catch_exp: vanilla catches never grant exp; a mod can flip
|
||||||
|
-- this to true to pay out the same award a faint would have.
|
||||||
|
if Runtime.wantsHook("battle.catch_exp")
|
||||||
|
and Runtime.call("battle.catch_exp", function() return false end, { battle = self }) then
|
||||||
|
self:awardExp()
|
||||||
|
end
|
||||||
local game = self.game
|
local game = self.game
|
||||||
local dex = game.save.pokedex
|
local dex = game.save.pokedex
|
||||||
local species = self.enemy.mon.species
|
local species = self.enemy.mon.species
|
||||||
@@ -3797,12 +4148,15 @@ function BattleState:throwBall(ball)
|
|||||||
self:act(function() self:endOfTurn() end)
|
self:act(function() self:endOfTurn() end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if self.ghost then
|
if self.ghost or self.noCatch then
|
||||||
-- ItemUseBall's can't-be-caught path (item_effects.asm:149-153):
|
-- ItemUseBall's can't-be-caught path (item_effects.asm:149-153):
|
||||||
-- the ball is thrown (TossBallAnimation still picks the arc from
|
-- the ball is thrown (TossBallAnimation still picks the arc from
|
||||||
-- wCurItem, so a Master/Ultra toss keeps its flicker), dodged
|
-- wCurItem, so a Master/Ultra toss keeps its flicker), dodged
|
||||||
-- ($10 anim data, no wobbles), and the turn is spent like any
|
-- ($10 anim data, no wobbles), and the turn is spent like any
|
||||||
-- failed throw
|
-- failed throw. battle.noCatch is the .notOldManBattle half of the
|
||||||
|
-- same check (item_effects.asm:166-175): the POKEMON_TOWER_6F
|
||||||
|
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
||||||
|
-- so it is not a ghost battle any more (#444)
|
||||||
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
||||||
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
||||||
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
|
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
|
||||||
@@ -3890,7 +4244,10 @@ function BattleState:finish()
|
|||||||
-- way back -- an unrecoverable state, not merely a wrong one.
|
-- way back -- an unrecoverable state, not merely a wrong one.
|
||||||
-- playerMonFainted is the path that should have caught this; if we land
|
-- 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.
|
-- here it did not, so say so rather than silently papering over it.
|
||||||
if self.result ~= "lose" and not Party.firstHealthy(self.game.save.party) then
|
-- The old-man / PROF.OAK demo also skips it: the party never fought
|
||||||
|
-- (Yellow's Pallet intro runs before the player owns a mon at all).
|
||||||
|
if self.result ~= "lose" and not self.demo
|
||||||
|
and not Party.firstHealthy(self.game.save.party) then
|
||||||
Logger.warn("battle finished %s with no healthy party; forcing blackout",
|
Logger.warn("battle finished %s with no healthy party; forcing blackout",
|
||||||
tostring(self.result))
|
tostring(self.result))
|
||||||
self.result = "lose"
|
self.result = "lose"
|
||||||
@@ -4096,6 +4453,18 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
|
|||||||
end
|
end
|
||||||
if battler.fainted then return end
|
if battler.fainted then return end
|
||||||
|
|
||||||
|
-- MarowakAnim's rOBP1 fades (updateFx): alpha over the paper is what those
|
||||||
|
-- palette shifts look like, and the test has to sit ahead of the plain
|
||||||
|
-- fast path below (#492)
|
||||||
|
local fadePf = self.picFx and self.picFx[battler]
|
||||||
|
if fadePf and fadePf.fade then
|
||||||
|
local cr, cg, cb, ca = love.graphics.getColor()
|
||||||
|
love.graphics.setColor(cr, cg, cb, ca * fadePf.fade)
|
||||||
|
love.graphics.draw(img, x, y, 0, scale, scale)
|
||||||
|
love.graphics.setColor(cr, cg, cb, ca)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
local pf = self.picFx and self.picFx[battler]
|
local pf = self.picFx and self.picFx[battler]
|
||||||
if not pf or (not pf.kind and not pf.hidden and not pf.minimized
|
if not pf or (not pf.kind and not pf.hidden and not pf.minimized
|
||||||
and (pf.ox or 0) == 0 and (pf.oy or 0) == 0) then
|
and (pf.ox or 0) == 0 and (pf.oy or 0) == 0) then
|
||||||
@@ -4283,7 +4652,9 @@ function BattleState:sgbBattlePals()
|
|||||||
-- true white, which is what drew a white box around each pic on the pink
|
-- 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
|
-- 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.
|
-- mid shades (and the green bar the user prefers) stay untouched.
|
||||||
if PaletteFX.mode == "ogred" then
|
-- Boot-ROM OG only (Red/Blue): snap zone paper to the global GBC white/black.
|
||||||
|
-- OG YELLOW keeps each CGBBasePalettes endpoint (already near-white / near-black).
|
||||||
|
if PaletteFX.mode == "ogred" and not require("src.core.GameVersion").isYellow() then
|
||||||
local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4]
|
local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4]
|
||||||
for i = 0, 3 do
|
for i = 0, 3 do
|
||||||
local c = out[i]
|
local c = out[i]
|
||||||
@@ -4802,7 +5173,9 @@ function BattleState:drawTextArea()
|
|||||||
Font.drawCode(Font.BORDER.br, 80, 96)
|
Font.drawCode(Font.BORDER.br, 80, 96)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
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)
|
-- unknown ids (mod-injected moves) print raw instead of crashing
|
||||||
|
local def = self.data.moves[mv.id]
|
||||||
|
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
|
||||||
end
|
end
|
||||||
Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED,
|
Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED,
|
||||||
40, 96 + self.moveIndex * 8)
|
40, 96 + self.moveIndex * 8)
|
||||||
@@ -4811,10 +5184,10 @@ function BattleState:drawTextArea()
|
|||||||
end
|
end
|
||||||
local sel = self.player.curMoves[self.moveIndex]
|
local sel = self.player.curMoves[self.moveIndex]
|
||||||
if sel then
|
if sel then
|
||||||
|
local def = self.data.moves[sel.id]
|
||||||
if self.player.disabledSlot == self.moveIndex then
|
if self.player.disabledSlot == self.moveIndex then
|
||||||
Font.draw(Strings("disabled!"), 8, 80)
|
Font.draw(Strings("disabled!"), 8, 80)
|
||||||
else
|
elseif def then
|
||||||
local def = self.data.moves[sel.id]
|
|
||||||
Font.draw(Strings("TYPE/"), 8, 72)
|
Font.draw(Strings("TYPE/"), 8, 72)
|
||||||
-- the type record's display name (a mod type shows its name, and
|
-- the type record's display name (a mod type shows its name, and
|
||||||
-- PSYCHIC_TYPE prints PSYCHIC like the original)
|
-- PSYCHIC_TYPE prints PSYCHIC like the original)
|
||||||
@@ -4857,7 +5230,7 @@ function BattleState:drawClassic()
|
|||||||
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
|
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
|
||||||
sx = self.frame % 4 < 2 and 2 or -2
|
sx = self.frame % 4 < 2 and 2 or -2
|
||||||
end
|
end
|
||||||
local slide = (self.introSlide or 0) * 4 -- intro slide-in offset
|
local slide = (self.introSlide or 0) * 2 -- intro slide-in offset (2px/frame)
|
||||||
|
|
||||||
if self:colorMode() then
|
if self:colorMode() then
|
||||||
-- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the
|
-- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||||
|
-- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a
|
||||||
|
-- failed accuracy roll (core.asm:5260), and the miss handler still
|
||||||
|
-- runs the explode effect ("even if Explosion or Selfdestruct
|
||||||
|
-- missed, its effect still needs to be activated", core.asm:3223),
|
||||||
|
-- so the user faints against a mid-Fly/Dig target too (#528)
|
||||||
|
if record and record.onMiss then record.onMiss(ctx, "invulnerable") end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -186,7 +192,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
-- hitRow carries the blink instead.
|
-- hitRow carries the blink instead.
|
||||||
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
||||||
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
||||||
|
-- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm
|
||||||
|
-- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake
|
||||||
|
-- the screen vertically) for a damaging move with no added effect, and
|
||||||
|
-- 5 / 2 (a horizontal shake) as soon as the move HAS one -- which is why
|
||||||
|
-- Bubblebeam and Confusion shake instead of blinking (#354)
|
||||||
|
local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT"
|
||||||
local hitFx = { sfx = hitSfx,
|
local hitFx = { sfx = hitSfx,
|
||||||
|
animType = user.isPlayer and (added and 5 or 4)
|
||||||
|
or (added and 2 or 1),
|
||||||
blink = battle:animationsOn() and target or nil }
|
blink = battle:animationsOn() and target or nil }
|
||||||
|
|
||||||
local totalDealt = 0
|
local totalDealt = 0
|
||||||
|
|||||||
@@ -609,10 +609,16 @@ MoveEffects.full = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
BIDE_EFFECT = {
|
BIDE_EFFECT = {
|
||||||
|
-- BideEffect (effects.asm:764-789) is a ResidualEffects2 entry: the
|
||||||
|
-- storing turn plays XSTATITEM_ANIM (XSTATITEM_DUPLICATE_ANIM on the
|
||||||
|
-- enemy side) and never BIDE's own animation, which belongs to the
|
||||||
|
-- release turn in .UnleashEnergy (#375)
|
||||||
perform = function(ctx)
|
perform = function(ctx)
|
||||||
local user = ctx.user
|
local user = ctx.user
|
||||||
user.bideTurns = ctx.rng(2, 3)
|
user.bideTurns = ctx.rng(2, 3)
|
||||||
user.bideDamage = 0
|
user.bideDamage = 0
|
||||||
|
ctx.battle:cancelMoveAnim()
|
||||||
|
ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM")
|
||||||
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -183,6 +183,21 @@ local function drawCommandMenu(battle)
|
|||||||
Font.drawBox(0, 13, 20, 5)
|
Font.drawBox(0, 13, 20, 5)
|
||||||
Font.drawBox(20, 13, 18, 5)
|
Font.drawBox(20, 13, 18, 5)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
-- The old-man / PROF.OAK catch demo has no party, so makeOldManDemo parks
|
||||||
|
-- the WILD mon in battle.player as a placeholder (BattleState:1209). The
|
||||||
|
-- classic layout's demo branch never names anyone (DisplayBattleMenu,
|
||||||
|
-- core.asm:2038-2049, just draws the menu), so naming it here printed
|
||||||
|
-- "What will PIKACHU do?" over Oak's scripted throw (#557). Leave the
|
||||||
|
-- prompt side blank and run the same scripted hand the classic does.
|
||||||
|
if battle.demo then
|
||||||
|
Font.draw(Strings("FIGHT"), 176, 112)
|
||||||
|
Font.drawCode(0xE1, 240, 112); Font.drawCode(0xE2, 248, 112)
|
||||||
|
Font.draw(Strings("ITEM"), 176, 128)
|
||||||
|
Font.draw(Strings("RUN"), 240, 128)
|
||||||
|
-- next to FIGHT for the first 80 frames, then ITEM
|
||||||
|
Font.drawCode(0xED, 168, (battle.demoTimer or 0) <= 80 and 112 or 128)
|
||||||
|
return
|
||||||
|
end
|
||||||
Font.draw(Strings("What will"), 8, 112)
|
Font.draw(Strings("What will"), 8, 112)
|
||||||
local who = battle.player and battle.player.name or ""
|
local who = battle.player and battle.player.name or ""
|
||||||
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
|
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
|
||||||
@@ -305,22 +320,40 @@ function WideBattle.draw(battle)
|
|||||||
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
|
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
|
||||||
sx = battle.frame % 4 < 2 and 2 or -2
|
sx = battle.frame % 4 < 2 and 2 or -2
|
||||||
end
|
end
|
||||||
local slide = (battle.introSlide or 0) * 4
|
local slide = (battle.introSlide or 0) * 2
|
||||||
|
|
||||||
-- Each side keeps its original sprite pixels and placement math: the two
|
-- Each side keeps its original sprite pixels and placement math: the two
|
||||||
-- 160x144 OAM regions are translated apart and clipped into the wider
|
-- 160x144 OAM regions are translated apart and clipped into the wider
|
||||||
-- battlefield rather than either monster being scaled. wideRegion tells
|
-- battlefield rather than either monster being scaled. wideRegion tells
|
||||||
-- drawBattlerPic its own side window is already the clip.
|
-- drawBattlerPic its own side window is already the clip. A shake moves
|
||||||
|
-- each region's clip with its contents, so a pic pushed toward a region
|
||||||
|
-- edge is not sheared off it (a vertical shake used to clip the player's
|
||||||
|
-- feet at FIELD_BOTTOM).
|
||||||
battle.wideRegion = true
|
battle.wideRegion = true
|
||||||
inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
|
inRegion(sx, 32 + sy, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
|
||||||
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
|
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
|
||||||
inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
|
inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
|
||||||
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
|
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
|
||||||
battle.wideRegion = nil
|
battle.wideRegion = nil
|
||||||
|
|
||||||
drawHUDs(battle, slide)
|
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the
|
||||||
|
-- shakes move IS the whole screen: PredefShakeScreenHorizontally,
|
||||||
|
-- PredefShakeScreenVertically and AnimationShakeScreenHorizontallySlow
|
||||||
|
-- displace the HUDs and the message window along with the pics, exactly as
|
||||||
|
-- drawClassic offsets its whole BG canvas. Shaking only the two pic
|
||||||
|
-- regions left the foe gliding sideways across a nailed-down screen on
|
||||||
|
-- every applying-attack shake -- TAIL WHIP and every other status move
|
||||||
|
-- (#562). The OAM anim layer stays put, as it does in drawClassic.
|
||||||
|
local function shaken(fn)
|
||||||
|
if sx == 0 and sy == 0 then return fn() end
|
||||||
|
g.push()
|
||||||
|
g.translate(sx, sy)
|
||||||
|
fn()
|
||||||
|
g.pop()
|
||||||
|
end
|
||||||
|
shaken(function() drawHUDs(battle, slide) end)
|
||||||
drawAnimationLayer(battle)
|
drawAnimationLayer(battle)
|
||||||
drawTextArea(battle)
|
shaken(function() drawTextArea(battle) end)
|
||||||
|
|
||||||
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
|
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
|
||||||
g.setColor(1, 1, 1, 0.85)
|
g.setColor(1, 1, 1, 0.85)
|
||||||
|
|||||||
+118
-4
@@ -25,6 +25,32 @@ local SAMPLE_RATE = ChipSynth.SAMPLE_RATE
|
|||||||
local MUSIC_BUFFER_SAMPLES = ChipSynth.MUSIC_BUFFER_SAMPLES
|
local MUSIC_BUFFER_SAMPLES = ChipSynth.MUSIC_BUFFER_SAMPLES
|
||||||
local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT
|
local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Per-channel mix (edit these)
|
||||||
|
-- Applied on load and whenever this file hot-reloads.
|
||||||
|
-- Runtime: ChipAudio.setChannelVolume / setChannelPitch.
|
||||||
|
-- [1] pulse 1 [2] pulse 2 [3] wave [4] noise / drums
|
||||||
|
-- Volume: 1 = authentic, 0 = mute, >1 boosts
|
||||||
|
-- Pitch: 1 = authentic, 2 = +1 octave, 0.5 = -1 octave
|
||||||
|
-- The shipped values stay at 1: 0.25 / 0.5 on the wave channel buried the Ch3
|
||||||
|
-- countermelodies an octave low (#429), and ChipSynth already applies the
|
||||||
|
-- wave channel's own hardware octave (frequency * 0.5).
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
local CHANNEL_VOLUME = {
|
||||||
|
[1] = 1, -- pulse 1
|
||||||
|
[2] = 1, -- pulse 2
|
||||||
|
[3] = 1, -- wave
|
||||||
|
[4] = 1, -- noise / drums
|
||||||
|
}
|
||||||
|
local CHANNEL_PITCH = {
|
||||||
|
[1] = 1, -- pulse 1
|
||||||
|
[2] = 1, -- pulse 2
|
||||||
|
[3] = 1, -- wave
|
||||||
|
[4] = 1, -- noise / drums
|
||||||
|
}
|
||||||
|
ChipSynth.setChannelVolumes(CHANNEL_VOLUME)
|
||||||
|
ChipSynth.setChannelPitches(CHANNEL_PITCH)
|
||||||
|
|
||||||
-- currentMusic: { source, gen, threaded, started, finished, engine }
|
-- currentMusic: { source, gen, threaded, started, finished, engine }
|
||||||
-- threaded songs stream from the worker (engine is nil here);
|
-- threaded songs stream from the worker (engine is nil here);
|
||||||
-- the fallback path owns a local engine and fills the source itself.
|
-- the fallback path owns a local engine and fills the source itself.
|
||||||
@@ -32,6 +58,14 @@ local currentMusic
|
|||||||
local pendingBuf -- a current-gen buffer popped from the worker but not yet
|
local pendingBuf -- a current-gen buffer popped from the worker but not yet
|
||||||
-- queued because the Source was momentarily full
|
-- queued because the Source was momentarily full
|
||||||
|
|
||||||
|
-- Music holds playback while a fanfare owns the music channels (#398).
|
||||||
|
-- Pausing the Source is not enough on its own: this module is what starts a
|
||||||
|
-- chip song (immediately on the sync path, on the first worker buffer on the
|
||||||
|
-- threaded one), so a song that begins during a jingle would come up
|
||||||
|
-- underneath it. Music.duckForFanfare sets the hold, Music releases it when
|
||||||
|
-- the jingle ends.
|
||||||
|
local musicHeld = false
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- worker management
|
-- worker management
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
@@ -125,7 +159,7 @@ local function playMusicSync(data, header, allowLoops)
|
|||||||
currentMusic = { source = source, engine = engine, threaded = false,
|
currentMusic = { source = source, engine = engine, threaded = false,
|
||||||
started = true, finished = false }
|
started = true, finished = false }
|
||||||
fillSync(MUSIC_FILL_INITIAL)
|
fillSync(MUSIC_FILL_INITIAL)
|
||||||
source:play()
|
if not musicHeld then source:play() end
|
||||||
return source
|
return source
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -152,13 +186,23 @@ function ChipAudio.playMusic(data, header, allowLoops)
|
|||||||
musicGen = musicGen + 1
|
musicGen = musicGen + 1
|
||||||
local gen = musicGen
|
local gen = musicGen
|
||||||
cmdCh:push({ cmd = "play", gen = gen, header = header,
|
cmdCh:push({ cmd = "play", gen = gen, header = header,
|
||||||
allowLoops = allowLoops, audio = slimAudio(data) })
|
allowLoops = allowLoops, audio = slimAudio(data),
|
||||||
|
channelVolumes = ChipSynth.getChannelVolumes(),
|
||||||
|
channelPitches = ChipSynth.getChannelPitches() })
|
||||||
currentMusic = { source = source, gen = gen, threaded = true,
|
currentMusic = { source = source, gen = gen, threaded = true,
|
||||||
started = false, finished = false }
|
started = false, finished = false }
|
||||||
-- playback starts in update() once the first buffer arrives (~1 frame)
|
-- playback starts in update() once the first buffer arrives (~1 frame)
|
||||||
return source
|
return source
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function pushChannelMix()
|
||||||
|
if workerReady and cmdCh then
|
||||||
|
cmdCh:push({ cmd = "channelMix",
|
||||||
|
volumes = ChipSynth.getChannelVolumes(),
|
||||||
|
pitches = ChipSynth.getChannelPitches() })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- move finished buffers from the worker into the Source; start playback once
|
-- move finished buffers from the worker into the Source; start playback once
|
||||||
-- the first one lands
|
-- the first one lands
|
||||||
local function updateThreaded()
|
local function updateThreaded()
|
||||||
@@ -189,7 +233,7 @@ local function updateThreaded()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if not m.started then
|
if not m.started and not musicHeld then
|
||||||
if (MUSIC_BUFFER_COUNT - m.source:getFreeBufferCount()) > 0 then
|
if (MUSIC_BUFFER_COUNT - m.source:getFreeBufferCount()) > 0 then
|
||||||
pcall(function() m.source:play() end)
|
pcall(function() m.source:play() end)
|
||||||
m.started = true
|
m.started = true
|
||||||
@@ -212,7 +256,7 @@ end
|
|||||||
-- pause/resume behavior.
|
-- pause/resume behavior.
|
||||||
function ChipAudio.ensureMusicPlaying()
|
function ChipAudio.ensureMusicPlaying()
|
||||||
local m = currentMusic
|
local m = currentMusic
|
||||||
if not m or m.finished then return end
|
if not m or m.finished or musicHeld then return end
|
||||||
if m.threaded then
|
if m.threaded then
|
||||||
if not m.started then return end
|
if not m.started then return end
|
||||||
local ok, playing = pcall(function() return m.source:isPlaying() end)
|
local ok, playing = pcall(function() return m.source:isPlaying() end)
|
||||||
@@ -230,6 +274,18 @@ function ChipAudio.ensureMusicPlaying()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Silence the song for the length of a fanfare and start whatever was held
|
||||||
|
-- back once it ends. Held state outlives a song change: Music.play may swap
|
||||||
|
-- songs while the jingle is still sounding.
|
||||||
|
function ChipAudio.holdMusic(held)
|
||||||
|
held = not not held
|
||||||
|
if held == musicHeld then return end
|
||||||
|
musicHeld = held
|
||||||
|
if held then return end
|
||||||
|
ChipAudio.update()
|
||||||
|
ChipAudio.ensureMusicPlaying()
|
||||||
|
end
|
||||||
|
|
||||||
-- Threaded playMusic returns an empty QueueableSource and only calls
|
-- Threaded playMusic returns an empty QueueableSource and only calls
|
||||||
-- Source:play once the first worker buffer lands (~1 frame later). Until
|
-- Source:play once the first worker buffer lands (~1 frame later). Until
|
||||||
-- then Source:isPlaying is false -- callers that treat that as "song over"
|
-- then Source:isPlaying is false -- callers that treat that as "song over"
|
||||||
@@ -270,6 +326,64 @@ function ChipAudio.invalidate()
|
|||||||
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
|
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- End the worker thread. LOVE waits for every live love.thread before the
|
||||||
|
-- process exits and the worker's command loop only returns on "quit", so
|
||||||
|
-- skipping this leaves the process running after the window is gone (#339).
|
||||||
|
function ChipAudio.shutdown()
|
||||||
|
ChipAudio.stopMusic()
|
||||||
|
if workerReady and cmdCh then cmdCh:push({ cmd = "quit" }) end
|
||||||
|
if worker then pcall(function() worker:wait() end) end
|
||||||
|
worker, cmdCh, outCh = nil, nil, nil
|
||||||
|
workerReady = false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Runtime mix for one hardware channel (1..4). Takes effect on the next
|
||||||
|
-- synthesized buffer (live music) and on any SFX/cry rendered after the call.
|
||||||
|
function ChipAudio.setChannelVolume(hw, scale)
|
||||||
|
ChipSynth.setChannelVolume(hw, scale)
|
||||||
|
pushChannelMix()
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipAudio.getChannelVolume(hw)
|
||||||
|
return ChipSynth.getChannelVolume(hw)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipAudio.setChannelVolumes(volumes)
|
||||||
|
ChipSynth.setChannelVolumes(volumes)
|
||||||
|
pushChannelMix()
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipAudio.getChannelVolumes()
|
||||||
|
return ChipSynth.getChannelVolumes()
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipAudio.setChannelPitch(hw, scale)
|
||||||
|
ChipSynth.setChannelPitch(hw, scale)
|
||||||
|
pushChannelMix()
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipAudio.getChannelPitch(hw)
|
||||||
|
return ChipSynth.getChannelPitch(hw)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipAudio.setChannelPitches(pitches)
|
||||||
|
ChipSynth.setChannelPitches(pitches)
|
||||||
|
pushChannelMix()
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipAudio.getChannelPitches()
|
||||||
|
return ChipSynth.getChannelPitches()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- aliases for channel 4 (noise / drums)
|
||||||
|
function ChipAudio.setNoiseVolume(scale)
|
||||||
|
ChipAudio.setChannelVolume(4, scale)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipAudio.getNoiseVolume()
|
||||||
|
return ChipAudio.getChannelVolume(4)
|
||||||
|
end
|
||||||
|
|
||||||
-- a stale song must not keep sounding past the flush that replaced its
|
-- a stale song must not keep sounding past the flush that replaced its
|
||||||
-- program (20 §2 cache contract, chip music row)
|
-- program (20 §2 cache contract, chip music row)
|
||||||
Assets.register(ChipAudio.invalidate)
|
Assets.register(ChipAudio.invalidate)
|
||||||
|
|||||||
+124
-47
@@ -14,26 +14,97 @@ local bit = require("bit")
|
|||||||
|
|
||||||
local ChipSynth = {}
|
local ChipSynth = {}
|
||||||
|
|
||||||
local SAMPLE_RATE = 22050
|
local SAMPLE_RATE = 44100
|
||||||
local TICKS_PER_SECOND = 15360
|
local TICKS_PER_SECOND = 15360
|
||||||
local FRAME_TICKS = 256
|
local FRAME_TICKS = 256
|
||||||
local GB_CLOCK = 4194304
|
local GB_CLOCK = 4194304
|
||||||
|
|
||||||
-- one 4096-sample stereo SoundData is the unit both the worker hands off and
|
-- one 8192-sample stereo SoundData is the unit both the worker hands off and
|
||||||
-- the synchronous fallback queues; the source keeps MUSIC_BUFFER_COUNT of them
|
-- the synchronous fallback queues; the source keeps MUSIC_BUFFER_COUNT of them
|
||||||
-- (~6s) for stall tolerance (window resize, a long GC pause)
|
-- (~6s at 44100) for stall tolerance (window resize, a long GC pause)
|
||||||
local MUSIC_BUFFER_SAMPLES = 4096
|
local MUSIC_BUFFER_SAMPLES = 8192
|
||||||
local MUSIC_BUFFER_COUNT = 32
|
local MUSIC_BUFFER_COUNT = 32
|
||||||
|
|
||||||
ChipSynth.SAMPLE_RATE = SAMPLE_RATE
|
ChipSynth.SAMPLE_RATE = SAMPLE_RATE
|
||||||
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
|
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
|
||||||
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT
|
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT
|
||||||
|
|
||||||
|
-- Runtime mix per hardware channel (1 pulse, 2 pulse, 3 wave, 4 noise).
|
||||||
|
-- Volume: 1 = authentic GB, 0 = mute. Pitch: 1 = authentic, 2 = +1 octave,
|
||||||
|
-- 0.5 = -1 octave. Applied at sample time so a live change reaches the next
|
||||||
|
-- buffer on both the sync path and the worker (via ChipAudio).
|
||||||
|
local channelVolume = { 1, 1, 1, 1 }
|
||||||
|
local channelPitch = { 1, 1, 1, 1 }
|
||||||
|
|
||||||
|
local function clampScale(scale)
|
||||||
|
return math.max(0, tonumber(scale) or 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setChannelTable(table, hw, scale)
|
||||||
|
hw = tonumber(hw)
|
||||||
|
if not hw or hw < 1 or hw > 4 then return end
|
||||||
|
table[hw] = clampScale(scale)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setChannelTables(table, values)
|
||||||
|
if type(values) ~= "table" then return end
|
||||||
|
for hw = 1, 4 do
|
||||||
|
if values[hw] ~= nil then table[hw] = clampScale(values[hw]) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipSynth.setChannelVolume(hw, scale)
|
||||||
|
setChannelTable(channelVolume, hw, scale)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipSynth.getChannelVolume(hw)
|
||||||
|
return channelVolume[tonumber(hw) or 0] or 1
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipSynth.setChannelVolumes(volumes)
|
||||||
|
setChannelTables(channelVolume, volumes)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipSynth.getChannelVolumes()
|
||||||
|
return { channelVolume[1], channelVolume[2], channelVolume[3], channelVolume[4] }
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipSynth.setChannelPitch(hw, scale)
|
||||||
|
setChannelTable(channelPitch, hw, scale)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipSynth.getChannelPitch(hw)
|
||||||
|
return channelPitch[tonumber(hw) or 0] or 1
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipSynth.setChannelPitches(pitches)
|
||||||
|
setChannelTables(channelPitch, pitches)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipSynth.getChannelPitches()
|
||||||
|
return { channelPitch[1], channelPitch[2], channelPitch[3], channelPitch[4] }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- aliases for the noise/drum layer
|
||||||
|
function ChipSynth.setNoiseVolume(scale)
|
||||||
|
ChipSynth.setChannelVolume(4, scale)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChipSynth.getNoiseVolume()
|
||||||
|
return ChipSynth.getChannelVolume(4)
|
||||||
|
end
|
||||||
|
|
||||||
local PITCHES = {
|
local PITCHES = {
|
||||||
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
|
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
|
||||||
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
|
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
|
||||||
}
|
}
|
||||||
local DUTY = { [0] = 0.125, [1] = 0.25, [2] = 0.5, [3] = 0.75 }
|
-- LuaGB / DMG 8-step duty tables (index 0-3); stored on channels as that index
|
||||||
|
local WAVE_PATTERN_TABLES = {
|
||||||
|
[0] = {0, 0, 0, 0, 0, 0, 0, 1},
|
||||||
|
[1] = {1, 0, 0, 0, 0, 0, 0, 1},
|
||||||
|
[2] = {1, 0, 0, 0, 0, 1, 1, 1},
|
||||||
|
[3] = {0, 1, 1, 1, 1, 1, 1, 0},
|
||||||
|
}
|
||||||
local WAVE_LEVEL = { [0] = 0, [1] = 1, [2] = 0.5, [3] = 0.25 }
|
local WAVE_LEVEL = { [0] = 0, [1] = 1, [2] = 0.5, [3] = 0.25 }
|
||||||
local NOISE_DIVISORS = {
|
local NOISE_DIVISORS = {
|
||||||
[0] = 8, [1] = 16, [2] = 32, [3] = 48,
|
[0] = 8, [1] = 16, [2] = 32, [3] = 48,
|
||||||
@@ -41,7 +112,7 @@ local NOISE_DIVISORS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
local function snapTicks(ticks)
|
local function snapTicks(ticks)
|
||||||
return math.floor((ticks * 735 + 256) / 512)
|
return math.floor((ticks * 1470 + 256) / 512)
|
||||||
end
|
end
|
||||||
|
|
||||||
local cachedProgramFile
|
local cachedProgramFile
|
||||||
@@ -143,7 +214,7 @@ function Channel.new(engine, spec, options)
|
|||||||
speed = 12,
|
speed = 12,
|
||||||
volume = 12,
|
volume = 12,
|
||||||
fade = 0,
|
fade = 0,
|
||||||
duty = 0.5,
|
duty = 2,
|
||||||
octave = 4,
|
octave = 4,
|
||||||
waveInstrument = 0,
|
waveInstrument = 0,
|
||||||
waveLevel = 1,
|
waveLevel = 1,
|
||||||
@@ -317,7 +388,7 @@ function Channel:nextEvent()
|
|||||||
target = self:frequency(bit.band(packed, 0x0F), octave),
|
target = self:frequency(bit.band(packed, 0x0F), octave),
|
||||||
}
|
}
|
||||||
elseif command == 0xEC then
|
elseif command == 0xEC then
|
||||||
self.duty = DUTY[bit.band(self:byte(), 3)] or 0.5
|
self.duty = bit.band(self:byte(), 3)
|
||||||
elseif command == 0xED then
|
elseif command == 0xED then
|
||||||
self.engine.tempo = self:byte() * 0x100 + self:byte()
|
self.engine.tempo = self:byte() * 0x100 + self:byte()
|
||||||
elseif command == 0xEE then
|
elseif command == 0xEE then
|
||||||
@@ -329,10 +400,10 @@ function Channel:nextEvent()
|
|||||||
elseif command == 0xFC then
|
elseif command == 0xFC then
|
||||||
local packed = self:byte()
|
local packed = self:byte()
|
||||||
self.duty = {
|
self.duty = {
|
||||||
DUTY[bit.band(bit.rshift(packed, 6), 3)],
|
bit.band(bit.rshift(packed, 6), 3),
|
||||||
DUTY[bit.band(bit.rshift(packed, 4), 3)],
|
bit.band(bit.rshift(packed, 4), 3),
|
||||||
DUTY[bit.band(bit.rshift(packed, 2), 3)],
|
bit.band(bit.rshift(packed, 2), 3),
|
||||||
DUTY[bit.band(packed, 3)],
|
bit.band(packed, 3),
|
||||||
}
|
}
|
||||||
elseif command == 0xFD then
|
elseif command == 0xFD then
|
||||||
self.callStack[#self.callStack + 1] = self.address + 2
|
self.callStack[#self.callStack + 1] = self.address + 2
|
||||||
@@ -423,27 +494,24 @@ function Channel:sampleNoise(parameter)
|
|||||||
parameter = parameter or 0
|
parameter = parameter or 0
|
||||||
local divisor = NOISE_DIVISORS[bit.band(parameter, 7)]
|
local divisor = NOISE_DIVISORS[bit.band(parameter, 7)]
|
||||||
local shift = bit.rshift(parameter, 4)
|
local shift = bit.rshift(parameter, 4)
|
||||||
local output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
|
if shift < 14 then
|
||||||
if shift >= 14 then return output end
|
local pitch = channelPitch[self.hardware] or 1
|
||||||
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE
|
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE * pitch
|
||||||
local width7 = bit.band(parameter, 8) ~= 0
|
local width7 = bit.band(parameter, 8) ~= 0
|
||||||
local remaining = cycles
|
local remaining = cycles
|
||||||
local area = 0
|
while remaining > 0 do
|
||||||
|
local untilClock = 1 - self.noiseClock
|
||||||
while remaining > 0 do
|
local span = math.min(remaining, untilClock)
|
||||||
local untilClock = 1 - self.noiseClock
|
self.noiseClock = self.noiseClock + span
|
||||||
local span = math.min(remaining, untilClock)
|
remaining = remaining - span
|
||||||
output = bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
|
if self.noiseClock >= 1 - 1e-12 then
|
||||||
area = area + output * span
|
self.noiseClock = 0
|
||||||
self.noiseClock = self.noiseClock + span
|
self:clockNoise(width7)
|
||||||
remaining = remaining - span
|
end
|
||||||
if self.noiseClock >= 1 - 1e-12 then
|
|
||||||
self.noiseClock = 0
|
|
||||||
self:clockNoise(width7)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- LuaGB: instantaneous inverted LFSR LSB (high when bit0 == 0)
|
||||||
return area / cycles
|
return bit.band(self.noiseLfsr, 1) == 0 and 1 or -1
|
||||||
end
|
end
|
||||||
|
|
||||||
local function sweepCalculation(register, sweep)
|
local function sweepCalculation(register, sweep)
|
||||||
@@ -481,7 +549,7 @@ function Channel:sampleDrum(event, sampleIndex)
|
|||||||
end
|
end
|
||||||
local elapsed = (sampleIndex - segment.startSample) / SAMPLE_RATE
|
local elapsed = (sampleIndex - segment.startSample) / SAMPLE_RATE
|
||||||
local volume = envelopeVolume(segment.volume, segment.fade, elapsed)
|
local volume = envelopeVolume(segment.volume, segment.fade, elapsed)
|
||||||
return self:sampleNoise(segment.parameter) * volume / 15 * 0.35
|
return self:sampleNoise(segment.parameter) * volume / 15
|
||||||
end
|
end
|
||||||
|
|
||||||
function Channel:sample()
|
function Channel:sample()
|
||||||
@@ -498,11 +566,14 @@ function Channel:sample()
|
|||||||
event.sample = sampleIndex + 1
|
event.sample = sampleIndex + 1
|
||||||
if event.silence then return 0 end
|
if event.silence then return 0 end
|
||||||
|
|
||||||
if event.drum then return self:sampleDrum(event, sampleIndex) end
|
local gain = channelVolume[self.hardware] or 1
|
||||||
|
if event.drum then
|
||||||
|
return self:sampleDrum(event, sampleIndex) * gain
|
||||||
|
end
|
||||||
local volume = envelopeVolume(
|
local volume = envelopeVolume(
|
||||||
event.volume or 0, event.fade or 0, event.elapsed)
|
event.volume or 0, event.fade or 0, event.elapsed)
|
||||||
if event.noise then
|
if event.noise then
|
||||||
return self:sampleNoise(event.noiseParameter) * volume / 15 * 0.35
|
return self:sampleNoise(event.noiseParameter) * volume / 15 * gain
|
||||||
end
|
end
|
||||||
|
|
||||||
local register = event.register
|
local register = event.register
|
||||||
@@ -527,7 +598,8 @@ function Channel:sample()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local frequency = 131072 / (2048 - math.min(register, 2047))
|
local pitch = channelPitch[self.hardware] or 1
|
||||||
|
local frequency = 131072 / (2048 - math.min(register, 2047)) * pitch
|
||||||
if event.wave then frequency = frequency * 0.5 end
|
if event.wave then frequency = frequency * 0.5 end
|
||||||
local phase = self.phase
|
local phase = self.phase
|
||||||
self.phase = (phase + frequency / SAMPLE_RATE) % 1
|
self.phase = (phase + frequency / SAMPLE_RATE) % 1
|
||||||
@@ -537,13 +609,18 @@ function Channel:sample()
|
|||||||
-- a def-local program may omit its wave table entirely
|
-- a def-local program may omit its wave table entirely
|
||||||
if not wave then return 0 end
|
if not wave then return 0 end
|
||||||
local index = math.min(32, math.floor(phase * 32) + 1)
|
local index = math.min(32, math.floor(phase * 32) + 1)
|
||||||
return wave[index] * event.waveLevel * 0.55
|
return wave[index] * event.waveLevel * gain
|
||||||
end
|
end
|
||||||
local duty = event.duty
|
local duty = event.duty
|
||||||
if type(duty) == "table" then
|
if type(duty) == "table" then
|
||||||
duty = duty[frame % 4 + 1]
|
duty = duty[frame % 4 + 1]
|
||||||
end
|
end
|
||||||
return (phase < duty and 1 or -1) * volume / 15 * 0.5
|
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
|
||||||
|
local step = math.floor(phase * 8) % 8
|
||||||
|
if pattern[step + 1] == 0 then
|
||||||
|
return -volume / 15 * gain
|
||||||
|
end
|
||||||
|
return volume / 15 * gain
|
||||||
end
|
end
|
||||||
|
|
||||||
local Engine = {}
|
local Engine = {}
|
||||||
@@ -597,8 +674,8 @@ local function readWaves(banks, audio, engineNumber)
|
|||||||
for byteIndex = 0, 15 do
|
for byteIndex = 0, 15 do
|
||||||
local packed = romByte(
|
local packed = romByte(
|
||||||
banks, spec.bank, spec.address + wave * 16 + byteIndex)
|
banks, spec.bank, spec.address + wave * 16 + byteIndex)
|
||||||
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5
|
values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8
|
||||||
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
|
values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8
|
||||||
end
|
end
|
||||||
waves[#waves + 1] = values
|
waves[#waves + 1] = values
|
||||||
end
|
end
|
||||||
@@ -606,8 +683,8 @@ local function readWaves(banks, audio, engineNumber)
|
|||||||
for byteIndex = 0, 15 do
|
for byteIndex = 0, 15 do
|
||||||
local packed = romByte(
|
local packed = romByte(
|
||||||
banks, spec.bank, spec.address + 5 * 16 + byteIndex)
|
banks, spec.bank, spec.address + 5 * 16 + byteIndex)
|
||||||
values[#values + 1] = (bit.rshift(packed, 4) - 7.5) / 7.5
|
values[#values + 1] = (bit.rshift(packed, 4) - 8) / 8
|
||||||
values[#values + 1] = (bit.band(packed, 0x0F) - 7.5) / 7.5
|
values[#values + 1] = (bit.band(packed, 0x0F) - 8) / 8
|
||||||
end
|
end
|
||||||
for _ = 1, 4 do waves[#waves + 1] = values end
|
for _ = 1, 4 do waves[#waves + 1] = values end
|
||||||
return waves
|
return waves
|
||||||
@@ -615,7 +692,7 @@ end
|
|||||||
|
|
||||||
-- def-local waves are authored either as raw 0-15 nibbles (the ROM's own
|
-- 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
|
-- units) or as the -1..1 samples readWaves produces; the synth wants the
|
||||||
-- latter
|
-- latter (LuaGB: (nibble - 8) / 8)
|
||||||
local function normalizeWaves(source)
|
local function normalizeWaves(source)
|
||||||
local waves = {}
|
local waves = {}
|
||||||
for index, values in ipairs(source) do
|
for index, values in ipairs(source) do
|
||||||
@@ -625,7 +702,7 @@ local function normalizeWaves(source)
|
|||||||
end
|
end
|
||||||
local wave = {}
|
local wave = {}
|
||||||
for position, value in ipairs(values) do
|
for position, value in ipairs(values) do
|
||||||
wave[position] = nibbles and (value - 7.5) / 7.5 or value
|
wave[position] = nibbles and (value - 8) / 8 or value
|
||||||
end
|
end
|
||||||
waves[index] = wave
|
waves[index] = wave
|
||||||
end
|
end
|
||||||
@@ -690,7 +767,7 @@ end
|
|||||||
function Engine:sample()
|
function Engine:sample()
|
||||||
local value = 0
|
local value = 0
|
||||||
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
|
for _, channel in ipairs(self.channels) do value = value + channel:sample() end
|
||||||
return math.max(-1, math.min(1, value * 0.5))
|
return math.max(-1, math.min(1, value / 4))
|
||||||
end
|
end
|
||||||
|
|
||||||
function Engine:sampleStereo()
|
function Engine:sampleStereo()
|
||||||
@@ -701,8 +778,8 @@ function Engine:sampleStereo()
|
|||||||
if not event or event.panLeft ~= false then left = left + value end
|
if not event or event.panLeft ~= false then left = left + value end
|
||||||
if not event or event.panRight ~= false then right = right + value end
|
if not event or event.panRight ~= false then right = right + value end
|
||||||
end
|
end
|
||||||
return math.max(-1, math.min(1, left * 0.5)),
|
return math.max(-1, math.min(1, left / 4)),
|
||||||
math.max(-1, math.min(1, right * 0.5))
|
math.max(-1, math.min(1, right / 4))
|
||||||
end
|
end
|
||||||
|
|
||||||
function Engine:sampleChannel(number)
|
function Engine:sampleChannel(number)
|
||||||
@@ -711,7 +788,7 @@ function Engine:sampleChannel(number)
|
|||||||
local value = channel:sample()
|
local value = channel:sample()
|
||||||
if channel.number == number then selected = value end
|
if channel.number == number then selected = value end
|
||||||
end
|
end
|
||||||
return math.max(-1, math.min(1, selected * 0.5))
|
return math.max(-1, math.min(1, selected / 4))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
|
-- render `samples` frames into a fresh SoundData (mono or stereo). love.sound
|
||||||
|
|||||||
+38
-8
@@ -14,12 +14,12 @@ local MODULES = {
|
|||||||
-- Optional for compatibility with developer and stale caches.
|
-- Optional for compatibility with developer and stale caches.
|
||||||
local OPTIONAL = { "audio", "palettes", "icons" }
|
local OPTIONAL = { "audio", "palettes", "icons" }
|
||||||
|
|
||||||
-- The rules the engine still carries as literals. The constants registry
|
-- Vanilla defaults for rules exposed through the constants registry. A
|
||||||
-- deep-merges over these, so a value has to exist before a mod can patch
|
-- value has to exist before a mod can patch it; each one matches the
|
||||||
-- it; each one is the number the engine hard-codes today, so seeding them
|
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
|
||||||
-- changes nothing on a mod-free boot.
|
-- boot.
|
||||||
local CONSTANT_DEFAULTS = {
|
local CONSTANT_DEFAULTS = {
|
||||||
bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua)
|
bagSize = 20, -- BAG_ITEM_CAPACITY (Bag.capacity fallback)
|
||||||
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
|
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
|
||||||
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
|
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
|
||||||
moveMax = 4,
|
moveMax = 4,
|
||||||
@@ -38,6 +38,22 @@ local CONSTANT_DEFAULTS = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- data/events/trades.asm in Pokemon Yellow has its own TradeMons table.
|
||||||
|
-- Old Yellow imports were built from the Red manifest, so correct that
|
||||||
|
-- table after loading as well as in the fixed import manifest below.
|
||||||
|
local YELLOW_TRADES = {
|
||||||
|
{ give = "LICKITUNG", get = "DUGTRIO", dialogset = 1, nickname = "GURIO" },
|
||||||
|
{ give = "CLEFAIRY", get = "MR_MIME", dialogset = 1, nickname = "MILES" },
|
||||||
|
{ give = "BUTTERFREE", get = "BEEDRILL", dialogset = 3, nickname = "STINGER" },
|
||||||
|
{ give = "KANGASKHAN", get = "MUK", dialogset = 1, nickname = "STICKY" },
|
||||||
|
{ give = "MEW", get = "MEW", dialogset = 3, nickname = "BART" },
|
||||||
|
{ give = "TANGELA", get = "PARASECT", dialogset = 1, nickname = "SPIKE" },
|
||||||
|
{ give = "PIDGEOT", get = "PIDGEOT", dialogset = 2, nickname = "MARTY" },
|
||||||
|
{ give = "GOLDUCK", get = "RHYDON", dialogset = 2, nickname = "BUFFY" },
|
||||||
|
{ give = "GROWLITHE", get = "DEWGONG", dialogset = 3, nickname = "CEZANNE" },
|
||||||
|
{ give = "CUBONE", get = "MACHOKE", dialogset = 3, nickname = "RICKY" },
|
||||||
|
}
|
||||||
|
|
||||||
-- 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 = {
|
||||||
@@ -58,6 +74,12 @@ local function copy(value)
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Data:applyVersionedFieldData()
|
||||||
|
if require("src.core.GameVersion").isYellow() then
|
||||||
|
self.field.trades = copy(YELLOW_TRADES)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Fills only what the cache is missing, so an importer that learns to
|
-- Fills only what the cache is missing, so an importer that learns to
|
||||||
-- stamp one of these keys silently takes over from the engine.
|
-- stamp one of these keys silently takes over from the engine.
|
||||||
function Data:seedDefaults()
|
function Data:seedDefaults()
|
||||||
@@ -77,6 +99,7 @@ function Data:seedDefaults()
|
|||||||
if constants.dexDigits == nil then
|
if constants.dexDigits == nil then
|
||||||
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
|
constants.dexDigits = math.max(3, #tostring(constants.dexSize))
|
||||||
end
|
end
|
||||||
|
self:applyVersionedFieldData()
|
||||||
local boot = self.field.boot
|
local boot = self.field.boot
|
||||||
if boot == nil then
|
if boot == nil then
|
||||||
boot = {}
|
boot = {}
|
||||||
@@ -85,6 +108,13 @@ function Data:seedDefaults()
|
|||||||
for key, value in pairs(BOOT_DEFAULTS) do
|
for key, value in pairs(BOOT_DEFAULTS) do
|
||||||
if boot[key] == nil then boot[key] = copy(value) end
|
if boot[key] == nil then boot[key] = copy(value) end
|
||||||
end
|
end
|
||||||
|
-- Yellow boots its own attract movie (engine/movie/intro_yellow.asm);
|
||||||
|
-- only the un-overridden default flips, so a total conversion that set
|
||||||
|
-- field.boot.screens.splash keeps its choice on any version.
|
||||||
|
if boot.screens.splash == BOOT_DEFAULTS.screens.splash
|
||||||
|
and require("src.core.GameVersion").isYellow() then
|
||||||
|
boot.screens.splash = "YellowIntro"
|
||||||
|
end
|
||||||
-- the naming screen presets the importer already extracts but nothing
|
-- the naming screen presets the importer already extracts but nothing
|
||||||
-- ever read (field.presetNames)
|
-- ever read (field.presetNames)
|
||||||
if boot.namePresets == nil then
|
if boot.namePresets == nil then
|
||||||
@@ -114,8 +144,8 @@ end
|
|||||||
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
|
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
|
||||||
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
|
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
|
||||||
-- object index [1] so he behaves like the real leader:
|
-- object index [1] so he behaves like the real leader:
|
||||||
-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers
|
-- * range 0: FightingDojoDefaultScript, not trainer sight, starts his
|
||||||
-- spots the player in his DOWN line and he challenges on sight (#197),
|
-- battle from the single tile to his left (#495),
|
||||||
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
|
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
|
||||||
-- * after = the "Stay and train at Karate with us!" re-talk line.
|
-- * after = the "Stay and train at Karate with us!" re-talk line.
|
||||||
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
|
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
|
||||||
@@ -130,7 +160,7 @@ function Data:seedFightingDojoKarateMaster()
|
|||||||
headers.FightingDojo = headers.FightingDojo or {}
|
headers.FightingDojo = headers.FightingDojo or {}
|
||||||
if headers.FightingDojo[1] then return end
|
if headers.FightingDojo[1] then return end
|
||||||
headers.FightingDojo[1] = {
|
headers.FightingDojo[1] = {
|
||||||
range = 4,
|
range = 0,
|
||||||
battle = "_FightingDojoKarateMasterText",
|
battle = "_FightingDojoKarateMasterText",
|
||||||
won = "_FightingDojoKarateMasterDefeatedText",
|
won = "_FightingDojoKarateMasterDefeatedText",
|
||||||
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
|
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
|
||||||
|
|||||||
+169
-13
@@ -90,10 +90,13 @@ function Game:load()
|
|||||||
self.save.player.x, self.save.player.y, self.save.player.facing)
|
self.save.player.x, self.save.player.y, self.save.player.facing)
|
||||||
else
|
else
|
||||||
local titleState = self:makeTitleState()
|
local titleState = self:makeTitleState()
|
||||||
-- the copyright splash + Nidorino-vs-Gengar attract movie plays
|
-- the copyright splash + attract movie plays before the title
|
||||||
-- before the title (engine/movie/splash.asm + intro.asm); the ids come
|
-- (engine/movie/splash.asm + intro.asm; Yellow swaps in its own
|
||||||
-- from field.boot.screens so a total conversion owns the whole boot
|
-- 18-scene movie, engine/movie/intro_yellow.asm); the ids come from
|
||||||
Screens.push(self, bootScreens(self).splash or "IntroMovie", function()
|
-- field.boot.screens so a total conversion owns the whole boot
|
||||||
|
local splash = require("src.core.GameVersion").isYellow()
|
||||||
|
and "YellowIntro" or "IntroMovie"
|
||||||
|
Screens.push(self, bootScreens(self).splash or splash, function()
|
||||||
StateStack:push(titleState)
|
StateStack:push(titleState)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -169,7 +172,28 @@ function Game:returnToTitle()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Game:step(dt)
|
function Game:step(dt)
|
||||||
|
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
|
||||||
|
-- the same fixed-step boundary as a physical controller. Run them before
|
||||||
|
-- Input:step promotes queued edges so a button chosen here is visible to
|
||||||
|
-- this logic tick, not one tick later. With no wrapper this is a no-op.
|
||||||
|
ModRuntime.call("input.step", function() end, self, dt)
|
||||||
self.input:step()
|
self.input:step()
|
||||||
|
-- A+B+SELECT+START held for 16 steps: SoftReset (home/init.asm) stops the
|
||||||
|
-- audio, whites the palettes out and falls through into Init, i.e. the
|
||||||
|
-- power-on boot, so everything unsaved is gone and the title sequence
|
||||||
|
-- comes back -- exactly what returnToTitle does for the START menu's QUIT.
|
||||||
|
-- The check lives here, above stack:update, so it fires from any state:
|
||||||
|
-- overworld, battle, a menu or a cutscene, the way _Joypad's does (#563).
|
||||||
|
-- Input:reset clears the four still-physically-held buttons so the title
|
||||||
|
-- screen does not read A as a menu choice on its first frame.
|
||||||
|
-- a tool mod (or a harness) may hand Game a stand-in input with no
|
||||||
|
-- chord bookkeeping; those simply never soft reset
|
||||||
|
if self.input.softResetStep and self.input:softResetStep() then
|
||||||
|
Input:reset()
|
||||||
|
TouchControls:reset()
|
||||||
|
self:returnToTitle()
|
||||||
|
return
|
||||||
|
end
|
||||||
-- serviced unconditionally: a link battle's ENet transport must not
|
-- serviced unconditionally: a link battle's ENet transport must not
|
||||||
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
||||||
-- on top of BattleState (see LinkBattle.new)
|
-- on top of BattleState (see LinkBattle.new)
|
||||||
@@ -241,35 +265,93 @@ end
|
|||||||
-- exactly as the owning state computed it
|
-- exactly as the owning state computed it
|
||||||
local function sameZones(_, zones) return zones end
|
local function sameZones(_, zones) return zones end
|
||||||
|
|
||||||
|
-- A wide battle owns the surface until it leaves the stack. The party,
|
||||||
|
-- bag, choice and text states it opens still draw their original 160px UI,
|
||||||
|
-- but the canvas must not snap to 160px between those states.
|
||||||
|
function Game.wideBattleInStack(stack)
|
||||||
|
for i = #(stack and stack.states or {}), 1, -1 do
|
||||||
|
local state = stack.states[i]
|
||||||
|
if state and state.isWideBattleLayout and state:isWideBattleLayout() then
|
||||||
|
return state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
|
||||||
|
-- into both margins, keeping the canvas' paper color continuous; narrower
|
||||||
|
-- sprite and status zones move with the classic UI content.
|
||||||
|
local function centerClassicZones(zones, offset)
|
||||||
|
if not zones or offset == 0 then return zones end
|
||||||
|
local shifted = {}
|
||||||
|
for i, zone in ipairs(zones) do
|
||||||
|
local copy = {}
|
||||||
|
for key, value in pairs(zone) do copy[key] = value end
|
||||||
|
if copy.x == 0 and copy.w == Renderer.WIDTH then
|
||||||
|
copy.w = copy.w + offset * 2
|
||||||
|
else
|
||||||
|
copy.x = (copy.x or 0) + offset
|
||||||
|
end
|
||||||
|
shifted[i] = copy
|
||||||
|
end
|
||||||
|
return shifted
|
||||||
|
end
|
||||||
|
|
||||||
function Game:draw()
|
function Game:draw()
|
||||||
-- the UI canvas clears transparent when the overworld's world pass
|
-- the UI canvas clears transparent when the overworld's world pass
|
||||||
-- shows through beneath it; opaque full-screen states get the classic
|
-- shows through beneath it; opaque full-screen states get the classic
|
||||||
-- white clear
|
-- white clear
|
||||||
local base = self.stack:visibleBase()
|
local base = self.stack:visibleBase()
|
||||||
local worldBelow = self.stack.states[base] == self.overworld
|
local worldBelow = self.stack.states[base] == self.overworld
|
||||||
-- The UI surface is resolved once, before any state draws: the top state
|
-- A wide battle holds its 304px surface through every menu or prompt it
|
||||||
-- may want more than the Game Boy's 160x144 (the widescreen battle layout
|
-- opens. States that do not draw the wide battle composition are centred
|
||||||
-- asks for 304x144). Anything else keeps the classic surface, so a menu
|
-- in that surface below, so their classic coordinates and hit testing stay
|
||||||
-- pushed over a wide battle brings the screen straight back to 160x144.
|
-- unchanged. Outside a battle, including the title screen, the option is
|
||||||
|
-- intentionally inactive because it is a battle-layout setting.
|
||||||
local top = self.stack:top()
|
local top = self.stack:top()
|
||||||
if top and top.uiSize then
|
local wideBattle = Game.wideBattleInStack(self.stack)
|
||||||
|
local classicOffset = 0
|
||||||
|
if wideBattle and wideBattle.uiSize then
|
||||||
|
Renderer:setUISize(wideBattle:uiSize())
|
||||||
|
classicOffset = math.floor((select(1, Renderer:uiSize()) - Renderer.WIDTH) / 2)
|
||||||
|
elseif top and top.uiSize then
|
||||||
Renderer:setUISize(top:uiSize())
|
Renderer:setUISize(top:uiSize())
|
||||||
else
|
else
|
||||||
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
||||||
end
|
end
|
||||||
Renderer:beginFrame(worldBelow)
|
Renderer:beginFrame(worldBelow)
|
||||||
self.stack:draw()
|
for i = self.stack:visibleBase(), #self.stack.states do
|
||||||
|
local state = self.stack.states[i]
|
||||||
|
local wideState = state and state.isWideBattleLayout
|
||||||
|
and state:isWideBattleLayout()
|
||||||
|
if state and state.draw then
|
||||||
|
if classicOffset ~= 0 and not wideState then
|
||||||
|
love.graphics.push()
|
||||||
|
love.graphics.translate(classicOffset, 0)
|
||||||
|
state:draw()
|
||||||
|
love.graphics.pop()
|
||||||
|
else
|
||||||
|
state:draw()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
-- SGB colorization: the topmost state that knows its palette owns the
|
-- SGB colorization: the topmost state that knows its palette owns the
|
||||||
-- screen (overlays like text boxes inherit from what's beneath them);
|
-- screen (overlays like text boxes inherit from what's beneath them);
|
||||||
-- the overworld's world pass colors each visible map area separately
|
-- the overworld's world pass colors each visible map area separately
|
||||||
local zones, worldZones
|
local zones, worldZones, zoneOwner
|
||||||
for i = #self.stack.states, 1, -1 do
|
for i = #self.stack.states, 1, -1 do
|
||||||
local s = self.stack.states[i]
|
local s = self.stack.states[i]
|
||||||
if s.sgbPalettes then
|
if s.sgbPalettes then
|
||||||
zones = s:sgbPalettes(self)
|
zones = s:sgbPalettes(self)
|
||||||
|
zoneOwner = s
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
if classicOffset ~= 0 and zoneOwner
|
||||||
|
and not (zoneOwner.isWideBattleLayout
|
||||||
|
and zoneOwner:isWideBattleLayout()) then
|
||||||
|
zones = centerClassicZones(zones, classicOffset)
|
||||||
|
end
|
||||||
-- 14's render.zones: weather/lighting overlays and custom colorization
|
-- 14's render.zones: weather/lighting overlays and custom colorization
|
||||||
-- recolor or add zones before the blit
|
-- recolor or add zones before the blit
|
||||||
if ModRuntime.wantsHook("render.zones") then
|
if ModRuntime.wantsHook("render.zones") then
|
||||||
@@ -278,7 +360,13 @@ function Game:draw()
|
|||||||
if worldBelow and self.overworld.sgbWorldZones then
|
if worldBelow and self.overworld.sgbWorldZones then
|
||||||
worldZones = self.overworld:sgbWorldZones()
|
worldZones = self.overworld:sgbWorldZones()
|
||||||
end
|
end
|
||||||
Renderer:endFrame(zones, worldZones)
|
local viewport = Renderer:endFrame(zones, worldZones)
|
||||||
|
-- Persistent tool status is screen-space UI: draw it over the completed
|
||||||
|
-- render pipeline with exact playfield/margin geometry, but below mobile
|
||||||
|
-- controls. It never becomes an updating game state.
|
||||||
|
if ModRuntime.wantsHook("render.hud") then
|
||||||
|
ModRuntime.call("render.hud", function() end, self, viewport)
|
||||||
|
end
|
||||||
-- on-screen mobile controls: pure screen-space, over the finished frame
|
-- on-screen mobile controls: pure screen-space, over the finished frame
|
||||||
TouchControls:draw()
|
TouchControls:draw()
|
||||||
end
|
end
|
||||||
@@ -403,8 +491,16 @@ function Game:restartWithMods()
|
|||||||
require("src.core.HostShell").restart()
|
require("src.core.HostShell").restart()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Releases reach Input even while a top state captures raw input: a
|
||||||
|
-- swallowed key-up would strand a held-state flag for a key Input saw go
|
||||||
|
-- down before the capture armed (the stuck-flag hazard Input:reset
|
||||||
|
-- exists for). The top state only OBSERVES the release afterwards,
|
||||||
|
-- unlike onKeyPressed above which owns the press, so BindingsMenu can
|
||||||
|
-- commit a capture on the key-up (#589).
|
||||||
function Game:keyreleased(key)
|
function Game:keyreleased(key)
|
||||||
Input:keyreleased(key)
|
Input:keyreleased(key)
|
||||||
|
local top = self.stack and self.stack:top()
|
||||||
|
if top and top.onKeyReleased then top:onKeyReleased(key) end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:gamepadpressed(joystick, button)
|
function Game:gamepadpressed(joystick, button)
|
||||||
@@ -421,7 +517,10 @@ function Game:gamepadpressed(joystick, button)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Game:gamepadreleased(joystick, button)
|
function Game:gamepadreleased(joystick, button)
|
||||||
|
-- same observe-after-Input contract as Game:keyreleased (#589)
|
||||||
Input:gamepadreleased(joystick, button)
|
Input:gamepadreleased(joystick, button)
|
||||||
|
local top = self.stack and self.stack:top()
|
||||||
|
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:gamepadaxis(joystick, axis, value)
|
function Game:gamepadaxis(joystick, axis, value)
|
||||||
@@ -430,6 +529,40 @@ function Game:gamepadaxis(joystick, axis, value)
|
|||||||
Input:gamepadaxis(joystick, axis, value)
|
Input:gamepadaxis(joystick, axis, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
|
||||||
|
-- generic joystick path anyway: any sensor-style device that still reaches us
|
||||||
|
-- has gravity pinning an axis past the deadzone, which would hide the touch
|
||||||
|
-- overlay every instant and steer the player by tilt through the axis-1/2
|
||||||
|
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
|
||||||
|
-- never as "* Accelerometer".
|
||||||
|
local function isAccelerometer(joystick)
|
||||||
|
local name = joystick and joystick.getName and joystick:getName()
|
||||||
|
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:joystickpressed(joystick, button)
|
||||||
|
if isAccelerometer(joystick) then return end
|
||||||
|
TouchControls:noteGamepad()
|
||||||
|
Input:joystickpressed(joystick, button)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:joystickreleased(joystick, button)
|
||||||
|
if isAccelerometer(joystick) then return end
|
||||||
|
Input:joystickreleased(joystick, button)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:joystickaxis(joystick, axis, value)
|
||||||
|
if isAccelerometer(joystick) then return end
|
||||||
|
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
|
||||||
|
Input:joystickaxis(joystick, axis, value)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:joystickhat(joystick, hat, direction)
|
||||||
|
if isAccelerometer(joystick) then return end
|
||||||
|
if direction ~= "c" then TouchControls:noteGamepad() end
|
||||||
|
Input:joystickhat(joystick, hat, direction)
|
||||||
|
end
|
||||||
|
|
||||||
-- Window focus/visibility flips: a release due while unfocused/hidden can
|
-- Window focus/visibility flips: a release due while unfocused/hidden can
|
||||||
-- be swallowed by the OS. Reset on both edges -- gaining focus with a
|
-- be swallowed by the OS. Reset on both edges -- gaining focus with a
|
||||||
-- physically held key won't re-fire keypressed, so trusting leftover
|
-- physically held key won't re-fire keypressed, so trusting leftover
|
||||||
@@ -483,6 +616,11 @@ end
|
|||||||
-- Capture the live world state into the save table and persist it.
|
-- Capture the live world state into the save table and persist it.
|
||||||
-- Options are flushed to options.lua as part of SaveData.save.
|
-- Options are flushed to options.lua as part of SaveData.save.
|
||||||
function Game:writeSave()
|
function Game:writeSave()
|
||||||
|
-- Tool sessions can be deliberately ephemeral. Give them one narrow veto
|
||||||
|
-- before captureSave mutates the snapshot or any progress bytes reach disk.
|
||||||
|
if ModRuntime.call("save.write", function() return true end, self) == false then
|
||||||
|
return false
|
||||||
|
end
|
||||||
if self.overworld and self.overworld.captureSave then
|
if self.overworld and self.overworld.captureSave then
|
||||||
self.overworld:captureSave(self.save)
|
self.overworld:captureSave(self.save)
|
||||||
end
|
end
|
||||||
@@ -493,7 +631,7 @@ function Game:writeSave()
|
|||||||
if ModRuntime.wants("save.writing") then
|
if ModRuntime.wants("save.writing") then
|
||||||
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
|
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
|
||||||
end
|
end
|
||||||
SaveData.save(self.save)
|
return SaveData.save(self.save)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
|
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
|
||||||
@@ -523,7 +661,25 @@ function Game:applyOptions(opts)
|
|||||||
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||||
-- fpsCap key pace at the standard rate (issue #88)
|
-- fpsCap key pace at the standard rate (issue #88)
|
||||||
require("src.core.FrameCap").applyOptions(opts)
|
require("src.core.FrameCap").applyOptions(opts)
|
||||||
|
-- Scale the optional presentation extras to the device's performance
|
||||||
|
-- tier. Every heavy feature was just applied from the stored options
|
||||||
|
-- above; here we clamp the *live* state down for a weaker device without
|
||||||
|
-- rewriting what the player saved, so raising the tier later restores
|
||||||
|
-- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the
|
||||||
|
-- default on a normal desktop, and every options.lua predating this
|
||||||
|
-- option) clamps nothing, so it is a no-op for the common case.
|
||||||
|
local caps = require("src.core.Performance").applyOptions(opts)
|
||||||
|
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
|
||||||
|
if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end
|
||||||
|
local Zoom = require("src.render.Zoom")
|
||||||
|
Zoom.allowSurvey = caps.survey
|
||||||
|
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
||||||
|
if caps.fpsMax then
|
||||||
|
local FrameCap = require("src.core.FrameCap")
|
||||||
|
if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end
|
||||||
|
end
|
||||||
Input:applyBindings(opts.bindings)
|
Input:applyBindings(opts.bindings)
|
||||||
|
TouchControls:applyOptions(opts)
|
||||||
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
||||||
if gbcCleared then self:writeOptions() end
|
if gbcCleared then self:writeOptions() end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
-- Which Gen-1 game this process is running: Red (the historical default) or
|
-- Which Gen-1 game this process is running: Red (the historical default),
|
||||||
-- Blue. One source of truth for everything that differs by version -- the
|
-- Blue, or Yellow. One source of truth for everything that differs by
|
||||||
-- accepted ROM hash, the import manifest, where the extracted cache lives,
|
-- version -- the accepted ROM hash, the import manifest, where the
|
||||||
-- and the save-file suffix -- so the importer, cache mount, SaveData, title
|
-- extracted cache lives, and the save-file suffix -- so the importer,
|
||||||
-- screen and palette all agree.
|
-- cache mount, SaveData, title screen and palette all agree.
|
||||||
--
|
--
|
||||||
-- Red keeps every un-suffixed path it always used (save.lua, the root cache),
|
-- 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
|
-- so existing installs are untouched; Blue is namespaced under blue/ and
|
||||||
-- _blue so both can be imported and played side by side.
|
-- _blue, Yellow under yellow/ and _yellow, so all three can be imported and
|
||||||
|
-- played side by side.
|
||||||
--
|
--
|
||||||
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
-- 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
|
-- and tests. The active version is a process-global set once at boot from
|
||||||
@@ -19,6 +20,7 @@ GameVersion.VERSIONS = {
|
|||||||
id = "red",
|
id = "red",
|
||||||
label = "Red",
|
label = "Red",
|
||||||
displayName = "Pokemon Red",
|
displayName = "Pokemon Red",
|
||||||
|
launcherName = "Red", -- game-panel header in the launcher
|
||||||
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
|
sha1 = "ea9bcae617fdf159b045185467ae58b2e4a48b9a",
|
||||||
manifest = "tools/rom_manifest.json",
|
manifest = "tools/rom_manifest.json",
|
||||||
cachePrefix = "", -- Red owns the cache root (backwards compatible)
|
cachePrefix = "", -- Red owns the cache root (backwards compatible)
|
||||||
@@ -28,15 +30,26 @@ GameVersion.VERSIONS = {
|
|||||||
id = "blue",
|
id = "blue",
|
||||||
label = "Blue",
|
label = "Blue",
|
||||||
displayName = "Pokemon Blue",
|
displayName = "Pokemon Blue",
|
||||||
|
launcherName = "Blue",
|
||||||
sha1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2",
|
sha1 = "d7037c83e1ae5b39bde3c30787637ba1d4c48ce2",
|
||||||
manifest = "tools/rom_manifest_blue.json",
|
manifest = "tools/rom_manifest_blue.json",
|
||||||
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
|
cachePrefix = "blue/", -- blue/data/generated, blue/assets/generated
|
||||||
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
|
saveSuffix = "_blue", -- save_blue.lua / .bak / .tmp
|
||||||
},
|
},
|
||||||
|
yellow = {
|
||||||
|
id = "yellow",
|
||||||
|
label = "Yellow",
|
||||||
|
displayName = "Pokemon Yellow",
|
||||||
|
launcherName = "Yellow (alpha)",
|
||||||
|
sha1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1",
|
||||||
|
manifest = "tools/rom_manifest_yellow.json",
|
||||||
|
cachePrefix = "yellow/", -- yellow/data/generated, yellow/assets/generated
|
||||||
|
saveSuffix = "_yellow", -- save_yellow.lua / .bak / .tmp
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Launcher column order (Yellow is still a placeholder, handled by the UI).
|
-- Launcher column order.
|
||||||
GameVersion.ORDER = { "red", "blue" }
|
GameVersion.ORDER = { "red", "blue", "yellow" }
|
||||||
|
|
||||||
GameVersion.current = "red"
|
GameVersion.current = "red"
|
||||||
|
|
||||||
@@ -53,6 +66,10 @@ function GameVersion.isBlue()
|
|||||||
return GameVersion.current == "blue"
|
return GameVersion.current == "blue"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function GameVersion.isYellow()
|
||||||
|
return GameVersion.current == "yellow"
|
||||||
|
end
|
||||||
|
|
||||||
-- Metadata for a version id, defaulting to the active one.
|
-- Metadata for a version id, defaulting to the active one.
|
||||||
function GameVersion.info(id)
|
function GameVersion.info(id)
|
||||||
return GameVersion.VERSIONS[id or GameVersion.current]
|
return GameVersion.VERSIONS[id or GameVersion.current]
|
||||||
|
|||||||
@@ -26,9 +26,30 @@ end
|
|||||||
-- ("Failed to initialize filesystem: already initialized") and the relaunch
|
-- ("Failed to initialize filesystem: already initialized") and the relaunch
|
||||||
-- crashes. So on an AppImage we relaunch the executable; the fresh process's
|
-- crashes. So on an AppImage we relaunch the executable; the fresh process's
|
||||||
-- Boot step mounts any downloaded update exactly as a manual relaunch would.
|
-- Boot step mounts any downloaded update exactly as a manual relaunch would.
|
||||||
|
-- Android hits the same wall (#575): the vendored love.cpp loops runlove()
|
||||||
|
-- in-process on "restart", and PHYSFS_deinit in the old Filesystem module's
|
||||||
|
-- destructor fails ("files still open") whenever any physfs handle survives
|
||||||
|
-- lua_close, so the second PHYSFS_init throws the same "already initialized"
|
||||||
|
-- and the app dies. There we relaunch through the GameActivity.restartApp
|
||||||
|
-- JNI bridge (love.system.restartApp), which schedules our launch intent
|
||||||
|
-- and kills the process so no native state can leak into the fresh run.
|
||||||
-- On every other platform the in-process restart works, so keep it.
|
-- On every other platform the in-process restart works, so keep it.
|
||||||
function HostShell.restart()
|
function HostShell.restart()
|
||||||
if not (love and love.event and love.event.quit) then return end
|
if not (love and love.event and love.event.quit) then return end
|
||||||
|
|
||||||
|
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||||
|
if osName == "Android" then
|
||||||
|
-- restartApp kills the process on success, so a true return is never
|
||||||
|
-- observed; false means the bridge could not schedule the relaunch.
|
||||||
|
-- An older APK whose liblove predates the bridge (love.system.restartApp
|
||||||
|
-- is nil) has no crash-free in-process restart, so quit to the OS
|
||||||
|
-- cleanly and let the player relaunch by hand -- worse than restarting,
|
||||||
|
-- but better than the guaranteed crash of quit("restart") (#575).
|
||||||
|
if love.system.restartApp and love.system.restartApp() then return end
|
||||||
|
love.event.quit()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
local appimage = os.getenv("APPIMAGE")
|
local appimage = os.getenv("APPIMAGE")
|
||||||
if not appimage then
|
if not appimage then
|
||||||
love.event.quit("restart")
|
love.event.quit("restart")
|
||||||
|
|||||||
@@ -39,6 +39,20 @@ local DEFAULT_GAMEPAD_BINDINGS = {
|
|||||||
local STICK_ON = 0.5
|
local STICK_ON = 0.5
|
||||||
local STICK_OFF = 0.3
|
local STICK_OFF = 0.3
|
||||||
|
|
||||||
|
-- Generic SDL joysticks expose the left stick as the first two numbered
|
||||||
|
-- axes and the D-pad as a hat. This is common on Linux handhelds whose
|
||||||
|
-- controller has no game-controller database entry.
|
||||||
|
local RAW_BUTTON_BINDINGS = {
|
||||||
|
[1] = "a", [2] = "b",
|
||||||
|
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
|
||||||
|
}
|
||||||
|
|
||||||
|
local HAT_DIRECTIONS = {
|
||||||
|
u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" },
|
||||||
|
lu = { "left", "up" }, ru = { "right", "up" },
|
||||||
|
ld = { "left", "down" }, rd = { "right", "down" },
|
||||||
|
}
|
||||||
|
|
||||||
function Input:init()
|
function Input:init()
|
||||||
self:applyBindings(nil)
|
self:applyBindings(nil)
|
||||||
self:reset()
|
self:reset()
|
||||||
@@ -79,6 +93,7 @@ function Input:reset()
|
|||||||
self.sources = {}
|
self.sources = {}
|
||||||
self.stickAxis = { x = 0, y = 0 }
|
self.stickAxis = { x = 0, y = 0 }
|
||||||
self.stickDir = nil
|
self.stickDir = nil
|
||||||
|
self.hatDirs = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
|
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
|
||||||
@@ -179,6 +194,16 @@ function Input:gamepadreleased(joystick, button)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Input:joystickpressed(joystick, button)
|
||||||
|
local btn = RAW_BUTTON_BINDINGS[button]
|
||||||
|
if btn then press(self, btn, "joy:" .. button) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Input:joystickreleased(joystick, button)
|
||||||
|
local btn = RAW_BUTTON_BINDINGS[button]
|
||||||
|
if btn then release(self, btn, "joy:" .. button) end
|
||||||
|
end
|
||||||
|
|
||||||
-- left stick treated as a continuous held direction, same 4-way rule as
|
-- left stick treated as a continuous held direction, same 4-way rule as
|
||||||
-- the touch swipe d-pad: whichever axis has the larger magnitude wins.
|
-- the touch swipe d-pad: whichever axis has the larger magnitude wins.
|
||||||
function Input:gamepadaxis(joystick, axis, value)
|
function Input:gamepadaxis(joystick, axis, value)
|
||||||
@@ -214,12 +239,80 @@ function Input:gamepadaxis(joystick, axis, value)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Input:joystickaxis(joystick, axis, value)
|
||||||
|
if axis == 1 then
|
||||||
|
self:gamepadaxis(joystick, "leftx", value)
|
||||||
|
elseif axis == 2 then
|
||||||
|
self:gamepadaxis(joystick, "lefty", value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Input:joystickhat(joystick, hat, direction)
|
||||||
|
local source = "hat:" .. hat
|
||||||
|
for _, btn in ipairs(self.hatDirs[hat] or {}) do
|
||||||
|
release(self, btn, source)
|
||||||
|
end
|
||||||
|
local dirs = HAT_DIRECTIONS[direction] or {}
|
||||||
|
for _, btn in ipairs(dirs) do
|
||||||
|
press(self, btn, source)
|
||||||
|
end
|
||||||
|
self.hatDirs[hat] = dirs
|
||||||
|
end
|
||||||
|
|
||||||
function Input:isDown(btn)
|
function Input:isDown(btn)
|
||||||
return self.state[btn] or false
|
return self.state[btn] or false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- True when the on-screen overlay is one of the live sources holding this
|
||||||
|
-- button (see overlayPressed above). Player:turnWindow widens the
|
||||||
|
-- turn-in-place tap window on touch, where a press and release can never be
|
||||||
|
-- as short as a physical d-pad's (#415).
|
||||||
|
function Input:isTouchDown(btn)
|
||||||
|
local sources = self.sources[btn]
|
||||||
|
return (sources and sources["touch:" .. btn]) and true or false
|
||||||
|
end
|
||||||
|
|
||||||
function Input:wasPressed(btn)
|
function Input:wasPressed(btn)
|
||||||
return self.pressed[btn] or false
|
return self.pressed[btn] or false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Soft reset (#563). _Joypad (engine/joypad.asm) tests the RAW joypad read
|
||||||
|
-- with `cp PAD_BUTTONS` -- an equality, not a mask -- so the combo counts
|
||||||
|
-- only while A, B, SELECT and START are the only buttons down; any d-pad
|
||||||
|
-- direction in the mix cancels it. That test sits ahead of the wJoyIgnore
|
||||||
|
-- and BIT_DISABLE_JOYPAD masking below it, which is why the reset still
|
||||||
|
-- works mid-battle and mid-cutscene where ordinary input is thrown away.
|
||||||
|
-- TrySoftReset then burns one DelayFrame per pass and decrements hSoftReset,
|
||||||
|
-- seeded with 16 by Init (home/init.asm), so the combo has to survive 16
|
||||||
|
-- consecutive polls. That hold is also what keeps the on-screen overlay
|
||||||
|
-- safe: it already takes four separate fingers on four separate controls,
|
||||||
|
-- and they all have to stay put for better than a quarter of a second.
|
||||||
|
local SOFT_RESET_FRAMES = 16
|
||||||
|
|
||||||
|
function Input:softResetHeld()
|
||||||
|
if not (self.state.a and self.state.b
|
||||||
|
and self.state.start and self.state.select) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return not (self.state.up or self.state.down
|
||||||
|
or self.state.left or self.state.right)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Ticked once per fixed step by Game:step; true on the step the countdown
|
||||||
|
-- runs out. hSoftReset is never re-seeded on release in the original, so
|
||||||
|
-- its count leaks across a whole session; a port that copied that would
|
||||||
|
-- eventually reset on a stray four-button press, so the counter re-arms as
|
||||||
|
-- soon as the combo drops.
|
||||||
|
function Input:softResetStep()
|
||||||
|
if not self:softResetHeld() then
|
||||||
|
self.softResetFrames = nil
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local left = (self.softResetFrames or SOFT_RESET_FRAMES) - 1
|
||||||
|
self.softResetFrames = left
|
||||||
|
if left > 0 then return false end
|
||||||
|
self.softResetFrames = nil
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
return Input
|
return Input
|
||||||
|
|||||||
+12
-1
@@ -23,6 +23,13 @@ local volumeScale = 1
|
|||||||
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
||||||
local filterLevel = 0
|
local filterLevel = 0
|
||||||
|
|
||||||
|
-- Forward-declared here so applyVolume (below) closes over the real playback
|
||||||
|
-- state rather than a nil global: the table literal is assigned further down,
|
||||||
|
-- but a `local state = {}` there would leave every reference above it bound
|
||||||
|
-- to the global `state`. Before this, registering the `music.volume` mod
|
||||||
|
-- hook crashed applyVolume on `state.current` (a nil index).
|
||||||
|
local state
|
||||||
|
|
||||||
local function applyVolume(src)
|
local function applyVolume(src)
|
||||||
if not src then return end
|
if not src then return end
|
||||||
local vol = VOLUME * volumeScale
|
local vol = VOLUME * volumeScale
|
||||||
@@ -63,7 +70,7 @@ local function applyFilter(src)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local state = {
|
state = {
|
||||||
current = nil, -- song label
|
current = nil, -- song label
|
||||||
chip = false, -- the playing song is a synthesized channel program
|
chip = false, -- the playing song is a synthesized channel program
|
||||||
source = nil, -- currently playing source
|
source = nil, -- currently playing source
|
||||||
@@ -85,6 +92,7 @@ local function fanfareActive()
|
|||||||
local ok, playing = pcall(src.isPlaying, src)
|
local ok, playing = pcall(src.isPlaying, src)
|
||||||
if ok and playing then return true end
|
if ok and playing then return true end
|
||||||
state.fanfare = nil
|
state.fanfare = nil
|
||||||
|
require("src.core.ChipAudio").holdMusic(false)
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -94,6 +102,9 @@ end
|
|||||||
function Music.duckForFanfare(src)
|
function Music.duckForFanfare(src)
|
||||||
if not src then return end
|
if not src then return end
|
||||||
state.fanfare = src
|
state.fanfare = src
|
||||||
|
-- ChipAudio is what starts a chip song, so the pause below cannot hold one
|
||||||
|
-- that has not started yet (nor one Music.play swaps in mid-jingle) (#398)
|
||||||
|
require("src.core.ChipAudio").holdMusic(true)
|
||||||
if state.source then
|
if state.source then
|
||||||
local ok, playing = pcall(state.source.isPlaying, state.source)
|
local ok, playing = pcall(state.source.isPlaying, state.source)
|
||||||
if ok and playing then
|
if ok and playing then
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
-- Graphics performance tier: one knob that scales the port's optional,
|
||||||
|
-- non-faithful presentation extras down for weaker hardware.
|
||||||
|
--
|
||||||
|
-- The heavy extras are all things the Game Boy never had and this port
|
||||||
|
-- adds on top: the whole-screen 3D TILT (transforms the entire map as a
|
||||||
|
-- ground plane), the GBC FX post-process shader (a fullscreen pass), and
|
||||||
|
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
|
||||||
|
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
|
||||||
|
-- None of this touches game logic, which is fixed-step off dt
|
||||||
|
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
|
||||||
|
-- only in how much optional eye-candy the renderer is allowed to do.
|
||||||
|
--
|
||||||
|
-- The tier is persisted as save.options.performance:
|
||||||
|
-- auto pick a default from the device (see detect())
|
||||||
|
-- high everything on -- the historical behavior
|
||||||
|
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
|
||||||
|
-- low no TILT, no GBC FX, no survey zoom, FPS capped
|
||||||
|
--
|
||||||
|
-- AUTO only chooses the *default* for the current device; every tier is
|
||||||
|
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
|
||||||
|
-- from being overridden. The clamps are applied live in Game:applyOptions
|
||||||
|
-- against the stored options without rewriting them, so raising the tier
|
||||||
|
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
|
||||||
|
--
|
||||||
|
-- Zero requires: loads during love.conf and under plain Lua for tools and
|
||||||
|
-- tests, the same way src/core/GameVersion.lua does.
|
||||||
|
|
||||||
|
local Performance = {}
|
||||||
|
|
||||||
|
-- Option-row order (auto first, then most to least capable).
|
||||||
|
Performance.TIERS = { "auto", "high", "balanced", "low" }
|
||||||
|
|
||||||
|
Performance.LABELS = {
|
||||||
|
auto = "AUTO",
|
||||||
|
high = "HIGH",
|
||||||
|
balanced = "BALANCED",
|
||||||
|
low = "LOW",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- What each concrete tier permits. `auto` is resolved to one of these
|
||||||
|
-- before caps are read, so it has no row here. fpsMax = false means no
|
||||||
|
-- extra ceiling (the player's own MAX FPS still applies).
|
||||||
|
Performance.CAPS = {
|
||||||
|
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
|
||||||
|
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
|
||||||
|
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
|
||||||
|
-- renderer / options row read it back. Defaults to the unclamped tier so
|
||||||
|
-- a pre-boot launcher behaves exactly as it always did.
|
||||||
|
Performance.tier = "high"
|
||||||
|
|
||||||
|
local function loveOS()
|
||||||
|
if love and love.system and love.system.getOS then
|
||||||
|
return love.system.getOS()
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function processorCount()
|
||||||
|
if love and love.system and love.system.getProcessorCount then
|
||||||
|
return love.system.getProcessorCount()
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- CPU architecture via LuaJIT's jit.arch when present: "arm"/"arm64" on
|
||||||
|
-- phones and PortMaster handhelds, "x86"/"x64" on desktops. nil under a
|
||||||
|
-- plain-Lua tool or test with no jit table, which resolves to HIGH (no
|
||||||
|
-- clamping) so tooling is never surprised.
|
||||||
|
local function cpuArch()
|
||||||
|
return (jit and jit.arch) or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Default tier for the current device. Deliberately conservative: only
|
||||||
|
-- the platforms that are reliably weak drop below HIGH, and AUTO is always
|
||||||
|
-- overridable, so a wrong guess costs one OPTIONS row. A normal
|
||||||
|
-- multi-core desktop -- the common case, and every existing install whose
|
||||||
|
-- options.lua predates this option -- resolves to HIGH and behaves exactly
|
||||||
|
-- as before.
|
||||||
|
function Performance.detect()
|
||||||
|
local os = loveOS()
|
||||||
|
local arch = cpuArch()
|
||||||
|
local isArm = arch == "arm" or arch == "arm64"
|
||||||
|
local cores = processorCount()
|
||||||
|
|
||||||
|
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
|
||||||
|
-- already ships a build for): the weakest target here.
|
||||||
|
if isArm and os ~= "Android" and os ~= "iOS" then
|
||||||
|
return "low"
|
||||||
|
end
|
||||||
|
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
|
||||||
|
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
|
||||||
|
if os == "Android" or os == "iOS" then
|
||||||
|
return "balanced"
|
||||||
|
end
|
||||||
|
-- Desktop: a dual-core (or single-core) box is the low-end line.
|
||||||
|
if cores and cores <= 2 then
|
||||||
|
return "balanced"
|
||||||
|
end
|
||||||
|
return "high"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fold a stored option value to a concrete tier, resolving "auto" (and any
|
||||||
|
-- hand-edited garbage) through detect().
|
||||||
|
function Performance.resolve(value)
|
||||||
|
if value == "high" or value == "balanced" or value == "low" then
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
return Performance.detect()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Normalize a stored value to a valid option id (keeps "auto"); a bad value
|
||||||
|
-- degrades to auto so a corrupt options.lua still lands on something sane.
|
||||||
|
function Performance.normalize(value)
|
||||||
|
for _, id in ipairs(Performance.TIERS) do
|
||||||
|
if value == id then return id end
|
||||||
|
end
|
||||||
|
return "auto"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Row label for a stored value: the tier's own name (AUTO / HIGH / ...).
|
||||||
|
function Performance.label(value)
|
||||||
|
return Performance.LABELS[Performance.normalize(value)]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Concrete caps for a stored option value (resolving auto).
|
||||||
|
function Performance.caps(value)
|
||||||
|
return Performance.CAPS[Performance.resolve(value)]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Resolve the tier for these options, record it live, and return its caps.
|
||||||
|
-- Called from Game:applyOptions, which clamps the live presentation modules
|
||||||
|
-- against the returned caps.
|
||||||
|
function Performance.applyOptions(opts)
|
||||||
|
local tier = Performance.resolve(opts and opts.performance)
|
||||||
|
Performance.tier = tier
|
||||||
|
return Performance.CAPS[tier]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cycle the OPTIONS row: auto -> high -> balanced -> low -> auto (dir -1
|
||||||
|
-- reverses). Lua's `%` is non-negative for a positive modulus, so a
|
||||||
|
-- negative dir wraps correctly.
|
||||||
|
function Performance.cycle(value, dir)
|
||||||
|
value = Performance.normalize(value)
|
||||||
|
local i = 1
|
||||||
|
for idx, id in ipairs(Performance.TIERS) do
|
||||||
|
if id == value then i = idx break end
|
||||||
|
end
|
||||||
|
local n = #Performance.TIERS
|
||||||
|
local nextIdx = (i - 1 + (dir or 1)) % n + 1
|
||||||
|
return Performance.TIERS[nextIdx]
|
||||||
|
end
|
||||||
|
|
||||||
|
return Performance
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- Game Boy Printer stand-in. Yellow's printer jobs
|
||||||
|
-- (engine/printer/printer.asm: PrintPokedexEntry and friends) drove a
|
||||||
|
-- serial thermal printer; this port renders the same printout into a PNG
|
||||||
|
-- under prints/ in the save directory instead, and the caller shows a
|
||||||
|
-- dialog with where it landed. Scaled up 4x so the "print" is legible
|
||||||
|
-- on a modern screen.
|
||||||
|
|
||||||
|
local Logger = require("src.core.Logger")
|
||||||
|
|
||||||
|
local Printer = {}
|
||||||
|
|
||||||
|
local SCALE = 4
|
||||||
|
|
||||||
|
-- Render drawFn (which draws a w x h GB-pixel image at 0,0) into
|
||||||
|
-- prints/<name>_<stamp>.png. Returns the save-dir-relative path, or nil
|
||||||
|
-- and an error string (headless / no canvas support degrades gracefully).
|
||||||
|
function Printer.save(name, w, h, drawFn)
|
||||||
|
if not (love.graphics and love.graphics.newCanvas) then
|
||||||
|
return nil, "no graphics"
|
||||||
|
end
|
||||||
|
local ok, canvas = pcall(love.graphics.newCanvas, w * SCALE, h * SCALE)
|
||||||
|
if not ok then return nil, tostring(canvas) end
|
||||||
|
love.graphics.push("all")
|
||||||
|
love.graphics.setCanvas(canvas)
|
||||||
|
love.graphics.origin()
|
||||||
|
love.graphics.scale(SCALE, SCALE)
|
||||||
|
love.graphics.clear(1, 1, 1, 1)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
local drawOk, drawErr = pcall(drawFn)
|
||||||
|
love.graphics.pop()
|
||||||
|
if not drawOk then return nil, tostring(drawErr) end
|
||||||
|
local data
|
||||||
|
ok, data = pcall(canvas.newImageData, canvas)
|
||||||
|
if not ok then return nil, tostring(data) end
|
||||||
|
love.filesystem.createDirectory("prints")
|
||||||
|
local path = ("prints/%s_%s.png"):format(name, os.date("%Y-%m-%d_%H%M%S"))
|
||||||
|
local encOk, err = pcall(data.encode, data, "png", path)
|
||||||
|
if not encOk then return nil, tostring(err) end
|
||||||
|
Logger.info("printed %s -> %s/%s",
|
||||||
|
name, love.filesystem.getSaveDirectory(), path)
|
||||||
|
return path
|
||||||
|
end
|
||||||
|
|
||||||
|
return Printer
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- Usable window rect for mobile chrome (notch / Dynamic Island / home
|
||||||
|
-- indicator / Android display cutouts). Wraps love.window.getSafeArea when
|
||||||
|
-- the engine provides it; otherwise the full graphics window.
|
||||||
|
--
|
||||||
|
-- Desktop and headless stubs return the full window, so callers can always
|
||||||
|
-- layout against this rect without platform branches. Interactive chrome
|
||||||
|
-- (touch overlay, launcher) should prefer this over getDimensions; the game
|
||||||
|
-- canvas may still letterbox into the full framebuffer for immersion.
|
||||||
|
|
||||||
|
local SafeArea = {}
|
||||||
|
|
||||||
|
function SafeArea.rect()
|
||||||
|
local ww, wh = 0, 0
|
||||||
|
if love and love.graphics and love.graphics.getDimensions then
|
||||||
|
ww, wh = love.graphics.getDimensions()
|
||||||
|
end
|
||||||
|
if ww <= 0 then ww = 1 end
|
||||||
|
if wh <= 0 then wh = 1 end
|
||||||
|
|
||||||
|
if not (love and love.window and love.window.getSafeArea) then
|
||||||
|
return 0, 0, ww, wh
|
||||||
|
end
|
||||||
|
|
||||||
|
local x, y, w, h = love.window.getSafeArea()
|
||||||
|
if type(x) ~= "number" or type(y) ~= "number"
|
||||||
|
or type(w) ~= "number" or type(h) ~= "number"
|
||||||
|
or w <= 0 or h <= 0 then
|
||||||
|
return 0, 0, ww, wh
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
|
||||||
|
-- push layout outside the surface.
|
||||||
|
x = math.max(0, math.min(x, ww))
|
||||||
|
y = math.max(0, math.min(y, wh))
|
||||||
|
w = math.max(1, math.min(w, ww - x))
|
||||||
|
h = math.max(1, math.min(h, wh - y))
|
||||||
|
return x, y, w, h
|
||||||
|
end
|
||||||
|
|
||||||
|
return SafeArea
|
||||||
+66
-25
@@ -24,10 +24,11 @@ local GameVersion = require("src.core.GameVersion")
|
|||||||
|
|
||||||
local SaveData = {}
|
local SaveData = {}
|
||||||
|
|
||||||
-- Progress files carry the game-version suffix so Red and Blue saves coexist:
|
-- Progress files carry the game-version suffix so Red / Blue / Yellow saves
|
||||||
-- Red keeps save.lua / .bak / .tmp exactly as before; Blue is save_blue.lua
|
-- coexist: Red keeps save.lua / .bak / .tmp exactly as before; Blue is
|
||||||
-- (+ .bak/.tmp). options.lua is deliberately shared across versions (it holds
|
-- save_blue.lua and Yellow is save_yellow.lua (+ .bak/.tmp). options.lua is
|
||||||
-- global preferences and the mod enable-state, not per-playthrough data).
|
-- 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"
|
||||||
|
|
||||||
-- Main / backup / staged-witness names for a version (defaults to the active
|
-- Main / backup / staged-witness names for a version (defaults to the active
|
||||||
@@ -109,11 +110,14 @@ local function makePortableFs(dir)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
local function detectPortable()
|
-- Every folder a player might reasonably call "the game folder", best first:
|
||||||
if portableChecked then return portableBase end
|
-- the packaged-app container, the folder holding the executable, then the
|
||||||
portableChecked = true
|
-- source itself. Portable mode is the case where one of these holds the
|
||||||
portableBase = false
|
-- marker; the list itself is just locations, marker or not, which is also
|
||||||
if not (love and love.filesystem) then return false end
|
-- what the mods panel needs to notice a mod dropped beside the game by hand
|
||||||
|
-- (LauncherMods.strays). Empty on Android/iOS and outside LOVE.
|
||||||
|
function SaveData.gameFolders()
|
||||||
|
if not (love and love.filesystem) then return {} end
|
||||||
-- Desktop only: portable mode carries the save (and, since issue #74, the
|
-- Desktop only: portable mode carries the save (and, since issue #74, the
|
||||||
-- ROM cache) in the game folder next to the executable/source. On
|
-- 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
|
-- Android/iOS the source is a read-only package with no such folder, so
|
||||||
@@ -121,7 +125,7 @@ local function detectPortable()
|
|||||||
if love.system and love.system.getOS then
|
if love.system and love.system.getOS then
|
||||||
local osName = love.system.getOS()
|
local osName = love.system.getOS()
|
||||||
if osName ~= "Windows" and osName ~= "Linux" and osName ~= "OS X" then
|
if osName ~= "Windows" and osName ~= "Linux" and osName ~= "OS X" then
|
||||||
return false
|
return {}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local src = love.filesystem.getSource and love.filesystem.getSource()
|
local src = love.filesystem.getSource and love.filesystem.getSource()
|
||||||
@@ -149,15 +153,24 @@ local function detectPortable()
|
|||||||
-- Order: the packaged-app containing folder (macOS .app / Linux AppImage),
|
-- Order: the packaged-app containing folder (macOS .app / Linux AppImage),
|
||||||
-- then the source-base directory (next to a packaged .exe), then the source
|
-- then the source-base directory (next to a packaged .exe), then the source
|
||||||
-- itself (a `love <gamedir>` run drops portable.txt in the game folder).
|
-- 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
|
-- Built by appending so a nil (e.g. no .app in the path) never truncates
|
||||||
-- .app in the path) never truncates the ipairs scan.
|
-- the ipairs scan.
|
||||||
local candidates = {}
|
local candidates = {}
|
||||||
local appDir = appContainer(src) or appContainer(sbd) or appImageContainer()
|
local appDir = appContainer(src) or appContainer(sbd) or appImageContainer()
|
||||||
if appDir then candidates[#candidates + 1] = appDir end
|
if appDir then candidates[#candidates + 1] = appDir end
|
||||||
if sbd then candidates[#candidates + 1] = sbd end
|
if sbd and sbd ~= "" then candidates[#candidates + 1] = sbd end
|
||||||
if src then candidates[#candidates + 1] = src end
|
if src and src ~= "" then candidates[#candidates + 1] = src end
|
||||||
for _, base in ipairs(candidates) do
|
return candidates
|
||||||
if base ~= "" and pathExists(base .. SEP .. PORTABLE_MARKER) then
|
end
|
||||||
|
|
||||||
|
-- The game folder carrying portable.txt, or false. First candidate holding
|
||||||
|
-- the marker wins.
|
||||||
|
local function detectPortable()
|
||||||
|
if portableChecked then return portableBase end
|
||||||
|
portableChecked = true
|
||||||
|
portableBase = false
|
||||||
|
for _, base in ipairs(SaveData.gameFolders()) do
|
||||||
|
if pathExists(base .. SEP .. PORTABLE_MARKER) then
|
||||||
portableBase = base
|
portableBase = base
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
@@ -210,6 +223,9 @@ function SaveData.defaultOptions()
|
|||||||
-- 0-7 like the GB's NR50 master volume
|
-- 0-7 like the GB's NR50 master volume
|
||||||
musicVol = 7,
|
musicVol = 7,
|
||||||
sfxVol = 7,
|
sfxVol = 7,
|
||||||
|
-- Yellow only: 0-7 trim for Pikachu's PCM voice clips on top of sfxVol
|
||||||
|
-- (the PIKACHU VOL row appears only on Yellow; see Sound.lua)
|
||||||
|
pikaVol = 7,
|
||||||
musicFilter = 0,
|
musicFilter = 0,
|
||||||
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
|
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
|
||||||
speed = 1,
|
speed = 1,
|
||||||
@@ -225,6 +241,11 @@ function SaveData.defaultOptions()
|
|||||||
videoMode = "windowed",
|
videoMode = "windowed",
|
||||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||||
fpsCap = 60,
|
fpsCap = 60,
|
||||||
|
-- graphics performance tier: auto | high | balanced | low. "auto"
|
||||||
|
-- picks a default from the device (ARM handhelds/phones drop the heavy
|
||||||
|
-- extras); scales TILT / GBC FX / survey ZOOM / FPS but never game
|
||||||
|
-- logic. See src/core/Performance.lua.
|
||||||
|
performance = "auto",
|
||||||
-- Per-pipeline display levels, keyed by render_pipelines id (see
|
-- Per-pipeline display levels, keyed by render_pipelines id (see
|
||||||
-- src/render/Pipelines.lua). A level for a mod that is not installed
|
-- src/render/Pipelines.lua). A level for a mod that is not installed
|
||||||
-- is kept rather than pruned, so re-enabling the mod restores the mode
|
-- is kept rather than pruned, so re-enabling the mod restores the mode
|
||||||
@@ -233,6 +254,24 @@ function SaveData.defaultOptions()
|
|||||||
-- 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 = {},
|
||||||
|
-- GitHub release checks for mods with a manifest "github" field
|
||||||
|
-- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours.
|
||||||
|
modUpdateCache = {},
|
||||||
|
-- Community mod indexes the player has chosen to browse
|
||||||
|
-- (src/mods/ModIndex.lua), in the order they added them. Empty by
|
||||||
|
-- default and never populated automatically: adding an index is how a
|
||||||
|
-- player says they trust whoever publishes it, so the launcher asks
|
||||||
|
-- rather than shipping one. Rows are { url, feed, base, fallback,
|
||||||
|
-- label }.
|
||||||
|
modIndexes = {},
|
||||||
|
-- Parsed index listings keyed by feed URL; TTL is 24 hours, matching how
|
||||||
|
-- often the feeds themselves rebuild.
|
||||||
|
modIndexCache = {},
|
||||||
|
-- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua).
|
||||||
|
-- enabled=false hides it permanently (distinct from auto-hide-on-gamepad).
|
||||||
|
-- positions are optional normalized centers {x=0..1, y=0..1} per control
|
||||||
|
-- (dpad/a/b/start/select); nil means the default layout.
|
||||||
|
touchControls = { enabled = true },
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -323,8 +362,9 @@ end
|
|||||||
-- under options.saveSlots[version]; the active slot is also cached
|
-- under options.saveSlots[version]; the active slot is also cached
|
||||||
-- process-wide (like GameVersion.current) so the hot saveNames path does
|
-- process-wide (like GameVersion.current) so the hot saveNames path does
|
||||||
-- not re-read options every call. A false cache entry means "no slot in
|
-- not re-read options every call. A false cache entry means "no slot in
|
||||||
-- use" and the flat legacy path (save.lua / save_blue.lua) is used, which
|
-- use" and the flat legacy path (save.lua / save_blue.lua / save_yellow.lua)
|
||||||
-- keeps a brand-new install and every pre-slots caller working unchanged.
|
-- is used, which keeps a brand-new install and every pre-slots caller
|
||||||
|
-- working unchanged.
|
||||||
local activeSlotCache = {} -- version -> slotId in use, or false when none
|
local activeSlotCache = {} -- version -> slotId in use, or false when none
|
||||||
local slotsChecked = {} -- version -> true once resolved this process
|
local slotsChecked = {} -- version -> true once resolved this process
|
||||||
|
|
||||||
@@ -336,17 +376,18 @@ local function slotNames(version, id)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- the pre-slots flat names a version always used (save.lua for Red,
|
-- the pre-slots flat names a version always used (save.lua for Red,
|
||||||
-- save_blue.lua for Blue); still the destination before any slot exists
|
-- save_blue.lua / save_yellow.lua for the others); still the destination
|
||||||
|
-- before any slot exists
|
||||||
local function legacyNames(version)
|
local function legacyNames(version)
|
||||||
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
|
local main = "save" .. GameVersion.saveSuffix(version) .. ".lua"
|
||||||
return main, main .. ".bak", main .. ".tmp"
|
return main, main .. ".bak", main .. ".tmp"
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Slot resolution is only meaningful for versions GameVersion actually knows
|
-- Slot resolution is only meaningful for versions GameVersion actually knows
|
||||||
-- (red/blue). The launcher also renders a locked placeholder tab ("yellow")
|
-- (red / blue / yellow). An unknown id has no info entry and therefore no
|
||||||
-- that has no info entry and therefore no saveSuffix; resolving its legacy
|
-- saveSuffix; resolving its legacy names would index a nil info table and
|
||||||
-- names would index a nil info table and crash. Treat any unknown version as
|
-- crash. Treat any unknown version as having no slots so the slot APIs
|
||||||
-- having no slots so the slot APIs degrade to empty/no-op instead.
|
-- degrade to empty/no-op instead.
|
||||||
local function knownVersion(version)
|
local function knownVersion(version)
|
||||||
return GameVersion.info(version) ~= nil
|
return GameVersion.info(version) ~= nil
|
||||||
end
|
end
|
||||||
@@ -892,7 +933,7 @@ end)
|
|||||||
-- 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
|
-- write to the file matching this save's own version, not just the active
|
||||||
-- one, so a Blue playthrough always lands in save_blue.lua
|
-- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua
|
||||||
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
|
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)
|
||||||
@@ -1017,7 +1058,7 @@ local function reclaim(save, data, report)
|
|||||||
if type(entry) == "table" and known(data.items, entry.id) then
|
if type(entry) == "table" and known(data.items, entry.id) then
|
||||||
table.remove(orphaned.items, i)
|
table.remove(orphaned.items, i)
|
||||||
if entry.from == "pcItems" or type(save.inventory) ~= "table"
|
if entry.from == "pcItems" or type(save.inventory) ~= "table"
|
||||||
or not Bag.add(save, entry.id, entry.count or 1) then
|
or not Bag.add(save, entry.id, entry.count or 1, data) then
|
||||||
save.pcItems = save.pcItems or {}
|
save.pcItems = save.pcItems or {}
|
||||||
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
|
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
|
||||||
end
|
end
|
||||||
|
|||||||
+85
-9
@@ -16,6 +16,30 @@ local cache = {}
|
|||||||
-- scaling the 0.8 base every source gets
|
-- scaling the 0.8 base every source gets
|
||||||
local BASE_VOLUME = 0.8
|
local BASE_VOLUME = 0.8
|
||||||
local volumeScale = 1
|
local volumeScale = 1
|
||||||
|
-- port addition (Yellow only): 0-7 trim from save.options.pikaVol on top of
|
||||||
|
-- the SFX level, for Pikachu's voice clips alone. Yellow voices every
|
||||||
|
-- Pikachu cry with PCM samples that are far louder and far more frequent
|
||||||
|
-- than the chip cries around them (the follower alone talks on every
|
||||||
|
-- interaction), so this is the one source players want to pull down
|
||||||
|
-- without muting the rest of the SFX bus. 7 = untouched, 0 = silent.
|
||||||
|
local pikaScale = 1
|
||||||
|
|
||||||
|
-- cache keys whose volume the Pikachu trim applies to: the PCM clips, plus
|
||||||
|
-- the chip PIKACHU cry that a Yellow cache without extracted clips falls
|
||||||
|
-- back to (playCry). Red/Blue never reach the second branch, so a shared
|
||||||
|
-- options.lua carrying a low pikaVol cannot quiet their Pikachu.
|
||||||
|
local function isPikaKey(key)
|
||||||
|
if type(key) ~= "string" then return false end
|
||||||
|
if key:sub(1, 8) == "pikacry:" then return true end
|
||||||
|
return key == "cry:PIKACHU"
|
||||||
|
and require("src.core.GameVersion").isYellow()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function volumeFor(key)
|
||||||
|
local scale = volumeScale
|
||||||
|
if isPikaKey(key) then scale = scale * pikaScale end
|
||||||
|
return BASE_VOLUME * scale
|
||||||
|
end
|
||||||
|
|
||||||
-- Fanfares occupy the music's tone channels on the Game Boy: their sfx
|
-- Fanfares occupy the music's tone channels on the Game Boy: their sfx
|
||||||
-- headers claim channels 5-7 (= hardware channels 1-3), silencing the
|
-- headers claim channels 5-7 (= hardware channels 1-3), silencing the
|
||||||
@@ -91,7 +115,7 @@ local function playPath(data, key, def, pitch, tempo)
|
|||||||
reportBadDef("sfx", key, owner(data, "sfx", key), err)
|
reportBadDef("sfx", key, owner(data, "sfx", key), err)
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
s:setVolume(BASE_VOLUME * volumeScale)
|
s:setVolume(volumeFor(key))
|
||||||
cache[key] = s
|
cache[key] = s
|
||||||
src = s
|
src = s
|
||||||
end
|
end
|
||||||
@@ -111,6 +135,9 @@ local function played(kind, name, species)
|
|||||||
Runtime.emit("sound.played", { kind = kind, name = name, species = species })
|
Runtime.emit("sound.played", { kind = kind, name = name, species = species })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- returns the started source (nil headless, or when the def failed to load)
|
||||||
|
-- so callers that block on a fanfare like the original's
|
||||||
|
-- PlaySoundWaitForCurrent -> WaitForSoundToFinish can poll it
|
||||||
function Sound.play(data, name)
|
function Sound.play(data, name)
|
||||||
local sfx = data.audio and data.audio.sfx
|
local sfx = data.audio and data.audio.sfx
|
||||||
local def = sfx and sfx[name]
|
local def = sfx and sfx[name]
|
||||||
@@ -120,6 +147,7 @@ function Sound.play(data, name)
|
|||||||
require("src.core.Music").duckForFanfare(src)
|
require("src.core.Music").duckForFanfare(src)
|
||||||
end
|
end
|
||||||
played("sfx", name)
|
played("sfx", name)
|
||||||
|
return src
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Play a move's sound with its MoveSoundTable pitch/tempo modifiers
|
-- Play a move's sound with its MoveSoundTable pitch/tempo modifiers
|
||||||
@@ -192,10 +220,45 @@ local function newCrySource(data, species, def)
|
|||||||
return newFileSource(resolved)
|
return newFileSource(resolved)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Yellow's voiced Pikachu clips (audio/pikachu_pcm.asm
|
||||||
|
-- PlayPikachuSoundClip): 1-bit PCM decoded to WAVs at import
|
||||||
|
-- (data.audio.pikaCries = clip count). Returns the source, nil when the
|
||||||
|
-- cache carries no clips (Red/Blue) or headless.
|
||||||
|
function Sound.playPikaCry(data, n)
|
||||||
|
if not love.audio then return nil end
|
||||||
|
local count = data.audio and data.audio.pikaCries
|
||||||
|
if not count then return nil end
|
||||||
|
n = math.max(1, math.min(count, n or 1))
|
||||||
|
local key = "pikacry:" .. n
|
||||||
|
local src = cache[key]
|
||||||
|
if src == false then return nil end
|
||||||
|
if not src then
|
||||||
|
local ok, s = pcall(love.audio.newSource,
|
||||||
|
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n), "static")
|
||||||
|
if not ok then
|
||||||
|
cache[key] = false
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
s:setVolume(volumeFor(key))
|
||||||
|
cache[key] = s
|
||||||
|
src = s
|
||||||
|
end
|
||||||
|
src:stop()
|
||||||
|
src:play()
|
||||||
|
played("cry", "PIKACHU_PCM_" .. n, "PIKACHU")
|
||||||
|
return src
|
||||||
|
end
|
||||||
|
|
||||||
-- returns the source (nil headless) so callers that block on the cry
|
-- returns the source (nil headless) so callers that block on the cry
|
||||||
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
|
-- like the original's PlayCry -> WaitForSoundToFinish can poll it
|
||||||
function Sound.playCry(data, species)
|
function Sound.playCry(data, species)
|
||||||
if not love.audio then return nil end
|
if not love.audio then return nil end
|
||||||
|
-- Yellow voices every Pikachu cry with the PCM clips (the chip cry is
|
||||||
|
-- never used for the species there); clip 1 is the everyday "Pika!"
|
||||||
|
if species == "PIKACHU" then
|
||||||
|
local src = Sound.playPikaCry(data, 1)
|
||||||
|
if src then return src end
|
||||||
|
end
|
||||||
local cries = data.audio and data.audio.cries
|
local cries = data.audio and data.audio.cries
|
||||||
local def = cries and cries[species]
|
local def = cries and cries[species]
|
||||||
if not def then return nil end
|
if not def then return nil end
|
||||||
@@ -210,7 +273,7 @@ function Sound.playCry(data, species)
|
|||||||
owner(data, "cries", species), err)
|
owner(data, "cries", species), err)
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
s:setVolume(BASE_VOLUME * volumeScale)
|
s:setVolume(volumeFor(key))
|
||||||
cache[key] = s
|
cache[key] = s
|
||||||
src = s
|
src = s
|
||||||
end
|
end
|
||||||
@@ -286,7 +349,7 @@ function Sound.startLoop(data, name)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
s:setLooping(true)
|
s:setLooping(true)
|
||||||
s:setVolume(BASE_VOLUME * volumeScale)
|
s:setVolume(volumeFor(name))
|
||||||
loopCache[name] = s
|
loopCache[name] = s
|
||||||
src = s
|
src = s
|
||||||
end
|
end
|
||||||
@@ -310,14 +373,26 @@ end
|
|||||||
-- 0-7 SFX volume level (0 mutes); cached sources (menu beeps, cries,
|
-- 0-7 SFX volume level (0 mutes); cached sources (menu beeps, cries,
|
||||||
-- the low-health alarm loop) update immediately so the change is heard
|
-- the low-health alarm loop) update immediately so the change is heard
|
||||||
-- on the next play
|
-- on the next play
|
||||||
|
local function reapplyVolumes()
|
||||||
|
for key, src in pairs(cache) do
|
||||||
|
if src then pcall(src.setVolume, src, volumeFor(key)) end
|
||||||
|
end
|
||||||
|
for key, src in pairs(loopCache) do
|
||||||
|
if src then pcall(src.setVolume, src, volumeFor(key)) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function Sound.setVolumeLevel(level)
|
function Sound.setVolumeLevel(level)
|
||||||
volumeScale = math.max(0, math.min(7, level or 7)) / 7
|
volumeScale = math.max(0, math.min(7, level or 7)) / 7
|
||||||
for _, src in pairs(cache) do
|
reapplyVolumes()
|
||||||
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
|
end
|
||||||
end
|
|
||||||
for _, src in pairs(loopCache) do
|
-- 0-7 Pikachu-voice trim on top of the SFX level (7 = no trim, 0 mutes the
|
||||||
if src then pcall(src.setVolume, src, BASE_VOLUME * volumeScale) end
|
-- clips while the rest of the SFX bus keeps its level). Yellow only: on
|
||||||
end
|
-- Red/Blue no cached key answers isPikaKey, so this is inert there.
|
||||||
|
function Sound.setPikaVolumeLevel(level)
|
||||||
|
pikaScale = math.max(0, math.min(7, level or 7)) / 7
|
||||||
|
reapplyVolumes()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
||||||
@@ -351,6 +426,7 @@ Assets.register(Sound.invalidate)
|
|||||||
-- loading a save)
|
-- loading a save)
|
||||||
function Sound.applyOptions(opts)
|
function Sound.applyOptions(opts)
|
||||||
Sound.setVolumeLevel(opts and opts.sfxVol or 7)
|
Sound.setVolumeLevel(opts and opts.sfxVol or 7)
|
||||||
|
Sound.setPikaVolumeLevel(opts and opts.pikaVol or 7)
|
||||||
end
|
end
|
||||||
|
|
||||||
return Sound
|
return Sound
|
||||||
|
|||||||
+213
-50
@@ -12,12 +12,18 @@
|
|||||||
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
|
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
|
||||||
-- off everywhere.
|
-- off everywhere.
|
||||||
--
|
--
|
||||||
|
-- Player preferences (options.touchControls) can permanently disable the
|
||||||
|
-- overlay and/or override per-control positions as normalized window
|
||||||
|
-- fractions. The launcher editor (src/ui/TouchControlsEditor.lua) writes
|
||||||
|
-- those; applyOptions reads them at boot and whenever options change.
|
||||||
|
--
|
||||||
-- Controls press GB buttons through Input:overlayPressed/Released -- their
|
-- Controls press GB buttons through Input:overlayPressed/Released -- their
|
||||||
-- own input source, not a keyboard alias -- so a held overlay direction
|
-- own input source, not a keyboard alias -- so a held overlay direction
|
||||||
-- merges cleanly with a keyboard key or stick holding the same button,
|
-- merges cleanly with a keyboard key or stick holding the same button,
|
||||||
-- and a player rebind can never detach the overlay.
|
-- and a player rebind can never detach the overlay.
|
||||||
|
|
||||||
local Input = require("src.core.Input")
|
local Input = require("src.core.Input")
|
||||||
|
local SafeArea = require("src.core.SafeArea")
|
||||||
|
|
||||||
local TouchControls = {}
|
local TouchControls = {}
|
||||||
|
|
||||||
@@ -39,6 +45,7 @@ local DPAD_DEAD = 0.16
|
|||||||
local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
|
local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
|
||||||
|
|
||||||
local BUTTONS = { "a", "b", "start", "select" }
|
local BUTTONS = { "a", "b", "start", "select" }
|
||||||
|
local CONTROLS = { "dpad", "a", "b", "start", "select" }
|
||||||
|
|
||||||
local IMAGES = {
|
local IMAGES = {
|
||||||
dpad = "assets/touch/dpad.png",
|
dpad = "assets/touch/dpad.png",
|
||||||
@@ -52,6 +59,12 @@ local IMAGES = {
|
|||||||
select = "assets/touch/select.png",
|
select = "assets/touch/select.png",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local function clamp01(v)
|
||||||
|
if v < 0 then return 0 end
|
||||||
|
if v > 1 then return 1 end
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
local function wantsOverlay()
|
local function wantsOverlay()
|
||||||
local env = os.getenv("POKEPORT_TOUCH")
|
local env = os.getenv("POKEPORT_TOUCH")
|
||||||
if env == "1" then return true end
|
if env == "1" then return true end
|
||||||
@@ -60,8 +73,62 @@ local function wantsOverlay()
|
|||||||
return osName == "Android" or osName == "iOS"
|
return osName == "Android" or osName == "iOS"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Normalize a persisted touchControls table into {enabled, positions}.
|
||||||
|
-- Unknown / garbage keys are dropped so a bad options.lua cannot brick
|
||||||
|
-- the overlay.
|
||||||
|
function TouchControls.normalizeConfig(tc)
|
||||||
|
local out = { enabled = true, positions = nil }
|
||||||
|
if type(tc) ~= "table" then return out end
|
||||||
|
if tc.enabled == false then out.enabled = false end
|
||||||
|
if type(tc.positions) == "table" then
|
||||||
|
local pos = {}
|
||||||
|
for _, name in ipairs(CONTROLS) do
|
||||||
|
local p = tc.positions[name]
|
||||||
|
if type(p) == "table" and type(p.x) == "number" and type(p.y) == "number" then
|
||||||
|
pos[name] = { x = clamp01(p.x), y = clamp01(p.y) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if next(pos) then out.positions = pos end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pure default layout in LOVE units for a usable rect of size ww x wh at
|
||||||
|
-- origin (ox, oy). Shared by layout() and the editor's Reset path so
|
||||||
|
-- defaults stay in one place. ox/oy default to 0 for the headless tests
|
||||||
|
-- and for callers that already pass a full-window size.
|
||||||
|
function TouchControls.defaultLayout(ww, wh, ox, oy)
|
||||||
|
ox, oy = ox or 0, oy or 0
|
||||||
|
local short = math.min(ww, wh)
|
||||||
|
local dpadW = math.min(180, short * 0.34)
|
||||||
|
local abW = dpadW * 0.46
|
||||||
|
local ssW = dpadW * 0.30
|
||||||
|
local margin = dpadW * 0.12
|
||||||
|
return {
|
||||||
|
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
|
||||||
|
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
|
||||||
|
b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW },
|
||||||
|
start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
|
||||||
|
select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function loadImages()
|
||||||
|
local img = {}
|
||||||
|
for name, path in pairs(IMAGES) do
|
||||||
|
local ok, im = pcall(love.graphics.newImage, path)
|
||||||
|
if not ok then return nil end
|
||||||
|
im:setFilter("linear", "linear")
|
||||||
|
img[name] = im
|
||||||
|
end
|
||||||
|
return img
|
||||||
|
end
|
||||||
|
|
||||||
function TouchControls:init()
|
function TouchControls:init()
|
||||||
self.active = wantsOverlay()
|
self.active = wantsOverlay()
|
||||||
|
self.enabled = true
|
||||||
|
self.positions = nil
|
||||||
|
self.preview = false
|
||||||
self.controllerHidden = false
|
self.controllerHidden = false
|
||||||
self.touches = {}
|
self.touches = {}
|
||||||
-- per-GB-button owner count: two fingers on A must not double-press it,
|
-- per-GB-button owner count: two fingers on A must not double-press it,
|
||||||
@@ -70,51 +137,94 @@ function TouchControls:init()
|
|||||||
self.dpadTouch = nil
|
self.dpadTouch = nil
|
||||||
self.layoutW, self.layoutH = nil, nil
|
self.layoutW, self.layoutH = nil, nil
|
||||||
self.img = nil
|
self.img = nil
|
||||||
if not self.active then return end
|
-- Images load whenever the platform wants the overlay OR the launcher
|
||||||
-- soft-fail: a missing/corrupt PNG must never block boot; the overlay
|
-- editor forces a preview (desktop testing of the editor).
|
||||||
-- stays off and keyboard/controller play still works
|
if self.active then
|
||||||
local img = {}
|
self.img = loadImages()
|
||||||
for name, path in pairs(IMAGES) do
|
end
|
||||||
local ok, im = pcall(love.graphics.newImage, path)
|
end
|
||||||
if not ok then
|
|
||||||
img = nil
|
-- Ensure art is loaded for the launcher editor even when wantsOverlay()
|
||||||
break
|
-- is false (desktop without POKEPORT_TOUCH).
|
||||||
end
|
function TouchControls:ensureImages()
|
||||||
-- smooth UI icons; the global default filter is nearest for GB pixels
|
if self.img then return true end
|
||||||
im:setFilter("linear", "linear")
|
self.img = loadImages()
|
||||||
img[name] = im
|
return self.img ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Apply options.touchControls. Called from Game:applyOptions and from
|
||||||
|
-- the launcher editor after a save.
|
||||||
|
function TouchControls:applyOptions(opts)
|
||||||
|
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
|
||||||
|
self.enabled = cfg.enabled
|
||||||
|
self.positions = cfg.positions
|
||||||
|
self.layoutW, self.layoutH = nil, nil
|
||||||
|
self.layoutOx, self.layoutOy = nil, nil
|
||||||
|
if not self.enabled then
|
||||||
|
self.controllerHidden = false
|
||||||
|
self:reset()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchControls:config()
|
||||||
|
return {
|
||||||
|
enabled = self.enabled ~= false,
|
||||||
|
positions = self.positions,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Preview mode: force-draw the overlay for the layout editor, ignoring
|
||||||
|
-- platform / enabled / gamepad gates. Gameplay input still respects
|
||||||
|
-- enabled via touchpressed.
|
||||||
|
function TouchControls:setPreview(on)
|
||||||
|
self.preview = on and true or false
|
||||||
|
if on then
|
||||||
|
self:ensureImages()
|
||||||
|
self.controllerHidden = false
|
||||||
end
|
end
|
||||||
self.img = img
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function TouchControls:visible()
|
function TouchControls:visible()
|
||||||
return self.active and self.img ~= nil and not self.controllerHidden
|
if self.preview then return self.img ~= nil end
|
||||||
|
return self.active and self.enabled ~= false and self.img ~= nil
|
||||||
|
and not self.controllerHidden
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
|
||||||
|
local function clampZone(zone, x0, y0, x1, y1)
|
||||||
|
local half = zone.w * 0.5
|
||||||
|
zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx))
|
||||||
|
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Layout in LOVE units (density-independent on mobile), recomputed when
|
-- Layout in LOVE units (density-independent on mobile), recomputed when
|
||||||
-- the window size changes (rotation, resize). D-pad bottom-left, B/A
|
-- the window or safe area changes (rotation, resize, notch insets).
|
||||||
-- bottom-right with A above B (the Game Boy diagonal), START/SELECT
|
-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
|
||||||
-- flanking the bottom center.
|
-- diagonal), START/SELECT flanking the bottom center -- all inside the
|
||||||
|
-- device safe area so thumbs clear the home indicator / cutouts.
|
||||||
|
-- Custom positions (normalized 0..1 within the safe rect) override centers
|
||||||
|
-- while sizes stay derived from the short edge.
|
||||||
function TouchControls:layout()
|
function TouchControls:layout()
|
||||||
local ww, wh = love.graphics.getDimensions()
|
local ox, oy, sw, sh = SafeArea.rect()
|
||||||
if self.layoutW == ww and self.layoutH == wh then return self.L end
|
if self.layoutW == sw and self.layoutH == sh
|
||||||
self.layoutW, self.layoutH = ww, wh
|
and self.layoutOx == ox and self.layoutOy == oy and self.L then
|
||||||
local short = math.min(ww, wh)
|
return self.L
|
||||||
-- ~a third of the short edge, capped so tablets don't get a dinner plate
|
end
|
||||||
local dpadW = math.min(180, short * 0.34)
|
self.layoutW, self.layoutH = sw, sh
|
||||||
local abW = dpadW * 0.46
|
self.layoutOx, self.layoutOy = ox, oy
|
||||||
local ssW = dpadW * 0.30
|
self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
|
||||||
local margin = dpadW * 0.12
|
if self.positions then
|
||||||
-- START/SELECT hug the bottom center: on a narrow portrait phone the
|
for _, name in ipairs(CONTROLS) do
|
||||||
-- d-pad and B leave little room, so a tight pair is what keeps them off
|
local p = self.positions[name]
|
||||||
-- the neighboring controls
|
local zone = self.L[name]
|
||||||
self.L = {
|
if p and zone then
|
||||||
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
|
zone.cx = ox + p.x * sw
|
||||||
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
|
zone.cy = oy + p.y * sh
|
||||||
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
|
clampZone(zone, ox, oy, ox + sw, oy + sh)
|
||||||
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
end
|
||||||
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
end
|
||||||
}
|
end
|
||||||
|
local ssW = self.L.start.w
|
||||||
local fontSize = math.max(8, math.floor(ssW * 0.26))
|
local fontSize = math.max(8, math.floor(ssW * 0.26))
|
||||||
if not self.labelFont or self.fontSize ~= fontSize then
|
if not self.labelFont or self.fontSize ~= fontSize then
|
||||||
self.fontSize = fontSize
|
self.fontSize = fontSize
|
||||||
@@ -123,12 +233,49 @@ function TouchControls:layout()
|
|||||||
return self.L
|
return self.L
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Move one control to a screen-space point and persist its normalized
|
||||||
|
-- position within the safe rect. Used by the layout editor while dragging.
|
||||||
|
function TouchControls:setControlCenter(name, cx, cy)
|
||||||
|
local ox, oy, sw, sh = SafeArea.rect()
|
||||||
|
local L = self:layout()
|
||||||
|
local zone = L[name]
|
||||||
|
if not zone then return end
|
||||||
|
zone.cx, zone.cy = cx, cy
|
||||||
|
clampZone(zone, ox, oy, ox + sw, oy + sh)
|
||||||
|
self.positions = self.positions or {}
|
||||||
|
self.positions[name] = {
|
||||||
|
x = sw > 0 and (zone.cx - ox) / sw or 0,
|
||||||
|
y = sh > 0 and (zone.cy - oy) / sh or 0,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchControls:clearPositions()
|
||||||
|
self.positions = nil
|
||||||
|
self.layoutW, self.layoutH = nil, nil
|
||||||
|
self.layoutOx, self.layoutOy = nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
local function inCircle(zone, x, y, slop)
|
local function inCircle(zone, x, y, slop)
|
||||||
local r = zone.w * 0.5 * slop
|
local r = zone.w * 0.5 * slop
|
||||||
local dx, dy = x - zone.cx, y - zone.cy
|
local dx, dy = x - zone.cx, y - zone.cy
|
||||||
return dx * dx + dy * dy <= r * r
|
return dx * dx + dy * dy <= r * r
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Which control (if any) contains (x, y). Prefer face buttons over the
|
||||||
|
-- d-pad when they overlap, matching touchpressed's order.
|
||||||
|
function TouchControls:hitTest(x, y)
|
||||||
|
local L = self:layout()
|
||||||
|
for _, btn in ipairs(BUTTONS) do
|
||||||
|
if inCircle(L[btn], x, y, SLOP[btn]) then return btn end
|
||||||
|
end
|
||||||
|
local dz = L.dpad
|
||||||
|
local half = dz.w * 0.65
|
||||||
|
if math.abs(x - dz.cx) <= half and math.abs(y - dz.cy) <= half then
|
||||||
|
return "dpad"
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
local function dpadDir(zone, x, y)
|
local function dpadDir(zone, x, y)
|
||||||
local dx, dy = x - zone.cx, y - zone.cy
|
local dx, dy = x - zone.cx, y - zone.cy
|
||||||
local dead = zone.w * DPAD_DEAD
|
local dead = zone.w * DPAD_DEAD
|
||||||
@@ -165,7 +312,9 @@ local function setDpad(self, touch, dir)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function TouchControls:touchpressed(id, x, y)
|
function TouchControls:touchpressed(id, x, y)
|
||||||
if not (self.active and self.img) then return end
|
-- preview mode is layout-edit only: never press GB buttons
|
||||||
|
if self.preview then return end
|
||||||
|
if not (self.active and self.enabled ~= false and self.img) then return end
|
||||||
-- a controller hid the overlay; the first touch only brings it back
|
-- a controller hid the overlay; the first touch only brings it back
|
||||||
if self.controllerHidden then
|
if self.controllerHidden then
|
||||||
self.controllerHidden = false
|
self.controllerHidden = false
|
||||||
@@ -192,6 +341,7 @@ function TouchControls:touchpressed(id, x, y)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function TouchControls:touchmoved(id, x, y)
|
function TouchControls:touchmoved(id, x, y)
|
||||||
|
if self.preview then return end
|
||||||
local touch = self.touches[id]
|
local touch = self.touches[id]
|
||||||
-- only the d-pad tracks movement (slide between directions without
|
-- only the d-pad tracks movement (slide between directions without
|
||||||
-- lifting); buttons hold until release wherever the finger wanders
|
-- lifting); buttons hold until release wherever the finger wanders
|
||||||
@@ -200,6 +350,7 @@ function TouchControls:touchmoved(id, x, y)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function TouchControls:touchreleased(id, x, y)
|
function TouchControls:touchreleased(id, x, y)
|
||||||
|
if self.preview then return end
|
||||||
local touch = self.touches[id]
|
local touch = self.touches[id]
|
||||||
if not touch then return end
|
if not touch then return end
|
||||||
self.touches[id] = nil
|
self.touches[id] = nil
|
||||||
@@ -216,7 +367,7 @@ end
|
|||||||
-- touchreleased and would strand its button held forever. Called from
|
-- touchreleased and would strand its button held forever. Called from
|
||||||
-- Game alongside Input:reset() on focus/visibility loss.
|
-- Game alongside Input:reset() on focus/visibility loss.
|
||||||
function TouchControls:reset()
|
function TouchControls:reset()
|
||||||
for btn in pairs(self.held) do
|
for btn in pairs(self.held or {}) do
|
||||||
Input:overlayReleased(btn)
|
Input:overlayReleased(btn)
|
||||||
end
|
end
|
||||||
self.held = {}
|
self.held = {}
|
||||||
@@ -225,9 +376,13 @@ function TouchControls:reset()
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- a gamepad is being used: hide the overlay (dropping anything it held)
|
-- a gamepad is being used: hide the overlay (dropping anything it held)
|
||||||
-- until the next screen touch asks for it back
|
-- until the next screen touch asks for it back. No-op when the player
|
||||||
|
-- permanently disabled the overlay -- there is nothing to hide, and a
|
||||||
|
-- later accidental touch must not resurrect it.
|
||||||
function TouchControls:noteGamepad()
|
function TouchControls:noteGamepad()
|
||||||
if not self.active or self.controllerHidden then return end
|
if not self.active or self.enabled == false or self.controllerHidden then
|
||||||
|
return
|
||||||
|
end
|
||||||
self.controllerHidden = true
|
self.controllerHidden = true
|
||||||
self:reset()
|
self:reset()
|
||||||
end
|
end
|
||||||
@@ -242,40 +397,46 @@ function TouchControls:joystickremoved()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function drawIcon(img, zone, pressed)
|
local function drawIcon(img, zone, pressed, alphaMul)
|
||||||
love.graphics.setColor(1, 1, 1, pressed and BACK_PRESSED or BACK)
|
alphaMul = alphaMul or 1
|
||||||
|
love.graphics.setColor(1, 1, 1, (pressed and BACK_PRESSED or BACK) * alphaMul)
|
||||||
love.graphics.circle("fill", zone.cx, zone.cy, zone.w * 0.58)
|
love.graphics.circle("fill", zone.cx, zone.cy, zone.w * 0.58)
|
||||||
local scale = zone.w / img:getWidth()
|
local scale = zone.w / img:getWidth()
|
||||||
love.graphics.setColor(1, 1, 1, pressed and ALPHA_PRESSED or ALPHA)
|
love.graphics.setColor(1, 1, 1, (pressed and ALPHA_PRESSED or ALPHA) * alphaMul)
|
||||||
love.graphics.draw(img, zone.cx - zone.w / 2,
|
love.graphics.draw(img, zone.cx - zone.w / 2,
|
||||||
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Screen-space, called by Game:draw after Renderer:endFrame so the
|
-- Screen-space, called by Game:draw after Renderer:endFrame so the
|
||||||
-- overlay rides on top of everything (world, UI, CRT/GBC FX included).
|
-- overlay rides on top of everything (world, UI, CRT/GBC FX included).
|
||||||
|
-- Also used by the launcher layout editor under preview mode.
|
||||||
function TouchControls:draw()
|
function TouchControls:draw()
|
||||||
if not self:visible() then return end
|
if not self:visible() then return end
|
||||||
local L = self:layout()
|
local L = self:layout()
|
||||||
|
-- when the player disabled the overlay but the editor is previewing,
|
||||||
|
-- draw dimmed so the layout is still editable
|
||||||
|
local alphaMul = (self.preview and self.enabled == false) and 0.45 or 1
|
||||||
love.graphics.push("all")
|
love.graphics.push("all")
|
||||||
love.graphics.origin()
|
love.graphics.origin()
|
||||||
|
|
||||||
local dpadTouch = self.dpadTouch and self.touches[self.dpadTouch]
|
local dpadTouch = self.dpadTouch and self.touches[self.dpadTouch]
|
||||||
local dir = dpadTouch and dpadTouch.dir
|
local dir = dpadTouch and dpadTouch.dir
|
||||||
drawIcon(dir and self.img["dpad_" .. dir] or self.img.dpad, L.dpad,
|
drawIcon(dir and self.img["dpad_" .. dir] or self.img.dpad, L.dpad,
|
||||||
dir ~= nil)
|
dir ~= nil, alphaMul)
|
||||||
for _, btn in ipairs(BUTTONS) do
|
for _, btn in ipairs(BUTTONS) do
|
||||||
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil)
|
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil, alphaMul)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the +/- glyphs alone don't say which is which; shadowed so the text
|
-- the +/- glyphs alone don't say which is which; shadowed so the text
|
||||||
-- reads on both the black letterbox and battle's white one
|
-- reads on both the black letterbox and battle's white one. Each label
|
||||||
|
-- tracks its own control's cy/w so dragging START cannot move SELECT.
|
||||||
love.graphics.setFont(self.labelFont)
|
love.graphics.setFont(self.labelFont)
|
||||||
local ly = L.start.cy + L.start.w * 0.66
|
|
||||||
local function label(text, zone)
|
local function label(text, zone)
|
||||||
|
local ly = zone.cy + zone.w * 0.66
|
||||||
local w = self.labelFont:getWidth(text)
|
local w = self.labelFont:getWidth(text)
|
||||||
love.graphics.setColor(0, 0, 0, 0.6)
|
love.graphics.setColor(0, 0, 0, 0.6 * alphaMul)
|
||||||
love.graphics.print(text, zone.cx - w / 2 + 1, ly + 1)
|
love.graphics.print(text, zone.cx - w / 2 + 1, ly + 1)
|
||||||
love.graphics.setColor(1, 1, 1, ALPHA + 0.2)
|
love.graphics.setColor(1, 1, 1, (ALPHA + 0.2) * alphaMul)
|
||||||
love.graphics.print(text, zone.cx - w / 2, ly)
|
love.graphics.print(text, zone.cx - w / 2, ly)
|
||||||
end
|
end
|
||||||
label("START", L.start)
|
label("START", L.start)
|
||||||
@@ -284,4 +445,6 @@ function TouchControls:draw()
|
|||||||
love.graphics.pop()
|
love.graphics.pop()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
TouchControls.CONTROLS = CONTROLS
|
||||||
|
|
||||||
return TouchControls
|
return TouchControls
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
--
|
--
|
||||||
-- Protocol -- main thread pushes command tables onto the "chipaudio_cmd"
|
-- Protocol -- main thread pushes command tables onto the "chipaudio_cmd"
|
||||||
-- channel and drains produced buffers off "chipaudio_out":
|
-- channel and drains produced buffers off "chipaudio_out":
|
||||||
-- cmd = "play" { gen, header, allowLoops, audio } start a song
|
-- cmd = "play" { gen, header, allowLoops, audio,
|
||||||
|
-- channelVolumes?, channelPitches? }
|
||||||
-- cmd = "stop" halt production
|
-- cmd = "stop" halt production
|
||||||
|
-- cmd = "channelMix" { volumes, pitches } per-hw volume/pitch
|
||||||
-- cmd = "invalidate" drop the bank cache
|
-- cmd = "invalidate" drop the bank cache
|
||||||
-- cmd = "quit" end the thread
|
-- cmd = "quit" end the thread
|
||||||
-- out buffers are tagged with the play's `gen` so the main thread can
|
-- out buffers are tagged with the play's `gen` so the main thread can
|
||||||
@@ -45,6 +47,12 @@ local function handle(cmd)
|
|||||||
engine = nil
|
engine = nil
|
||||||
outCh:clear() -- drop any buffers left from the previous song
|
outCh:clear() -- drop any buffers left from the previous song
|
||||||
data = { audio = cmd.audio }
|
data = { audio = cmd.audio }
|
||||||
|
if cmd.channelVolumes ~= nil then
|
||||||
|
ChipSynth.setChannelVolumes(cmd.channelVolumes)
|
||||||
|
end
|
||||||
|
if cmd.channelPitches ~= nil then
|
||||||
|
ChipSynth.setChannelPitches(cmd.channelPitches)
|
||||||
|
end
|
||||||
local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
|
local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
|
||||||
{ allowLoops = cmd.allowLoops })
|
{ allowLoops = cmd.allowLoops })
|
||||||
if ok then
|
if ok then
|
||||||
@@ -58,6 +66,9 @@ local function handle(cmd)
|
|||||||
engine = nil
|
engine = nil
|
||||||
finished = false
|
finished = false
|
||||||
outCh:clear()
|
outCh:clear()
|
||||||
|
elseif cmd.cmd == "channelMix" then
|
||||||
|
if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end
|
||||||
|
if cmd.pitches ~= nil then ChipSynth.setChannelPitches(cmd.pitches) end
|
||||||
elseif cmd.cmd == "invalidate" then
|
elseif cmd.cmd == "invalidate" then
|
||||||
ChipSynth.invalidateBanks()
|
ChipSynth.invalidateBanks()
|
||||||
elseif cmd.cmd == "quit" then
|
elseif cmd.cmd == "quit" then
|
||||||
|
|||||||
+1
-1
@@ -214,7 +214,7 @@ function VERBS.give(self, rest)
|
|||||||
end
|
end
|
||||||
elseif game.data.items and game.data.items[id] then
|
elseif game.data.items and game.data.items[id] then
|
||||||
local n = tonumber(count) or 1
|
local n = tonumber(count) or 1
|
||||||
if require("src.inventory.Bag").add(save, id, n) then
|
if require("src.inventory.Bag").add(save, id, n, game.data) then
|
||||||
self:print(("%s x%d added"):format(id, n))
|
self:print(("%s x%d added"):format(id, n))
|
||||||
else
|
else
|
||||||
self:print("bag full")
|
self:print("bag full")
|
||||||
|
|||||||
+86
-20
@@ -32,11 +32,11 @@ local CacheFs = {}
|
|||||||
local SEP = package.config:sub(1, 1)
|
local SEP = package.config:sub(1, 1)
|
||||||
|
|
||||||
-- Cache-relative paths are prefixed with this before every read/write, so a
|
-- 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
|
-- Blue/Yellow import lands under its GameVersion.cachePrefix (blue/, yellow/)
|
||||||
-- keeps the historical root. The launcher sets it per import / per readiness
|
-- while a Red import keeps the historical root. The launcher sets it per
|
||||||
-- check; it stays "" for Red. Runtime *reads* (require / newImage) do NOT go
|
-- import / per readiness check; it stays "" for Red. Runtime *reads*
|
||||||
-- through here -- CacheFs.mountVersion overlays the active version's subtree
|
-- (require / newImage) do NOT go through here -- CacheFs.mountVersion overlays
|
||||||
-- onto the un-prefixed paths instead.
|
-- the active version's subtree onto the un-prefixed paths instead.
|
||||||
CacheFs.prefix = ""
|
CacheFs.prefix = ""
|
||||||
|
|
||||||
local function withPrefix(rel)
|
local function withPrefix(rel)
|
||||||
@@ -126,9 +126,9 @@ local function resolveMount()
|
|||||||
if okl and lib then
|
if okl and lib then
|
||||||
local oks, fn = pcall(function() return lib.PHYSFS_mount end)
|
local oks, fn = pcall(function() return lib.PHYSFS_mount end)
|
||||||
if oks and fn then
|
if oks and fn then
|
||||||
physfsMountFn = function(d, append)
|
physfsMountFn = function(d, mountPoint, append)
|
||||||
if append == nil then append = true end
|
if append == nil then append = true end
|
||||||
local okr, ret = pcall(fn, d, "", append and 1 or 0)
|
local okr, ret = pcall(fn, d, mountPoint or "", append and 1 or 0)
|
||||||
return okr and ret ~= 0
|
return okr and ret ~= 0
|
||||||
end
|
end
|
||||||
break
|
break
|
||||||
@@ -145,7 +145,7 @@ end
|
|||||||
local function mountReadable(dir, append)
|
local function mountReadable(dir, append)
|
||||||
local fn = resolveMount()
|
local fn = resolveMount()
|
||||||
if not fn then return false end
|
if not fn then return false end
|
||||||
return fn(dir, append)
|
return fn(dir, "", append)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PHYSFS_unmount, resolved the same way PHYSFS_mount is. Only
|
-- PHYSFS_unmount, resolved the same way PHYSFS_mount is. Only
|
||||||
@@ -179,6 +179,38 @@ local function resolveUnmount()
|
|||||||
return physfsUnmountFn
|
return physfsUnmountFn
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- PHYSFS_getMountPoint, resolved the same way: a non-NULL return means `dir`
|
||||||
|
-- is already somewhere in the search path. withMounted needs it because
|
||||||
|
-- PHYSFS_mount reports success for an already-mounted directory without
|
||||||
|
-- adding a second entry, so its unmount would drop a mount it did not make
|
||||||
|
-- (#413).
|
||||||
|
local physfsMountPointFn = nil
|
||||||
|
local function resolveMountPoint()
|
||||||
|
if physfsMountPointFn ~= nil then return physfsMountPointFn end
|
||||||
|
physfsMountPointFn = false
|
||||||
|
local ok, ffi = pcall(require, "ffi")
|
||||||
|
if not ok then return physfsMountPointFn end
|
||||||
|
pcall(ffi.cdef, "const char *PHYSFS_getMountPoint(const char *dir);")
|
||||||
|
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_getMountPoint end)
|
||||||
|
if oks and fn then
|
||||||
|
physfsMountPointFn = function(d)
|
||||||
|
local okr, ret = pcall(fn, d)
|
||||||
|
return okr and ret ~= nil and ret ~= ffi.NULL
|
||||||
|
end
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return physfsMountPointFn
|
||||||
|
end
|
||||||
|
|
||||||
-- The portable game folder when the cache should live there, else nil.
|
-- The portable game folder when the cache should live there, else nil.
|
||||||
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
|
-- Resolved (and, for a fused build, mounted) once and cached. Requires a
|
||||||
-- desktop portable install (SaveData) and a working windowless mkdir.
|
-- desktop portable install (SaveData) and a working windowless mkdir.
|
||||||
@@ -330,16 +362,16 @@ end
|
|||||||
-- Overlay the active version's extracted cache onto the un-prefixed read
|
-- Overlay the active version's extracted cache onto the un-prefixed read
|
||||||
-- paths, so require("data.generated.*") and love.graphics.newImage(
|
-- paths, so require("data.generated.*") and love.graphics.newImage(
|
||||||
-- "assets/generated/*") resolve to that version's files. Red lives at the
|
-- "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
|
-- cache root and needs nothing; non-Red versions (blue/, yellow/, …) are
|
||||||
-- it wins over any Red copy at the root and over the game source. Called
|
-- *prepended* so they win over any Red copy at the root and over the game
|
||||||
-- once at boot, before Game:load (main.lua). Returns true when nothing was
|
-- source. Called once at boot, before Game:load (main.lua). Returns true
|
||||||
-- needed or the mount succeeded.
|
-- when nothing was needed or the mount succeeded.
|
||||||
function CacheFs.mountVersion(version)
|
function CacheFs.mountVersion(version)
|
||||||
local prefix = require("src.core.GameVersion").cachePrefix(version)
|
local prefix = require("src.core.GameVersion").cachePrefix(version)
|
||||||
if prefix == "" then return true end -- Red: already at the root
|
if prefix == "" then return true end -- Red: already at the root
|
||||||
local sub = prefix:gsub("/+$", "") -- "blue/" -> "blue"
|
local sub = prefix:gsub("/+$", "") -- "blue/" / "yellow/" -> bare dir
|
||||||
-- The cache root is the portable game folder when active, else LÖVE's OS
|
-- The cache root is the portable game folder when active, else LÖVE's OS
|
||||||
-- save directory (where love.filesystem wrote blue/...).
|
-- save directory (where love.filesystem wrote blue/... or yellow/...).
|
||||||
local base = CacheFs.root()
|
local base = CacheFs.root()
|
||||||
if not base and love.filesystem.getSaveDirectory then
|
if not base and love.filesystem.getSaveDirectory then
|
||||||
base = love.filesystem.getSaveDirectory()
|
base = love.filesystem.getSaveDirectory()
|
||||||
@@ -355,12 +387,12 @@ function CacheFs.mountVersion(version)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Undo mountVersion. A process normally mounts exactly one version and then
|
-- Undo mountVersion. A process normally mounts exactly one version and then
|
||||||
-- boots it, but the launcher can open the save editor on a Blue save, close
|
-- boots it, but the launcher can open the save editor on a Blue/Yellow save,
|
||||||
-- it, and press Play on Red: with blue/ still prepended, Red's
|
-- close it, and press Play on Red: with that version's subtree still
|
||||||
-- require("data.generated.*") and its generated art would silently resolve to
|
-- prepended, Red's require("data.generated.*") and its generated art would
|
||||||
-- Blue's files. Callers must also drop the generated modules from
|
-- silently resolve to the other game's files. Callers must also drop the
|
||||||
-- package.loaded (src.core.Data:unloadGenerated) -- unmounting alone only
|
-- generated modules from package.loaded (src.core.Data:unloadGenerated) --
|
||||||
-- fixes the read path, not what require already cached.
|
-- unmounting alone only fixes the read path, not what require already cached.
|
||||||
--
|
--
|
||||||
-- Returns true when nothing was mounted or the unmount took. Red is a no-op
|
-- Returns true when nothing was mounted or the unmount took. Red is a no-op
|
||||||
-- because its cache lives at the root and was never overlaid.
|
-- because its cache lives at the root and was never overlaid.
|
||||||
@@ -385,4 +417,38 @@ function CacheFs.unmountVersion(version)
|
|||||||
return done
|
return done
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Mount `dir` at `mountPoint` for the length of `fn()`, then take it back off
|
||||||
|
-- the read path and hand back whatever fn returned.
|
||||||
|
--
|
||||||
|
-- Every other mount here is permanent and lands at the physfs root: this one
|
||||||
|
-- exists to *look* at a folder the game has deliberately not mounted, which
|
||||||
|
-- is a different job. The mods panel uses it to read a mods/ folder sitting
|
||||||
|
-- beside the executable of a non-portable install (LauncherMods.strays).
|
||||||
|
-- Because it unmounts again, and because a non-empty mountPoint keeps the
|
||||||
|
-- tree in its own corner of the namespace while it is up, a folder inspected
|
||||||
|
-- this way can never shadow a game file or change what the running game
|
||||||
|
-- resolves -- which is what makes it safe to point at a folder whose contents
|
||||||
|
-- nobody has validated.
|
||||||
|
--
|
||||||
|
-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, the
|
||||||
|
-- mount was refused, or `dir` is already on the read path), which callers must
|
||||||
|
-- treat as "could not look", not as "nothing there". An error inside fn still
|
||||||
|
-- unmounts before it propagates.
|
||||||
|
function CacheFs.withMounted(dir, mountPoint, fn)
|
||||||
|
if not dir or dir == "" then return nil end
|
||||||
|
local mount, unmount = resolveMount(), resolveUnmount()
|
||||||
|
if not (mount and unmount) then return nil end
|
||||||
|
-- A directory already in the search path cannot be borrowed: PHYSFS_mount
|
||||||
|
-- returns success without adding an entry, and the unmount below would then
|
||||||
|
-- remove the mount somebody else is relying on -- for the portable game
|
||||||
|
-- folder, the one that makes its cache and mods readable at all (#413)
|
||||||
|
local mountedAt = resolveMountPoint()
|
||||||
|
if mountedAt and mountedAt(dir) then return nil end
|
||||||
|
if not mount(dir, mountPoint, true) then return nil end
|
||||||
|
local ok, res = pcall(fn)
|
||||||
|
unmount(dir)
|
||||||
|
if not ok then error(res, 0) end
|
||||||
|
return res
|
||||||
|
end
|
||||||
|
|
||||||
return CacheFs
|
return CacheFs
|
||||||
|
|||||||
+391
-49
@@ -163,8 +163,12 @@ function RomExtractor:extractTilesets()
|
|||||||
for pos = offset, offset + 15 do block[#block + 1] = blocksRaw[pos] end
|
for pos = offset, offset + 15 do block[#block + 1] = blocksRaw[pos] end
|
||||||
blocks[#blocks + 1] = block
|
blocks[#blocks + 1] = block
|
||||||
end
|
end
|
||||||
|
-- Red/Blue keep collision lists in ROM0; Yellow moved them to bank 1
|
||||||
|
-- (pokeyellow Overworld_Coll at 01:4ac2). Pointers in $4000-$7FFF are
|
||||||
|
-- banked; treat ROM0-range pointers as bank 0.
|
||||||
|
local collBank = collisionPointer < 0x4000 and 0 or 1
|
||||||
local walkable = sorted(self:readTerminated(
|
local walkable = sorted(self:readTerminated(
|
||||||
0, collisionPointer, 0xFF))
|
collBank, collisionPointer, 0xFF))
|
||||||
local warpPointer = self.rom:word(
|
local warpPointer = self.rom:word(
|
||||||
warpPointers.bank, warpPointers.address + (index - 1) * 2)
|
warpPointers.bank, warpPointers.address + (index - 1) * 2)
|
||||||
local warpTiles = unique(self:readTerminated(
|
local warpTiles = unique(self:readTerminated(
|
||||||
@@ -447,14 +451,26 @@ function RomExtractor:extractSprites()
|
|||||||
local pointer = self.rom:word(pointerTable.bank, address)
|
local pointer = self.rom:word(pointerTable.bank, address)
|
||||||
local firstHalf = self.rom:byte(pointerTable.bank, address + 2)
|
local firstHalf = self.rom:byte(pointerTable.bank, address + 2)
|
||||||
local bank = self.rom:byte(pointerTable.bank, address + 3)
|
local bank = self.rom:byte(pointerTable.bank, address + 3)
|
||||||
local byteLength = spec.imageWidth * spec.imageHeight / 4
|
local width = spec.imageWidth
|
||||||
local frames = spec.imageHeight / 16
|
local height = spec.imageHeight
|
||||||
|
local byteLength = width * height / 4
|
||||||
|
local frames = height / 16
|
||||||
local expected = firstHalf * (frames >= 6 and 2 or 1)
|
local expected = firstHalf * (frames >= 6 and 2 or 1)
|
||||||
assert(byteLength == expected, constName .. ": sprite length mismatch")
|
if byteLength ~= expected then
|
||||||
|
-- Commercial ROM sheet length wins over pret PNG atlases (Yellow nurse
|
||||||
|
-- PNG is taller than the 12-tile SpriteSheetPointerTable entry).
|
||||||
|
byteLength = expected
|
||||||
|
assert(byteLength * 4 % width == 0,
|
||||||
|
constName .. ": ROM sprite length not tile-aligned")
|
||||||
|
height = byteLength * 4 / width
|
||||||
|
frames = height / 16
|
||||||
|
expected = firstHalf * (frames >= 6 and 2 or 1)
|
||||||
|
assert(byteLength == expected, constName .. ": sprite length mismatch")
|
||||||
|
end
|
||||||
local base = spec.imageBase
|
local base = spec.imageBase
|
||||||
if not written[base] then
|
if not written[base] then
|
||||||
self:write2bpp(self.rom:bytes(bank, pointer, byteLength),
|
self:write2bpp(self.rom:bytes(bank, pointer, byteLength),
|
||||||
spec.imageWidth, spec.imageHeight,
|
width, height,
|
||||||
"sprites/" .. base .. ".png", true)
|
"sprites/" .. base .. ".png", true)
|
||||||
written[base] = true
|
written[base] = true
|
||||||
end
|
end
|
||||||
@@ -862,20 +878,24 @@ function RomExtractor:extractPalettes()
|
|||||||
local order = self.manifest.paletteOrder
|
local order = self.manifest.paletteOrder
|
||||||
local paletteTable = self:symbol("SuperPalettes")
|
local paletteTable = self:symbol("SuperPalettes")
|
||||||
local function scale5(value) return round(value * 255 / 31) end
|
local function scale5(value) return round(value * 255 / 31) end
|
||||||
local palettes = {}
|
local function readTable(symbol, names)
|
||||||
for index, name in ipairs(order) do
|
local out = {}
|
||||||
local colors = {}
|
for index, name in ipairs(names) do
|
||||||
for color = 0, 3 do
|
local colors = {}
|
||||||
local value = self.rom:word(paletteTable.bank,
|
for color = 0, 3 do
|
||||||
paletteTable.address + (index - 1) * 8 + color * 2)
|
local value = self.rom:word(symbol.bank,
|
||||||
colors[#colors + 1] = {
|
symbol.address + (index - 1) * 8 + color * 2)
|
||||||
scale5(bit.band(value, 0x1F)),
|
colors[#colors + 1] = {
|
||||||
scale5(bit.band(bit.rshift(value, 5), 0x1F)),
|
scale5(bit.band(value, 0x1F)),
|
||||||
scale5(bit.band(bit.rshift(value, 10), 0x1F)),
|
scale5(bit.band(bit.rshift(value, 5), 0x1F)),
|
||||||
}
|
scale5(bit.band(bit.rshift(value, 10), 0x1F)),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
out[name] = colors
|
||||||
end
|
end
|
||||||
palettes[name] = colors
|
return out
|
||||||
end
|
end
|
||||||
|
local palettes = readTable(paletteTable, order)
|
||||||
local monsterTable = self:symbol("MonsterPalettes")
|
local monsterTable = self:symbol("MonsterPalettes")
|
||||||
local monsterPalettes = {}
|
local monsterPalettes = {}
|
||||||
for index, species in ipairs(self.manifest.dexOrder) do
|
for index, species in ipairs(self.manifest.dexOrder) do
|
||||||
@@ -887,6 +907,11 @@ function RomExtractor:extractPalettes()
|
|||||||
source = "ROM:SuperPalettes + MonsterPalettes",
|
source = "ROM:SuperPalettes + MonsterPalettes",
|
||||||
palettes = palettes, order = order, pokemon = monsterPalettes,
|
palettes = palettes, order = order, pokemon = monsterPalettes,
|
||||||
}
|
}
|
||||||
|
-- Yellow (and GBC carts) also carry CGBBasePalettes beside SuperPalettes.
|
||||||
|
if self.symbols["CGBBasePalettes"] then
|
||||||
|
data.cgbBase = readTable(self:symbol("CGBBasePalettes"), order)
|
||||||
|
data.source = data.source .. " + CGBBasePalettes"
|
||||||
|
end
|
||||||
self:write("palettes", data)
|
self:write("palettes", data)
|
||||||
self:tick("Color palettes", 1, 1)
|
self:tick("Color palettes", 1, 1)
|
||||||
return data
|
return data
|
||||||
@@ -915,6 +940,10 @@ function RomExtractor:extractIcons()
|
|||||||
GRASS = "assets/generated/icons/plant.png",
|
GRASS = "assets/generated/icons/plant.png",
|
||||||
SNAKE = "assets/generated/icons/snake.png",
|
SNAKE = "assets/generated/icons/snake.png",
|
||||||
QUADRUPED = "assets/generated/icons/quadruped.png",
|
QUADRUPED = "assets/generated/icons/quadruped.png",
|
||||||
|
-- Yellow's ICON_PIKACHU draws from the overworld PikachuSprite sheet
|
||||||
|
-- (data/icon_pointers.asm mon_icon_header PikachuSprite, 0/12);
|
||||||
|
-- only referenced when the manifest's iconOrder includes it
|
||||||
|
PIKACHU = "assets/generated/sprites/pikachu.png",
|
||||||
}
|
}
|
||||||
local frames = {
|
local frames = {
|
||||||
{ "bug", "BugIconFrame1", "BugIconFrame2" },
|
{ "bug", "BugIconFrame1", "BugIconFrame2" },
|
||||||
@@ -1048,7 +1077,9 @@ function RomExtractor:extractPokemon()
|
|||||||
local typeById = self:typesById()
|
local typeById = self:typesById()
|
||||||
local names = self:symbol("MonsterNames")
|
local names = self:symbol("MonsterNames")
|
||||||
local baseStats = self:symbol("BaseStats")
|
local baseStats = self:symbol("BaseStats")
|
||||||
local mewStats = self:symbol("MewBaseStats")
|
-- Red/Blue keep Mew outside BaseStats (pret pokered MewBaseStats).
|
||||||
|
-- Yellow stores Mew as dex 151 inside BaseStats (pret/pokeyellow).
|
||||||
|
local mewStats = self.symbols["MewBaseStats"] and self:symbol("MewBaseStats")
|
||||||
local decodedNames = {}
|
local decodedNames = {}
|
||||||
for index = 1, #speciesOrder do
|
for index = 1, #speciesOrder do
|
||||||
decodedNames[index] = self.rom:decodeText(
|
decodedNames[index] = self.rom:decodeText(
|
||||||
@@ -1067,7 +1098,7 @@ function RomExtractor:extractPokemon()
|
|||||||
local dex = assert(dexBySpecies[species],
|
local dex = assert(dexBySpecies[species],
|
||||||
"missing dex number for " .. species)
|
"missing dex number for " .. species)
|
||||||
local row
|
local row
|
||||||
if species == "MEW" then
|
if species == "MEW" and mewStats then
|
||||||
row = self.rom:bytes(mewStats.bank, mewStats.address, 28)
|
row = self.rom:bytes(mewStats.bank, mewStats.address, 28)
|
||||||
else
|
else
|
||||||
row = self.rom:bytes(
|
row = self.rom:bytes(
|
||||||
@@ -1148,6 +1179,13 @@ function RomExtractor:extractPokemon()
|
|||||||
completed = completed + 1
|
completed = completed + 1
|
||||||
self:tick("Pokemon", completed, #self.manifest.dexOrder + 10)
|
self:tick("Pokemon", completed, #self.manifest.dexOrder + 10)
|
||||||
end
|
end
|
||||||
|
-- Yellow-only third back pic: LoadPlayerBackPic (engine/battle/core.asm)
|
||||||
|
-- picks OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for
|
||||||
|
-- BATTLE_TYPE_PIKACHU, the Pallet Town catch scene (#557). Outside the
|
||||||
|
-- ticked loop so the progress denominator stays as it was.
|
||||||
|
if self.symbols["ProfOakPicBack"] then
|
||||||
|
self:writeCompressedPic("ProfOakPicBack", "battle/profoakb.png")
|
||||||
|
end
|
||||||
local balls = self:symbol("PokeballTileGraphics")
|
local balls = self:symbol("PokeballTileGraphics")
|
||||||
self:write2bpp(self.rom:bytes(balls.bank, balls.address, 64),
|
self:write2bpp(self.rom:bytes(balls.bank, balls.address, 64),
|
||||||
32, 8, "battle/balls.png", true)
|
32, 8, "battle/balls.png", true)
|
||||||
@@ -1276,6 +1314,16 @@ function RomExtractor:extractTrainers()
|
|||||||
}
|
}
|
||||||
self:tick("Trainers", index, #order)
|
self:tick("Trainers", index, #order)
|
||||||
end
|
end
|
||||||
|
-- Yellow's Jessie & James fight as OPP_ROCKET but carry their own pic
|
||||||
|
-- (home/trainers2.asm IsFightingJessieJames); only Yellow's manifest has
|
||||||
|
-- the symbol, so Red and Blue skip this (#439).
|
||||||
|
if self.symbols.JessieJamesPic then
|
||||||
|
local relative = "battle/trainers/jessie_james.png"
|
||||||
|
self:writeCompressedPic("JessieJamesPic", relative)
|
||||||
|
if out.OPP_ROCKET then
|
||||||
|
out.OPP_ROCKET.picJessieJames = "assets/generated/" .. relative
|
||||||
|
end
|
||||||
|
end
|
||||||
self:write("trainers", out)
|
self:write("trainers", out)
|
||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
@@ -1410,6 +1458,142 @@ function RomExtractor:extractText()
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function RomExtractor:extractYellowTitleArt()
|
||||||
|
-- pret/pokeyellow engine/movie/title_yellow.asm: the Yellow title is a
|
||||||
|
-- tilemap composition over BOTH tile banks. LoadYellowTitleScreenGFX
|
||||||
|
-- loads PokemonLogoGraphics into vChars2 (BG ids $00-$7F),
|
||||||
|
-- TitlePikachuBGGraphics into vChars1 (ids $80-$EF),
|
||||||
|
-- TitlePikachuOBGraphics at vChars1 tile $70 (ids $F0-$FC, also the eye
|
||||||
|
-- OAM tiles), and PokemonLogoCornerGraphics at vChars1 tile $7D (ids
|
||||||
|
-- $FD-$FF). Every tilemap mixes ids from several of those sheets, so a
|
||||||
|
-- single-sheet lookup shows checkerboard garbage where a foreign-bank id
|
||||||
|
-- lands (e.g. blank id $00 = logo tile 0, not Pikachu BG tile 0).
|
||||||
|
if not self.symbols["TitlePikachuBGGraphics"] then return end
|
||||||
|
-- raw sheets, kept for debugging / mod reference
|
||||||
|
self:raw2bpp("TitlePikachuBGGraphics", 128, 32,
|
||||||
|
"title/pikachu_bg.png", { transparent = true })
|
||||||
|
self:raw2bpp("TitlePikachuOBGraphics", 96, 8,
|
||||||
|
"title/pikachu_ob.png", { transparent = true })
|
||||||
|
|
||||||
|
-- Tile counts are the Graphics..GraphicsEnd symbol gaps in pokeyellow.sym.
|
||||||
|
local function sheetTiles(label, count, transparent)
|
||||||
|
local symbol = self:symbol(label)
|
||||||
|
local raw = self.rom:bytes(symbol.bank, symbol.address, count * 16)
|
||||||
|
local tiles = {}
|
||||||
|
for offset = 1, #raw, 16 do
|
||||||
|
local one = {}
|
||||||
|
for i = offset, offset + 15 do one[#one + 1] = raw[i] end
|
||||||
|
tiles[#tiles + 1] = ImageWriter.decode2bpp(one, 8, 8, transparent)
|
||||||
|
end
|
||||||
|
return tiles
|
||||||
|
end
|
||||||
|
local logo = sheetTiles("PokemonLogoGraphics", 115)
|
||||||
|
local corner = sheetTiles("PokemonLogoCornerGraphics", 3)
|
||||||
|
local bg = sheetTiles("TitlePikachuBGGraphics", 64)
|
||||||
|
local ob = sheetTiles("TitlePikachuOBGraphics", 12)
|
||||||
|
local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true)
|
||||||
|
local function tileFor(id)
|
||||||
|
if id < 0x80 then return logo[id + 1] end
|
||||||
|
if id < 0xF0 then return bg[id - 0x80 + 1] end
|
||||||
|
if id < 0xFD then return ob[id - 0xF0 + 1] end
|
||||||
|
return corner[id - 0xFD + 1]
|
||||||
|
end
|
||||||
|
-- OAM-style blit: color-0 pixels stay whatever the target already holds
|
||||||
|
-- (ImageWriter.blit copies alpha-0 pixels wholesale, which would punch
|
||||||
|
-- holes into the face under the eye sprites).
|
||||||
|
local function blitSprite(target, tile, tx, ty, flipX)
|
||||||
|
for y = 0, 7 do
|
||||||
|
for x = 0, 7 do
|
||||||
|
local sx = flipX and 7 - x or x
|
||||||
|
local r, g, b, a = tile:getPixel(sx, y)
|
||||||
|
if a ~= 0 then target:setPixel(tx + x, ty + y, r, g, b, a) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- cells = { {id, col, row}, ... }; untouched cells stay transparent
|
||||||
|
local function compose(cols, rows, cells)
|
||||||
|
local pose = ImageWriter.blank(cols * 8, rows * 8, 1, 1, 1, 0)
|
||||||
|
for _, cell in ipairs(cells) do
|
||||||
|
local tile = tileFor(cell[1])
|
||||||
|
if tile then ImageWriter.blit(pose, tile, cell[2] * 8, cell[3] * 8) end
|
||||||
|
end
|
||||||
|
return pose
|
||||||
|
end
|
||||||
|
local function mapCells(map, cols, rows)
|
||||||
|
local ids = self.rom:bytes(map.bank, map.address, cols * rows)
|
||||||
|
local cells = {}
|
||||||
|
for index, id in ipairs(ids) do
|
||||||
|
cells[#cells + 1] =
|
||||||
|
{ id, (index - 1) % cols, math.floor((index - 1) / cols) }
|
||||||
|
end
|
||||||
|
return cells
|
||||||
|
end
|
||||||
|
|
||||||
|
-- TitleScreen_PlacePokemonLogo: 16x7 box at (2,1). Yellow's logo sheet
|
||||||
|
-- is deduplicated (unlike Red's sequential rip), so the raw2bpp
|
||||||
|
-- pokemon_logo.png from extractField is scrambled; overwrite it with the
|
||||||
|
-- tilemap composition. Kept opaque: TitleState clears to white behind it.
|
||||||
|
self:save(compose(16, 7,
|
||||||
|
mapCells(self:symbol("TitleScreenPokemonLogoTilemap"), 16, 7)),
|
||||||
|
"title/pokemon_logo.png")
|
||||||
|
|
||||||
|
-- TitleScreen_PlacePikaSpeechBubble: 7x4 box at (6,4) plus the two tail
|
||||||
|
-- tiles $64/$65 the routine pokes at (9,8) -- one row below the box, over
|
||||||
|
-- blank cells of the Pikachu row. Composed 7x5 with the tail at (3,4);
|
||||||
|
-- matteColor0 clears the outside-the-balloon whites, the outline protects
|
||||||
|
-- the interior.
|
||||||
|
local bubbleCells = mapCells(
|
||||||
|
self:symbol("TitleScreenPikaBubbleTilemap"), 7, 4)
|
||||||
|
bubbleCells[#bubbleCells + 1] = { 0x64, 3, 4 }
|
||||||
|
bubbleCells[#bubbleCells + 1] = { 0x65, 4, 4 }
|
||||||
|
self:save(ImageWriter.matteColor0(compose(7, 5, bubbleCells)),
|
||||||
|
"title/pika_bubble.png")
|
||||||
|
|
||||||
|
-- TitleScreen_PlacePikachu: 12x9 box at (4,8) plus the right-ear edge
|
||||||
|
-- tiles it pokes down column 16 (rows 10-13) -- composed 13x9 with those
|
||||||
|
-- at relative column 12, rows 2-5. The open eyes are OAM
|
||||||
|
-- (TitleScreenPikachuEyesOAMData, copied at place time): OB tiles 0-3 at
|
||||||
|
-- screen (56,80)/(88,80) blocks, the left eye x-flipped (attr $22); baked
|
||||||
|
-- into the composition relative to the box origin px(32,64).
|
||||||
|
local pikaCells = mapCells(self:symbol("TitleScreenPikachuTilemap"), 12, 9)
|
||||||
|
pikaCells[#pikaCells + 1] = { 0x96, 12, 2 }
|
||||||
|
pikaCells[#pikaCells + 1] = { 0x9d, 12, 3 }
|
||||||
|
pikaCells[#pikaCells + 1] = { 0xa7, 12, 4 }
|
||||||
|
pikaCells[#pikaCells + 1] = { 0xb1, 12, 5 }
|
||||||
|
local pikachu = ImageWriter.matteColor0(compose(13, 9, pikaCells))
|
||||||
|
-- DoTitleScreenFunction's blink rewrites the eye OAM tile ids with
|
||||||
|
-- `and $f3 / or e` (e = 0 open / 4 half / 8 closed), so the OB sheet
|
||||||
|
-- holds three 4-tile eye sets. Bake the open set into pikachu.png and
|
||||||
|
-- save half/closed as standalone overlays for TitleState's blink.
|
||||||
|
local EYE_LAYOUT = {
|
||||||
|
{ 2, 24, 16, true }, { 1, 32, 16, true },
|
||||||
|
{ 4, 24, 24, true }, { 3, 32, 24, true },
|
||||||
|
{ 1, 56, 16 }, { 2, 64, 16 },
|
||||||
|
{ 3, 56, 24 }, { 4, 64, 24 },
|
||||||
|
}
|
||||||
|
-- Blink overlays for the (24,16)-(71,31) eye band: the BG face is
|
||||||
|
-- eyeless (the eyes are OAM), so each overlay = the blank-face crop
|
||||||
|
-- with the half (+4) / closed (+8) tile set composited color-0
|
||||||
|
-- transparent -- exactly what the hardware shows mid-blink.
|
||||||
|
local overlays = {}
|
||||||
|
for suffix, base in pairs({ eyes_half = 4, eyes_closed = 8 }) do
|
||||||
|
local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0)
|
||||||
|
ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16)
|
||||||
|
for _, e in ipairs(EYE_LAYOUT) do
|
||||||
|
blitSprite(overlay, obClear[base + e[1]], e[2] - 24, e[3] - 16, e[4])
|
||||||
|
end
|
||||||
|
overlays[suffix] = overlay
|
||||||
|
end
|
||||||
|
-- open eyes bake into pikachu.png AFTER the blank-face crops
|
||||||
|
for _, e in ipairs(EYE_LAYOUT) do
|
||||||
|
blitSprite(pikachu, obClear[e[1]], e[2], e[3], e[4])
|
||||||
|
end
|
||||||
|
self:save(pikachu, "title/pikachu.png")
|
||||||
|
for suffix, overlay in pairs(overlays) do
|
||||||
|
self:save(overlay, "title/" .. suffix .. ".png")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function RomExtractor:raw2bpp(label, width, height, relative, options)
|
function RomExtractor:raw2bpp(label, width, height, relative, options)
|
||||||
options = options or {}
|
options = options or {}
|
||||||
local expected = width * height / 4
|
local expected = width * height / 4
|
||||||
@@ -1454,6 +1638,8 @@ function RomExtractor:extractField()
|
|||||||
"title/copyright.png"); tick()
|
"title/copyright.png"); tick()
|
||||||
self:raw2bpp("GameFreakLogoGraphics", 72, 8,
|
self:raw2bpp("GameFreakLogoGraphics", 72, 8,
|
||||||
"title/gamefreak_inc.png"); tick()
|
"title/gamefreak_inc.png"); tick()
|
||||||
|
-- Yellow fixed Pikachu title art (no-op on Red/Blue manifests).
|
||||||
|
self:extractYellowTitleArt(); tick()
|
||||||
|
|
||||||
local fallingStar = self:raw2bpp(
|
local fallingStar = self:raw2bpp(
|
||||||
"FallingStar", 8, 8, "intro/falling_star.png",
|
"FallingStar", 8, 8, "intro/falling_star.png",
|
||||||
@@ -1502,35 +1688,71 @@ function RomExtractor:extractField()
|
|||||||
end
|
end
|
||||||
self:save(star, "intro/big_star.png"); tick()
|
self:save(star, "intro/big_star.png"); tick()
|
||||||
|
|
||||||
local gengar = self:symbol("FightIntroBackMon")
|
-- Yellow has no FightIntro Gengar/Nidorino fight (pret/pokeyellow
|
||||||
local gengarRaw = self.rom:bytes(
|
-- engine/movie/intro_yellow.asm); write blank placeholders so Title/
|
||||||
gengar.bank, gengar.address, 96 * 16)
|
-- Intro still find the expected paths. Red/Blue keep the tilemap rip.
|
||||||
local gengarTiles = {}
|
if self.symbols["FightIntroBackMon"] then
|
||||||
for offset = 1, #gengarRaw, 16 do
|
local gengar = self:symbol("FightIntroBackMon")
|
||||||
local raw = {}
|
local gengarRaw = self.rom:bytes(
|
||||||
for index = offset, offset + 15 do raw[#raw + 1] = gengarRaw[index] end
|
gengar.bank, gengar.address, 96 * 16)
|
||||||
gengarTiles[#gengarTiles + 1] = ImageWriter.decode2bpp(raw, 8, 8)
|
local gengarTiles = {}
|
||||||
end
|
for offset = 1, #gengarRaw, 16 do
|
||||||
for number = 1, 3 do
|
local raw = {}
|
||||||
local tilemap = self:symbol("GengarIntroTiles" .. number)
|
for index = offset, offset + 15 do raw[#raw + 1] = gengarRaw[index] end
|
||||||
local tileIds = self.rom:bytes(tilemap.bank, tilemap.address, 49)
|
gengarTiles[#gengarTiles + 1] = ImageWriter.decode2bpp(raw, 8, 8)
|
||||||
local pose = ImageWriter.blank(56, 56, 0, 0, 0, 0)
|
end
|
||||||
for index, tileId in ipairs(tileIds) do
|
for number = 1, 3 do
|
||||||
ImageWriter.blit(pose, gengarTiles[tileId + 1],
|
local tilemap = self:symbol("GengarIntroTiles" .. number)
|
||||||
(index - 1) % 7 * 8, math.floor((index - 1) / 7) * 8)
|
local tileIds = self.rom:bytes(tilemap.bank, tilemap.address, 49)
|
||||||
|
local pose = ImageWriter.blank(56, 56, 0, 0, 0, 0)
|
||||||
|
for index, tileId in ipairs(tileIds) do
|
||||||
|
ImageWriter.blit(pose, gengarTiles[tileId + 1],
|
||||||
|
(index - 1) % 7 * 8, math.floor((index - 1) / 7) * 8)
|
||||||
|
end
|
||||||
|
pose = ImageWriter.matteColor0(pose)
|
||||||
|
self:save(pose, "intro/gengar_" .. number .. ".png"); tick()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
for number = 1, 3 do
|
||||||
|
self:save(ImageWriter.blank(56, 56, 0, 0, 0, 0),
|
||||||
|
"intro/gengar_" .. number .. ".png"); tick()
|
||||||
end
|
end
|
||||||
pose = ImageWriter.matteColor0(pose)
|
|
||||||
self:save(pose, "intro/gengar_" .. number .. ".png"); tick()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
for number, label in ipairs({
|
if self.symbols["FightIntroFrontMon"] then
|
||||||
"FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3",
|
for number, label in ipairs({
|
||||||
}) do
|
"FightIntroFrontMon", "FightIntroFrontMon2", "FightIntroFrontMon3",
|
||||||
self:raw2bpp(label, 48, 48,
|
}) do
|
||||||
"intro/red_nidorino_" .. number .. ".png",
|
self:raw2bpp(label, 48, 48,
|
||||||
{ transparent = true, columns = true })
|
"intro/red_nidorino_" .. number .. ".png",
|
||||||
tick()
|
{ transparent = true, columns = true })
|
||||||
|
tick()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
for number = 1, 3 do
|
||||||
|
self:save(ImageWriter.blank(48, 48, 1, 1, 1, 0),
|
||||||
|
"intro/red_nidorino_" .. number .. ".png"); tick()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Optional Yellow-only intro atlas (pret/pokeyellow gfx/yellow_intro.asm).
|
||||||
|
if self.symbols["YellowIntroGraphics1"] then
|
||||||
|
self:raw2bpp("YellowIntroGraphics1", 128, 64,
|
||||||
|
"intro/yellow_intro_1.png")
|
||||||
|
end
|
||||||
|
if self.symbols["YellowIntroGraphics2"] then
|
||||||
|
-- atlas2 doubles as the intro's OBJ tile bank (vChars0); OBJ color 0
|
||||||
|
-- is hardware-transparent, and the BG draws it over a white clear so
|
||||||
|
-- BG cells lose nothing
|
||||||
|
self:raw2bpp("YellowIntroGraphics2", 128, 128,
|
||||||
|
"intro/yellow_intro_2.png", { transparent = true })
|
||||||
|
end
|
||||||
|
-- Yellow intro clouds (intro_yellow.asm YellowIntroCloudGFX): 8 tiles,
|
||||||
|
-- two 4-tile animation frames -- saved 32x16, one frame per row.
|
||||||
|
if self.symbols["YellowIntroCloudGFX"] then
|
||||||
|
self:raw2bpp("YellowIntroCloudGFX", 32, 16, "intro/clouds.png")
|
||||||
|
end
|
||||||
|
|
||||||
for number = 1, 2 do
|
for number = 1, 2 do
|
||||||
self:writeCompressedPic(
|
self:writeCompressedPic(
|
||||||
"ShrinkPic" .. number, "intro/shrink" .. number .. ".png")
|
"ShrinkPic" .. number, "intro/shrink" .. number .. ".png")
|
||||||
@@ -1563,10 +1785,27 @@ function RomExtractor:extractField()
|
|||||||
end
|
end
|
||||||
self:save(symbolSheet, "slots/symbols.png"); tick()
|
self:save(symbolSheet, "slots/symbols.png"); tick()
|
||||||
|
|
||||||
local emotes = ImageWriter.blank(48, 16, 1, 1, 1, 0)
|
-- Emote sheet layout comes from manifest.field.emotionBubbles so the
|
||||||
for index, label in ipairs({
|
-- versions can differ: Red ships the three shared bubbles, Yellow adds
|
||||||
"ShockEmote", "QuestionEmote", "HappyEmote",
|
-- the five Pikachu-only ones (emotion_bubbles.asm Skull/Heart/Bolt/
|
||||||
}) do
|
-- Zzz/FishEmote, used by the PikachuEmotionTable reactions).
|
||||||
|
local EMOTE_SYMBOLS = {
|
||||||
|
EXCLAMATION_BUBBLE = "ShockEmote", QUESTION_BUBBLE = "QuestionEmote",
|
||||||
|
SMILE_BUBBLE = "HappyEmote", SKULL_BUBBLE = "SkullEmote",
|
||||||
|
HEART_BUBBLE = "HeartEmote", BOLT_BUBBLE = "BoltEmote",
|
||||||
|
ZZZ_BUBBLE = "ZzzEmote", FISH_BUBBLE = "FishEmote",
|
||||||
|
}
|
||||||
|
local bubbleDefs = self.manifest.field.emotionBubbles
|
||||||
|
and self.manifest.field.emotionBubbles.bubbles
|
||||||
|
local emoteLabels = {}
|
||||||
|
for _, b in ipairs(bubbleDefs or {}) do
|
||||||
|
emoteLabels[#emoteLabels + 1] = EMOTE_SYMBOLS[b.name]
|
||||||
|
end
|
||||||
|
if #emoteLabels == 0 then
|
||||||
|
emoteLabels = { "ShockEmote", "QuestionEmote", "HappyEmote" }
|
||||||
|
end
|
||||||
|
local emotes = ImageWriter.blank(#emoteLabels * 16, 16, 1, 1, 1, 0)
|
||||||
|
for index, label in ipairs(emoteLabels) do
|
||||||
local symbol = self:symbol(label)
|
local symbol = self:symbol(label)
|
||||||
local image = ImageWriter.decode2bpp(
|
local image = ImageWriter.decode2bpp(
|
||||||
self.rom:bytes(symbol.bank, symbol.address, 64), 16, 16, true)
|
self.rom:bytes(symbol.bank, symbol.address, 64), 16, 16, true)
|
||||||
@@ -1574,6 +1813,55 @@ function RomExtractor:extractField()
|
|||||||
end
|
end
|
||||||
self:save(emotes, "emotes.png"); tick()
|
self:save(emotes, "emotes.png"); tick()
|
||||||
|
|
||||||
|
-- Yellow-only: the Surfing Pikachu minigame sheets
|
||||||
|
-- (gfx/surfing_pikachu.asm) at pret's canvas widths, so
|
||||||
|
-- src/ui/SurfingMinigame.lua's quads can be read off the source pngs.
|
||||||
|
-- 1a is the BG set (water/beach/score tiles, opaque); 1b the OAM pose
|
||||||
|
-- sheet and 1c the intro set (both color-0 transparent).
|
||||||
|
for _, spec in ipairs({
|
||||||
|
{ "SurfingPikachu1Graphics1", 65, 40, false, "minigame/surf_1a.png" },
|
||||||
|
{ "SurfingPikachu1Graphics2", 256, 128, true, "minigame/surf_1b.png" },
|
||||||
|
{ "SurfingPikachu1Graphics3", 144, 96, true, "minigame/surf_1c.png" },
|
||||||
|
}) do
|
||||||
|
if self.symbols[spec[1]] then
|
||||||
|
local symbol = self:symbol(spec[1])
|
||||||
|
local tilesPerRow = spec[3] / 8
|
||||||
|
local image = ImageWriter.decode2bpp(
|
||||||
|
self.rom:bytes(symbol.bank, symbol.address, spec[2] * 16),
|
||||||
|
spec[3], spec[2] / tilesPerRow * 8, spec[4])
|
||||||
|
self:save(image, spec[5])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per
|
||||||
|
-- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in
|
||||||
|
-- data/pikachu/pikachu_pic_animation.asm, the pic PikaPicAnimBGFrames_4
|
||||||
|
-- (PikaAnimTilemap_1, column order) paints. Scripts 18/22/23/24 have no
|
||||||
|
-- compressed base and take PikaPicAnimBGFrames_5 -> PikaAnimTilemap_9,
|
||||||
|
-- which is ROW order over a raw 25-tile sheet, hence no columns flag.
|
||||||
|
-- Script 26 shares script 11's base. The pikaframe overlays each script
|
||||||
|
-- draws ON TOP of the base are a second full pose out of the same blob and
|
||||||
|
-- stay unripped: PikachuFollower.picLift stands in for their motion
|
||||||
|
-- (#561, still on #407's stand-in).
|
||||||
|
local PIKAPIC_BASE = {
|
||||||
|
"Pic_e4000", "Pic_e411c", "Pic_e4272", "Pic_e4383", "Pic_e458b",
|
||||||
|
"Pic_e467b", "Pic_e476e", "Pic_e49d1", "Pic_e4b39", "Pic_e4c3e",
|
||||||
|
"Pic_e5000", "Pic_e523f", "Pic_e548e", "Pic_e56d1", "Pic_e5924",
|
||||||
|
"Pic_e5b7d", "Pic_e5ddd", "GFX_e6020", "Pic_e6340", "Pic_e6587",
|
||||||
|
"Pic_e67d6", "GFX_e6e6f", "GFX_e718f", "GFX_e74af", "Pic_e77cf",
|
||||||
|
"Pic_e5000", "Pic_f0abf", "Pic_f0cf4",
|
||||||
|
}
|
||||||
|
if self.symbols[PIKAPIC_BASE[1]] then
|
||||||
|
for script, label in ipairs(PIKAPIC_BASE) do
|
||||||
|
local path = "pikachu/pikapic_" .. script .. ".png"
|
||||||
|
if label:sub(1, 4) == "GFX_" then
|
||||||
|
self:raw2bpp(label, 40, 40, path, { matte = true })
|
||||||
|
else
|
||||||
|
self:writeCompressedPic(label, path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
self:raw1bpp("LedgeHoppingShadow", 8, 8,
|
self:raw1bpp("LedgeHoppingShadow", 8, 8,
|
||||||
"fx/shadow.png", true); tick()
|
"fx/shadow.png", true); tick()
|
||||||
for _, spec in ipairs({
|
for _, spec in ipairs({
|
||||||
@@ -1664,7 +1952,9 @@ end
|
|||||||
function RomExtractor:extractAudio()
|
function RomExtractor:extractAudio()
|
||||||
self:beginStage("Sound programs")
|
self:beginStage("Sound programs")
|
||||||
local metadata = copy(self.manifest.audio)
|
local metadata = copy(self.manifest.audio)
|
||||||
local bankOrder = { 2, 8, 31 }
|
-- Yellow adds a fourth music bank ($20: Jessie & James, Surfing
|
||||||
|
-- Pikachu, GB Printer); the manifest names the pack when it needs it.
|
||||||
|
local bankOrder = metadata.programBanks or { 2, 8, 31 }
|
||||||
local chunks = {}
|
local chunks = {}
|
||||||
for index, bank in ipairs(bankOrder) do
|
for index, bank in ipairs(bankOrder) do
|
||||||
local first = Rom.offset(bank, 0x4000) + 1
|
local first = Rom.offset(bank, 0x4000) + 1
|
||||||
@@ -1683,6 +1973,7 @@ function RomExtractor:extractAudio()
|
|||||||
for name, header in pairs(metadata.musicHeaders) do
|
for name, header in pairs(metadata.musicHeaders) do
|
||||||
songs[name] = header
|
songs[name] = header
|
||||||
end
|
end
|
||||||
|
metadata.pikaCries = self:extractPikachuCries()
|
||||||
local cries = {}
|
local cries = {}
|
||||||
local cryData = metadata.cryData
|
local cryData = metadata.cryData
|
||||||
for index, species in ipairs(self.manifest.constants.speciesOrder) do
|
for index, species in ipairs(self.manifest.constants.speciesOrder) do
|
||||||
@@ -1709,6 +2000,57 @@ function RomExtractor:extractAudio()
|
|||||||
return metadata
|
return metadata
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Yellow's voiced Pikachu clips (audio/pikachu_cries_pointers.asm
|
||||||
|
-- PikachuCriesPointerTable, 42 `dba` rows; each clip is `dw length` then
|
||||||
|
-- 1-bit PCM, MSB first -- home/pikachu_cries.asm PlayPikachuPCM toggles
|
||||||
|
-- rAUD3LEVEL per bit at roughly 190 CPU cycles a sample). Decoded to
|
||||||
|
-- plain 8-bit mono WAVs; returns the clip count for data.audio.pikaCries,
|
||||||
|
-- or nil when the manifest has no pointer table (Red/Blue).
|
||||||
|
function RomExtractor:extractPikachuCries()
|
||||||
|
if not self.symbols["PikachuCriesPointerTable"] then return nil end
|
||||||
|
local NUM = 42 -- NUM_PIKA_CRIES
|
||||||
|
local RATE = 22050 -- ~4.19 MHz / ~190 cycles per sample
|
||||||
|
-- byte -> 8 samples, MSB first (LoadNextSoundClipSample: `and $80`)
|
||||||
|
local lut = {}
|
||||||
|
for byte = 0, 255 do
|
||||||
|
local out = {}
|
||||||
|
for bit = 7, 0, -1 do
|
||||||
|
local on = math.floor(byte / 2 ^ bit) % 2 == 1
|
||||||
|
out[#out + 1] = string.char(on and 0xE0 or 0x20)
|
||||||
|
end
|
||||||
|
lut[byte] = table.concat(out)
|
||||||
|
end
|
||||||
|
local function u16(v)
|
||||||
|
return string.char(v % 256, math.floor(v / 256) % 256)
|
||||||
|
end
|
||||||
|
local function u32(v)
|
||||||
|
return string.char(v % 256, math.floor(v / 256) % 256,
|
||||||
|
math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256)
|
||||||
|
end
|
||||||
|
local CacheFs = require("src.import.CacheFs")
|
||||||
|
local pointers = self:symbol("PikachuCriesPointerTable")
|
||||||
|
for index = 0, NUM - 1 do
|
||||||
|
local row = self.rom:bytes(pointers.bank, pointers.address + index * 3, 3)
|
||||||
|
local bank, address = row[1], row[2] + row[3] * 256
|
||||||
|
local header = self.rom:bytes(bank, address, 2)
|
||||||
|
local length = header[1] + header[2] * 256
|
||||||
|
local raw = self.rom:bytes(bank, address + 2, length)
|
||||||
|
local samples = {}
|
||||||
|
for i, byte in ipairs(raw) do samples[i] = lut[byte] end
|
||||||
|
local pcm = table.concat(samples)
|
||||||
|
local wav = "RIFF" .. u32(36 + #pcm) .. "WAVEfmt " .. u32(16)
|
||||||
|
.. u16(1) .. u16(1) .. u32(RATE) .. u32(RATE) .. u16(1) .. u16(8)
|
||||||
|
.. "data" .. u32(#pcm) .. pcm
|
||||||
|
local ok, err = CacheFs.write(
|
||||||
|
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(index + 1),
|
||||||
|
wav)
|
||||||
|
if not ok then
|
||||||
|
error("could not write pika cry " .. (index + 1) .. ": " .. tostring(err))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return NUM
|
||||||
|
end
|
||||||
|
|
||||||
function RomExtractor:run()
|
function RomExtractor:run()
|
||||||
local results = {}
|
local results = {}
|
||||||
results.constants = self:extractConstants()
|
results.constants = self:extractConstants()
|
||||||
|
|||||||
+2471
-281
File diff suppressed because it is too large
Load Diff
@@ -75,7 +75,10 @@ function SaveFileIO.importToSlot(source, version)
|
|||||||
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
|
return false, ("A save file must be %d bytes (32 KB); this one is %d.")
|
||||||
:format(SAVE_SIZE, #bytes)
|
:format(SAVE_SIZE, #bytes)
|
||||||
end
|
end
|
||||||
local save, convertErr = SaveConvert.importSav(bytes, version)
|
-- 3rd arg: the crosswalk has to come from THIS game's ROM cache. The
|
||||||
|
-- launcher imports before the cache is mounted on the un-prefixed paths, so
|
||||||
|
-- SaveConvert cannot find the generated tables by itself here (#420).
|
||||||
|
local save, convertErr = SaveConvert.importSav(bytes, version, version)
|
||||||
if not save then return false, convertErr end
|
if not save then return false, convertErr end
|
||||||
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
|
-- Tag the game version and normalize the meta stamp: SaveConvert leaves
|
||||||
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
|
-- meta.format = "gen1_import", but SaveData.load's migration pass compares
|
||||||
@@ -103,7 +106,7 @@ function SaveFileIO.exportActiveSlot(version)
|
|||||||
version = version or GameVersion.get()
|
version = version or GameVersion.get()
|
||||||
local save = SaveData.load(version)
|
local save = SaveData.load(version)
|
||||||
if not save then return false, "this game has no save to export yet" end
|
if not save then return false, "this game has no save to export yet" end
|
||||||
local bytes, exportErr = SaveConvert.exportSav(save)
|
local bytes, exportErr = SaveConvert.exportSav(save, version)
|
||||||
if not bytes then return false, exportErr end
|
if not bytes then return false, exportErr end
|
||||||
local slotId = SaveData.activeSlot(version) or "save"
|
local slotId = SaveData.activeSlot(version) or "save"
|
||||||
local fs = love and love.filesystem
|
local fs = love and love.filesystem
|
||||||
|
|||||||
+23
-7
@@ -1,11 +1,26 @@
|
|||||||
-- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm):
|
-- The bag defaults to 20 slots (BAG_ITEM_CAPACITY,
|
||||||
-- a distinct item id occupies one slot regardless of quantity; badges
|
-- constants/menu_constants.asm), but mods may replace that limit through
|
||||||
-- live in the inventory table but are not bag items. save.bagOrder
|
-- Data.constants.bagSize. A distinct item id occupies one slot regardless
|
||||||
-- keeps acquisition order like wBagItems (SELECT can reorder it).
|
-- of quantity; badges live in the inventory table but are not bag items.
|
||||||
|
-- save.bagOrder keeps acquisition order like wBagItems (SELECT can reorder
|
||||||
|
-- it).
|
||||||
|
|
||||||
local Bag = {}
|
local Bag = {}
|
||||||
|
|
||||||
Bag.CAPACITY = 20
|
local DEFAULT_CAPACITY = 20
|
||||||
|
|
||||||
|
-- `data` is injectable for the save editor and headless mod tests. Normal
|
||||||
|
-- gameplay may omit it because the loader merges mods into the Data
|
||||||
|
-- singleton before any item can be added. The fallback keeps old/stale
|
||||||
|
-- generated caches and isolated callers at the vanilla limit.
|
||||||
|
function Bag.capacity(data)
|
||||||
|
data = data or require("src.core.Data")
|
||||||
|
local configured = data and data.constants and data.constants.bagSize
|
||||||
|
if type(configured) == "number" and configured >= 1 then
|
||||||
|
return math.floor(configured)
|
||||||
|
end
|
||||||
|
return DEFAULT_CAPACITY
|
||||||
|
end
|
||||||
|
|
||||||
local function isBadge(id)
|
local function isBadge(id)
|
||||||
return id:find("BADGE", 1, true) ~= nil
|
return id:find("BADGE", 1, true) ~= nil
|
||||||
@@ -55,9 +70,10 @@ end
|
|||||||
-- Add qty of an item; returns false (and adds nothing) when a new slot
|
-- Add qty of an item; returns false (and adds nothing) when a new slot
|
||||||
-- is needed and the bag is full, or when the stack would pass 99
|
-- is needed and the bag is full, or when the stack would pass 99
|
||||||
-- (AddItemToInventory's per-slot quantity cap).
|
-- (AddItemToInventory's per-slot quantity cap).
|
||||||
function Bag.add(save, id, qty)
|
function Bag.add(save, id, qty, data)
|
||||||
local inv = save.inventory
|
local inv = save.inventory
|
||||||
if not inv[id] and not isBadge(id) and Bag.slots(save) >= Bag.CAPACITY then
|
if not inv[id] and not isBadge(id)
|
||||||
|
and Bag.slots(save) >= Bag.capacity(data) then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
|
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
-- "ball" caller must throw it (battle only)
|
-- "ball" caller must throw it (battle only)
|
||||||
-- "learn", moveId caller must run the learn-move flow
|
-- "learn", moveId caller must run the learn-move flow
|
||||||
|
|
||||||
local Pokemon = require("src.pokemon.Pokemon")
|
|
||||||
local Flags = require("src.script.Flags")
|
local Flags = require("src.script.Flags")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
@@ -183,6 +182,12 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||||
end
|
end
|
||||||
local b = battle.player
|
local b = battle.player
|
||||||
|
-- PIKAHAPPY_USEDXITEM (item_effects.asm ItemUseXAccuracy /
|
||||||
|
-- GuardSpec / DireHit / XStat) on the active companion
|
||||||
|
if itemId ~= "POKE_DOLL" then
|
||||||
|
require("src.world.PikachuFollower")
|
||||||
|
.modifyHappiness(save, "USEDXITEM", b and b.mon)
|
||||||
|
end
|
||||||
if itemId == "X_ACCURACY" then
|
if itemId == "X_ACCURACY" then
|
||||||
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
|
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
|
||||||
-- (not an accuracy stage)
|
-- (not an accuracy stage)
|
||||||
@@ -253,6 +258,18 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
|
return "consumed", { Strings("%s's PP\nwas restored!", monName(data, target)) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- PIKAHAPPY_USEDITEM (item_effects.asm ItemUseMedicine, item id up to
|
||||||
|
-- CALCIUM): fires once a medicine has a target, before the effect
|
||||||
|
-- resolves -- potions, status cures, revives and vitamins all count,
|
||||||
|
-- RARE_CANDY does not (its success is a LEVELUP bump instead)
|
||||||
|
if target and (HEAL_AMOUNT[itemId] or STATUS_HEAL[itemId]
|
||||||
|
or itemId == "MAX_POTION" or itemId == "FULL_RESTORE"
|
||||||
|
or itemId == "REVIVE" or itemId == "MAX_REVIVE"
|
||||||
|
or VITAMINS[itemId]) then
|
||||||
|
require("src.world.PikachuFollower")
|
||||||
|
.modifyHappiness(save, "USEDITEM", target)
|
||||||
|
end
|
||||||
|
|
||||||
local heal = HEAL_AMOUNT[itemId]
|
local heal = HEAL_AMOUNT[itemId]
|
||||||
if heal or itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
|
if heal or itemId == "MAX_POTION" or itemId == "FULL_RESTORE" then
|
||||||
-- a FULL RESTORE on a statused mon already at full HP acts as a
|
-- a FULL RESTORE on a statused mon already at full HP acts as a
|
||||||
@@ -323,12 +340,29 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
local old = target.stats
|
local old = target.stats
|
||||||
target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp)
|
target.stats = Stats.calc(speciesDef, target.level, target.dvs, target.statExp)
|
||||||
target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp))
|
target.hp = math.min(target.stats.hp, target.hp + (target.stats.hp - old.hp))
|
||||||
|
-- PIKAHAPPY_LEVELUP on a candy level (item_effects.asm:1540)
|
||||||
|
require("src.world.PikachuFollower")
|
||||||
|
.modifyHappiness(save, "LEVELUP", target)
|
||||||
return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) },
|
return "consumed", { Strings("%s grew\nto level %d!", monName(data, target), target.level) },
|
||||||
{ leveledTo = target.level }
|
{ leveledTo = target.level }
|
||||||
end
|
end
|
||||||
|
|
||||||
if STONES[itemId] then
|
if STONES[itemId] then
|
||||||
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
if not target then return "failed", { Strings("It won't have\nany effect.") } end
|
||||||
|
-- Yellow's starter Pikachu never evolves: ItemUseEvoStone runs
|
||||||
|
-- IsThisPartyMonStarterPikachu (OT identity match) before
|
||||||
|
-- TryEvolvingMon and bails with the voiced cry + RefusingText.
|
||||||
|
-- The stone is NOT consumed on the refuse path.
|
||||||
|
if target.species == "PIKACHU"
|
||||||
|
and require("src.core.GameVersion").isYellow()
|
||||||
|
and target.ot == save.player.name
|
||||||
|
and target.otId == save.player.id then
|
||||||
|
require("src.core.Sound").playCry(data, "PIKACHU")
|
||||||
|
local raw = data.text and data.text._RefusingText
|
||||||
|
local line = raw and raw:gsub("{RAM:[^}]*}", monName(data, target))
|
||||||
|
or Strings("%s\nis refusing!", monName(data, target))
|
||||||
|
return "failed", { line }
|
||||||
|
end
|
||||||
local speciesDef = data.pokemon[target.species]
|
local speciesDef = data.pokemon[target.species]
|
||||||
for _, evo in ipairs(speciesDef.evolutions) do
|
for _, evo in ipairs(speciesDef.evolutions) do
|
||||||
if evo.method == "ITEM" and evo.item == itemId then
|
if evo.method == "ITEM" and evo.item == itemId then
|
||||||
@@ -398,6 +432,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
if battle then
|
if battle then
|
||||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||||
end
|
end
|
||||||
|
-- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2
|
||||||
|
-- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime
|
||||||
|
-- on that carry -- surfing refuses the rod with the same OAK text as
|
||||||
|
-- the mid-battle case above, no rod-specific message (#533)
|
||||||
|
if ow and ow.player and ow.player.surfing then
|
||||||
|
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||||
|
end
|
||||||
return "fish", itemId
|
return "fish", itemId
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,14 @@ end
|
|||||||
|
|
||||||
-- ------- the link surface
|
-- ------- the link surface
|
||||||
|
|
||||||
local SPECIES_FIELDS = { "baseStats", "types", "catchRate", "baseExp",
|
-- catchRate stays out (#511): no link mode ever reads it -- a ball thrown
|
||||||
|
-- in a link battle is a trainer-battle throw and always refused (pokered
|
||||||
|
-- engine/items/item_effects.asm ItemUseBall), and the trade rebuild never
|
||||||
|
-- touches it. Hashing it split Red/Blue from Yellow, whose only link
|
||||||
|
-- surface delta is the Dragonair/Dragonite catch-rate bytes
|
||||||
|
-- (data/pokemon/base_stats/dragonair.asm db 45 vs 27, dragonite.asm 45
|
||||||
|
-- vs 9), when the real cable links R/B/Y freely.
|
||||||
|
local SPECIES_FIELDS = { "baseStats", "types", "baseExp",
|
||||||
"growthRate", "evolutions" }
|
"growthRate", "evolutions" }
|
||||||
local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category",
|
local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category",
|
||||||
"priority", "highCrit", "fixedDamage", "multiHit",
|
"priority", "highCrit", "fixedDamage", "multiHit",
|
||||||
|
|||||||
+15
-12
@@ -178,19 +178,16 @@ end
|
|||||||
|
|
||||||
-- ------- subset negotiation
|
-- ------- subset negotiation
|
||||||
|
|
||||||
-- the species and moves this party actually references, so the exchange
|
-- every record hash, not just this party's slice: the receiver filters
|
||||||
-- stays small (six mons) instead of shipping the whole catalog
|
-- its OWN party against these (eligibleParty), so a message limited to the
|
||||||
|
-- sender's party read "not on the other game" for every species the two
|
||||||
|
-- parties did not happen to share, and a subset trade between different
|
||||||
|
-- games showed neither side (#511). The full catalog is ~300 short hash
|
||||||
|
-- strings -- still one message.
|
||||||
function Protocol.recordsMessage(data, party)
|
function Protocol.recordsMessage(data, party)
|
||||||
local species = Fingerprint.records(data, "pokemon")
|
return { type = "records",
|
||||||
local moves = Fingerprint.records(data, "moves")
|
pokemon = Fingerprint.records(data, "pokemon"),
|
||||||
local outSpecies, outMoves = {}, {}
|
moves = Fingerprint.records(data, "moves") }
|
||||||
for _, mon in ipairs(party or {}) do
|
|
||||||
if species[mon.species] then outSpecies[mon.species] = species[mon.species] end
|
|
||||||
for _, mv in ipairs(mon.moves or {}) do
|
|
||||||
if moves[mv.id] then outMoves[mv.id] = moves[mv.id] end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
return { type = "records", pokemon = outSpecies, moves = outMoves }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- a mon may cross the wire only if both peers rebuild it identically: the
|
-- a mon may cross the wire only if both peers rebuild it identically: the
|
||||||
@@ -382,6 +379,12 @@ function TradeSession:apply(game)
|
|||||||
Runtime.emit("pokemon.received",
|
Runtime.emit("pokemon.received",
|
||||||
{ mon = received, from = "link", peerName = self.peerName })
|
{ mon = received, from = "link", peerName = self.peerName })
|
||||||
self.party[self.myPick] = received
|
self.party[self.myPick] = received
|
||||||
|
-- PIKAHAPPY_TRADE (engine/link/cable_club.asm:801): trading the
|
||||||
|
-- companion away is the biggest happiness hit and zeroes the mood
|
||||||
|
if game and sent then
|
||||||
|
require("src.world.PikachuFollower")
|
||||||
|
.modifyHappiness(game.save, "TRADE", sent)
|
||||||
|
end
|
||||||
if game and game.save.pokedex then
|
if game and game.save.pokedex then
|
||||||
game.save.pokedex.seen[received.species] = true
|
game.save.pokedex.seen[received.species] = true
|
||||||
game.save.pokedex.owned[received.species] = true
|
game.save.pokedex.owned[received.species] = true
|
||||||
|
|||||||
+10
-3
@@ -384,9 +384,16 @@ function Tournament:update(dt)
|
|||||||
else
|
else
|
||||||
self.pendingBattleOpts.seed = msg.seed
|
self.pendingBattleOpts.seed = msg.seed
|
||||||
end
|
end
|
||||||
local battle, why = self.isHost
|
-- Split rather than `cond and newHost() or newGuest()`: the and/or
|
||||||
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
-- idiom truncates a call to its first result, so the second return
|
||||||
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
-- (the specific reason) was always dropped and every failure showed
|
||||||
|
-- the generic fallback instead of "same mods on both games" etc.
|
||||||
|
local battle, why
|
||||||
|
if self.isHost then
|
||||||
|
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||||
|
else
|
||||||
|
battle, why = LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
||||||
|
end
|
||||||
if not battle then
|
if not battle then
|
||||||
self:exitWith(why or Strings("Link battle\ncan't start."))
|
self:exitWith(why or Strings("Link battle\ncan't start."))
|
||||||
return
|
return
|
||||||
|
|||||||
+258
-15
@@ -16,9 +16,19 @@
|
|||||||
-- fused build), which is why those mods still loaded while landing in the
|
-- fused build), which is why those mods still loaded while landing in the
|
||||||
-- wrong place.
|
-- wrong place.
|
||||||
--
|
--
|
||||||
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
|
-- The same split decides where a mod is FOUND, and that has a sharp edge: a
|
||||||
-- no filesystem, so the engine tier can table-drive it; the discovery,
|
-- non-portable install never reads the game folder at all, so a mod unzipped
|
||||||
-- install, and uninstall paths reach for love.filesystem and SaveData.
|
-- next to the executable -- where most games would want it -- is not wrong so
|
||||||
|
-- much as invisible, with an empty panel and no error to explain it.
|
||||||
|
-- adoptStrays looks in those folders anyway (a scoped mount that comes down
|
||||||
|
-- again, CacheFs.withMounted) and copies what it finds into the tree the game
|
||||||
|
-- really reads, so the mistake costs a line of notice rather than a support
|
||||||
|
-- thread.
|
||||||
|
--
|
||||||
|
-- Split in two: the pure derivation (deriveList, locateRoot, pickStrays) has
|
||||||
|
-- no love and no filesystem, so the engine tier can table-drive it; the
|
||||||
|
-- discovery, install, uninstall, and stray-scan paths reach for
|
||||||
|
-- love.filesystem and SaveData.
|
||||||
|
|
||||||
local Manifest = require("src.mods.Manifest")
|
local Manifest = require("src.mods.Manifest")
|
||||||
local ManagerState = require("src.mods.ManagerState")
|
local ManagerState = require("src.mods.ManagerState")
|
||||||
@@ -85,8 +95,15 @@ function LauncherMods.deriveList(manifests, options)
|
|||||||
local byId, enabledSet = {}, {}
|
local byId, enabledSet = {}, {}
|
||||||
for _, m in ipairs(ordered) do
|
for _, m in ipairs(ordered) do
|
||||||
byId[m.id] = m
|
byId[m.id] = m
|
||||||
-- missing entry means enabled, matching the loader's default
|
-- missing entry means enabled, matching the loader -- except experimental
|
||||||
if mods[m.id] ~= false then enabledSet[m.id] = true end
|
-- mods, which stay off until the player opts in
|
||||||
|
if mods[m.id] == false then
|
||||||
|
-- stay off
|
||||||
|
elseif mods[m.id] == true then
|
||||||
|
enabledSet[m.id] = true
|
||||||
|
elseif not m.experimental then
|
||||||
|
enabledSet[m.id] = true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local out = {}
|
local out = {}
|
||||||
@@ -94,16 +111,19 @@ function LauncherMods.deriveList(manifests, options)
|
|||||||
local enabled = enabledSet[m.id] == true
|
local enabled = enabledSet[m.id] == true
|
||||||
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
|
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
|
||||||
local raw = m.raw or {}
|
local raw = m.raw or {}
|
||||||
|
local badge = tostring(raw.category or m.profile or "MOD"):upper()
|
||||||
|
if m.experimental then badge = "EXPERIMENTAL" end
|
||||||
out[#out + 1] = {
|
out[#out + 1] = {
|
||||||
id = m.id,
|
id = m.id,
|
||||||
name = m.name or m.id,
|
name = m.name or m.id,
|
||||||
version = m.version,
|
version = m.version,
|
||||||
-- category, then profile, then a generic fallback -- uppercased
|
badge = badge,
|
||||||
badge = tostring(raw.category or m.profile or "MOD"):upper(),
|
|
||||||
description = m.description or "",
|
description = m.description or "",
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
status = status,
|
status = status,
|
||||||
statusDetail = detail,
|
statusDetail = detail,
|
||||||
|
github = m.github,
|
||||||
|
experimental = m.experimental == true,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
return out
|
return out
|
||||||
@@ -136,6 +156,43 @@ function LauncherMods.locateRoot(paths)
|
|||||||
return nil, "no manifest.json found in the .zip"
|
return nil, "no manifest.json found in the .zip"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- pickStrays(found, installed) -> the rows worth adopting, pure.
|
||||||
|
-- found is an array of { id, name, folder, path } in scan order (game folder
|
||||||
|
-- order, then directory order); installed is the id -> true set of what the
|
||||||
|
-- game can already see. An installed id is dropped -- the player has a
|
||||||
|
-- working copy and the loose folder is just where they first put it -- and a
|
||||||
|
-- duplicate id across two game folders keeps the first, the same first-wins
|
||||||
|
-- rule discover() uses. Sorted by id so the notice reads the same every time.
|
||||||
|
function LauncherMods.pickStrays(found, installed)
|
||||||
|
installed = installed or {}
|
||||||
|
local out, seen = {}, {}
|
||||||
|
for _, row in ipairs(found or {}) do
|
||||||
|
local id = row.id
|
||||||
|
if id and not installed[id] and not seen[id] then
|
||||||
|
seen[id] = true
|
||||||
|
out[#out + 1] = { id = id, name = row.name or id,
|
||||||
|
folder = row.folder, path = row.path }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(out, function(a, b) return a.id < b.id end)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- isReadableRoot(folder, source, cacheRoot) -> is folder already on the
|
||||||
|
-- physfs read path, pure. source is love.filesystem.getSource(), cacheRoot
|
||||||
|
-- the mounted portable game folder (CacheFs.root()); a fused build has both,
|
||||||
|
-- and they are different paths (the archive inside the executable vs the
|
||||||
|
-- folder beside it). Either one's mods/ is readable already, so nothing in
|
||||||
|
-- it is a stray -- and the portable folder must additionally never be handed
|
||||||
|
-- to CacheFs.withMounted: PHYSFS_mount reports success for a directory
|
||||||
|
-- already in the search path WITHOUT adding a second entry, so the paired
|
||||||
|
-- unmount tears down the one real mount and the panel loses every mod in the
|
||||||
|
-- game folder (#413).
|
||||||
|
function LauncherMods.isReadableRoot(folder, source, cacheRoot)
|
||||||
|
if not folder or folder == "" then return false end
|
||||||
|
return folder == source or folder == cacheRoot
|
||||||
|
end
|
||||||
|
|
||||||
-- ------- discovery (love.filesystem)
|
-- ------- discovery (love.filesystem)
|
||||||
|
|
||||||
local function decodeManifest(raw, path)
|
local function decodeManifest(raw, path)
|
||||||
@@ -166,7 +223,9 @@ local function discover()
|
|||||||
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
||||||
local path = "mods/" .. name
|
local path = "mods/" .. name
|
||||||
local info = fs.getInfo(path)
|
local info = fs.getInfo(path)
|
||||||
if info and info.type == "directory" then
|
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
|
||||||
|
-- setSymlinksEnabled(true); see the matching note in Loader:_discover.
|
||||||
|
if info and (info.type == "directory" or info.type == "symlink") then
|
||||||
local raw = fs.read(path .. "/manifest.json")
|
local raw = fs.read(path .. "/manifest.json")
|
||||||
if raw then
|
if raw then
|
||||||
local manifest = decodeManifest(raw, path)
|
local manifest = decodeManifest(raw, path)
|
||||||
@@ -184,8 +243,15 @@ end
|
|||||||
-- options.mods enable-state the loader persists, so a toggle here is what the
|
-- options.mods enable-state the loader persists, so a toggle here is what the
|
||||||
-- game sees on its next boot.
|
-- game sees on its next boot.
|
||||||
function LauncherMods.list()
|
function LauncherMods.list()
|
||||||
local options = SaveData.loadOptions()
|
local ok, result = pcall(function()
|
||||||
return LauncherMods.deriveList(discover(), options)
|
local options = SaveData.loadOptions()
|
||||||
|
return LauncherMods.deriveList(discover(), options)
|
||||||
|
end)
|
||||||
|
if not ok then
|
||||||
|
-- a single bad options/mod file must not blank the launcher
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
return result or {}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
|
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
|
||||||
@@ -301,13 +367,128 @@ local function removeTree(path)
|
|||||||
fs.remove(path)
|
fs.remove(path)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- installZip(source) -> true, id | nil, errString
|
-- ------- strays: mods dropped beside the game that it cannot see
|
||||||
|
|
||||||
|
-- love.filesystem looks in two places for "mods/": the save directory, and --
|
||||||
|
-- portable installs only -- the game folder, which CacheFs mounts. A player
|
||||||
|
-- who unzips a mod next to the executable of an ordinary install, which is
|
||||||
|
-- where very nearly every other game would want it, gets no error and no mod.
|
||||||
|
-- The MODS panel simply stays empty, and there is nothing on screen to
|
||||||
|
-- suggest the files are twenty centimetres away in the wrong folder.
|
||||||
|
--
|
||||||
|
-- The scan mounts each game folder at a private mount point just long enough
|
||||||
|
-- to list mods/ inside it and drops it again (CacheFs.withMounted), so the
|
||||||
|
-- read path the game actually runs on is never touched and a stray can never
|
||||||
|
-- shadow a real file.
|
||||||
|
local STRAY_MOUNT = "stray_scan"
|
||||||
|
|
||||||
|
-- Run fn(mountedModsRoot) for each game folder that has a readable mods/
|
||||||
|
-- directory, one mount at a time. Folders already on the read path are
|
||||||
|
-- skipped (isReadableRoot): the physfs source, which is every `love <gamedir>`
|
||||||
|
-- dev run, and the portable game folder CacheFs mounted, where re-mounting is
|
||||||
|
-- what used to drop the mount (#413).
|
||||||
|
local function eachStrayRoot(fn)
|
||||||
|
local SaveData_ = require("src.core.SaveData")
|
||||||
|
local fs = love and love.filesystem
|
||||||
|
if not fs then return end
|
||||||
|
local source = fs.getSource and fs.getSource()
|
||||||
|
local cacheRoot = CacheFs.root()
|
||||||
|
local seen = {}
|
||||||
|
for _, folder in ipairs(SaveData_.gameFolders() or {}) do
|
||||||
|
if not seen[folder]
|
||||||
|
and not LauncherMods.isReadableRoot(folder, source, cacheRoot) then
|
||||||
|
seen[folder] = true
|
||||||
|
CacheFs.withMounted(folder, STRAY_MOUNT, function()
|
||||||
|
local root = STRAY_MOUNT .. "/mods"
|
||||||
|
if fs.getInfo(root) then fn(root, folder) end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Every valid mod folder sitting in a game folder's mods/, in scan order.
|
||||||
|
-- Only reads. The rows carry the mounted path, which is live for the length
|
||||||
|
-- of the mount and dead after it -- copying has to happen inside the same
|
||||||
|
-- scan, which is why adoption is a flag here rather than a second pass.
|
||||||
|
local function findStrays(fs, adopt, installed)
|
||||||
|
local found, adopted = {}, {}
|
||||||
|
eachStrayRoot(function(root, folder)
|
||||||
|
local batch = {}
|
||||||
|
for _, name in ipairs(fs.getDirectoryItems(root)) do
|
||||||
|
local path = root .. "/" .. name
|
||||||
|
local info = fs.getInfo(path)
|
||||||
|
if info and info.type == "directory" then
|
||||||
|
local raw = fs.read(path .. "/manifest.json")
|
||||||
|
local manifest = raw and decodeManifest(raw, path)
|
||||||
|
if manifest then
|
||||||
|
batch[#batch + 1] = { id = manifest.id,
|
||||||
|
name = manifest.name or manifest.id,
|
||||||
|
folder = folder, path = path }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- filtered per mount, so a copy only ever runs for a row that survived
|
||||||
|
-- the pure rules -- and so the second game folder sees the first one's
|
||||||
|
-- ids as taken
|
||||||
|
for _, row in ipairs(LauncherMods.pickStrays(batch, installed)) do
|
||||||
|
if adopt then
|
||||||
|
-- same root pin installZip uses: the mods tree is shared by Red and
|
||||||
|
-- Blue, never version-prefixed (#330)
|
||||||
|
local savedPrefix = CacheFs.prefix
|
||||||
|
CacheFs.prefix = ""
|
||||||
|
local dest = "mods/" .. row.id
|
||||||
|
local copied, copyErr = copyTree(row.path, dest)
|
||||||
|
if not copied then removeTree(dest) end
|
||||||
|
CacheFs.prefix = savedPrefix
|
||||||
|
if not copied then row.err = copyErr or "could not copy the files" end
|
||||||
|
end
|
||||||
|
installed[row.id] = true
|
||||||
|
row.path = nil -- dead once this mount comes down
|
||||||
|
adopted[#adopted + 1] = row
|
||||||
|
found[#found + 1] = row
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
return LauncherMods.pickStrays(found, {})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The strays, optionally adopted. A folder whose id the game can already see
|
||||||
|
-- is left out: the player has a working copy, and the loose one is just where
|
||||||
|
-- they first put it. Rows that failed to copy come back with .err set.
|
||||||
|
local function scanStrays(adopt)
|
||||||
|
local fs = love and love.filesystem
|
||||||
|
if not fs then return {} end
|
||||||
|
local installed = {}
|
||||||
|
for _, m in ipairs(discover()) do installed[m.id] = true end
|
||||||
|
return findStrays(fs, adopt, installed)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- strays() -> the rows, nothing copied.
|
||||||
|
function LauncherMods.strays() return scanStrays(false) end
|
||||||
|
|
||||||
|
-- adoptStrays() -> the rows, each one copied into the mods tree the game
|
||||||
|
-- really reads (rows carrying .err failed). Idempotent: a second call finds
|
||||||
|
-- the ids installed and returns nothing, so the panel can run this on every
|
||||||
|
-- open without duplicating anything or nagging twice. The loose folder is
|
||||||
|
-- deliberately left where it is -- deleting files outside the save directory
|
||||||
|
-- on the player's behalf is not this function's call to make.
|
||||||
|
function LauncherMods.adoptStrays() return scanStrays(true) end
|
||||||
|
|
||||||
|
-- installZip(source [, opts]) -> true, id | nil, errString
|
||||||
-- source is an external path or a love DroppedFile. The archive is validated
|
-- source is an external path or a love DroppedFile. The archive is validated
|
||||||
-- BEFORE anything is copied; every path unmounts and clears the staged temp
|
-- BEFORE anything is copied; every path unmounts and clears the staged temp
|
||||||
-- file, and a failed copy rolls its partial tree back. A dropped file outside
|
-- file, and a failed copy rolls its partial tree back. A dropped file outside
|
||||||
-- the save dir is staged into a save-dir temp first, because
|
-- the save dir is staged into a save-dir temp first, because
|
||||||
-- love.filesystem.mount only reaches a save-directory-relative path.
|
-- love.filesystem.mount only reaches a save-directory-relative path.
|
||||||
function LauncherMods.installZip(source)
|
-- opts.replace = true uninstalls an existing same-id mod first (updates /
|
||||||
|
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
|
||||||
|
function LauncherMods.installZip(source, opts)
|
||||||
|
local ok, result, err = pcall(LauncherMods._installZipInner, source, opts)
|
||||||
|
if not ok then return nil, "import failed: " .. tostring(result) end
|
||||||
|
return result, err
|
||||||
|
end
|
||||||
|
|
||||||
|
function LauncherMods._installZipInner(source, opts)
|
||||||
|
opts = opts or {}
|
||||||
if not (love and love.filesystem) then
|
if not (love and love.filesystem) then
|
||||||
return nil, "mod install needs LOVE"
|
return nil, "mod install needs LOVE"
|
||||||
end
|
end
|
||||||
@@ -348,12 +529,24 @@ function LauncherMods.installZip(source)
|
|||||||
cleanup()
|
cleanup()
|
||||||
return nil, "invalid mod manifest: " .. tostring(manifestErr)
|
return nil, "invalid mod manifest: " .. tostring(manifestErr)
|
||||||
end
|
end
|
||||||
|
if opts.expectId and manifest.id ~= opts.expectId then
|
||||||
|
cleanup()
|
||||||
|
return nil, ("zip is for '%s', expected '%s'")
|
||||||
|
:format(manifest.id, opts.expectId)
|
||||||
|
end
|
||||||
|
|
||||||
-- reject a duplicate before touching the mods tree
|
|
||||||
local dest = "mods/" .. manifest.id
|
local dest = "mods/" .. manifest.id
|
||||||
if fs.getInfo(dest) then
|
if fs.getInfo(dest) then
|
||||||
cleanup()
|
if not opts.replace then
|
||||||
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
cleanup()
|
||||||
|
return nil, "a mod named '" .. manifest.id .. "' is already installed"
|
||||||
|
end
|
||||||
|
-- drop the old tree before copy; enable-flag is preserved (uninstall
|
||||||
|
-- would clear it, which would surprise an update)
|
||||||
|
local savedPrefix = CacheFs.prefix
|
||||||
|
CacheFs.prefix = ""
|
||||||
|
removeTree(dest)
|
||||||
|
CacheFs.prefix = savedPrefix
|
||||||
end
|
end
|
||||||
|
|
||||||
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
|
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
|
||||||
@@ -376,6 +569,56 @@ function LauncherMods.installZip(source)
|
|||||||
return true, manifest.id
|
return true, manifest.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Install (or replace) a mod from a GitHub release zip URL.
|
||||||
|
-- Returns true, version | nil, errString. Soft-fails: download / install /
|
||||||
|
-- cleanup errors never throw into the launcher UI.
|
||||||
|
function LauncherMods.installFromRelease(modId, release)
|
||||||
|
local ok, result, err = pcall(function()
|
||||||
|
if type(modId) ~= "string" or modId == "" then
|
||||||
|
return nil, "missing mod id"
|
||||||
|
end
|
||||||
|
if type(release) ~= "table" or not release.zip or not release.zip.url then
|
||||||
|
return nil, "release has no downloadable .zip"
|
||||||
|
end
|
||||||
|
local ModUpdate = require("src.mods.ModUpdate")
|
||||||
|
local tmpName = ("mod_update_%s_%s.zip"):format(
|
||||||
|
tostring(modId), tostring(release.version or os.time()))
|
||||||
|
local localPath, dlErr = ModUpdate.downloadZip(release.zip.url, tmpName)
|
||||||
|
if not localPath then return nil, dlErr end
|
||||||
|
local installed, res = LauncherMods.installZip(localPath, {
|
||||||
|
replace = true, expectId = modId,
|
||||||
|
})
|
||||||
|
pcall(love.filesystem.remove, localPath)
|
||||||
|
if not installed then return nil, res end
|
||||||
|
return true, release.version or res
|
||||||
|
end)
|
||||||
|
if not ok then return nil, "install failed: " .. tostring(result) end
|
||||||
|
return result, err
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Install a mod listed in a community index (src/mods/ModIndex.lua).
|
||||||
|
-- The index only ever tells us WHERE the zip is; resolving that URL is
|
||||||
|
-- ModIndex's job and installing it is installFromRelease's, so this is the
|
||||||
|
-- seam between them and nothing about the archive is special-cased. expectId
|
||||||
|
-- comes from the listing, so a feed that points an entry at somebody else's
|
||||||
|
-- zip fails the manifest check instead of installing the wrong mod.
|
||||||
|
-- Returns true, version | nil, errString.
|
||||||
|
function LauncherMods.installFromIndex(entry)
|
||||||
|
local ok, result, err = pcall(function()
|
||||||
|
if type(entry) ~= "table" or type(entry.id) ~= "string" then
|
||||||
|
return nil, "index entry has no mod id"
|
||||||
|
end
|
||||||
|
local ModIndex = require("src.mods.ModIndex")
|
||||||
|
local release, why = ModIndex.releaseFor(entry)
|
||||||
|
if not release then
|
||||||
|
return nil, why or "this mod cannot be installed from the index"
|
||||||
|
end
|
||||||
|
return LauncherMods.installFromRelease(entry.id, release)
|
||||||
|
end)
|
||||||
|
if not ok then return nil, "install failed: " .. tostring(result) end
|
||||||
|
return result, err
|
||||||
|
end
|
||||||
|
|
||||||
-- uninstall(id) -> true | nil, errString
|
-- uninstall(id) -> true | nil, errString
|
||||||
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
|
||||||
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
|
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
|
||||||
|
|||||||
+17
-1
@@ -206,7 +206,11 @@ function Loader:_discover()
|
|||||||
for _, name in ipairs(self.fs.getDirectoryItems(root)) do
|
for _, name in ipairs(self.fs.getDirectoryItems(root)) do
|
||||||
local path = root .. "/" .. name
|
local path = root .. "/" .. name
|
||||||
local info = self.fs.getInfo(path)
|
local info = self.fs.getInfo(path)
|
||||||
if info and info.type == "directory" then
|
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
|
||||||
|
-- setSymlinksEnabled(true) -- PhysFS never resolves the symlink's
|
||||||
|
-- own getInfo, only traversal into it. readManifest below still
|
||||||
|
-- correctly no-ops on a symlink that isn't a directory.
|
||||||
|
if info and (info.type == "directory" or info.type == "symlink") then
|
||||||
local manifest, err = readManifest(self.fs, path)
|
local manifest, err = readManifest(self.fs, path)
|
||||||
if manifest then
|
if manifest then
|
||||||
if self.mods[manifest.id] then
|
if self.mods[manifest.id] then
|
||||||
@@ -834,6 +838,18 @@ function Loader:load(data)
|
|||||||
require("src.mods.Builtins").install(self.content, data)
|
require("src.mods.Builtins").install(self.content, data)
|
||||||
self:_loadState()
|
self:_loadState()
|
||||||
self:_discover()
|
self:_discover()
|
||||||
|
-- Experimental mods stay off until the player opts in: a missing
|
||||||
|
-- options.mods entry normally means enabled, but experimental flips that.
|
||||||
|
do
|
||||||
|
local options = SaveData.loadOptions(self.fs)
|
||||||
|
local modsOpt = options.mods or {}
|
||||||
|
for id, mod in pairs(self.mods) do
|
||||||
|
if not self.disabled[id] and modsOpt[id] == nil
|
||||||
|
and mod.manifest.experimental then
|
||||||
|
self.disabled[id] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
for id, mod in pairs(self.mods) do
|
for id, mod in pairs(self.mods) do
|
||||||
mod.enabled = not self.disabled[id]
|
mod.enabled = not self.disabled[id]
|
||||||
mod.state = mod.enabled and "pending" or "disabled"
|
mod.state = mod.enabled and "pending" or "disabled"
|
||||||
|
|||||||
@@ -324,6 +324,12 @@ function ManagerState:detailRows(m)
|
|||||||
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
|
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
|
||||||
action = function() self:goTo("permissions") end }
|
action = function() self:goTo("permissions") end }
|
||||||
end
|
end
|
||||||
|
if m.github then
|
||||||
|
rows[#rows + 1] = { inert = true, label = "GH " .. m.github }
|
||||||
|
end
|
||||||
|
if m.experimental then
|
||||||
|
rows[#rows + 1] = { inert = true, label = "EXPERIMENTAL" }
|
||||||
|
end
|
||||||
if m.error then
|
if m.error then
|
||||||
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
|
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
|
||||||
action = function() self:goTo("errors") end }
|
action = function() self:goTo("errors") end }
|
||||||
@@ -607,13 +613,26 @@ function ManagerState:beginToggle(m)
|
|||||||
r = ManagerState.resolveToggle(self:manifestMap(), m.id, want,
|
r = ManagerState.resolveToggle(self:manifestMap(), m.id, want,
|
||||||
self:enabledSet())
|
self:enabledSet())
|
||||||
end
|
end
|
||||||
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
|
local function proceed()
|
||||||
self:openBlocked(r)
|
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
|
||||||
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
|
self:openBlocked(r)
|
||||||
self:openCascade(r, m, want)
|
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
|
||||||
else
|
self:openCascade(r, m, want)
|
||||||
self:commitToggle(r.apply)
|
else
|
||||||
|
self:commitToggle(r.apply)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
-- Experimental mods ask once on enable; disable is silent.
|
||||||
|
if want and m.experimental then
|
||||||
|
self:openConfirm({
|
||||||
|
"EXPERIMENTAL MOD",
|
||||||
|
"THIS MOD IS MARKED",
|
||||||
|
"EXPERIMENTAL.",
|
||||||
|
"ENABLE ANYWAY?",
|
||||||
|
}, proceed)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
proceed()
|
||||||
end
|
end
|
||||||
|
|
||||||
function ManagerState:commitToggle(apply)
|
function ManagerState:commitToggle(apply)
|
||||||
|
|||||||
+99
-2
@@ -50,8 +50,94 @@ local function parseSpecs(list, field)
|
|||||||
return specs
|
return specs
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Optional GitHub repo for launcher auto-update / other-versions.
|
||||||
|
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
|
||||||
|
function Manifest.parseGithub(value)
|
||||||
|
if value == nil or value == "" then return nil end
|
||||||
|
assert(type(value) == "string", "github must be a string")
|
||||||
|
local trimmed = value:match("^%s*(.-)%s*$") or value
|
||||||
|
if trimmed == "" then return nil end
|
||||||
|
local owner, repo = trimmed:match(
|
||||||
|
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)/?$")
|
||||||
|
if not owner then
|
||||||
|
owner, repo = trimmed:match(
|
||||||
|
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)%.git/?$")
|
||||||
|
end
|
||||||
|
if not owner then
|
||||||
|
owner, repo = trimmed:match("^([%w%._%-]+)/([%w%._%-]+)$")
|
||||||
|
end
|
||||||
|
assert(owner and repo and owner ~= "" and repo ~= "",
|
||||||
|
"github must be owner/repo or a github.com URL")
|
||||||
|
repo = repo:gsub("%.git$", "")
|
||||||
|
return owner .. "/" .. repo
|
||||||
|
end
|
||||||
|
|
||||||
|
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
|
||||||
|
local function mergeConflictLists(conflicts, incompatible)
|
||||||
|
local seen, out = {}, {}
|
||||||
|
for _, list in ipairs({ array(conflicts), array(incompatible) }) do
|
||||||
|
for _, entry in ipairs(list) do
|
||||||
|
if not seen[entry] then
|
||||||
|
seen[entry] = true
|
||||||
|
out[#out + 1] = entry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
|
||||||
|
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
|
||||||
|
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
|
||||||
|
-- panel may draw must be scrubbed here -- the one place every mod manifest
|
||||||
|
-- passes through -- or a single mangled description crashes the whole MODS
|
||||||
|
-- panel instead of misrendering one card.
|
||||||
|
local function scrubUtf8(s)
|
||||||
|
if type(s) ~= "string" then return s end
|
||||||
|
s = s:gsub("^\239\187\191", "")
|
||||||
|
local out, i, n = {}, 1, #s
|
||||||
|
while i <= n do
|
||||||
|
local b = s:byte(i)
|
||||||
|
local len
|
||||||
|
if b < 0x80 then len = 1
|
||||||
|
elseif b >= 0xC2 and b <= 0xDF then len = 2
|
||||||
|
elseif b >= 0xE0 and b <= 0xEF then len = 3
|
||||||
|
elseif b >= 0xF0 and b <= 0xF4 then len = 4
|
||||||
|
end
|
||||||
|
local ok = len ~= nil and i + len - 1 <= n
|
||||||
|
if ok and len > 1 then
|
||||||
|
for j = i + 1, i + len - 1 do
|
||||||
|
local c = s:byte(j)
|
||||||
|
if c < 0x80 or c > 0xBF then ok = false; break end
|
||||||
|
end
|
||||||
|
if ok then
|
||||||
|
-- boundary lead bytes narrow their second byte: no overlongs
|
||||||
|
-- (E0/F0), no surrogates (ED), nothing past U+10FFFF (F4)
|
||||||
|
local b2 = s:byte(i + 1)
|
||||||
|
if (b == 0xE0 and b2 < 0xA0) or (b == 0xED and b2 > 0x9F)
|
||||||
|
or (b == 0xF0 and b2 < 0x90) or (b == 0xF4 and b2 > 0x8F) then
|
||||||
|
ok = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if ok then
|
||||||
|
out[#out + 1] = s:sub(i, i + len - 1)
|
||||||
|
i = i + len
|
||||||
|
else
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return table.concat(out)
|
||||||
|
end
|
||||||
|
|
||||||
function Manifest.validate(raw, path)
|
function Manifest.validate(raw, path)
|
||||||
assert(type(raw) == "table", "manifest must be an object")
|
assert(type(raw) == "table", "manifest must be an object")
|
||||||
|
-- scrubbed in place so every later reader agrees, including the launcher's
|
||||||
|
-- badge derivation, which reads raw.category rather than the validated copy
|
||||||
|
raw.name = scrubUtf8(raw.name)
|
||||||
|
raw.version = scrubUtf8(raw.version)
|
||||||
|
raw.description = scrubUtf8(raw.description)
|
||||||
|
raw.category = scrubUtf8(raw.category)
|
||||||
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
|
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
|
||||||
"manifest id must contain only letters, numbers, _ or -")
|
"manifest id must contain only letters, numbers, _ or -")
|
||||||
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
|
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
|
||||||
@@ -86,6 +172,12 @@ function Manifest.validate(raw, path)
|
|||||||
assert(gameVersionOk, ("malformed game_version %q: %s")
|
assert(gameVersionOk, ("malformed game_version %q: %s")
|
||||||
:format(tostring(raw.game_version), tostring(gameVersionErr)))
|
:format(tostring(raw.game_version), tostring(gameVersionErr)))
|
||||||
|
|
||||||
|
local github = Manifest.parseGithub(raw.github)
|
||||||
|
|
||||||
|
assert(raw.experimental == nil or type(raw.experimental) == "boolean",
|
||||||
|
"experimental must be a boolean")
|
||||||
|
local experimental = raw.experimental == true
|
||||||
|
|
||||||
-- overhauls and total conversions are assumed to move the link
|
-- overhauls and total conversions are assumed to move the link
|
||||||
-- fingerprint unless the manifest says otherwise; content packs are not
|
-- fingerprint unless the manifest says otherwise; content packs are not
|
||||||
local affectsLink = profile ~= "content"
|
local affectsLink = profile ~= "content"
|
||||||
@@ -97,6 +189,8 @@ function Manifest.validate(raw, path)
|
|||||||
return value
|
return value
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id = raw.id,
|
id = raw.id,
|
||||||
name = raw.name,
|
name = raw.name,
|
||||||
@@ -106,13 +200,16 @@ function Manifest.validate(raw, path)
|
|||||||
priority = tonumber(raw.priority) or 0,
|
priority = tonumber(raw.priority) or 0,
|
||||||
dependencies = array(raw.dependencies),
|
dependencies = array(raw.dependencies),
|
||||||
optional_dependencies = array(raw.optional_dependencies),
|
optional_dependencies = array(raw.optional_dependencies),
|
||||||
conflicts = array(raw.conflicts),
|
conflicts = conflicts,
|
||||||
|
incompatible = array(raw.incompatible),
|
||||||
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
|
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
|
||||||
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
|
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
|
||||||
conflictSpecs = parseSpecs(array(raw.conflicts), "conflicts"),
|
conflictSpecs = parseSpecs(conflicts, "conflicts"),
|
||||||
category = raw.category or "OTHER",
|
category = raw.category or "OTHER",
|
||||||
game_version = raw.game_version,
|
game_version = raw.game_version,
|
||||||
description = raw.description or "",
|
description = raw.description or "",
|
||||||
|
github = github,
|
||||||
|
experimental = experimental,
|
||||||
profile = profile,
|
profile = profile,
|
||||||
affects_link = affectsLink,
|
affects_link = affectsLink,
|
||||||
permissions = permissions,
|
permissions = permissions,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user