Compare commits
362 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8b7c468a4 | |||
| 33998aab5d | |||
| 6cfc85ca83 | |||
| 3a6557ffe2 | |||
| fe139ca7f4 | |||
| 5c617d3e32 | |||
| 8d11c04d43 | |||
| 8da51aa7ba | |||
| 78f87111de | |||
| 6ea59f1a79 | |||
| 51fb4db82e | |||
| 7f78de8718 | |||
| 76aab74bf1 | |||
| a2bf08c6ff | |||
| b831076839 | |||
| 45575a1fd8 | |||
| 56d92246cb | |||
| 4a00f2335c | |||
| e9cb830d71 | |||
| c2666d2b43 | |||
| 9ceec3070d | |||
| f89977a8e7 | |||
| 8d512b95cb | |||
| 0914387c16 | |||
| 105672c132 | |||
| 0dd6ecc219 | |||
| 9ef5c8dead | |||
| 12ff4dba2e | |||
| ff9992dff8 | |||
| 98c08e4b22 | |||
| 0bd3b26acc | |||
| 59b55a8f37 | |||
| 67e6b1fb04 | |||
| 17243a7d8b | |||
| 16b6b98ede | |||
| e4ce1063a1 | |||
| f099593136 | |||
| f944507520 | |||
| 6824949247 | |||
| 4f54255518 | |||
| cfe482e1fc | |||
| 0e5ea26e36 | |||
| d5886e69aa | |||
| 32fa215243 | |||
| dd5d8afd82 | |||
| f19f4e9341 | |||
| 3aec2ca89b | |||
| 0f7261dd92 | |||
| 0886573d34 | |||
| 5c041ea857 | |||
| 30cd9afc92 | |||
| cceb74a025 | |||
| 42540076c1 | |||
| a528868656 | |||
| c82af28fd4 | |||
| d72e5db0a5 | |||
| 9907cfb0a0 | |||
| a3cd5a132e | |||
| 0971b47bec | |||
| f2ea250364 | |||
| d86ac70d9e | |||
| be8023a994 | |||
| a0ab949399 | |||
| 30ed68ae22 | |||
| f2aa376304 | |||
| 1b48862923 | |||
| 25417ffac8 | |||
| 4dfbc5a828 | |||
| f0f5bc7551 | |||
| f002db2929 | |||
| c5791574f5 | |||
| 8c1fbfb429 | |||
| 8e5501a23b | |||
| 50fd0e9c7d | |||
| 17843c2f7a | |||
| 54c8d2706b | |||
| 44bfb1b93f | |||
| 7a78992881 | |||
| 6ed41c3b87 | |||
| 7414c176d9 | |||
| d7581dbece | |||
| eeb8c89d3d | |||
| 2591fde764 | |||
| a8f3a3155c | |||
| 2a927651c5 | |||
| 62ef647811 | |||
| fe4491a87b | |||
| 105bf65e02 | |||
| 8120f113af | |||
| 7e0c64cb78 | |||
| 4afb54c54f | |||
| 74f6b68034 | |||
| 5d1e7ff3c1 | |||
| 3c628140f7 | |||
| 01f68d4038 | |||
| 8bf99c3318 | |||
| f0829ed54f | |||
| 21276e20d1 | |||
| 9fb9648e4c | |||
| af7689a1bc | |||
| 59dba325b9 | |||
| d13064ee64 | |||
| 669c9f4d8b | |||
| 228306f883 | |||
| 0a2ef854e6 | |||
| 61c471343a | |||
| 175bff4b29 | |||
| 95eb1c3a35 | |||
| cf50976d65 | |||
| 3fcee7f951 | |||
| 17a4c83970 | |||
| 9712fb1e9b | |||
| cbf622f4aa | |||
| 7c1cdea2eb | |||
| 2bfa491b6a | |||
| 41fbfc9ba6 | |||
| 1fa7c0eb43 | |||
| 839b238e74 | |||
| ec22d57cd1 | |||
| 23ef283272 | |||
| 327579f9b6 | |||
| e5f9d2903d | |||
| 1f3aa4e111 | |||
| 3f88d1c49a | |||
| 5348ba1c65 | |||
| 2f35fd44a1 | |||
| 470fe70f07 | |||
| 399bf557f3 | |||
| fc2ca6cc26 | |||
| d9d42ec956 | |||
| 5e41c74682 | |||
| 02ad846dfa | |||
| 4e7eda65ed | |||
| 3fabe4f591 | |||
| 81234ef2ab | |||
| 4bd28390ea | |||
| fa886899fc | |||
| a81126a03a | |||
| 164c555bb4 | |||
| e5926893a9 | |||
| dbcaf705c2 | |||
| f6809be81d | |||
| 3ddf70888e | |||
| fe7dcf33ec | |||
| 733450bf86 | |||
| 1dae9622e1 | |||
| da0fa5c9ad | |||
| e576dea676 | |||
| 4cac51a831 | |||
| fef844e220 | |||
| 0f45bb5792 | |||
| c195c5da7d | |||
| cb26367f46 | |||
| 31dc89c0d7 | |||
| c95c3003f7 | |||
| 35b3fa6d9c | |||
| e217d62f42 | |||
| e62801caaf | |||
| e6008bb299 | |||
| 9a49646d8a | |||
| 9934e4d765 | |||
| b6a397460e | |||
| 2b75c07571 | |||
| ebbc55c4d0 | |||
| 4e6ee3629f | |||
| 46bd0f6709 | |||
| e6c1ed8753 | |||
| 2b92562538 | |||
| 078bb1ade6 | |||
| f205464aa4 | |||
| b820d3917c | |||
| fea81f03fc | |||
| 75aa71a886 | |||
| 398eefc380 | |||
| b549e319c2 | |||
| 1a57b67e14 | |||
| 378246728b | |||
| 41e326f376 | |||
| f9adeca5ca | |||
| 838f87c224 | |||
| 5b519165fc | |||
| 0bb0518093 | |||
| 41f549b271 | |||
| 6b012a31ce | |||
| 1a2b23a066 | |||
| f109530c5f | |||
| 4cb132199c | |||
| fe16b3259c | |||
| 2e46954fe0 | |||
| aedc63c40d | |||
| 00d13d3c35 | |||
| 1b8b3ad538 | |||
| 5f75cfd691 | |||
| 5f89def2ce | |||
| 7c26eb9a24 | |||
| d951fe8fc5 | |||
| ac01135ca8 | |||
| 5e89e35e02 | |||
| 7e7afeaf82 | |||
| c6fa6d294b | |||
| fc2d17fc78 | |||
| 6fb5aa08ff | |||
| 9f072285a6 | |||
| c58ebe4b9b | |||
| 0dd187fe30 | |||
| ea78792c03 | |||
| ba8aa7b263 | |||
| 243cb6a477 | |||
| c987fefede | |||
| 9954225701 | |||
| 2a038f7112 | |||
| 6bb2e078c0 | |||
| 323c59e54f | |||
| d26d63ed38 | |||
| 2009df3dd1 | |||
| 548e75a15e | |||
| 339c85b590 | |||
| 0f0420314b | |||
| a08b5e65f7 | |||
| 99b89c4e5e | |||
| 20586e0c48 | |||
| b54d0c605a | |||
| 2f75af902c | |||
| 9147a6413e | |||
| 9a467d4bf1 | |||
| e40f9337b6 | |||
| 1f0712d2d5 | |||
| 345bc9519a | |||
| 0b30bb2eff | |||
| f0e88aa581 | |||
| 317893bd61 | |||
| bd181271d9 | |||
| 4df09010a8 | |||
| 1a5b2b96dc | |||
| 43d89813fc | |||
| 5bc76ce6fe | |||
| f0f3e9634b | |||
| 1fe35335a5 | |||
| ca1beaefe7 | |||
| 24cf1b3317 | |||
| 88e2ec2042 | |||
| be60c7c1d5 | |||
| 9be21380c7 | |||
| 22fd196045 | |||
| d5293dffb8 | |||
| 9326b07583 | |||
| 90437a5d8d | |||
| c4ec5dc1c5 | |||
| 3b19826f1e | |||
| 95e4715bc3 | |||
| 81edc97d86 | |||
| 92c8bac714 | |||
| 1df04aa18d | |||
| 8654444d1a | |||
| 772c39edb8 | |||
| 74e7474349 | |||
| 3aefe41ea1 | |||
| 2223c31e93 | |||
| 568f0fa9eb | |||
| e91cf0f0c2 | |||
| 8a1f583d88 | |||
| 7518319c80 | |||
| 304df44742 | |||
| e92bab6f4f | |||
| 43755c5457 | |||
| d4455c508f | |||
| 7d01242cd1 | |||
| 9d77189cec | |||
| c8adbdcc64 | |||
| ac6dfe7134 | |||
| b1ad7c7254 | |||
| 218b9249a2 | |||
| 6fb5602cb0 | |||
| b942d37944 | |||
| b7ee191b6c | |||
| 4ea24a0a6f | |||
| 6ad8c8d73d | |||
| 8982952fa4 | |||
| b135878824 | |||
| e9463d39aa | |||
| dcee7ca8e4 | |||
| 1653aa0a5d | |||
| 2699c9a2f9 | |||
| efd81d8e34 | |||
| 925b224eda | |||
| 7504753ea8 | |||
| da60f40dfc | |||
| 560ebc5997 | |||
| 361d4b81df | |||
| 83cf1fdb00 | |||
| 228a0baf81 | |||
| df7cea4387 | |||
| 13b55a797e | |||
| 1ec33b1374 | |||
| c06991e03d | |||
| 2fcd7abbfd | |||
| 5ca17cfc99 | |||
| 05f1e3852c | |||
| 3b22a45a23 | |||
| f172d3129f | |||
| a7a84b6fab | |||
| d4afa09e03 | |||
| 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 | |||
| fe1714d50e | |||
| cd33858020 | |||
| fb58fa788b | |||
| d09cf80da5 | |||
| aab980b05a |
@@ -40,6 +40,7 @@ body:
|
||||
- Windows
|
||||
- Linux
|
||||
- Android
|
||||
- iOS
|
||||
- Multiple platforms
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
After Width: | Height: | Size: 626 KiB |
|
After Width: | Height: | Size: 582 KiB |
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 730 KiB |
@@ -10,8 +10,6 @@ name: ci
|
||||
# The T3 content tier asserts Pokemon Red facts; scripts/test.sh detects
|
||||
# data/generated/ is absent and skips it rather than failing.
|
||||
#
|
||||
# Runs alongside release.yml, which is untouched by this file.
|
||||
|
||||
on:
|
||||
push:
|
||||
# Integration branch + release branch. PRs already run via pull_request
|
||||
@@ -24,12 +22,170 @@ concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ios-changes:
|
||||
name: detect iOS changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.paths.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: paths
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(mobile/ios/|scripts/build_ios\.sh$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
ios-build:
|
||||
name: iOS build
|
||||
needs: ios-changes
|
||||
if: needs.ios-changes.outputs.changed == 'true'
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
outputs:
|
||||
ipa_url: ${{ steps.upload-ipa.outputs.artifact-url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: import signing certificate
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
keychain_path="$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db"
|
||||
ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}"
|
||||
p12="$ci_dir/signing.p12"
|
||||
passfile="$ci_dir/signing.pass"
|
||||
[ -f "$p12" ] && [ -f "$passfile" ] || exit 1
|
||||
p12pw="$(cat "$passfile")"
|
||||
kcpw="$(openssl rand -base64 24)"
|
||||
echo "::add-mask::$kcpw"
|
||||
security delete-keychain "$keychain_path" 2>/dev/null || true
|
||||
security create-keychain -p "$kcpw" "$keychain_path"
|
||||
security set-keychain-settings "$keychain_path"
|
||||
security unlock-keychain -p "$kcpw" "$keychain_path"
|
||||
security import "$p12" -P "$p12pw" -k "$keychain_path" -T /usr/bin/codesign -T /usr/bin/security
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$kcpw" "$keychain_path" >/dev/null
|
||||
existing="$(security list-keychains -d user | sed -e 's/^[[:space:]]*//' -e 's/"//g')"
|
||||
security list-keychains -d user -s "$keychain_path" $existing
|
||||
- name: install xcbeautify
|
||||
run: brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
|
||||
- name: build iOS release
|
||||
env:
|
||||
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
if [ "$CANONICAL_REPOSITORY" = true ]; then
|
||||
scripts/build_ios.sh --fetch --device --release
|
||||
else
|
||||
scripts/build_ios.sh --fetch --release
|
||||
fi
|
||||
- name: upload iOS release artifact
|
||||
id: upload-ipa
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-ios-ipa
|
||||
path: dist/ios/gen1recomp.ipa
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
- name: clean up signing keychain
|
||||
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: security delete-keychain "$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db" 2>/dev/null || true
|
||||
|
||||
switch-changes:
|
||||
name: detect Switch changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.paths.outputs.changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: paths
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
switch-selftest:
|
||||
name: Switch offline selftest
|
||||
needs: switch-changes
|
||||
if: needs.switch-changes.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: install luajit
|
||||
run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
- name: Switch offline selftest
|
||||
run: bash scripts/switch/selftest_build_switch.sh
|
||||
- name: verify_payload self-test
|
||||
run: bash scripts/switch/verify_payload.sh --self-test
|
||||
- name: Switch CI workflow content gate
|
||||
run: luajit tests/switch_ci_workflows_test.lua
|
||||
- name: Switch transfer docs content gate
|
||||
run: luajit tests/switch_transfer_docs_test.lua
|
||||
# NX runtime regressions gate this job via switch-changes; run the NX
|
||||
# engine suites here too so a PR touching them gets feedback on the
|
||||
# fork-safe ubuntu runner before the self-hosted Mac build.
|
||||
- name: NX engine suites (headless)
|
||||
run: |
|
||||
luajit tests/engine/assets_version_fallback_test.lua
|
||||
luajit tests/engine/nx_generated_guard_test.lua
|
||||
luajit tests/engine/nx_yellow_boot_test.lua
|
||||
|
||||
switch-build:
|
||||
name: Switch fused build
|
||||
needs: [switch-changes, switch-selftest]
|
||||
if: |
|
||||
always()
|
||||
&& needs.switch-changes.outputs.changed == 'true'
|
||||
&& needs.switch-selftest.result == 'success'
|
||||
&& github.repository == 'bryanthaboi/gen1recomp'
|
||||
&& (github.event_name != 'pull_request'
|
||||
|| github.event.pull_request.head.repo.full_name == github.repository)
|
||||
runs-on: ["self-hosted", "macOS"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build Switch fused NRO
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="$(printf '%s' "$GITHUB_SHA" | cut -c1-7)"
|
||||
scripts/build_switch.sh --fetch --fused --version "$VER"
|
||||
echo "SWITCH_VER=$VER" >> "$GITHUB_ENV"
|
||||
- name: upload Switch NRO artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-switch-nro
|
||||
path: |
|
||||
dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro
|
||||
dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
headless:
|
||||
name: headless suites (no ROM)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# LuaJIT, not lua5.4: LOVE 11.x embeds LuaJIT 2.1 and the engine is
|
||||
# written to Lua 5.1 semantics, so CI must run the interpreter the
|
||||
@@ -47,7 +203,7 @@ jobs:
|
||||
name: fixture dataset integrity
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
- run: python3 -m pip install --upgrade pillow
|
||||
|
||||
@@ -89,7 +245,7 @@ jobs:
|
||||
name: screenshot differ (capture not yet wired)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- run: python3 -m pip install --upgrade pillow
|
||||
|
||||
# 21-testing-and-ci §"Testing & acceptance criteria": compare_shots
|
||||
@@ -137,7 +293,7 @@ jobs:
|
||||
name: mod lint (no ROM-derived content)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# the MK305 dump check key-diffs shipped tables through luajit, and
|
||||
# modkit treats a missing interpreter as a fatal MK100 -- without
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: iOS artifact comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ci]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
run: |
|
||||
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-ios-ipa") | .id')"
|
||||
[ -n "$artifact_id" ] || exit 0
|
||||
head_owner="${HEAD_REPOSITORY%%/*}"
|
||||
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
|
||||
[ -n "$pr_number" ] || exit 0
|
||||
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||
# Upsert via comment-tag only — do not delete-all bot comments (clobbers Switch).
|
||||
- name: Get build info
|
||||
id: build-info
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)"
|
||||
build_time="$(date "+%Y-%m-%d %H:%M:%S")"
|
||||
echo "hash=$commit_hash" >> "$GITHUB_OUTPUT"
|
||||
echo "time=$build_time" >> "$GITHUB_OUTPUT"
|
||||
- name: comment iOS artifact
|
||||
if: steps.artifact.outputs.pr_number != ''
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
message: |
|
||||
[gen1recomp.ipa](${{ steps.artifact.outputs.artifact_url }})
|
||||
|
||||
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
|
||||
**Build Time**: `${{ steps.build-info.outputs.time }}`
|
||||
|
||||
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
|
||||
pr-number: ${{ steps.artifact.outputs.pr_number }}
|
||||
comment-tag: ios-build-result
|
||||
github-token: ${{ github.token }}
|
||||
@@ -1,8 +1,9 @@
|
||||
name: Release
|
||||
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
|
||||
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
|
||||
# Mac runner, and publishes them as a GitHub Release.
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
||||
# IPA, a Nintendo Switch SD-ready zip (experimental), and the Anbernic RG34XXSP
|
||||
# (Stock OS 64-bit MOD / PortMaster) port on the self-hosted Mac runner, and
|
||||
# publishes them as a GitHub Release.
|
||||
#
|
||||
# Versioning:
|
||||
# - First ever release is 0.1.0.
|
||||
@@ -24,6 +25,7 @@ on:
|
||||
paths-ignore:
|
||||
- '.github/**'
|
||||
- '**.md'
|
||||
- 'mobile/ios/app-repo.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -42,11 +44,17 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: [self-hosted, macOS]
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
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
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@@ -121,6 +129,7 @@ jobs:
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Import signing certificate into a temporary keychain
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/pokemon-signing.keychain-db"
|
||||
@@ -170,6 +179,35 @@ jobs:
|
||||
set -euo pipefail
|
||||
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
|
||||
env:
|
||||
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$CANONICAL_REPOSITORY" = true ]; then
|
||||
scripts/build_ios.sh --fetch --device --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
else
|
||||
scripts/build_ios.sh --fetch --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
fi
|
||||
|
||||
- name: Build Switch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Hard-fail gate: Switch ships with every release (never soft-fail).
|
||||
# PR CI is path-gated (ubuntu selftest + canonical fused); release
|
||||
# always builds Switch regardless of which files changed.
|
||||
# Needs native switch-tools (nacptool/elf2nro) and/or Docker on the
|
||||
# Mac self-hosted runner; see docs/switch-build.md.
|
||||
scripts/build_switch.sh --fetch --fused \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -178,6 +216,7 @@ jobs:
|
||||
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Notarize & staple macOS app
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}"
|
||||
@@ -212,6 +251,7 @@ jobs:
|
||||
echo "Notarized + stapled ✓"
|
||||
|
||||
- name: Stage release assets
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
id: assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -226,6 +266,16 @@ jobs:
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
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"
|
||||
|
||||
swzip="dist/switch/gen1recomp-${v}-switch.zip"
|
||||
[ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; }
|
||||
cp "$swzip" "$outdir/gen1recomp-${v}-switch.zip"
|
||||
# Local fused .nro stays under dist/switch/ for PR CI / debug; release
|
||||
# publishes the SD-ready zip only.
|
||||
|
||||
# Anbernic handheld port (suffix names the CFW it targets, so a
|
||||
# future RG35XX/other-CFW pack can ship alongside it).
|
||||
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
|
||||
@@ -247,6 +297,7 @@ jobs:
|
||||
cat "$outdir/sha256sums.txt"
|
||||
|
||||
- name: Publish GitHub Release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -336,21 +387,92 @@ jobs:
|
||||
fi
|
||||
printf 'Release notes:\n%s\n' "$notes"
|
||||
|
||||
release_files=(
|
||||
"dist/release/gen1recomp-${v}-macos.zip"
|
||||
"dist/release/gen1recomp-${v}-windows.zip"
|
||||
"dist/release/gen1recomp-${v}-linux.zip"
|
||||
"dist/release/gen1recomp-${v}-android.apk"
|
||||
"dist/release/gen1recomp-${v}-ios.ipa"
|
||||
"dist/release/gen1recomp-${v}-switch.zip"
|
||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||
"dist/release/gen1recomp-${v}.love"
|
||||
"dist/release/sha256sums.txt"
|
||||
)
|
||||
|
||||
gh release create "$tag" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "$v" \
|
||||
--notes "$notes" \
|
||||
"dist/release/gen1recomp-${v}-macos.zip" \
|
||||
"dist/release/gen1recomp-${v}-windows.zip" \
|
||||
"dist/release/gen1recomp-${v}-linux.zip" \
|
||||
"dist/release/gen1recomp-${v}-android.apk" \
|
||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
|
||||
"dist/release/gen1recomp-${v}.love" \
|
||||
"dist/release/sha256sums.txt"
|
||||
"${release_files[@]}"
|
||||
|
||||
echo "Published release $tag"
|
||||
|
||||
- name: Update iOS app repository
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
ipa="dist/release/gen1recomp-${v}-ios.ipa"
|
||||
app_repo="mobile/ios/app-repo.json"
|
||||
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
|
||||
[ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; }
|
||||
|
||||
date="$(date -u +"%Y-%m-%d")"
|
||||
size="$(wc -c < "$ipa" | tr -d '[:space:]')"
|
||||
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp-${v}-ios.ipa"
|
||||
localized_description="Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"
|
||||
release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)"
|
||||
if [ -n "$release_notes" ]; then
|
||||
localized_description="$release_notes"
|
||||
fi
|
||||
entry="$(jq -n \
|
||||
--arg version "$v" \
|
||||
--arg date "$date" \
|
||||
--arg download_url "$download_url" \
|
||||
--arg localized_description "$localized_description" \
|
||||
--argjson size "$size" \
|
||||
'{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')"
|
||||
|
||||
if jq -e --arg version "$v" \
|
||||
'any(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions[]?; .version == $version)' \
|
||||
"$app_repo" >/dev/null; then
|
||||
jq --arg version "$v" --argjson entry "$entry" \
|
||||
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= map(if .version == $version then $entry else . end)' \
|
||||
"$app_repo" > "$app_repo.tmp"
|
||||
else
|
||||
jq --argjson entry "$entry" \
|
||||
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= [$entry] + .' \
|
||||
"$app_repo" > "$app_repo.tmp"
|
||||
fi
|
||||
mv "$app_repo.tmp" "$app_repo"
|
||||
|
||||
# main is PR-only for everyone except deploy keys (the "main protection"
|
||||
# ruleset's bypass actor), so this push must authenticate with the
|
||||
# RELEASE_DEPLOY_KEY deploy key over SSH; the workflow's GITHUB_TOKEN
|
||||
# would be rejected by the branch protection.
|
||||
- name: Commit iOS app repository
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
env:
|
||||
DEPLOY_KEY: ${{ secrets.RELEASE_DEPLOY_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add mobile/ios/app-repo.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "app-repo.json unchanged; nothing to push"
|
||||
exit 0
|
||||
fi
|
||||
key="$RUNNER_TEMP/release-deploy-key"
|
||||
printf '%s\n' "$DEPLOY_KEY" > "$key"
|
||||
chmod 600 "$key"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git commit -m "chore(ios): update app-repo.json [skip ci]"
|
||||
git -c core.sshCommand="ssh -i $key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
|
||||
push "git@github.com:${GITHUB_REPOSITORY}.git" "HEAD:${GITHUB_REF_NAME}"
|
||||
rm -f "$key"
|
||||
|
||||
- name: Clean up signing keychain
|
||||
if: always()
|
||||
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
security delete-keychain "$RUNNER_TEMP/pokemon-signing.keychain-db" 2>/dev/null || true
|
||||
rm -f "$RUNNER_TEMP/release-deploy-key"
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Switch artifact comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [ci]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
run: |
|
||||
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-switch-nro") | .id')"
|
||||
[ -n "$artifact_id" ] || exit 0
|
||||
head_owner="${HEAD_REPOSITORY%%/*}"
|
||||
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
|
||||
[ -n "$pr_number" ] || exit 0
|
||||
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
|
||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||
# Upsert via comment-tag only — do not delete-all bot comments (clobbers iOS).
|
||||
- name: Get build info
|
||||
id: build-info
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)"
|
||||
build_time="$(date "+%Y-%m-%d %H:%M:%S")"
|
||||
echo "hash=$commit_hash" >> "$GITHUB_OUTPUT"
|
||||
echo "time=$build_time" >> "$GITHUB_OUTPUT"
|
||||
- name: comment Switch artifact
|
||||
if: steps.artifact.outputs.pr_number != ''
|
||||
uses: thollander/actions-comment-pull-request@v3
|
||||
with:
|
||||
message: |
|
||||
[gen1recomp-switch.nro](${{ steps.artifact.outputs.artifact_url }})
|
||||
|
||||
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
|
||||
**Build Time**: `${{ steps.build-info.outputs.time }}`
|
||||
|
||||
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
|
||||
pr-number: ${{ steps.artifact.outputs.pr_number }}
|
||||
comment-tag: switch-build-result
|
||||
github-token: ${{ github.token }}
|
||||
@@ -16,6 +16,7 @@ __pycache__/
|
||||
.*
|
||||
!.github/
|
||||
!.gitignore
|
||||
!.luacheckrc
|
||||
|
||||
# Android build outputs / local SDK path / packaged payload (keep love-android sources)
|
||||
mobile/android/app/build/
|
||||
@@ -30,7 +31,10 @@ mobile/ios/love-src/
|
||||
mobile/ios/cache/
|
||||
mobile/ios/build/
|
||||
|
||||
# Final packaged build artifacts (mac/win/web/android/ios) — see scripts/build.sh
|
||||
# love-nx vendor binaries (fetch per docs/switch-development.md; also covered by .*)
|
||||
.bazinga/love-nx/
|
||||
|
||||
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
|
||||
/dist/
|
||||
|
||||
# Legacy manual convenience-copy location (superseded by /dist/android/)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -118,6 +118,7 @@ not a hard error, so the list can grow without breaking old mods.
|
||||
| `QUEST` | New story, NPCs, dialogue, cutscenes | content |
|
||||
| `MECHANIC` | New or changed battle/field mechanics via hooks/effects | overhaul |
|
||||
| `GRAPHICS` | Sprite / tileset / palette / font changes | content |
|
||||
| `LANGUAGE` | A translation: `text`, `strings` and the glyphs it needs | content |
|
||||
| `AUDIO` | Music, sfx, cries | content |
|
||||
| `UI` | New or modified screens, menus, overlays | content / overhaul |
|
||||
| `TOOL` | Dev/QoL utilities, overlays, inter-mod libraries | content |
|
||||
@@ -128,6 +129,15 @@ not a hard error, so the list can grow without breaking old mods.
|
||||
keeps validating with the value it has shipped since before the taxonomy
|
||||
existed.
|
||||
|
||||
A translation may also set `"language": true` in the manifest. That is the
|
||||
one claim online play acts on: an install running nothing but verified
|
||||
translations may take an ONLINE MATCH or a TOURNAMENT instead of being
|
||||
asked to restart vanilla. The claim is checked, not taken -- the mod
|
||||
qualifies only if every record it writes lands in `text`, `strings` or
|
||||
`font`, it wraps no hook, subscribes to no event and requests no
|
||||
permission. Anything else and it is an ordinary content mod that happens to
|
||||
ship text.
|
||||
|
||||
### 4. `mod.card`
|
||||
|
||||
The manifest is the *engine's* contract: identity, load order, dependencies,
|
||||
|
||||
@@ -4,6 +4,9 @@ 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
|
||||
supplied by the player.
|
||||
|
||||
> [!CAUTION]
|
||||
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project.
|
||||
|
||||
<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)
|
||||
@@ -50,30 +53,44 @@ supplied by the player.
|
||||
|
||||
|
||||
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
|
||||
game content input.
|
||||
or download a disassembly. A canonical US Poke Red, Blue, or Yellow ROM is the
|
||||
only game content input.
|
||||
|
||||
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
|
||||
do not ask for the ROM again. Red and Blue can both be imported and played
|
||||
side by side.
|
||||
do not ask for the ROM again. Red, Blue, and Yellow can all be imported and
|
||||
played side by side.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Open the desktop app. On first boot, choose your legally obtained `.gb` file
|
||||
or drop it onto the window. Import takes a few seconds and the game starts
|
||||
automatically.
|
||||
Open the desktop app. On first boot, choose your legally obtained `.gb` /
|
||||
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
||||
game starts automatically.
|
||||
|
||||
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
|
||||
verifies SHA-1 before creating any game data:
|
||||
Only the canonical 1 MiB US Red, Blue, and Yellow ROMs are accepted. The
|
||||
importer verifies SHA-1 before creating any game data:
|
||||
|
||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||
|
||||
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
|
||||
audio channel programs copied out of the verified ROM.
|
||||
|
||||
### A note on Windows Defender warnings
|
||||
|
||||
Windows Defender sometimes flags the Windows build with a generic
|
||||
machine-learning detection such as `Trojan:Win32/Wacatac!ml` (#621). This is
|
||||
a known false positive: the exe is the official LÖVE runtime with the game
|
||||
archive appended (the standard way LÖVE games ship), and Defender's
|
||||
heuristics distrust unsigned executables with appended data. Every release
|
||||
publishes SHA-256 checksums (`sha256sums.txt`) so you can verify your
|
||||
download, and you can confirm a flagged file yourself on
|
||||
[VirusTotal](https://www.virustotal.com), where these builds come back clean
|
||||
on every engine except Defender's heuristic. False positives are reported to
|
||||
Microsoft as they come up.
|
||||
|
||||
## Controls
|
||||
|
||||
|
||||
@@ -95,6 +112,7 @@ supported out of the box.
|
||||
| Key | What it does |
|
||||
| --------- | ---------------------------------------------------- |
|
||||
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||
| `1` | Cycle GAME SPEED up (controller: R2 faster, L2 slower) |
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
@@ -104,8 +122,19 @@ supported out of the box.
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
|
||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||
and persist in `options.lua`.
|
||||
COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the
|
||||
Options menu 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
|
||||
|
||||
@@ -141,11 +170,11 @@ famous bugs included:
|
||||
|
||||
## 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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -176,6 +205,23 @@ even on a different computer, as long as the same folder comes along.
|
||||
already written to either location is touched automatically, so copy files
|
||||
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).
|
||||
|
||||
<div>
|
||||
<a href="https://intradeus.github.io/http-protocol-redirector?r=sidestore://source?url=https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/sidestore-badge.png" alt="Add to SideStore" height="60"></a>
|
||||
|
||||
<a href="https://intradeus.github.io/http-protocol-redirector?r=feather://source/https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/feather-badge.png" alt="Add to Feather" height="60"></a>
|
||||
|
||||
<a href="https://intradeus.github.io/http-protocol-redirector?r=altstore://source?url=https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/altstore-badge.png" alt="Add to AltStore" height="60"></a>
|
||||
|
||||
<a href="https://github.com/bryanthaboi/gen1recomp/releases/latest"><img src="./.github/resources/github-badge.png" alt="Download from GitHub" height="60"></a>
|
||||
</div>
|
||||
|
||||
## Handhelds
|
||||
|
||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||
@@ -183,6 +229,27 @@ ships with every release as `gen1recomp-*-rg34xxsp-stockos64-mod.zip`.
|
||||
Install steps, controls, and troubleshooting live in
|
||||
[docs/anbernic-rg34xxsp.md](docs/anbernic-rg34xxsp.md).
|
||||
|
||||
## Nintendo Switch
|
||||
|
||||
Releases ship an SD-ready `gen1recomp-*-switch.zip` (issue
|
||||
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Runtime target
|
||||
is pinned [love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`.
|
||||
Requires a console that can run Switch homebrew. Hardware evidence: **OLED**
|
||||
(author) and **V1 / Erista** boot (community).
|
||||
|
||||
- Players: [docs/switch-install.md](docs/switch-install.md) — download the
|
||||
zip, extract at the microSD root (install or update), title-override
|
||||
launch, import your own legal ROM, Joy-Con controls and shortcuts.
|
||||
- Builders: [docs/switch-build.md](docs/switch-build.md) — `--fetch` /
|
||||
`--loose` / `--fused`, toolchain, Docker fallback, and **CI vs release**
|
||||
(path-gated ubuntu selftest, canonical fused PR artifact, release hard-fail).
|
||||
|
||||
Limitations, Dusklight-derived method, and how we tested:
|
||||
[docs/switch-development.md](docs/switch-development.md) and
|
||||
[docs/switch-hardware-evidence.md](docs/switch-hardware-evidence.md). Community
|
||||
help — especially HOS / love-nx packaging and broader hardware coverage — is
|
||||
welcome.
|
||||
|
||||
## Modding
|
||||
|
||||
The game ships a native mod platform: content registries, events and hooks,
|
||||
@@ -230,4 +297,7 @@ This project would not be possible without [pret](https://github.com/pret) >
|
||||
the pret band of decompiling maniacs > and their
|
||||
[pokered](https://github.com/pret/pokered) disassembly.
|
||||
|
||||
Nintendo Switch port: [andrewqsantos](https://github.com/andrewqsantos).
|
||||
Switch hardware testing (V1 boot): [booshankles](https://github.com/booshankles).
|
||||
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Plain Pixel font
|
||||
|
||||
"Plain Pixel Font" by Douglas Vautour (Burpy Fresh) is licensed under
|
||||
CC-BY 4.0: https://burpyfresh.itch.io
|
||||
|
||||
Version 0.009 (CJK character additions), unmodified. Characters for most
|
||||
languages have a 5x11 base but can extend vertically; double-width
|
||||
characters such as Hiragana and Katakana are 11x11.
|
||||
|
||||
Bundled so a translation mod can opt into TTF text rendering
|
||||
(`mod.content.font:register("ttf", {})`; see the Translation support
|
||||
section of docs/new-features.md) instead of drawing hundreds of glyph-page
|
||||
tiles. The tile font extracted from the player's ROM stays the default.
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -89,7 +89,7 @@ mkdir -p "$GAME_SRC"
|
||||
# tools/save-editor is part of that payload: the launcher's Edit button on a
|
||||
# save row opens it in-process (main.lua).
|
||||
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
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 developer = os.getenv("POKEPORT_DEV") == "1"
|
||||
if arg then
|
||||
@@ -44,7 +49,7 @@ function love.conf(t)
|
||||
t.window.minwidth = 480
|
||||
t.window.minheight = 360
|
||||
end
|
||||
t.version = "11.5"
|
||||
t.version = love._os == "iOS" and "12.0" or "11.5"
|
||||
t.window.vsync = 1
|
||||
t.modules.joystick = true
|
||||
t.modules.physics = false
|
||||
@@ -53,7 +58,17 @@ function love.conf(t)
|
||||
-- engine before conf runs (LÖVE 11.x / 11.5).
|
||||
local osName = love._os
|
||||
local mobile = osName == "Android" or osName == "iOS"
|
||||
if mobile then
|
||||
local nx = osName == "NX"
|
||||
if nx then
|
||||
-- Switch (love-nx): hint handheld 720p. SDL auto-switches portable↔dock
|
||||
-- (720p↔1080p) only when the window is resizable and not exclusive
|
||||
-- fullscreen; NxDisplay.sync also applies the size on boot and dock change.
|
||||
t.window.width = 1280
|
||||
t.window.height = 720
|
||||
t.window.fullscreen = false
|
||||
t.window.resizable = true
|
||||
t.window.highdpi = false
|
||||
elseif mobile then
|
||||
-- resizable is what unlocks orientation. SDL's Android backend, given no
|
||||
-- SDL_HINT_ORIENTATIONS (LÖVE sets none), calls setRequestedOrientation
|
||||
-- at window creation -- FULL_SENSOR when the window is resizable (rotates
|
||||
@@ -62,7 +77,14 @@ function love.conf(t)
|
||||
-- the game follow the device. The renderer letterboxes the 160x144
|
||||
-- viewport into whatever size results, and the on-screen touch controls
|
||||
-- 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.
|
||||
-- A persisted ORIENTATION lock (#592) overrides all of this after boot:
|
||||
-- src/core/Orientation.lua sets SDL_HINT_ORIENTATIONS over the FFI and
|
||||
-- re-triggers the request, from main.lua for the launcher and from
|
||||
-- Game:applyOptions in game.
|
||||
-- iOS follows the Info.plist orientations
|
||||
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
|
||||
t.window.resizable = true
|
||||
-- Starting size is a tall portrait hint; the OS resizes to the real
|
||||
|
||||
@@ -1683,158 +1683,172 @@ return {
|
||||
{ 0, 0, 0 },
|
||||
},
|
||||
},
|
||||
-- Species -> palette NAME, taken from pokered-gbc data/pokemon/palettes.asm
|
||||
-- ELSE branch -- the one that is NOT gated on GEN_2_GRAPHICS.
|
||||
--
|
||||
-- That file has two tables. The IF GEN_2_GRAPHICS branch assigns a
|
||||
-- PER-SPECIES palette (PAL_BULBASAUR, PAL_SQUIRTLE, ...) authored for Gen 2
|
||||
-- sprite art; the ELSE branch keeps Gen 1's own assignments (GREENMON,
|
||||
-- CYANMON, ...). This port extracts Gen 1 pics from the ROM, so the Gen 1
|
||||
-- branch is the matching one. The per-species table was imported here by
|
||||
-- mistake, which pointed every mon at colours shaded for different art:
|
||||
-- Bulbasaur wore PAL_BULBASAUR's red-orange over a sprite that has no red
|
||||
-- on it, and Squirtle wore PAL_SQUIRTLE's shell brown on his head.
|
||||
--
|
||||
-- The palette VALUES below are still pokered-gbc's, so ADVANCED keeps its
|
||||
-- richer colours -- only which palette each species points at changed.
|
||||
pokemon = {
|
||||
ABRA = "ABRA",
|
||||
AERODACTYL = "AERODACTYL",
|
||||
ALAKAZAM = "ALAKAZAM",
|
||||
ARBOK = "ARBOK",
|
||||
ARCANINE = "ARCANINE",
|
||||
ARTICUNO = "ARTICUNO",
|
||||
BEEDRILL = "BEEDRILL",
|
||||
BELLSPROUT = "BELLSPROUT",
|
||||
BLASTOISE = "BLASTOISE",
|
||||
BULBASAUR = "BULBASAUR",
|
||||
BUTTERFREE = "BUTTERFREE",
|
||||
CATERPIE = "CATERPIE",
|
||||
CHANSEY = "CHANSEY",
|
||||
CHARIZARD = "CHARIZARD",
|
||||
CHARMANDER = "CHARMANDER",
|
||||
CHARMELEON = "CHARMELEON",
|
||||
CLEFABLE = "CLEFABLE",
|
||||
CLEFAIRY = "CLEFAIRY",
|
||||
CLOYSTER = "CLOYSTER",
|
||||
CUBONE = "CUBONE",
|
||||
DEWGONG = "DEWGONG",
|
||||
DIGLETT = "DIGLETT",
|
||||
DITTO = "DITTO",
|
||||
DODRIO = "DODRIO",
|
||||
DODUO = "DODUO",
|
||||
DRAGONAIR = "DRAGONAIR",
|
||||
DRAGONITE = "DRAGONITE",
|
||||
DRATINI = "DRATINI",
|
||||
DROWZEE = "DROWZEE",
|
||||
DUGTRIO = "DUGTRIO",
|
||||
EEVEE = "EEVEE",
|
||||
EKANS = "EKANS",
|
||||
ELECTABUZZ = "ELECTABUZZ",
|
||||
ELECTRODE = "ELECTRODE",
|
||||
EXEGGCUTE = "EXEGGCUTE",
|
||||
EXEGGUTOR = "EXEGGUTOR",
|
||||
FARFETCHD = "FARFETCH_D",
|
||||
FEAROW = "FEAROW",
|
||||
FLAREON = "FLAREON",
|
||||
GASTLY = "GASTLY",
|
||||
GENGAR = "GENGAR",
|
||||
GEODUDE = "GEODUDE",
|
||||
GLOOM = "GLOOM",
|
||||
GOLBAT = "GOLBAT",
|
||||
GOLDEEN = "GOLDEEN",
|
||||
GOLDUCK = "GOLDUCK",
|
||||
GOLEM = "GOLEM",
|
||||
GRAVELER = "GRAVELER",
|
||||
GRIMER = "GRIMER",
|
||||
GROWLITHE = "GROWLITHE",
|
||||
GYARADOS = "GYARADOS",
|
||||
HAUNTER = "HAUNTER",
|
||||
HITMONCHAN = "HITMONCHAN",
|
||||
HITMONLEE = "HITMONLEE",
|
||||
HORSEA = "HORSEA",
|
||||
HYPNO = "HYPNO",
|
||||
IVYSAUR = "IVYSAUR",
|
||||
JIGGLYPUFF = "JIGGLYPUFF",
|
||||
JOLTEON = "JOLTEON",
|
||||
JYNX = "JYNX",
|
||||
KABUTO = "KABUTO",
|
||||
KABUTOPS = "KABUTOPS",
|
||||
KADABRA = "KADABRA",
|
||||
KAKUNA = "KAKUNA",
|
||||
KANGASKHAN = "KANGASKHAN",
|
||||
KINGLER = "KINGLER",
|
||||
KOFFING = "KOFFING",
|
||||
KRABBY = "KRABBY",
|
||||
LAPRAS = "LAPRAS",
|
||||
LICKITUNG = "LICKITUNG",
|
||||
MACHAMP = "MACHAMP",
|
||||
MACHOKE = "MACHOKE",
|
||||
MACHOP = "MACHOP",
|
||||
MAGIKARP = "MAGIKARP",
|
||||
MAGMAR = "MAGMAR",
|
||||
MAGNEMITE = "MAGNEMITE",
|
||||
MAGNETON = "MAGNETON",
|
||||
MANKEY = "MANKEY",
|
||||
MAROWAK = "MAROWAK",
|
||||
MEOWTH = "MEOWTH",
|
||||
METAPOD = "METAPOD",
|
||||
MEW = "MEW",
|
||||
MEWTWO = "MEWTWO",
|
||||
MOLTRES = "MOLTRES",
|
||||
MR_MIME = "MR_MIME",
|
||||
MUK = "MUK",
|
||||
NIDOKING = "NIDOKING",
|
||||
NIDOQUEEN = "NIDOQUEEN",
|
||||
NIDORAN_F = "NIDORAN_F",
|
||||
NIDORAN_M = "NIDORAN_M",
|
||||
NIDORINA = "NIDORINA",
|
||||
NIDORINO = "NIDORINO",
|
||||
NINETALES = "NINETALES",
|
||||
ODDISH = "ODDISH",
|
||||
OMANYTE = "OMANYTE",
|
||||
OMASTAR = "OMASTAR",
|
||||
ONIX = "ONIX",
|
||||
PARAS = "PARAS",
|
||||
PARASECT = "PARASECT",
|
||||
PERSIAN = "PERSIAN",
|
||||
PIDGEOT = "PIDGEOT",
|
||||
PIDGEOTTO = "PIDGEOTTO",
|
||||
PIDGEY = "PIDGEY",
|
||||
PIKACHU = "PIKACHU",
|
||||
PINSIR = "PINSIR",
|
||||
POLIWAG = "POLIWAG",
|
||||
POLIWHIRL = "POLIWHIRL",
|
||||
POLIWRATH = "POLIWRATH",
|
||||
PONYTA = "PONYTA",
|
||||
PORYGON = "PORYGON",
|
||||
PRIMEAPE = "PRIMEAPE",
|
||||
PSYDUCK = "PSYDUCK",
|
||||
RAICHU = "RAICHU",
|
||||
RAPIDASH = "RAPIDASH",
|
||||
RATICATE = "RATICATE",
|
||||
RATTATA = "RATTATA",
|
||||
RHYDON = "RHYDON",
|
||||
RHYHORN = "RHYHORN",
|
||||
SANDSHREW = "SANDSHREW",
|
||||
SANDSLASH = "SANDSLASH",
|
||||
SCYTHER = "SCYTHER",
|
||||
SEADRA = "SEADRA",
|
||||
SEAKING = "SEAKING",
|
||||
SEEL = "SEEL",
|
||||
SHELLDER = "SHELLDER",
|
||||
SLOWBRO = "SLOWBRO",
|
||||
SLOWPOKE = "SLOWPOKE",
|
||||
SNORLAX = "SNORLAX",
|
||||
SPEAROW = "SPEAROW",
|
||||
SQUIRTLE = "SQUIRTLE",
|
||||
STARMIE = "STARMIE",
|
||||
STARYU = "STARYU",
|
||||
TANGELA = "TANGELA",
|
||||
TAUROS = "TAUROS",
|
||||
TENTACOOL = "TENTACOOL",
|
||||
TENTACRUEL = "TENTACRUEL",
|
||||
VAPOREON = "VAPOREON",
|
||||
VENOMOTH = "VENOMOTH",
|
||||
VENONAT = "VENONAT",
|
||||
VENUSAUR = "VENUSAUR",
|
||||
VICTREEBEL = "VICTREEBEL",
|
||||
VILEPLUME = "VILEPLUME",
|
||||
VOLTORB = "VOLTORB",
|
||||
VULPIX = "VULPIX",
|
||||
WARTORTLE = "WARTORTLE",
|
||||
WEEDLE = "WEEDLE",
|
||||
WEEPINBELL = "WEEPINBELL",
|
||||
WEEZING = "WEEZING",
|
||||
WIGGLYTUFF = "WIGGLYTUFF",
|
||||
ZAPDOS = "ZAPDOS",
|
||||
ZUBAT = "ZUBAT",
|
||||
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 = "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm + color/**",
|
||||
world = {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
-- BikeShop (BIKE_SHOP) flavor dialogue
|
||||
-- Source: pokered/scripts/BikeShop.asm, pokered/text/BikeShop.asm
|
||||
--
|
||||
-- TEXT_BIKESHOP_CLERK is skipped: it drives the actual voucher-for-bicycle
|
||||
-- exchange (YesNoChoice purchase menu, GiveItem, RemoveItemByID, SetEvent
|
||||
-- EVENT_GOT_BICYCLE). That is a significant standalone feature outside the
|
||||
-- scope of these two flavor NPCs and is left unported here.
|
||||
-- TEXT_BIKESHOP_CLERK lives in data/scripts/story2.lua (M.BIKE_SHOP): the
|
||||
-- voucher exchange and the BICYCLE/CANCEL price window need more than
|
||||
-- command rows (#568).
|
||||
|
||||
return {
|
||||
BIKE_SHOP = {
|
||||
@@ -20,12 +19,15 @@ return {
|
||||
-- CheckEvent EVENT_GOT_BICYCLE ; jr nz, .gotBike
|
||||
-- before the player owns a bike -> TheseBikesAreExpensiveText
|
||||
-- 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 = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_BICYCLE" }, -- 2
|
||||
{ "jump_if_true", 5 }, -- 3
|
||||
{ "check_item", "BICYCLE" }, -- 2
|
||||
{ "jump_if_true", 6 }, -- 3
|
||||
{ "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4
|
||||
{ "jump", 6 }, -- 5
|
||||
{ "jump", "end" }, -- 5
|
||||
{ "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6
|
||||
},
|
||||
},
|
||||
|
||||
@@ -35,13 +35,25 @@ local function middleAgedMan(game, ow, npc, done)
|
||||
-- .loop: print WhichBadgeText, then show the badge list menu again
|
||||
push(game, t._CeruleanBadgeHouseMiddleAgedManWhichBadgeText, function()
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Strings = require("src.core.Strings")
|
||||
local items = {}
|
||||
for _, id in ipairs(BADGE_ORDER) do
|
||||
local idef = game.data.items[id]
|
||||
items[#items + 1] = { label = idef and idef.name or id, value = id }
|
||||
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, {
|
||||
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)
|
||||
end,
|
||||
onCancel = function()
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
-- 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
|
||||
-- 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 = {}
|
||||
|
||||
@@ -12,6 +16,29 @@ local function push(game, ow, npc, done, text)
|
||||
game.stack:push(TextBox.new(game, text, done))
|
||||
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 = {
|
||||
talk = {
|
||||
-- CeruleanCityCooltrainerF1Text (scripts/CeruleanCity.asm:362-393)
|
||||
@@ -23,32 +50,20 @@ M.CERULEAN_CITY = {
|
||||
local text
|
||||
if roll >= 180 then
|
||||
text = t._CeruleanCityCooltrainerF1SlowbroUseSonicboomText
|
||||
or t._CeruleanCityCooltrainerF1ElectrodeUseSonicboomText
|
||||
elseif roll >= 100 then
|
||||
text = t._CeruleanCityCooltrainerF1SlowbroPunchText
|
||||
or t._CeruleanCityCooltrainerF1ElectrodePunchText
|
||||
else
|
||||
text = t._CeruleanCityCooltrainerF1SlowbroWithdrawText
|
||||
or t._CeruleanCityCooltrainerF1ElectrodeWithdrawText
|
||||
end
|
||||
push(game, ow, npc, done, text)
|
||||
end,
|
||||
|
||||
-- CeruleanCitySlowbroText (scripts/CeruleanCity.asm:395-436)
|
||||
-- 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.
|
||||
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,
|
||||
TEXT_CERULEANCITY_SLOWBRO = talkMon,
|
||||
-- Yellow map object uses TEXT_CERULEANCITY_ELECTRODE
|
||||
TEXT_CERULEANCITY_ELECTRODE = talkMon,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ return {
|
||||
-- else -> .WhatsLostIsLostText (player has TM_DIG)
|
||||
TEXT_CERULEANTRASHEDHOUSE_FISHING_GURU = {
|
||||
{ "check_item", "TM_DIG" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_CeruleanTrashedHouseFishingGuruTheyStoleATMText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_CeruleanTrashedHouseFishingGuruWhatsLostIsLostText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Fuchsia City (pokered/scripts/FuchsiaCity.asm)
|
||||
--
|
||||
-- The exhibit signs are text_asm bodies: PrintText of a single text_far,
|
||||
-- then DisplayPokedex for the exhibited species. The preview marks the
|
||||
-- species seen but not owned, same as the S.S. Anne passenger's Snorlax.
|
||||
--
|
||||
-- The fossil sign branches on the Mt. Moon fossil events. The exhibit
|
||||
-- holds the fossil the player did NOT take: taking the Dome Fossil puts
|
||||
-- Omanyte on display, taking the Helix Fossil puts Kabuto. With neither
|
||||
-- event set the sign only prints its undetermined line and no dex entry
|
||||
-- opens.
|
||||
--
|
||||
-- The other text pointers (city sign, Safari Game signs, mart/center/gym
|
||||
-- and warden signs, the four NPCs, the exhibited-mon FuchsiaCityPokemonText
|
||||
-- rows) are plain text_far wrappers that resolve through Data:resolveText,
|
||||
-- so they are not ported here.
|
||||
return {
|
||||
FUCHSIA_CITY = {
|
||||
talk = {
|
||||
-- FuchsiaCityChanseySignText: PrintText(_FuchsiaCityChanseySignText),
|
||||
-- then DisplayPokedex CHANSEY.
|
||||
TEXT_FUCHSIACITY_CHANSEY_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityChanseySignText" },
|
||||
{ "mark_seen", "CHANSEY" },
|
||||
{ "push_screen", "DexEntryMenu", "CHANSEY" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityVoltorbSignText: PrintText(_FuchsiaCityVoltorbSignText),
|
||||
-- then DisplayPokedex VOLTORB.
|
||||
TEXT_FUCHSIACITY_VOLTORB_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityVoltorbSignText" },
|
||||
{ "mark_seen", "VOLTORB" },
|
||||
{ "push_screen", "DexEntryMenu", "VOLTORB" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityKangaskhanSignText: PrintText(_FuchsiaCityKangaskhanSignText),
|
||||
-- then DisplayPokedex KANGASKHAN.
|
||||
TEXT_FUCHSIACITY_KANGASKHAN_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityKangaskhanSignText" },
|
||||
{ "mark_seen", "KANGASKHAN" },
|
||||
{ "push_screen", "DexEntryMenu", "KANGASKHAN" },
|
||||
},
|
||||
|
||||
-- FuchsiaCitySlowpokeSignText: PrintText(_FuchsiaCitySlowpokeSignText),
|
||||
-- then DisplayPokedex SLOWPOKE.
|
||||
TEXT_FUCHSIACITY_SLOWPOKE_SIGN = {
|
||||
{ "show_text", "_FuchsiaCitySlowpokeSignText" },
|
||||
{ "mark_seen", "SLOWPOKE" },
|
||||
{ "push_screen", "DexEntryMenu", "SLOWPOKE" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityLaprasSignText: PrintText(_FuchsiaCityLaprasSignText),
|
||||
-- then DisplayPokedex LAPRAS.
|
||||
TEXT_FUCHSIACITY_LAPRAS_SIGN = {
|
||||
{ "show_text", "_FuchsiaCityLaprasSignText" },
|
||||
{ "mark_seen", "LAPRAS" },
|
||||
{ "push_screen", "DexEntryMenu", "LAPRAS" },
|
||||
},
|
||||
|
||||
-- FuchsiaCityFossilSignText: CheckEvent EVENT_GOT_DOME_FOSSIL /
|
||||
-- CheckEventReuseA EVENT_GOT_HELIX_FOSSIL pick the text and the
|
||||
-- displayed entry; with neither set only the undetermined line prints.
|
||||
TEXT_FUCHSIACITY_FOSSIL_SIGN = {
|
||||
{ "check_flag", "EVENT_GOT_DOME_FOSSIL" }, -- 1
|
||||
{ "jump_if_true", 7 }, -- 2
|
||||
{ "check_flag", "EVENT_GOT_HELIX_FOSSIL" }, -- 3
|
||||
{ "jump_if_true", 11 }, -- 4
|
||||
{ "show_text", "_FuchsiaCityFossilSignUndeterminedText" }, -- 5
|
||||
{ "jump", "end" }, -- 6
|
||||
{ "show_text", "_FuchsiaCityFossilSignOmanyteText" }, -- 7
|
||||
{ "mark_seen", "OMANYTE" }, -- 8
|
||||
{ "push_screen", "DexEntryMenu", "OMANYTE" }, -- 9
|
||||
{ "jump", "end" }, -- 10
|
||||
{ "show_text", "_FuchsiaCityFossilSignKabutoText" }, -- 11
|
||||
{ "mark_seen", "KABUTO" }, -- 12
|
||||
{ "push_screen", "DexEntryMenu", "KABUTO" }, -- 13
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -85,6 +85,47 @@ return {
|
||||
alreadyGotLabel = "_GameCornerGentlemanCloselyWatchTheReelsText",
|
||||
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!",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ return {
|
||||
TEXT_LAVENDERMART_COOLTRAINER_M = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_RESCUED_MR_FUJI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_LavenderMartCooltrainerMReviveText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_LavenderMartCooltrainerMNuggetText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,9 +13,9 @@ M.MR_FUJIS_HOUSE = {
|
||||
TEXT_MRFUJISHOUSE_SUPER_NERD = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 2
|
||||
{ "jump_if_true", 5 }, -- 3
|
||||
{ "jump_if_true", 6 }, -- 3
|
||||
{ "show_text", "_MrFujisHouseSuperNerdMrFujiIsntHereText" }, -- 4
|
||||
{ "jump", 6 }, -- 5
|
||||
{ "jump", "end" }, -- 5
|
||||
{ "show_text", "_MrFujisHouseSuperNerdMrFujiHadBeenPrayingText" }, -- 6
|
||||
},
|
||||
|
||||
@@ -25,9 +25,9 @@ M.MR_FUJIS_HOUSE = {
|
||||
TEXT_MRFUJISHOUSE_LITTLE_GIRL = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 2
|
||||
{ "jump_if_true", 5 }, -- 3
|
||||
{ "jump_if_true", 6 }, -- 3
|
||||
{ "show_text", "_MrFujisHouseLittleGirlThisIsMrFujisHouseText" }, -- 4
|
||||
{ "jump", 6 }, -- 5
|
||||
{ "jump", "end" }, -- 5
|
||||
{ "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6
|
||||
},
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ return {
|
||||
TEXT_ROUTE16GATE1F_GUARD = {
|
||||
{ "face_player" },
|
||||
{ "check_item", "BICYCLE" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_Route16Gate1FGuardNoPedestriansAllowedText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_Route16Gate1FGuardCyclingRoadExplanationText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -14,9 +14,9 @@ return {
|
||||
TEXT_ROUTE18GATE1F_GUARD = {
|
||||
{ "face_player" },
|
||||
{ "check_item", "BICYCLE" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_Route18Gate1FGuardYouNeedABicycleText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_Route18Gate1FGuardCyclingRoadUphillText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,9 +12,9 @@ return {
|
||||
TEXT_SILPHCO10F_SILPH_WORKER_F = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo10FSilphWorkerFImScaredText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo10FSilphWorkerFQuietAboutMyCryingText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,9 +10,9 @@ return {
|
||||
-- not set: _SilphCo3FSilphWorkerMWhatShouldIDoText
|
||||
TEXT_SILPHCO3F_SILPH_WORKER_M = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo3FSilphWorkerMWhatShouldIDoText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo3FSilphWorkerMYouSavedUsText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,9 +7,9 @@ return {
|
||||
TEXT_SILPHCO4F_SILPH_WORKER_M = {
|
||||
{"face_player"},
|
||||
{"check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI"},
|
||||
{"jump_if_true", 5},
|
||||
{"jump_if_true", 6},
|
||||
{"show_text", "_SilphCo4FSilphWorkerMImHidingText"},
|
||||
{"jump", 6},
|
||||
{"jump", "end"},
|
||||
{"show_text", "_SilphCo4FSilphWorkerMTeamRocketIsGoneText"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,9 +7,9 @@ return {
|
||||
TEXT_SILPHCO5F_SILPH_WORKER_M = {
|
||||
{"face_player"},
|
||||
{"check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI"},
|
||||
{"jump_if_true", 5},
|
||||
{"jump_if_true", 6},
|
||||
{"show_text", "_SilphCo5FSilphWorkerMThatsYouRightText"},
|
||||
{"jump", 6},
|
||||
{"jump", "end"},
|
||||
{"show_text", "_SilphCo5FSilphWorkerMYoureOurHeroText"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,9 +11,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_M1 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM1TookOverTheBuildingText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM1BackToWorkText" },
|
||||
},
|
||||
|
||||
@@ -21,9 +21,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_M2 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerMHelpMePleaseText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerMWeGotEngagedText" },
|
||||
},
|
||||
|
||||
@@ -31,9 +31,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_F1 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF1SuchACowardText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF1HaveToMarryHimText" },
|
||||
},
|
||||
|
||||
@@ -41,9 +41,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_F2 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF2TeamRocketConquerWorldText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerF2TeamRocketRanText" },
|
||||
},
|
||||
|
||||
@@ -51,9 +51,9 @@ return {
|
||||
TEXT_SILPHCO6F_SILPH_WORKER_M3 = {
|
||||
{ "face_player" },
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "jump_if_true", 6 },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM3TargetedSilphText" },
|
||||
{ "jump", 6 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo6FSilphWorkerM3WorkForSilphText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,9 +10,9 @@ return {
|
||||
-- set: _SilphCo7FSilphWorkerM2CancelledMasterBallText
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M2 = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM2AfterTheMasterBallText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM2CancelledMasterBallText" },
|
||||
},
|
||||
|
||||
@@ -22,9 +22,9 @@ return {
|
||||
-- set: _SilphCo7FSilphWorkerM3YouChasedOffTeamRocketText
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M3 = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM3ItWouldBeBadText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM3YouChasedOffTeamRocketText" },
|
||||
},
|
||||
|
||||
@@ -34,9 +34,9 @@ return {
|
||||
-- set: _SilphCo7FSilphWorkerM4SafeAtLastText
|
||||
TEXT_SILPHCO7F_SILPH_WORKER_M4 = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM4ItsReallyDangerousHereText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM4SafeAtLastText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,9 +10,9 @@ return {
|
||||
-- set: _SilphCo8FSilphWorkerMThanksForSavingUsText
|
||||
TEXT_SILPHCO8F_SILPH_WORKER_M = {
|
||||
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
{ "jump_if_true", 4 },
|
||||
{ "jump_if_true", 5 },
|
||||
{ "show_text", "_SilphCo8FSilphWorkerMSilphIsFinishedText" },
|
||||
{ "jump", 5 },
|
||||
{ "jump", "end" },
|
||||
{ "show_text", "_SilphCo8FSilphWorkerMThanksForSavingUsText" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
-- 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 Font = require("src.render.Font")
|
||||
local Theme = require("src.ui.Theme")
|
||||
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" },
|
||||
}
|
||||
|
||||
-- The headings list is a two-column menu, which src/ui/Menu.lua does not do
|
||||
-- (it stacks one column), so the layout lives here (#591). .blackboardLoop:
|
||||
-- TextBoxBorder at hlcoord 0, 0 with `lb bc, 6, 10` is the 12x8 box,
|
||||
-- StatusAilmentText1 (" SLP"/" PSN"/" PAR") is placed at hlcoord 1, 2 and
|
||||
-- StatusAilmentText2 (" BRN"/" FRZ"/" QUIT") at hlcoord 6, 2. LEFT/RIGHT
|
||||
-- move wTopMenuItemX between those two columns and swap wMenuItemOffset
|
||||
-- between 0 and 3 while leaving wCurrentMenuItem (the row) alone; UP/DOWN
|
||||
-- are not in wMenuWatchedKeys, so they only slide the cursor and loop.
|
||||
local BOARD_LABELS = {}
|
||||
for i, row in ipairs(STATUS_LABELS) do BOARD_LABELS[i] = row[1] end
|
||||
BOARD_LABELS[#BOARD_LABELS + 1] = " QUIT"
|
||||
local BOARD_COL_X = { 1, 6 }
|
||||
local BOARD_ROW_Y = 2
|
||||
local BOARD_ROWS = 3
|
||||
|
||||
local StatusBoard = {}
|
||||
StatusBoard.__index = StatusBoard
|
||||
|
||||
function StatusBoard.new(game, onPick, onQuit)
|
||||
return setmetatable({ game = game, col = 1, row = 1, labels = BOARD_LABELS,
|
||||
onPick = onPick, onQuit = onQuit }, StatusBoard)
|
||||
end
|
||||
|
||||
-- flat index = pokered's wMenuItemOffset (0 or 3) + wCurrentMenuItem (0..2),
|
||||
-- so 1..5 are the statuses in ViridianBlackboardStatusPointers order and 6
|
||||
-- is QUIT
|
||||
function StatusBoard:selection()
|
||||
return (self.col - 1) * BOARD_ROWS + self.row
|
||||
end
|
||||
|
||||
function StatusBoard:update()
|
||||
local input = self.game.input
|
||||
if input:wasPressed("up") then
|
||||
-- wMenuWrappingEnabled is never set here, so both ends are hard stops
|
||||
if self.row > 1 then self.row = self.row - 1 end
|
||||
elseif input:wasPressed("down") then
|
||||
if self.row < BOARD_ROWS then self.row = self.row + 1 end
|
||||
elseif input:wasPressed("left") then
|
||||
self.col = 1
|
||||
elseif input:wasPressed("right") then
|
||||
self.col = 2
|
||||
elseif input:wasPressed("a") or input:wasPressed("b") then
|
||||
-- HandleMenuInput_ (home/window.asm) beeps for the PAD_A | PAD_B branch,
|
||||
-- and B and QUIT share .exitBlackboard
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
local sel = self:selection()
|
||||
if input:wasPressed("b") or sel > #STATUS_LABELS then
|
||||
self.onQuit()
|
||||
else
|
||||
self.onPick(sel)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function StatusBoard:draw()
|
||||
Font.drawBox(0, 0, 12, 8)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, label in ipairs(BOARD_LABELS) do
|
||||
local col = i <= BOARD_ROWS and 1 or 2
|
||||
local row = i - (col - 1) * BOARD_ROWS
|
||||
Font.draw(label, BOARD_COL_X[col] * 8, (BOARD_ROW_Y + row - 1) * 8)
|
||||
end
|
||||
-- wTopMenuItemX equals the column PlaceString started at, so the cursor
|
||||
-- covers the blank each label leads with
|
||||
Font.drawCode(Theme.cursor, BOARD_COL_X[self.col] * 8,
|
||||
(BOARD_ROW_Y + self.row - 1) * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
local function blackboard(game)
|
||||
local text = game.data.text or {}
|
||||
local openBoard
|
||||
-- wCurrentMenuItem / wMenuItemOffset are zeroed once, above .blackboardLoop,
|
||||
-- and nothing inside the loop clears them again: after a status blurb
|
||||
-- `jp .blackboardLoop` comes back with the cursor still on the row and
|
||||
-- column the player just picked. One StatusBoard lives for the whole
|
||||
-- reading and is re-pushed each pass, so only entering the blackboard
|
||||
-- resets to the left column / top row (#591).
|
||||
local board
|
||||
-- .blackboardLoop reprints ViridianSchoolBlackboardText2 and only then
|
||||
-- calls HandleMenuInput, so the prompt is on screen for exactly as long as
|
||||
-- the headings list is. That text ends in `done`, not `prompt`
|
||||
-- (data/text/text_2.asm:646), so PrintText returns with the box still up
|
||||
-- and never waits for a button: TextBox opts.stay holds it open under the
|
||||
-- list and these callbacks pop the pair together (#591).
|
||||
local function closeBoard()
|
||||
game.stack:pop() -- the headings list
|
||||
game.stack:pop() -- the held "Which heading" box under it
|
||||
end
|
||||
local function pick(i)
|
||||
closeBoard()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text[STATUS_LABELS[i][2]] or STATUS_LABELS[i][1], openBoard))
|
||||
end
|
||||
function openBoard()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
|
||||
nil, { stay = { onShown = function()
|
||||
board = board or StatusBoard.new(game, pick, closeBoard)
|
||||
game.stack:push(board)
|
||||
end } }))
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._ViridianSchoolBlackboardText1
|
||||
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
|
||||
openBoard))
|
||||
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,
|
||||
},
|
||||
}
|
||||
@@ -15,6 +15,7 @@ local files = {
|
||||
"data.scripts.flavor.cerulean_trashed_house",
|
||||
"data.scripts.flavor.copycats_house_1f",
|
||||
"data.scripts.flavor.copycats_house_2f",
|
||||
"data.scripts.flavor.fuchsia_city",
|
||||
"data.scripts.flavor.game_corner",
|
||||
"data.scripts.flavor.lavender_cubone_house",
|
||||
"data.scripts.flavor.lavender_mart",
|
||||
@@ -60,6 +61,7 @@ local files = {
|
||||
"data.scripts.flavor.victory_road_2f",
|
||||
"data.scripts.flavor.viridian_city",
|
||||
"data.scripts.flavor.viridian_nickname_house",
|
||||
"data.scripts.flavor.viridian_school_house", -- #503
|
||||
"data.scripts.flavor.wardens_house",
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,8 @@ end
|
||||
if GameVersion.isYellow() then
|
||||
for _, file in ipairs({ "data.scripts.yellow_gifts",
|
||||
"data.scripts.yellow_jessie_james",
|
||||
"data.scripts.yellow_beach_house" }) do
|
||||
"data.scripts.yellow_beach_house",
|
||||
"data.scripts.yellow_viridian_old_man" }) do
|
||||
for mapId, mod in pairs(require(file)) do
|
||||
MapScripts.attachBase(mapId, mod)
|
||||
end
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
-- takes it ("I'll take this one, then!") and both balls disappear.
|
||||
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
|
||||
-- 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
|
||||
-- has walked you in, else "gramps isn't around" (#218); with
|
||||
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
|
||||
@@ -20,10 +22,10 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
rivalBallX, rivalBall)
|
||||
return {
|
||||
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1
|
||||
{ "jump_if_true", 20 }, -- 2
|
||||
{ "jump_if_true", 22 }, -- 2
|
||||
-- no picking until Oak has walked you in (OaksLabScript gating)
|
||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
||||
{ "jump_if_false", 20 }, -- 4
|
||||
{ "jump_if_false", 25 }, -- 4
|
||||
-- the Pokédex "new species" entry shows before the ask (predef
|
||||
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
|
||||
-- sets the owned bits so ShowPokedexData prints height/weight/text;
|
||||
@@ -31,30 +33,45 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ "push_screen", "DexEntryMenu",
|
||||
{ species = species, forceOwned = true } }, -- 5
|
||||
{ "ask", askText }, -- 6
|
||||
{ "jump_if_false", 21 }, -- 7
|
||||
{ "jump_if_false", "end" }, -- 7
|
||||
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
|
||||
-- inside give_pokemon). Show the received text first so the
|
||||
-- nickname prompt follows "you got X", matching Gen1.
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
|
||||
{ "give_pokemon", species, 5 }, -- 9
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
|
||||
{ "set_flag", choseFlag }, -- 11
|
||||
-- The received text carries sound_get_key_item (OaksLab.asm
|
||||
-- OaksLabReceivedMonText); the jingle plays as the box opens
|
||||
-- (same beat as the Yellow port's starter, #668).
|
||||
{ "play_sound", "Get_Key_Item" }, -- 8
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
|
||||
{ "give_pokemon", species, 5 }, -- 10
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
|
||||
{ "set_flag", choseFlag }, -- 12
|
||||
-- POKé BALLs are not handed out here in the original -- Oak gives
|
||||
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
|
||||
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 13
|
||||
-- the rival walks to the countering ball (around the furniture)
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
|
||||
{ "face_object", 1, "up" }, -- 14
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 16
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 14
|
||||
{ "face_object", 1, "up" }, -- 15
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
|
||||
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17
|
||||
{ "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
|
||||
{ "show_text", "_OaksLabRivalReceivedMonText",
|
||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||
or "BULBASAUR" } }, -- 17
|
||||
{ "jump", 21 }, -- 18
|
||||
{ "jump", 21 }, -- 19 (spacer)
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
|
||||
or "BULBASAUR" } }, -- 19
|
||||
{ "jump", "end" }, -- 20
|
||||
{ "jump", "end" }, -- 21 (spacer)
|
||||
-- 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" }, -- 22
|
||||
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 23
|
||||
-- OaksLabLastMonScript ends at TextScriptEnd; the port used to fall
|
||||
-- through into the pre-pick line below (#601 remnant, reported on #600)
|
||||
{ "jump", "end" }, -- 24
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 25
|
||||
}
|
||||
end
|
||||
|
||||
@@ -62,10 +79,22 @@ return {
|
||||
talk = {
|
||||
-- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_
|
||||
-- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk-
|
||||
-- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating
|
||||
-- (DisplayDexRating) is still skipped.
|
||||
-- in, full Pokédex speech, rival exit, Route 22 arm).
|
||||
TEXT_OAKSLAB_OAK1 = {
|
||||
{ "face_player" },
|
||||
-- OaksLabOak1Text leads with the dex-rating branch (#600): with
|
||||
-- EVENT_PALLET_AFTER_GETTING_POKEBALLS set (converted saves), or
|
||||
-- 2+ species owned once the Pokédex is in hand, Oak asks how it is
|
||||
-- coming and rates it (predef DisplayDexRating). Red keeps the
|
||||
-- GOT_POKEDEX gate that Yellow's copy of this text drops
|
||||
-- (data/scripts/oaks_lab_yellow.lua).
|
||||
{ "check_flag", "EVENT_PALLET_AFTER_GETTING_POKEBALLS" },
|
||||
{ "jump_if_true", "dex_rating" },
|
||||
{ "check_dex_owned", 2 },
|
||||
{ "jump_if_false", "no_rating" },
|
||||
{ "check_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "jump_if_true", "dex_rating" },
|
||||
{ "label", "no_rating" },
|
||||
{ "check_item", "POKE_BALL" },
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
|
||||
@@ -151,6 +180,14 @@ return {
|
||||
|
||||
{ "label", "come_see" },
|
||||
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
-- .HowIsYourPokedexComingText ends on `prompt` and OaksLabOak1Text
|
||||
-- sets wDoNotWaitForButtonPressAfterDisplayingText, so the seen/owned
|
||||
-- tally follows with no button wait (engine/events/pokedex_rating.asm)
|
||||
{ "label", "dex_rating" },
|
||||
{ "show_text", "_OaksLabOak1HowIsYourPokedexComingText" },
|
||||
{ "dex_rating" },
|
||||
},
|
||||
|
||||
TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
|
||||
@@ -245,7 +282,12 @@ return {
|
||||
and y >= 6 then
|
||||
local rival = ow:npcByIndex(1)
|
||||
if not rival then return false end
|
||||
-- OaksLabRivalChallengesPlayerScript swaps in the rival encounter
|
||||
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
||||
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
||||
local rows = {
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
|
||||
}
|
||||
-- the rival routes to a free cell beside the player
|
||||
@@ -283,8 +325,15 @@ return {
|
||||
table.insert(rows, { "jump_if_false", base + 6 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
||||
-- walk out past the player. The fanfare was dropped here (#683) -- the
|
||||
-- parcel scene above already plays Music_MeetRival on both arrival and
|
||||
-- departure (lines 144-146), and this exit should match (#596).
|
||||
table.insert(rows, { "stop_music" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival" })
|
||||
table.insert(rows, { "move_npc_to", 1, 4, 11 })
|
||||
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
ow.runner:run(rows, { npc = rival })
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -71,8 +71,12 @@ return {
|
||||
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
|
||||
{ "set_flag", "EVENT_GOT_POKEDEX" },
|
||||
{ "set_flag", "EVENT_OAK_GOT_PARCEL" },
|
||||
-- OaksLabOakGivesPokedexScript: HideObject TOGGLE_LYING_OLD_MAN /
|
||||
-- ShowObject TOGGLE_OLD_MAN_2 -- Yellow's tutorial old man stands
|
||||
-- on the sleeper's cell (18,9); the Red/Blue walker OLD_MAN at
|
||||
-- (17,5) never appears in Yellow (#617)
|
||||
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
|
||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
|
||||
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN2" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
{ "move_npc_to", RIVAL, 4, 7 },
|
||||
@@ -143,17 +147,25 @@ return {
|
||||
-- OaksLabRivalExclamationScript: "!" over the rival
|
||||
{ "emote", RIVAL, "shock" },
|
||||
}
|
||||
-- .RivalPushesPlayerAwayFromEeveeBall + the PAD_RIGHT x2 shove:
|
||||
-- the rival cuts across to the ball WHILE the player standing
|
||||
-- below it is bumped two tiles right (both movements run in the
|
||||
-- same beat, so the walk overlaps the shove like the original)
|
||||
-- .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", "right" }, { wait = false } }
|
||||
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 }
|
||||
-- let the rival finish the last stretch to (7,4)
|
||||
rows[#rows + 1] = { "wait", 40 }
|
||||
-- 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
|
||||
@@ -275,10 +287,12 @@ return {
|
||||
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
|
||||
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
|
||||
-- walk out past the player (#683).
|
||||
table.insert(rows, { "wait", 20 })
|
||||
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
|
||||
table.insert(rows, { "stop_music" })
|
||||
table.insert(rows, { "play_music", "Music_MeetRival" })
|
||||
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" })
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
-- (.PlayerNextToSafariZoneWorker1CoordsArray). Paying ¥500 hands over
|
||||
-- 30 SAFARI BALLs and starts the 502-step game
|
||||
-- (SafariZoneGateWouldYouLikeToJoinScript: wSafariSteps = 502,
|
||||
-- wNumSafariBalls = SAFARI_BALLS_RECEIVED). Declining walks you back
|
||||
-- so you can't slip past. Returning to the gate ends the game and the
|
||||
-- worker takes the leftover balls back.
|
||||
-- wNumSafariBalls = SAFARI_BALLS_RECEIVED), then auto-walks the player
|
||||
-- up through the north warp into the zone. Declining walks you back
|
||||
-- so you can't slip past. Returning to the gate ends the game, the
|
||||
-- worker takes the leftover balls back and the auto-walk drops you 3
|
||||
-- cells below the warp you came in by (#540).
|
||||
--
|
||||
-- Step/ball bookkeeping lives in src/world/OverworldController.lua
|
||||
-- (safariStep/safariGameOver, from
|
||||
@@ -19,7 +21,33 @@ local FEE = 500
|
||||
local BALLS = 30
|
||||
local STEPS = 502
|
||||
|
||||
local function startGame(game, t, done, balls, introText)
|
||||
-- SafariZoneGateSafariZoneWorker1WouldYouLikeToJoinText .success closes with
|
||||
-- `ld a, PAD_UP / ld c, 3 / SafariZoneEntranceAutoWalk`: paying walks the
|
||||
-- player up out of the gate and through the north warp, it is never left to
|
||||
-- the player. EVENT_IN_SAFARI_ZONE is already set when that walk runs, so
|
||||
-- the two gate steps taken before the warp fires are charged against
|
||||
-- wSafariSteps (home/overworld.asm:307-310) -- which is why the counter
|
||||
-- reads 500/500 on arrival even though the script wrote 502 (#540). The
|
||||
-- port's counter only runs on the nine interior maps (FieldDefaults
|
||||
-- safari.stepMaps, OverworldState:inSafariStepZone), so charge those two
|
||||
-- steps here instead.
|
||||
local function walkIntoZone(game, ow)
|
||||
local p = ow.player
|
||||
-- only from the two trigger cells in front of the worker, which are the
|
||||
-- columns the north warps sit on; a player who paid after TALKING to him
|
||||
-- from somewhere else walks in on their own, as they do today
|
||||
local w = p.cellY == 2 and ow.map:warpAtCell(p.cellX, 0) or nil
|
||||
if not w then return end
|
||||
ow:scriptMove(p, "up", 2, function()
|
||||
local st = game.save.safari
|
||||
if st then st.steps = st.steps - 2 end
|
||||
-- scripted steps skip onStepComplete (and with it CheckWarpsNoCollision),
|
||||
-- so take that warp explicitly once the walk lands on it
|
||||
ow:takeWarp(w.def)
|
||||
end)
|
||||
end
|
||||
|
||||
local function startGame(game, ow, t, done, balls, introText)
|
||||
game.save.safari = { balls = balls or BALLS, steps = STEPS }
|
||||
game.save.safariNags = nil
|
||||
local TextBox = require("src.render.TextBox")
|
||||
@@ -31,7 +59,10 @@ local function startGame(game, t, done, balls, introText)
|
||||
local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText
|
||||
or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!"
|
||||
local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!"
|
||||
game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, done))
|
||||
game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, function()
|
||||
if done then done() end
|
||||
walkIntoZone(game, ow)
|
||||
end))
|
||||
end
|
||||
|
||||
-- Yellow's soft-lock fix (scripts/SafariZoneGate_2.asm): a player short of
|
||||
@@ -52,7 +83,7 @@ local function yellowLowCost(game, ow, t, done, back)
|
||||
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)
|
||||
startGame(game, ow, t, done, balls, intro)
|
||||
return
|
||||
end
|
||||
local nag = game.save.safariNags or 0
|
||||
@@ -62,7 +93,7 @@ local function yellowLowCost(game, ow, t, done, back)
|
||||
(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)
|
||||
startGame(game, ow, t, done, 1, intro)
|
||||
return
|
||||
end
|
||||
local nags = {
|
||||
@@ -100,7 +131,7 @@ local function joinPrompt(game, ow, done)
|
||||
end
|
||||
else
|
||||
game.save.money = game.save.money - FEE
|
||||
startGame(game, t, done)
|
||||
startGame(game, ow, t, done)
|
||||
end
|
||||
end))
|
||||
end))
|
||||
@@ -135,27 +166,38 @@ M.SAFARI_ZONE_GATE = {
|
||||
-- no walks you back into the zone
|
||||
onEnter = function(game, ow)
|
||||
if not game.save.safari or ow.player.cellY > 1 then return end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local t = game.data.text
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._SafariZoneGateSafariZoneWorker1LeavingEarlyText or "Leaving early?",
|
||||
function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
-- back into the zone through the entrance warp
|
||||
local w = game.data.maps.SAFARI_ZONE_CENTER.warps[1]
|
||||
ow:startWarpTo("SAFARI_ZONE_CENTER", w.x, w.y, "up")
|
||||
return
|
||||
end
|
||||
game.save.safari = nil
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._SafariZoneGateSafariZoneWorker1ReturnSafariBallsText
|
||||
or "Please return any\nSAFARI BALLs you\nhave left.")
|
||||
.. "\f" .. (t._SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText
|
||||
or "Did you get a\ngood haul?\fCome again!")))
|
||||
end))
|
||||
end))
|
||||
-- QUEUED, never pushed: onEnter runs inside the arriving warp's
|
||||
-- Transition midpoint, and Transition:finish pops whatever is on top
|
||||
-- the same frame (Timing.WARP_FADE_IN is 0) -- so a box pushed here is
|
||||
-- swallowed, and on a build where it survived it drew over a screen
|
||||
-- still faded to black (#540). Same contract as M.HALL_OF_FAME in
|
||||
-- data/scripts/story.lua.
|
||||
--
|
||||
-- SafariZoneGateLeavingSafariScript .leaving_early: YES prints the
|
||||
-- return-balls text, faces the player down and runs
|
||||
-- SafariZoneEntranceAutoWalk with `PAD_DOWN, c = 3`, landing on the
|
||||
-- counter row 3 cells below the warp you came in by; NO prints
|
||||
-- "Good Luck!" and walks one step back up through that same warp.
|
||||
local rightSide = ow.player.cellX ~= 3
|
||||
local dest = game.data.maps.SAFARI_ZONE_CENTER.warps[rightSide and 2 or 1]
|
||||
ow:queueScript({
|
||||
{ "ask", "_SafariZoneGateSafariZoneWorker1LeavingEarlyText" },
|
||||
{ "jump_if_false", "stay" },
|
||||
-- the port never reaches SafariZoneGateLeavingSafariScript's own
|
||||
-- GOOD_HAUL_COME_AGAIN branch (safariGameOver warps straight to the
|
||||
-- counter), so the sign-off rides on this path
|
||||
{ "show_text", "_SafariZoneGateSafariZoneWorker1ReturnSafariBallsText" },
|
||||
{ "show_text", "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText" },
|
||||
-- no value: set_field assigns nil, which is how save.safari is cleared
|
||||
{ "set_field", "safari" },
|
||||
-- move_player runs through scriptMove, which skips onStepComplete, so
|
||||
-- walking back down past (x,2) cannot re-fire the join trigger
|
||||
{ "move_player", "down", 3 },
|
||||
{ "jump", "end" },
|
||||
{ "label", "stay" },
|
||||
{ "show_text", "_SafariZoneGateSafariZoneWorker1GoodLuckText" },
|
||||
{ "warp", "SAFARI_ZONE_CENTER", dest.x, dest.y, "up" },
|
||||
})
|
||||
end,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
-- field.seafoam (SEAFOAM_ISLANDS_1F/B1F/B3F holes+holeDestination and
|
||||
-- B3F's pluggedByHolesOn) plus the generic
|
||||
-- OverworldState:boulderIntoHole in src/world/OverworldController.lua;
|
||||
-- no per-map onEnter hook is needed here.
|
||||
-- no per-map onEnter hook is needed for the boulders.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -23,4 +23,46 @@ M.VERMILION_GYM = {
|
||||
end,
|
||||
}
|
||||
|
||||
-- The PLAYER falling down those same holes (#599). field.seafoam's holes
|
||||
-- carry only the BOULDER's object cell on the floor below (landsAt), not
|
||||
-- the player's landing, so the player's landing is spelled out here: it
|
||||
-- comes from data/maps/special_warps.asm DungeonWarpList/DungeonWarpData,
|
||||
-- which the importer does not extract. Same shape as MANSION_HOLES in
|
||||
-- data/scripts/story6.lua and VICTORY_ROAD_3F.onStep in
|
||||
-- data/scripts/story.lua; CAVERN $22 is a walkable tile, so the fall has
|
||||
-- to be an onStep, not a collision block.
|
||||
--
|
||||
-- Sources: scripts/SeafoamIslands1F.asm Seafoam1HolesCoords (17,6)/(24,6),
|
||||
-- B1F.asm Seafoam2HolesCoords (18,6)/(23,6), B2F.asm Seafoam3HolesCoords
|
||||
-- (19,6)/(22,6), B3F.asm Seafoam4HolesCoords (3,16)/(6,16). Each floor
|
||||
-- sets wDungeonWarpDestinationMap and calls IsPlayerOnDungeonWarp, and
|
||||
-- wCoordIndex picks that floor's DungeonWarpData row. Unconditional in
|
||||
-- the original: a plugged hole still drops the player. The B3F and B4F
|
||||
-- landings are water; setMap's CheckForceBikeOrSurf pass
|
||||
-- (OverworldState:checkForcedMovement) mounts SURF on arrival.
|
||||
local HOLE_FALLS = {
|
||||
SEAFOAM_ISLANDS_1F = { { 17, 6, "SEAFOAM_ISLANDS_B1F", 18, 7 },
|
||||
{ 24, 6, "SEAFOAM_ISLANDS_B1F", 23, 7 } },
|
||||
SEAFOAM_ISLANDS_B1F = { { 18, 6, "SEAFOAM_ISLANDS_B2F", 19, 7 },
|
||||
{ 23, 6, "SEAFOAM_ISLANDS_B2F", 22, 7 } },
|
||||
SEAFOAM_ISLANDS_B2F = { { 19, 6, "SEAFOAM_ISLANDS_B3F", 18, 7 },
|
||||
{ 22, 6, "SEAFOAM_ISLANDS_B3F", 19, 7 } },
|
||||
SEAFOAM_ISLANDS_B3F = { { 3, 16, "SEAFOAM_ISLANDS_B4F", 4, 14 },
|
||||
{ 6, 16, "SEAFOAM_ISLANDS_B4F", 5, 14 } },
|
||||
}
|
||||
|
||||
for mapId, holes in pairs(HOLE_FALLS) do
|
||||
M[mapId] = M[mapId] or {}
|
||||
M[mapId].onStep = function(game, ow, x, y)
|
||||
for _, h in ipairs(holes) do
|
||||
if x == h[1] and y == h[2] then
|
||||
require("src.core.Sound").play(game.data, "Faint_Fall")
|
||||
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -188,7 +188,12 @@ M.BILLS_HOUSE = {
|
||||
end
|
||||
if ow.player.facing == "down" then
|
||||
-- the player is standing on his straight path: walk around
|
||||
-- (.PokemonWalkAroundPlayerMovement)
|
||||
-- (.PokemonWalkAroundPlayerMovement). BillsHouseScript2 runs
|
||||
-- BillsHousePikachuWatchPlayer first on this branch, so a
|
||||
-- Pikachu that is still following steps clear of Bill's detour
|
||||
-- and turns to watch the player (#455).
|
||||
require("src.world.PikachuFollower")
|
||||
.onBillWalksAroundPlayer(game, ow)
|
||||
ow:scriptMove(npc, "right", 1, function()
|
||||
ow:scriptMove(npc, "up", 2, function()
|
||||
ow:scriptMove(npc, "left", 1, function()
|
||||
@@ -600,12 +605,34 @@ local function snorlaxWake(mapId, objName, beatFlag, wokeUpText, calmedText)
|
||||
}
|
||||
end
|
||||
|
||||
-- A beaten Snorlax is gone for good: Route12/Route16DefaultScript run
|
||||
-- HideObject in the same breath as the battle that sets
|
||||
-- EVENT_BEAT_ROUTEnn_SNORLAX, so "event set, object still on the map" is a
|
||||
-- state the asm cannot produce. Here it can (a mod's world:toggleObject, a
|
||||
-- save edited or migrated from a build older than the flag), and it is a
|
||||
-- dead end: ItemEffects' adjacentSleepingSnorlax refuses to wake a Snorlax
|
||||
-- whose beat flag is set (ItemUsePokeFlute's CheckEvent, engine/items/
|
||||
-- item_effects.asm), so the sleeper sits in the road forever and Cycling
|
||||
-- Road is unreachable (#585). Reconcile the toggle from the flag on every
|
||||
-- entry -- the mirror of SaveData.lua's toggle -> flag backfill, and the
|
||||
-- same repair shape the Silph Co. floors use below.
|
||||
local function hideBeatenSnorlax(mapId, objName, beatFlag)
|
||||
return function(game, ow)
|
||||
if not game.save.flags[beatFlag] then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.hide_object({ game = game, save = game.save, overworld = ow },
|
||||
mapId, objName)
|
||||
end
|
||||
end
|
||||
|
||||
-- snorlaxWake is looked up by ItemEffects.lua/BagMenu.lua (via
|
||||
-- data/scripts/init.lua's M.get) and run when the flute wakes Snorlax;
|
||||
-- objName/beatFlag let ItemEffects find the NPC and check whether it's
|
||||
-- already been beaten before allowing the wake.
|
||||
M.ROUTE_12 = {
|
||||
talk = { TEXT_ROUTE12_SNORLAX = { { "show_text", "_Route12SnorlaxText" } } },
|
||||
onEnter = hideBeatenSnorlax("ROUTE_12", "ROUTE12_SNORLAX",
|
||||
"EVENT_BEAT_ROUTE12_SNORLAX"),
|
||||
snorlaxWake = {
|
||||
objName = "ROUTE12_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE12_SNORLAX",
|
||||
script = snorlaxWake("ROUTE_12", "ROUTE12_SNORLAX", "EVENT_BEAT_ROUTE12_SNORLAX",
|
||||
@@ -614,6 +641,8 @@ M.ROUTE_12 = {
|
||||
}
|
||||
M.ROUTE_16 = {
|
||||
talk = { TEXT_ROUTE16_SNORLAX = { { "show_text", "_Route16Text7" } } },
|
||||
onEnter = hideBeatenSnorlax("ROUTE_16", "ROUTE16_SNORLAX",
|
||||
"EVENT_BEAT_ROUTE16_SNORLAX"),
|
||||
snorlaxWake = {
|
||||
objName = "ROUTE16_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE16_SNORLAX",
|
||||
script = snorlaxWake("ROUTE_16", "ROUTE16_SNORLAX", "EVENT_BEAT_ROUTE16_SNORLAX",
|
||||
@@ -645,11 +674,18 @@ M.SAFARI_ZONE_SECRET_HOUSE = {
|
||||
M.WARDENS_HOUSE = {
|
||||
talk = {
|
||||
TEXT_WARDENSHOUSE_WARDEN = {
|
||||
-- Labelled rather than hand-numbered: the branches here have been
|
||||
-- re-pointed twice now (#535, #645), and every insert used to mean
|
||||
-- renumbering three jumps that had no way of announcing they were stale.
|
||||
{ "face_player" }, -- 1
|
||||
{ "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, so the warden said nothing on every
|
||||
-- visit after the trade. pokered's .got_item branch
|
||||
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText.
|
||||
{ "jump_if_true", "got_hm04" }, -- 3
|
||||
{ "check_item", "GOLD_TEETH" }, -- 4
|
||||
{ "jump_if_false", 15 }, -- 5
|
||||
{ "jump_if_false", "no_teeth" }, -- 5
|
||||
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
|
||||
{ "take_item", "GOLD_TEETH", 1 }, -- 7
|
||||
{ "set_flag", "EVENT_GAVE_GOLD_TEETH" }, -- 8
|
||||
@@ -658,9 +694,27 @@ M.WARDENS_HOUSE = {
|
||||
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
|
||||
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
|
||||
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
|
||||
{ "jump", 16 }, -- 13 (already got it)
|
||||
{ "jump", 16 }, -- 14 (unused)
|
||||
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
|
||||
{ "jump", "end" }, -- 13 (jp .done)
|
||||
|
||||
-- #645: WardensHouseWardenText prints Gibberish1, then YesNoChoice,
|
||||
-- and the warden answers the same gibberish either way -- Gibberish2
|
||||
-- on yes, Gibberish3 on no (scripts/WardensHouse.asm). The port
|
||||
-- printed the question and walked off before the answer.
|
||||
{ "label", "no_teeth" }, -- 14
|
||||
{ "ask", "_WardensHouseWardenGibberish1Text" }, -- 15
|
||||
{ "jump_if_true", "gibberish_yes" }, -- 16
|
||||
{ "show_text", "_WardensHouseWardenGibberish3Text" }, -- 17
|
||||
{ "jump", "end" }, -- 18
|
||||
{ "label", "gibberish_yes" }, -- 19
|
||||
{ "show_text", "_WardensHouseWardenGibberish2Text" }, -- 20
|
||||
{ "jump", "end" }, -- 21
|
||||
|
||||
-- #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.
|
||||
{ "label", "got_hm04" }, -- 22
|
||||
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 23
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -712,6 +766,28 @@ local function silphRocketsLeave(game, ow, onlyMap)
|
||||
end
|
||||
end
|
||||
|
||||
-- SilphCo11FGiovanniAfterBattleScript (scripts/SilphCo11F.asm) is the whole
|
||||
-- aftermath: DisplayTextID TEXT_SILPHCO11F_GIOVANNI_YOU_RUINED_OUR_PLANS,
|
||||
-- GBFadeOutToBlack, SilphCo11FTeamRocketLeavesScript, Delay3,
|
||||
-- GBFadeInFromBlack, then SetEvent. The port had only the hide pass, so the
|
||||
-- speech never played and every rocket blinked out in front of the player
|
||||
-- (#722). Same hide list as silphRocketsLeave, spelled as script rows so the
|
||||
-- fade can hold over it.
|
||||
local function silphAftermathRows()
|
||||
local rows = {
|
||||
{ "show_text", "_SilphCo11FGiovanniYouRuinedOurPlansText" },
|
||||
{ "fade", "out" },
|
||||
}
|
||||
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
|
||||
for _, name in ipairs(floor[2]) do
|
||||
rows[#rows + 1] = { "hide_object", floor[1], name }
|
||||
end
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 3 } -- Delay3
|
||||
rows[#rows + 1] = { "fade", "in" }
|
||||
return rows
|
||||
end
|
||||
|
||||
M.SILPH_CO_11F = {
|
||||
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
|
||||
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
|
||||
@@ -738,11 +814,15 @@ M.SILPH_CO_11F = {
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves
|
||||
-- after the loss (the street rockets are handled by
|
||||
-- M.SAFFRON_CITY.onEnter in story4.lua).
|
||||
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech,
|
||||
-- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph
|
||||
-- rocket leaves off-screen (the street rockets are handled by
|
||||
-- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the
|
||||
-- battle's own callbacks are still unwinding, so queueScript starts
|
||||
-- it on the first idle overworld frame -- after the end-battle
|
||||
-- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722).
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
|
||||
silphRocketsLeave(game, ow)
|
||||
ow:queueScript(silphAftermathRows())
|
||||
end
|
||||
end)
|
||||
end)
|
||||
@@ -888,6 +968,7 @@ M.VICTORY_ROAD_3F = {
|
||||
-- fall is onStep, not a collision block.
|
||||
onStep = function(game, ow, x, y)
|
||||
if x == 23 and y == 15 then
|
||||
require("src.core.Sound").play(game.data, "Faint_Fall")
|
||||
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
|
||||
return true
|
||||
end
|
||||
@@ -926,40 +1007,62 @@ M.VICTORY_ROAD_3F = {
|
||||
local championsRoomRivalScript = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
|
||||
{ "jump_if_true", 25 }, -- 3 past end
|
||||
-- "end" rather than a row number past the tail: this script grew by a row
|
||||
-- when the follow-Oak walk landed (#704), which silently turned the old
|
||||
-- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past
|
||||
-- it, so a returning champion warped straight into the induction.
|
||||
{ "jump_if_true", "end" }, -- 3
|
||||
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
|
||||
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
|
||||
{ "jump_if_false", 25 }, -- 6 past end
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
|
||||
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
|
||||
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
|
||||
-- playBattle("final") then no-ops on the same song, so the theme stays
|
||||
-- continuous into the fight
|
||||
{ "play_music", "Music_FinalBattle" }, -- 5
|
||||
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 6
|
||||
-- losing halts here; the numeric target this replaced pointed at the
|
||||
-- closing warp, which inducted a player who had just lost the fight (#704)
|
||||
{ "jump_if_false", "end" }, -- 7
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 8
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 9
|
||||
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
|
||||
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
|
||||
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
|
||||
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
|
||||
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
|
||||
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10
|
||||
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
|
||||
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
|
||||
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
|
||||
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
|
||||
{ "show_text", "_ChampionsRoomOakText" }, -- 11
|
||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
|
||||
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
|
||||
{ "play_music", "Music_Cities1", { keep = true } }, -- 11
|
||||
{ "show_text", "_ChampionsRoomOakText" }, -- 12
|
||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 13
|
||||
{ "move_npc", 2, "up", 5 }, -- 14 OakEntranceAfterVictoryMovement
|
||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||
{ "face_object", 1, "left" }, -- 14
|
||||
{ "face_object", 2, "down" }, -- 15
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
|
||||
{ "face_object", 1, "left" }, -- 15
|
||||
{ "face_object", 2, "down" }, -- 16
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 17
|
||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||
{ "face_object", 2, "right" }, -- 17
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
|
||||
{ "face_object", 2, "right" }, -- 18
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 19
|
||||
-- OakComeWithMeScript: Oak faces down again, then exits up
|
||||
{ "face_object", 2, "down" }, -- 19
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
|
||||
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
|
||||
{ "face_object", 2, "down" }, -- 20
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 21
|
||||
{ "move_npc", 2, "up", 2 }, -- 22 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 23
|
||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
|
||||
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the
|
||||
-- screen just fading on the spot (#704). The entrance walk leaves the
|
||||
-- player at (4,3) and both north-wall warps sit on row 0, so the original
|
||||
-- only ever spends three of those simulated steps -- CheckWarpsNoCollision
|
||||
-- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the
|
||||
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
|
||||
-- as they do in the original (CollisionCheckOnLand skips its checks while
|
||||
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
|
||||
-- rival's cell at (4,2) is the ported behavior, not a clip.
|
||||
{ "move_player", "up", 3 }, -- 24
|
||||
-- hand the induction off to the HALL_OF_FAME room (consumed by its
|
||||
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
|
||||
{ "set_field", "pendingHallOfFame", true }, -- 23
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
|
||||
{ "set_field", "pendingHallOfFame", true }, -- 25
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 26
|
||||
}
|
||||
|
||||
M.CHAMPIONS_ROOM = {
|
||||
|
||||
@@ -413,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 = {
|
||||
talk = {
|
||||
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")
|
||||
if (game.save.inventory.BICYCLE or 0) > 0 then
|
||||
game.stack:push(TextBox.new(game, "How's the\nBICYCLE treating\nyou?", done))
|
||||
elseif (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
|
||||
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))
|
||||
local t = game.data.text
|
||||
local function say(text, after, opts)
|
||||
game.stack:push(TextBox.new(game, text, after, opts))
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -111,8 +111,9 @@ M.POKEMON_TOWER_5F = {
|
||||
--
|
||||
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
|
||||
-- Silph Scope check at the trigger -- the scope only decides whether the
|
||||
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
|
||||
-- balls dodged). An earlier version of this port turned the player back
|
||||
-- disguise sticks (IsGhostBattle -> makeGhost: "too scared to move", balls
|
||||
-- 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
|
||||
-- impassable on any route that skips Rocket Hideout; vanilla lets the
|
||||
-- battle open and a POKE_DOLL end it (see wBattleResult below).
|
||||
@@ -133,7 +134,13 @@ M.POKEMON_TOWER_6F = {
|
||||
-- or not the scope revealed it, so the "can't be caught" state rides
|
||||
-- the battle instead of IsGhostBattle alone (#444)
|
||||
battle.noCatch = true
|
||||
if not game.save.inventory.SILPH_SCOPE then
|
||||
-- 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()
|
||||
end
|
||||
battle.onFinish = function(result)
|
||||
@@ -353,6 +360,38 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
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)
|
||||
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
|
||||
-- owns both the engage and the BeatGiovanniScript aftermath.
|
||||
@@ -502,146 +541,290 @@ M.GAME_CORNER = {
|
||||
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)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local t = game.data.text
|
||||
local function line(suffix, fallback)
|
||||
return t["_GameCornerClerk1" .. suffix]
|
||||
or t["_GameCornerClerk" .. suffix]
|
||||
or fallback
|
||||
end
|
||||
-- GameCornerDrawCoinBox (scripts/GameCorner.asm; pokeyellow's copy is
|
||||
-- identical): TextBoxBorder at hlcoord 11,0 with b=5 c=7, a 9x7-tile
|
||||
-- window in the top right holding MONEY at (12,2) over the amount on
|
||||
-- row 3 and COIN at (12,4) over the count on row 5. Both
|
||||
-- PrintBCDNumber calls pass LEADING_ZEROES, whose bit 7 SUPPRESSES
|
||||
-- leading zeroes (home/print_bcd.asm), and neither passes LEFT_ALIGN,
|
||||
-- so both numbers read plain and right-aligned against the inner edge
|
||||
-- at column 18. The asm draws the box before the offer and redraws it
|
||||
-- after the purchase, so it stands for the whole exchange: a draw-only
|
||||
-- state under the dialogue gets that lifetime, since StateStack draws
|
||||
-- every state above the last opaque one and updates only the top
|
||||
-- (src/core/StateStack.lua), and reading save each frame is the
|
||||
-- redraw (#624).
|
||||
local coinBox = { draw = function()
|
||||
Font.drawBox(11, 0, 9, 7)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("MONEY"), 96, 16)
|
||||
local money = ("¥%d"):format(game.save.money or 0)
|
||||
Font.draw(money, 152 - Font.width(money), 24)
|
||||
Font.draw(Strings("COIN"), 96, 32)
|
||||
local coins = ("%d"):format(game.save.coins or 0)
|
||||
Font.draw(coins, 152 - Font.width(coins), 40)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end }
|
||||
game.stack:push(coinBox)
|
||||
-- Every branch below finishes here. A TextBox pops itself before its
|
||||
-- onDone runs, so the coin box is top of the stack again by then and
|
||||
-- this pop takes it down, never someone else's state.
|
||||
local function finish()
|
||||
game.stack:pop()
|
||||
done()
|
||||
end
|
||||
-- YesNoChoice is called with the offer still printed, so the prompt
|
||||
-- has to ride the open text box (opts.choice) instead of being pushed
|
||||
-- after it closes, which is what made the question vanish (#624).
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._GameCornerClerk1DoYouNeedSomeGameCoinsText
|
||||
or "Do you need some\ngame coins?\f¥1000 for 50."), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
line("DoYouNeedSomeGameCoinsText",
|
||||
"Do you need some\ngame coins?\f¥1000 for 50."),
|
||||
nil, { choice = function(yes)
|
||||
if not yes then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1PleaseComePlaySometimeText
|
||||
or "No? Please come\nplay sometime!", done))
|
||||
line("PleaseComePlaySometimeText",
|
||||
"No? Please come\nplay sometime!"), finish))
|
||||
return
|
||||
end
|
||||
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
|
||||
-- the COIN CASE and room for at least 9 coins (Has9990Coins)
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1DontHaveCoinCaseText
|
||||
or "You don't have a\nCOIN CASE!", done))
|
||||
line("DontHaveCoinCaseText",
|
||||
"You don't have a\nCOIN CASE!"), finish))
|
||||
return
|
||||
end
|
||||
if (game.save.coins or 0) >= 9990 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1CoinCaseIsFullText
|
||||
or "Oops! Your COIN\nCASE is full.", done))
|
||||
line("CoinCaseIsFullText",
|
||||
"Oops! Your COIN\nCASE is full."), finish))
|
||||
return
|
||||
end
|
||||
if game.save.money < 1000 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1CantAffordTheCoinsText
|
||||
or "You can't afford\nthe coins!", done))
|
||||
line("CantAffordTheCoinsText",
|
||||
"You can't afford\nthe coins!"), finish))
|
||||
return
|
||||
end
|
||||
game.save.money = game.save.money - 1000
|
||||
game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
|
||||
-- the thanks text is the plain _GameCornerClerk1ThanksHereAre50-
|
||||
-- CoinsText; the new count belongs in the coin box the asm
|
||||
-- redraws here, not appended to the line (#624)
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._GameCornerClerk1ThanksHereAre50CoinsText
|
||||
or "Thanks! Here are\nyour 50 coins!")
|
||||
.. ("\fCOINS: %d"):format(game.save.coins), done))
|
||||
end))
|
||||
end))
|
||||
line("ThanksHereAre50CoinsText",
|
||||
"Thanks! Here are\nyour 50 coins!"), finish))
|
||||
end }))
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
-- 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).
|
||||
-- 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.
|
||||
-- Each counter owns ONE window of three prizes, not the whole catalogue:
|
||||
-- GetPrizeMenuId (engine/events/prize_menu.asm) subtracts
|
||||
-- TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 from hTextID and indexes
|
||||
-- PrizeDifferentMenuPtrs with the result, so vendor 1 sells
|
||||
-- PrizeMenuMon1Entries, vendor 2 PrizeMenuMon2Entries and vendor 3
|
||||
-- PrizeMenuTMsEntries (#623). The mon windows and their levels differ per
|
||||
-- version; the TM window is identical in all three, so it is shared.
|
||||
local PRIZE_TMS = {
|
||||
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
|
||||
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
|
||||
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
|
||||
}
|
||||
local RED_PRIZES = {
|
||||
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
|
||||
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
|
||||
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
|
||||
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
|
||||
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
||||
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
|
||||
local RED_PRIZE_WINDOWS = {
|
||||
{
|
||||
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
|
||||
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
|
||||
},
|
||||
{
|
||||
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
|
||||
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
|
||||
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
||||
},
|
||||
PRIZE_TMS,
|
||||
}
|
||||
local BLUE_PRIZES = {
|
||||
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
|
||||
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
|
||||
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
|
||||
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
|
||||
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
|
||||
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
|
||||
local BLUE_PRIZE_WINDOWS = {
|
||||
{
|
||||
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
|
||||
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
|
||||
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
|
||||
},
|
||||
{
|
||||
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
|
||||
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
|
||||
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
|
||||
},
|
||||
PRIZE_TMS,
|
||||
}
|
||||
-- Yellow keeps the three windows but restocks both mon counters
|
||||
-- (pokeyellow/data/events/prizes.asm, prize_mon_levels.asm)
|
||||
local YELLOW_PRIZE_WINDOWS = {
|
||||
{
|
||||
{ kind = "mon", species = "ABRA", level = 15, cost = 230 },
|
||||
{ kind = "mon", species = "VULPIX", level = 18, cost = 1000 },
|
||||
{ kind = "mon", species = "WIGGLYTUFF", level = 22, cost = 2680 },
|
||||
},
|
||||
{
|
||||
{ kind = "mon", species = "SCYTHER", level = 30, cost = 6500 },
|
||||
{ kind = "mon", species = "PINSIR", level = 30, cost = 6500 },
|
||||
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
|
||||
},
|
||||
PRIZE_TMS,
|
||||
}
|
||||
|
||||
local function activePrizes()
|
||||
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
|
||||
local function prizeWindow(n)
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local windows = RED_PRIZE_WINDOWS
|
||||
if GameVersion.isBlue() then
|
||||
windows = BLUE_PRIZE_WINDOWS
|
||||
elseif GameVersion.isYellow() then
|
||||
windows = YELLOW_PRIZE_WINDOWS
|
||||
end
|
||||
return windows[n]
|
||||
end
|
||||
|
||||
-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize
|
||||
-- Prize counters (engine/events/prize_menu.asm CeladonPrizeMenu; the prize
|
||||
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
|
||||
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
|
||||
-- with no case prints RequireCoinCaseText and returns without ever opening a
|
||||
-- window; only with the case does it print ExchangeCoinsForPrizesText and then
|
||||
-- show the prizes. #194: the port used to open the window unconditionally and
|
||||
-- skip both text boxes.
|
||||
local function prizeCounter(game, ow, npc, done)
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Commands = require("src.script.Commands")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
-- IsItemInBag COIN_CASE: without the case, deny and open no window
|
||||
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
-- skip both text boxes. wMaxMenuItem is 3, i.e. this window's three prizes
|
||||
-- plus the NO THANKS row, and HandlePrizeChoice confirms the pick with
|
||||
-- SoYouWantPrizeText + YesNoChoice before any coins move; every branch then
|
||||
-- rets out of CeladonPrizeMenu, so one transaction ends the conversation and
|
||||
-- buying again means talking to the counter again (#623).
|
||||
local function prizeCounter(window)
|
||||
return function(game, ow, npc, done)
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Commands = require("src.script.Commands")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
-- IsItemInBag COIN_CASE: without the case, deny and open no window
|
||||
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
|
||||
return
|
||||
end
|
||||
-- ExchangeCoinsForPrizesText plays before the prize window opens.
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
|
||||
return
|
||||
end
|
||||
-- ExchangeCoinsForPrizesText plays before the prize window opens.
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
|
||||
function()
|
||||
local items = {}
|
||||
for _, p in ipairs(activePrizes()) do
|
||||
local label
|
||||
if p.kind == "mon" then
|
||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||
else
|
||||
label = game.data.items[p.item].name
|
||||
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
|
||||
function()
|
||||
local items = {}
|
||||
for _, p in ipairs(prizeWindow(window)) do
|
||||
local label
|
||||
if p.kind == "mon" then
|
||||
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
|
||||
else
|
||||
label = game.data.items[p.item].name
|
||||
end
|
||||
table.insert(items,
|
||||
{ label = label, right = tostring(p.cost), value = p })
|
||||
end
|
||||
table.insert(items,
|
||||
{ label = label, right = tostring(p.cost), value = p })
|
||||
end
|
||||
local list
|
||||
list = ListMenu.new(game, "PRIZES (COINS)", items, {
|
||||
footer = ("COINS %d"):format(game.save.coins or 0),
|
||||
onChoose = function(item)
|
||||
local p = item.value
|
||||
-- NoThanksText (data/events/prizes.asm) sits under the three prizes
|
||||
table.insert(items, { label = "NO THANKS" })
|
||||
local list
|
||||
-- close the window first: every ending in HandlePrizeChoice leaves
|
||||
-- the menu for good, and the closing line belongs over the map
|
||||
local function finish(msg)
|
||||
list:close()
|
||||
game.stack:push(TextBox.new(game, msg, done))
|
||||
end
|
||||
local function buy(p)
|
||||
if (game.save.coins or 0) < p.cost then
|
||||
list.footer = "Not enough coins!"
|
||||
finish(t._SorryNeedMoreCoinsText or "Sorry, you need\nmore coins.")
|
||||
return
|
||||
end
|
||||
-- HasEnoughCoins passed, so hand the prize over first and only
|
||||
-- subtract once it landed: the asm rets before .subtractCoins when
|
||||
-- the bag is full, or when both the party and every box are full
|
||||
local roomless = t._OopsYouDontHaveEnoughRoomText
|
||||
or "Oops! You don't\nhave enough room."
|
||||
if p.kind == "mon" then
|
||||
-- no runner here, so give_pokemon reports through ctx.lastCheck
|
||||
-- and skips the AskName prompt (Commands.give_pokemon)
|
||||
local ctx = { save = game.save, game = game }
|
||||
Commands.give_pokemon(ctx, p.species, p.level)
|
||||
if not ctx.lastCheck then
|
||||
finish(roomless)
|
||||
return
|
||||
end
|
||||
elseif not require("src.inventory.Bag").add(
|
||||
game.save, p.item, 1, game.data) then
|
||||
finish(roomless)
|
||||
return
|
||||
end
|
||||
game.save.coins = game.save.coins - p.cost
|
||||
if p.kind == "mon" then
|
||||
Commands.give_pokemon({ save = game.save, game = game },
|
||||
p.species, p.level)
|
||||
else
|
||||
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
|
||||
end
|
||||
list.footer = ("Got it! COINS %d"):format(game.save.coins)
|
||||
end,
|
||||
onCancel = done,
|
||||
})
|
||||
game.stack:push(list)
|
||||
end))
|
||||
-- no thank-you line: HereYouGoText is unreferenced in the asm,
|
||||
-- which just redraws the coin box (PrintPrizePrice) and returns
|
||||
list:close()
|
||||
done()
|
||||
end
|
||||
list = ListMenu.new(game, "PRIZES (COINS)", items, {
|
||||
footer = ("COINS %d"):format(game.save.coins or 0),
|
||||
onChoose = function(item)
|
||||
local p = item.value
|
||||
if not p then -- NO THANKS is the B exit (cp 3 -> .noChoice)
|
||||
list:close()
|
||||
done()
|
||||
return
|
||||
end
|
||||
local name = (p.kind == "mon")
|
||||
and game.data.pokemon[p.species].name
|
||||
or game.data.items[p.item].name
|
||||
-- SoYouWantPrizeText names the prize out of wNameBuffer, which
|
||||
-- is not one of TextBox's RAM tokens, so fill it in here
|
||||
local ask = (t._SoYouWantPrizeText
|
||||
or "So, you want\n{RAM:wNameBuffer}?")
|
||||
:gsub("{RAM:wNameBuffer}", name)
|
||||
game.stack:push(TextBox.new(game, ask, nil, {
|
||||
choice = function(yes)
|
||||
if not yes then
|
||||
finish(t._OhFineThenText or "Oh, fine then.")
|
||||
return
|
||||
end
|
||||
buy(p)
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
onCancel = done,
|
||||
})
|
||||
game.stack:push(list)
|
||||
end))
|
||||
end
|
||||
end
|
||||
|
||||
M.GAME_CORNER_PRIZE_ROOM = {
|
||||
talk = { -- the three prize counters are bg events
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter,
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter,
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter,
|
||||
talk = { -- the three prize counters are bg events, one window each
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter(1),
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter(2),
|
||||
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter(3),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,28 @@ local function dojoBall(species, ownBall, otherBall, askKey)
|
||||
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 = {
|
||||
talk = {
|
||||
TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL =
|
||||
@@ -197,6 +219,7 @@ M.FIGHTING_DOJO = {
|
||||
end
|
||||
return false
|
||||
end,
|
||||
onStep = dojoMasterGate,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
@@ -176,6 +176,20 @@ M.ROUTE_18_GATE_2F = {
|
||||
{ "face_player" },
|
||||
{ "trade", 6, "EVENT_TRADED_SLOWBRO_FOR_LICKITUNG" }, -- MARC
|
||||
},
|
||||
-- Yellow replaces the youngster with a cook trading SPIKE
|
||||
-- (TANGELA -> PARASECT): pokeyellow/scripts/Route18Gate2F.asm
|
||||
-- Route18Gate2FCookText runs TRADE_FOR_SPIKE, index 6 in the Yellow
|
||||
-- TradeMons table that Data:applyVersionedFieldData swaps in. Red
|
||||
-- maps have no COOK object here and Yellow maps have no YOUNGSTER,
|
||||
-- so each version only ever fires its own row (#651). Both rows
|
||||
-- share the Red-flavoured done flag on purpose: a .sav tracks
|
||||
-- "trade slot 6 completed" in one wCompletedInGameTradeFlags bit
|
||||
-- either version reads, and the save codec maps that bit to this
|
||||
-- flag name (src/save_convert/GenSave.lua EXTRA_FLAG_BITS).
|
||||
TEXT_ROUTE18GATE2F_COOK = {
|
||||
{ "face_player" },
|
||||
{ "trade", 6, "EVENT_TRADED_SLOWBRO_FOR_LICKITUNG" }, -- SPIKE (Yellow)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -790,8 +804,19 @@ local function bikeGateGuard(coords, stopText, explainText)
|
||||
local t = text(game)
|
||||
push(game, t[stopText] or "Hey! Wait up!", 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
|
||||
ow:scriptMove(ow.player, "up", dist)
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
||||
else
|
||||
shoveRight()
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
@@ -118,6 +118,7 @@ local MANSION_HOLES = {
|
||||
M.POKEMON_MANSION_3F.onStep = function(game, ow, x, y)
|
||||
for _, h in ipairs(MANSION_HOLES) do
|
||||
if x == h[1] and y == h[2] then
|
||||
require("src.core.Sound").play(game.data, "Faint_Fall")
|
||||
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -108,8 +108,16 @@ return {
|
||||
"_ViridianGymGiovanniTM27ExplanationText",
|
||||
} },
|
||||
|
||||
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
|
||||
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
|
||||
-- Silph Co. Giovanni: unlocks the president's Master Ball gift.
|
||||
-- SilphCo11FGiovanniStartBattleScript (scripts/SilphCo11F.asm) hands the
|
||||
-- battle SilphCo10FGiovanniILostAgainText through SaveEndBattleTextPointers,
|
||||
-- but he has no def_trainers header on 11F, so engageTrainer finds no
|
||||
-- header.won to give it -- this chain is the port's stand-in for that loss
|
||||
-- line (#722). The "Blast it all!" speech, the fade and the rockets
|
||||
-- leaving are SilphCo11FGiovanniAfterBattleScript, ported in M.SILPH_CO_11F
|
||||
-- (data/scripts/story.lua).
|
||||
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI",
|
||||
dialogue = { "_SilphCo10FGiovanniILostAgainText" } },
|
||||
|
||||
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
|
||||
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
-- The Viridian City old-man catch tutorial, Yellow's way
|
||||
-- (pokeyellow scripts/ViridianCity.asm, scripts/ViridianCity_2.asm,
|
||||
-- scripts/OaksLab.asm). Registered on top of the shared tables by
|
||||
-- data/scripts/init.lua on a Yellow boot.
|
||||
--
|
||||
-- Yellow has TWO gambler objects where Red/Blue have one:
|
||||
-- * VIRIDIANCITY_OLD_MAN at (17,5) -- a Red/Blue leftover; its
|
||||
-- toggle stays OFF forever in Yellow, no script ever shows it.
|
||||
-- * VIRIDIANCITY_OLD_MAN2 at (18,9) -- replaces the sleeper the
|
||||
-- moment the Pokédex is given (OaksLabOakGivesPokedexScript:
|
||||
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN_2).
|
||||
--
|
||||
-- This is the tutorial old man. The Red/Blue "Are you in a hurry?"
|
||||
-- yes/no script must NOT run against him: Yellow's
|
||||
-- _ViridianCityOldManHadMyCoffeeNowText is the apology speech ("I've had
|
||||
-- my coffee now ... I'll show you how to catch POKéMON as my apology"),
|
||||
-- and the shared story.lua TEXT_VIRIDIANCITY_OLD_MAN rows hang an
|
||||
-- invented yes/no over it -- YES printed the TimeIsMoney alias
|
||||
-- (_ViridianCityOldManLosingMyTouchText) and NO ran the demo, every
|
||||
-- talk, forever (#617).
|
||||
--
|
||||
-- The real flow (ViridianCityCheckWaitingOldMan + ViridianCityOldMan2Text
|
||||
-- + ViridianCityOldManInitialCatchTrainingScript + ...EndInitial... +
|
||||
-- ViridianCityPostInitialCatchTraining): stepping into (19,9) -- the gap
|
||||
-- east of the sleeper's cell -- faces the old man right and the player
|
||||
-- left, prints the apology, and without any choice runs the demo battle
|
||||
-- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5), which he FAILS -- the ball shakes
|
||||
-- three times and breaks open. After it, the same text pointer
|
||||
-- now prints _ViridianCityOldManLosingMyTouchText ("That didn't work!
|
||||
-- I must be losing my touch."), the old man walks off (down 6 with the
|
||||
-- player on (19,9), right 1 otherwise, Pikachu nudged out of the way
|
||||
-- first) and TOGGLE_OLD_MAN_2 hides. A direct talk does the same.
|
||||
|
||||
local M = {}
|
||||
|
||||
local OLD_MAN2 = "VIRIDIANCITY_OLD_MAN2"
|
||||
|
||||
-- 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.
|
||||
-- story5's VIRIDIAN_CITY.onStep chains story.lua's sleeping-old-man
|
||||
-- gate and its own gym-lock step (same pattern as yellow_jessie_james).
|
||||
local baseViridianStep = require("data.scripts.story5").VIRIDIAN_CITY.onStep
|
||||
|
||||
-- pokeyellow text/ViridianCity.asm, _ViridianCityOldManHadMyCoffeeNowText
|
||||
-- and _ViridianCityOldManLosingMyTouchText, spelled with the extractor's
|
||||
-- markers (line -> \n, cont -> \v, para -> \f)
|
||||
local function text(game)
|
||||
return {
|
||||
apology = game.data.text._ViridianCityOldManHadMyCoffeeNowText
|
||||
or "Ahh, I've had my\ncoffee now and I\vfeel great!\fSure, you can go\n"
|
||||
.. "through!\fI'm sorry I was\nso rude to you!\fI see you're using\n"
|
||||
.. "a POKéDEX.\fI'll show you how\nto catch POKéMON\vas my apology.",
|
||||
losingMyTouch = game.data.text._ViridianCityOldManLosingMyTouchText
|
||||
or "That didn't work!\nI must be losing\vmy touch.\fI've run out of\n"
|
||||
.. "POKé BALLs too.\fI have to get some\nat POKéMON MART.",
|
||||
}
|
||||
end
|
||||
|
||||
-- The row list for the initial-tutorial branch, keyed by where the
|
||||
-- player stands when the battle ends (ViridianCityPostInitialCatchTraining
|
||||
-- reads wXCoord: (19,9) walks the old man down the corridor, anywhere
|
||||
-- else walks him right 1 after moving the follower Pikachu aside).
|
||||
local function oldMan2Rows(game, ow, npc)
|
||||
local rows = {
|
||||
{ "show_text", "_ViridianCityOldManHadMyCoffeeNowText" },
|
||||
-- ViridianCityOldManInitialCatchTrainingScript sets
|
||||
-- EVENT_INITIAL_CATCH_TRAINING before the battle runs, and
|
||||
-- ItemUseBall's .oldManBattle branch turns that event into anim data
|
||||
-- $63: three shakes, then the ball breaks open. The losing-my-touch
|
||||
-- line below only follows a throw that failed (#636).
|
||||
{ "old_man_demo", "fail" },
|
||||
{ "set_flag", "EVENT_COMPLETED_CATCH_TRAINING" },
|
||||
{ "show_text", "_ViridianCityOldManLosingMyTouchText" },
|
||||
}
|
||||
if ow.player and ow.player.cellX == 19 then
|
||||
rows[#rows + 1] =
|
||||
{ "walk_npc", npc.def.index,
|
||||
{ "down", "down", "down", "down", "down", "down" } }
|
||||
else
|
||||
-- ViridianCityMovePikachu (scripts/ViridianCity_2.asm): Pikachu
|
||||
-- steps out of the old man's way before he turns right
|
||||
local PikachuFollower = require("src.world.PikachuFollower")
|
||||
local pika = ow and PikachuFollower.current(ow)
|
||||
if pika then
|
||||
rows[#rows + 1] = { "walk_npc", pika.def.index, { "right" } }
|
||||
end
|
||||
rows[#rows + 1] = { "walk_npc", npc.def.index, { "right" } }
|
||||
end
|
||||
rows[#rows + 1] =
|
||||
{ "hide_object", "VIRIDIAN_CITY", OLD_MAN2 }
|
||||
return rows
|
||||
end
|
||||
|
||||
-- The shared talk handler: TEXT_VIRIDIANCITY_OLD_MAN2's text_asm branch
|
||||
-- (ViridianCityOldMan2Text) on EVENT_COMPLETED_CATCH_TRAINING.
|
||||
local function oldMan2Talk(game, ow, npc, done)
|
||||
if game.save.flags and game.save.flags.EVENT_COMPLETED_CATCH_TRAINING then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, text(game).losingMyTouch, done))
|
||||
return
|
||||
end
|
||||
ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done })
|
||||
end
|
||||
|
||||
M.VIRIDIAN_CITY = {
|
||||
talk = {
|
||||
TEXT_VIRIDIANCITY_OLD_MAN2 = oldMan2Talk,
|
||||
},
|
||||
|
||||
-- Re-apply the Pokédex swap for a save that already holds the flag but
|
||||
-- was never standing here when it fired (converted .sav imports, same
|
||||
-- shape as story.lua's VIRIDIAN_CITY.onEnter). Yellow shows OLD_MAN2,
|
||||
-- not the Red/Blue OLD_MAN at (17,5), and also puts away a stray
|
||||
-- OLD_MAN a save made by the pre-#617 build left standing.
|
||||
onEnter = function(game, ow)
|
||||
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
|
||||
return
|
||||
end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY")
|
||||
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN")
|
||||
Commands.show_object(ctx, "VIRIDIAN_CITY", OLD_MAN2)
|
||||
end,
|
||||
|
||||
-- ViridianCityCheckWaitingOldMan: with the Pokédex held and the
|
||||
-- tutorial undone, (19,9) -- the gap east of the old man, the same
|
||||
-- cell the sleeper used to gate -- faces him right, turns the player
|
||||
-- left and starts the OLD_MAN2 flow with no choice.
|
||||
onStep = function(game, ow, x, y)
|
||||
if baseViridianStep and baseViridianStep(game, ow, x, y) then
|
||||
return true
|
||||
end
|
||||
local flags = game.save.flags
|
||||
if not flags.EVENT_GOT_POKEDEX then return false end
|
||||
if flags.EVENT_COMPLETED_CATCH_TRAINING then return false end
|
||||
if x ~= 19 or y ~= 9 then return false end
|
||||
local man
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def and npc.def.name == OLD_MAN2 then man = npc break end
|
||||
end
|
||||
if not man then return false end
|
||||
man.facing = "right"
|
||||
ow.player.facing = "left"
|
||||
oldMan2Talk(game, ow, man, nil)
|
||||
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
|
||||
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
|
||||
[PortMaster](https://portmaster.games/), so the device does not need a
|
||||
separate `love_11.5` runtime download on first launch. The launcher resolves
|
||||
|
||||
@@ -12,7 +12,7 @@ this port.
|
||||
|
||||
| # | 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`) |
|
||||
| 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` |
|
||||
@@ -38,6 +38,10 @@ this port.
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 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).
|
||||
@@ -93,9 +96,6 @@ ZIP**, then double-click the zip to unpack it.
|
||||
|
||||
## Optional goodies
|
||||
|
||||
- **Pokéwalker mode** (real steps → EXP): in-game **mod manager →
|
||||
POKEWALKER → SYNC STEPS on**, allow step access when iOS asks, and go
|
||||
for a walk.
|
||||
- **Mods**: launcher → **MODS** tab → **Import mod .zip**.
|
||||
- **Save import/export**: buttons on each game's tab, using the normal
|
||||
iOS file picker.
|
||||
|
||||
@@ -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).
|
||||
@@ -45,6 +45,9 @@ same system picker and install the chosen archive on return.
|
||||
draws one chip per game plus a MODS chip and rebuilds `self.tabRects` every
|
||||
frame so `mousepressed` can dispatch clicks; switching tabs mid-import is
|
||||
allowed (a dropped ROM still routes by SHA-1 regardless of which tab shows).
|
||||
On **NX**, **Scan again** is stricter: it only starts an import whose SHA-1
|
||||
matches the open game tab, so a shared `imports/` folder with Red+Yellow
|
||||
cannot jump Yellow → Red.
|
||||
|
||||
- A game tab (`_drawGamePanel`) shows the ROM card, the SAVE FILES card, the
|
||||
Play button, and the SAVE SLOT card in a responsive two-column grid (see
|
||||
@@ -150,6 +153,17 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
|
||||
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
|
||||
`options.mods[id]` so a later reinstall starts from the loader's default
|
||||
(enabled). The mods panel Delete control calls this and re-derives the list.
|
||||
- A mod that declares `github` shows its total GitHub downloads (every
|
||||
release's summed asset `download_count`, from the same cached release
|
||||
fetch the update check uses) as a highlighted body line like "12,345
|
||||
downloads across all releases - Released 2024-05-31 - Updated 2026-07-01"
|
||||
(first and latest `published_at`). Old cache entries written before the
|
||||
counts existed show no line rather than a wrong zero; a manual check
|
||||
refreshes them.
|
||||
- The MODS panel sorts its rows by Name, Popularity (downloads),
|
||||
Release date (first release), or Last updated, chosen by chips under the
|
||||
header and persisted in `options.modSort`. Mods without release data
|
||||
(no `github` field, or a stale cache) sink to the bottom of data sorts.
|
||||
|
||||
## Import / Export save
|
||||
|
||||
@@ -157,9 +171,14 @@ The SAVE FILES card wires a raw Gen1 `.sav` battery image to the save slots
|
||||
through `src/import/SaveFileIO.lua`, which sits on top of
|
||||
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
|
||||
|
||||
- **Import save** is live once the game's ROM is imported (playable). It opens
|
||||
a native `.sav` picker (`chooseSav` on desktop; on Android,
|
||||
`love.system.pickFile("sav")` → `picked_save.sav`, same SAF path as ROMs).
|
||||
- **Import save** is live once the game's ROM is imported (playable).
|
||||
On desktop it opens a native `.sav` picker (`chooseSav`); on Android,
|
||||
`love.system.pickFile("sav")` → `picked_save.sav`, same SAF path as ROMs.
|
||||
On **NX (Switch)** there is no picker: copy a `.sav` into
|
||||
`getSaveDirectory()/imports/saves/<red|blue|yellow>/` via MTP / SD / FTP
|
||||
(one folder per game), then press **Import save** on that game’s tab to
|
||||
ensure the inbox and rescan (same pattern as the ROM `imports/` and mod
|
||||
`imports/mods/` inboxes). Hidden `._*.sav` AppleDouble sidecars are skipped.
|
||||
`SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
|
||||
relative name, a dropped LOVE file, or raw bytes),
|
||||
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
|
||||
@@ -167,19 +186,27 @@ through `src/import/SaveFileIO.lua`, which sits on top of
|
||||
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
|
||||
The meta stamp is re-stamped off `gen1_import` to the current numeric format
|
||||
so `SaveData.load`'s migration pass accepts the slot. On success the SAVE SLOT
|
||||
panel is refreshed with the new slot selected.
|
||||
panel is refreshed with the new slot selected. On **NX**, a successful inbox
|
||||
import retires the file to `*.sav.imported` and records a content hash in
|
||||
`imports/saves/<game>/.imported-sha1` so a second **Import save** (or the same
|
||||
bytes under a new name) does not clone slots; failures leave the original
|
||||
`.sav`. Only that game’s folder is scanned.
|
||||
- **Export save** is live only when the active slot actually holds a save
|
||||
(checked against `listSlots`). `SaveFileIO.exportActiveSlot` loads the active
|
||||
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
|
||||
`rawImport`, so this is a zero-filled template export, which is valid), and
|
||||
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
|
||||
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
|
||||
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
|
||||
shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
|
||||
writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` in the save
|
||||
directory (`exports/` and `exports/<version>/` are created as needed). On
|
||||
desktop it returns the absolute path (`love.filesystem.getSaveDirectory()`),
|
||||
which the notice line shows with an "Open folder" affordance
|
||||
(`love.system.openURL("file://" .. dir)`).
|
||||
On Android the bytes are also staged as `pending_export.sav` and
|
||||
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
|
||||
player can save to Downloads / Drive / etc.; on return `export_done.flag`
|
||||
makes focus show "Save exported."
|
||||
On **NX**, export success sets a notice with the `exports/<game>/` path and an
|
||||
MTP-oriented hint — no `openURL` / Open folder (pull the file via MTP /
|
||||
SD / FTP instead).
|
||||
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
|
||||
currently active game tab; when a non-game tab (mods, or the locked yellow
|
||||
placeholder) is showing it defaults to red, the always-present first game
|
||||
|
||||
@@ -167,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.
|
||||
- `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
|
||||
that reach outside their permission set.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# New features (deliberate additions beyond the original)
|
||||
|
||||
Intentional enhancements this port adds on top of faithful Pokémon Red
|
||||
behavior. They have no Game Boy equivalent and are kept by design.
|
||||
Intentional enhancements this port adds on top of faithful Pokémon Red, Blue,
|
||||
and Yellow behavior. They have no Game Boy equivalent and are kept by design.
|
||||
Genuine divergences from the original (things still missing, wrong, or
|
||||
approximated) live in docs/known-differences.md; faithfully-ported
|
||||
behavior is in docs/behavior-porting-notes.md.
|
||||
approximated) live in docs/known-differences.md; faithfully-ported behavior is
|
||||
in docs/behavior-porting-notes.md.
|
||||
|
||||
## Survey zoom
|
||||
|
||||
@@ -86,14 +86,20 @@ Game Boy equivalent:
|
||||
## Colors mode
|
||||
|
||||
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**.
|
||||
through **OG RED → SGB → 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:
|
||||
|
||||
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
|
||||
red BG palette + one green OBJ palette, every map, no per-map variation
|
||||
(Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally).
|
||||
The player/NPCs stay green over the red terrain via the OBP bake +
|
||||
post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`).
|
||||
- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart --
|
||||
one global BG palette + one OBJ palette, every map, no per-map variation
|
||||
(Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally).
|
||||
The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP
|
||||
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
|
||||
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
||||
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
|
||||
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)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
A link session is decided by the battle and nothing else, so for its
|
||||
@@ -161,10 +209,15 @@ duration:
|
||||
closes, and apply again after. Fast-forward otherwise runs one peer's
|
||||
queue faster than the peer it is locked to and drains a tournament shot
|
||||
clock faster than the opponent racing it.
|
||||
- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with
|
||||
mods enabled offers to switch them all off and relaunch (mods merge at
|
||||
boot, so a restart is the only way). The restart is confirmed, not
|
||||
silent. They stay listed as disabled, ready to switch back on.
|
||||
- **Online play runs vanilla, except for your language.** Picking ONLINE
|
||||
MATCH or TOURNAMENT with mods enabled offers to switch the gameplay ones
|
||||
off and relaunch (mods merge at boot, so a restart is the only way). The
|
||||
restart is confirmed, not silent. They stay listed as disabled, ready to
|
||||
switch back on. A mod that declares itself a translation and provably
|
||||
writes nothing but text stays on: the two games hash the same link
|
||||
surface, so a Spanish install and an English one can battle and trade,
|
||||
each reading the game in its own language and naming the other player's
|
||||
party out of its own text.
|
||||
- **Only a meaningful split ends a match.** The per-turn state signature
|
||||
both peers exchange is split three ways: `actives` and `bench` carry
|
||||
species, HP, status, stat stages, PP and the rest of the party, and a
|
||||
@@ -265,6 +318,31 @@ the last controller restores it immediately. Layout re-derives from the
|
||||
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||
|
||||
The launcher's **Touch Controls** button opens a drag editor: move each
|
||||
button freely, resize the whole pad with **-/+** (60% to 160%), **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 a
|
||||
different screen keeps the relative placement.
|
||||
|
||||
Portrait and landscape are edited and saved separately (#633): the editor
|
||||
follows whichever orientation is on screen, and **Reset** only clears that
|
||||
one, so a layout that works held upright does not have to double as the
|
||||
one used sideways. An `options.lua` from before this split keeps its single
|
||||
layout in both orientations until one of them is edited. In-game, Options →
|
||||
**TOUCH PAD** toggles the same on/off flag without leaving a play session.
|
||||
|
||||
## Screen orientation lock (Android)
|
||||
|
||||
Options → **ORIENTATION** (also in the launcher's gear menu) locks the
|
||||
screen to **PORTRAIT**, **LANDSCAPE** (either landscape, following the
|
||||
device), or **REVERSE LANDSCAPE**, or leaves it on **AUTO** (#592). AUTO
|
||||
allows every orientation but defers to the system: with auto-rotate turned
|
||||
off in Android's quick settings, the game stays put instead of following
|
||||
the sensor (#716). Changes apply immediately -- the screen rotates as the
|
||||
row is stepped -- and persist in `options.lua`. Android only: iOS follows
|
||||
the app's fixed orientation list, and desktop windows rotate nothing.
|
||||
|
||||
## Translation support
|
||||
|
||||
Every string the player can read is now reachable from a mod, so a
|
||||
@@ -298,6 +376,28 @@ plus a glyph-page and charmap stub, a naming-grid stub, and a
|
||||
be packed). `--refresh` re-harvests after an engine update, keeping
|
||||
existing translations and parking orphaned keys rather than dropping them.
|
||||
|
||||
A translation can also skip glyph pages entirely: scaffolding with
|
||||
`--pixel-font` (or registering `mod.content.font:register("ttf", {})` in
|
||||
an existing mod) renders text through a bundled TTF covering Latin with
|
||||
diacritics, Cyrillic, kana and CJK, while box borders and `<PK>`-style
|
||||
macro glyphs keep their tiles. The font is "Plain Pixel Font" by Douglas
|
||||
Vautour (Burpy Fresh), licensed under CC-BY 4.0 (5x11 base characters,
|
||||
11x11 double-width; see `assets/fonts/plainpixel/README.md`). Options on
|
||||
the registry entry: `file` for a mod-shipped TTF, `size` (the font's
|
||||
design em; Plain Pixel rasterizes cleanly only at multiples of 15),
|
||||
`spacing` added to every advance, `yOffset` for vertical alignment
|
||||
against the 8px cell grid, `bold`, which double-prints at a 1px
|
||||
offset for fonts whose strokes read too light, and `tiles`, the
|
||||
characters that keep their ROM tile instead of coming from the TTF.
|
||||
|
||||
`tiles` matters for a CJK translation. Sizing the font so a kana fills
|
||||
the 8px cell leaves Latin narrower than the tile font it replaces, which
|
||||
pulls the numeric columns out of line: the party menu's `:L12` stops
|
||||
sitting over `34/ 34`. Naming `"0123456789/:"` keeps those on the
|
||||
vanilla tiles, so numbers render exactly as they do in English while
|
||||
kana still come from the font. It takes a string of characters, or a
|
||||
list when a multi-character charmap sequence is meant.
|
||||
|
||||
See the wiki's Translations guide.
|
||||
|
||||
## Save editor (bundled, reachable from the launcher)
|
||||
@@ -324,8 +424,11 @@ semantics - so the two windows read as one app. Six tabs:
|
||||
party dock, so deposit and withdraw live in one place. Empty slots are
|
||||
clickable and create a mon there.
|
||||
- **Items**: money, a searchable item picker (replacing the arrows that
|
||||
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
|
||||
with no slot cap, and the eight badges as toggle chips.
|
||||
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. The picker, the bag
|
||||
and PC storage all scroll under the mouse wheel, so the whole catalog is
|
||||
reachable one-handed without typing a query.
|
||||
- **Events**: flags, defeated trainers, taken items and per-map object
|
||||
toggles, with a real filter field and a two-column paged grid.
|
||||
- **Map**: any map rendered with the game's own renderer, warps followable,
|
||||
@@ -367,3 +470,74 @@ 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.
|
||||
|
||||
Controllers a system has no mapping for (common on Linux handhelds and
|
||||
off-brand pads) report bare button numbers rather than names. Those are
|
||||
rebindable on the same screen and show up as JOY1, JOY2 and so on in the
|
||||
controller column. Recognized controllers are read only through their
|
||||
named buttons, so a rebind on those is never shadowed by the factory
|
||||
layout underneath it.
|
||||
|
||||
## Mod profiles (#593)
|
||||
|
||||
The mod manager's PROFILES tab holds named setups. A profile remembers which
|
||||
mods are on, every mod's own options, and which save slot each game version
|
||||
plays, so swapping profiles swaps the whole playthrough and not just the mod
|
||||
list. The setup that existed before profiles shipped becomes PROFILE 1 the
|
||||
first time the manager opens.
|
||||
|
||||
EXPORT.. writes the selected profile to `profiles/<NAME>.g1rmodlist` in the
|
||||
save directory; drop a `.g1rmodlist` someone shared into that folder and
|
||||
IMPORT.. adds it. Imported profiles never overwrite an existing one (a name
|
||||
clash gets a number). Mods the shared profile names but that are not installed
|
||||
are reported when the profile is applied; installing them is still a manual
|
||||
trip through the mods list or Find Mods.
|
||||
|
||||
## Windows: no console windows on launcher actions
|
||||
|
||||
Checking for updates, browsing a mod index, adding a mod repo, installing a
|
||||
mod and picking a ROM all run a host tool (curl, PowerShell) in a child
|
||||
process. On Windows those children used to each open their own console
|
||||
window, so a session could end up buried under half a dozen of them. The
|
||||
game now claims one console for itself at boot and hides it; the children
|
||||
inherit that invisible console and nothing pops up. Nothing else changes:
|
||||
file pickers are ordinary desktop dialogs and still appear normally, and a
|
||||
run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers)
|
||||
keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt
|
||||
out.
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# What This Port Requires
|
||||
|
||||
The packaged desktop app requires one user-supplied input on first boot: a
|
||||
canonical 1 MiB US Pokemon Red ROM.
|
||||
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM.
|
||||
|
||||
The importer verifies SHA-1
|
||||
`ea9bcae617fdf159b045185467ae58b2e4a48b9a`. Other revisions, Virtual
|
||||
Console releases, and Pokemon Blue are rejected rather than decoded with
|
||||
incorrect addresses.
|
||||
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
|
||||
for specific hashes). Other revisions and Virtual Console releases are rejected
|
||||
rather than decoded with incorrect addresses.
|
||||
|
||||
After verification, the app generates its private cache in the LÖVE save
|
||||
directory. It does not keep a copy of the ROM. Later boots use the cache.
|
||||
@@ -15,9 +14,11 @@ Python and Pillow are not required by the packaged app.
|
||||
## Bundled Metadata
|
||||
|
||||
Assembly removes high-level names and some relationships that the Lua port
|
||||
needs. `tools/rom_manifest.json` therefore contains:
|
||||
needs. The version-specific files `tools/rom_manifest.json`,
|
||||
`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore
|
||||
contain:
|
||||
|
||||
- the 3,268 ROM symbol addresses actually read by the extractor
|
||||
- the ROM symbol addresses actually read by the extractor
|
||||
- symbolic IDs and ordering for maps, species, moves, items, and trainers
|
||||
- source-erased dimensions, image names, and map object integration names
|
||||
- hand-ported field/script integration tables
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# RFC 0001 — Port Yellow's `IsSurfingPikachuInParty` surf sprite
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Player.lua`, `FieldDefaults.lua`,
|
||||
`OverworldController.lua`, `RomExtractor.lua`, `PaletteFX.lua`. Tools:
|
||||
`build_rom_data.py`, `extract/sprites.py`, `make_rom_manifest.py`,
|
||||
`make_yellow_manifest.py`. Tests: `parity_surfing_pikachu_sprite.lua`,
|
||||
`mod_world_tests.lua`.
|
||||
|
||||
**Regeneration required.** The manifest and sprite sheet update by
|
||||
re-running `make_yellow_manifest.py` against a `pret/pokeyellow`
|
||||
checkout, then re-importing the Yellow ROM.
|
||||
|
||||
## Motivation
|
||||
|
||||
Yellow's `IsSurfingPikachuInParty` + `LoadSurfingPlayerSpriteGraphics2`
|
||||
(`home/map_objects.asm`, `home/overworld.asm`) swap the player's
|
||||
overworld sheet to `SurfingPikachuSprite` (`gfx/sprites/
|
||||
surfing_pikachu.2bpp`, a 16×96 walk sheet — not the minigame sheets)
|
||||
when the party mon that knows SURF is a Pikachu. The recomp misses this
|
||||
in two places:
|
||||
|
||||
1. **Extraction.** `SurfingPikachuSprite` is not in
|
||||
`SpriteSheetPointerTable` — loaded by its own `ld de,` like
|
||||
`RedBikeSprite`. The extractor never sees it, and the symbol is not
|
||||
in the Yellow manifest.
|
||||
2. **Engine rule.** `field.playerSprites.surf` is one static
|
||||
(`SPRITE_SEEL`), cached at boot. No seam for "swap when the SURF-mon
|
||||
is a Pikachu."
|
||||
|
||||
## The decision it extends
|
||||
|
||||
No prior D-number. Extends the surf-field-move port in
|
||||
`docs/behavior-porting-notes.md` (the `IsSurfingAllowed` exact port)
|
||||
with the player-sprite swap vanilla runs alongside it.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only.
|
||||
|
||||
### `field.playerSprites.surfPikachu`
|
||||
|
||||
New optional key alongside `walk`/`surf`/`bike`/`fly`, defaults to
|
||||
`SPRITE_SURFING_PIKACHU`. Guarded in `Player.new` so before extraction
|
||||
lands the ride keeps the Seel — no plain on-water Pikachu.
|
||||
|
||||
### `Player.surfPikachuSprite`
|
||||
|
||||
`Player.new` builds a second `SpriteRenderer` when the field resolves.
|
||||
`pose()` picks it when `surfing and surfingPikachu`.
|
||||
|
||||
### `Player.surfingPikachu` (runtime)
|
||||
|
||||
Runtime-only boolean (not persisted); re-derived so a party change
|
||||
between save and load is honored.
|
||||
|
||||
### `OverworldState:syncSurfingPikachu()`
|
||||
|
||||
Sets `player.surfingPikachu` from `partyKnows("SURF")`. Called at every
|
||||
surf-state toggle: trySurf, dismount, flyTo, beginTeleportOut,
|
||||
warpToHealPoint, forced-surf tile, setMap boot-restore.
|
||||
|
||||
### Importer — `SPRITE_SURFING_PIKACHU`
|
||||
|
||||
`make_yellow_manifest.py` adds `SurfingPikachuSprite` to
|
||||
`YELLOW_EXTRA_SYMBOLS`. `make_rom_manifest.py`'s `sprite_metadata()`
|
||||
gains a `surfPikachu` entry (guarded, so Red/Blue unchanged).
|
||||
`RomExtractor.extractSprites` + `build_rom_data.py` + `extract/sprites.py`
|
||||
each gain a parallel extract mirroring `RedBikeSprite`.
|
||||
|
||||
### `PaletteFX.spriteObp`
|
||||
|
||||
`SurfingPikachuSprite` joins `RedBikeSprite` in the no-bracket-index
|
||||
special case, wearing the player's OBP palette so it colors in GBC mode.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** `surf` still defaults to `SPRITE_SEEL`; `surfPikachu`
|
||||
only resolves on a Yellow import after regeneration. No manifest or
|
||||
`mod.save` shape changes. An eligibility hook that swaps a rental
|
||||
SURF-mon still drives the sprite pick via `partyKnows`.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod** (`mod_world_tests.lua`): `surf == "SPRITE_SEEL"`,
|
||||
`surfPikachu == "SPRITE_SURFING_PIKACHU"` seeded at boot. The 19229-check
|
||||
`world & maps v2` suite stays green.
|
||||
- **Mod-API** (`parity_surfing_pikachu_sprite.lua`): `syncSurfingPikachu`
|
||||
+ `Player:pose` across four party shapes (12/12). The existing
|
||||
`parity_cinnabar_east_surf.lua` (24/24) stays green.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. Additive: a new `field.playerSprites` key, a new
|
||||
runtime flag, a new engine method, a new sprite id.
|
||||
@@ -0,0 +1,204 @@
|
||||
# Build the Nintendo Switch NRO — contributor guide
|
||||
|
||||
Want to play a release build instead? Download the SD-ready zip and extract it
|
||||
at your microSD root — see [switch-install.md](switch-install.md).
|
||||
|
||||
This guide is for contributors who build Gen1Recomp for Switch from source.
|
||||
Hardware evidence, MTP operator loops, and deeper notes live in
|
||||
[switch-development.md](switch-development.md).
|
||||
|
||||
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`;
|
||||
> issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531)). Hardware
|
||||
> evidence: **OLED** (author) and **V1 boot** (community). See
|
||||
> [switch-development.md](switch-development.md) for known limitations.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites by OS
|
||||
|
||||
All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or
|
||||
WSL — not cmd.exe or PowerShell (AD-008).
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman).
|
||||
2. Install Switch tools:
|
||||
|
||||
```sh
|
||||
sudo dkp-pacman -S switch-dev
|
||||
```
|
||||
|
||||
3. Ensure `nacptool` and `elf2nro` are on `PATH` (or under
|
||||
`$DEVKITPRO/tools/bin` — the fused script prepends that when set).
|
||||
|
||||
**Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused
|
||||
builds can fall back to the pinned image when native tools are missing.
|
||||
|
||||
### Windows (Git Bash / MSYS2 / WSL)
|
||||
|
||||
1. Use a bash environment:
|
||||
- **MSYS2** with the [devkitPro](https://devkitpro.org/wiki/devkitPro_pacman)
|
||||
packages (preferred for native `nacptool`/`elf2nro`), or
|
||||
- **WSL** (Ubuntu/etc.) with the Linux pacman flow above, or
|
||||
- **Git Bash** for `--fetch` / `--loose`; for `--fused` prefer MSYS2 or
|
||||
WSL if Docker bind-mounts from Git Bash paths misbehave.
|
||||
2. Install `switch-dev` (or rely on Docker fallback — see below).
|
||||
3. Do **not** expect `scripts/build_switch.sh` to run under cmd/PowerShell.
|
||||
|
||||
### What you must install yourself
|
||||
|
||||
| You install | Script does **not** install |
|
||||
| ----------- | --------------------------- |
|
||||
| bash, git, zip tooling the repo already expects | — |
|
||||
| `dkp-pacman` + `switch-dev` (native fused) | `dkp-pacman -S …` |
|
||||
| Docker (optional fused fallback) | Docker Engine |
|
||||
| A legal `.gb` ROM (to play) | Any ROM or game data |
|
||||
|
||||
---
|
||||
|
||||
## Mode glossary
|
||||
|
||||
`scripts/build_switch.sh` supports three modes (combinable as noted):
|
||||
|
||||
| Mode | What it does |
|
||||
| ---- | ------------ |
|
||||
| `--fetch` | Downloads pinned **love.nro** + **love.elf** into `.bazinga/love-nx/11.5-nx1/` and verifies SHA-256 against `scripts/switch/love-nx-11.5-nx1.sha256`. |
|
||||
| `--loose` | Packs `game.love`, copies pinned `love.nro` → `dist/switch/loose/` as `gen1recomp.nro` + `game.love` side by side. Needs the pin. |
|
||||
| `--fused` | Builds `dist/switch/gen1recomp-<ver>-switch.nro` (game in romfs) via `nacptool` + `elf2nro`, then packs `dist/switch/gen1recomp-<ver>-switch.zip` (SD-ready tree). Needs the pin + toolchain (native or Docker). GitHub Releases publish the **zip only**. |
|
||||
|
||||
Rules:
|
||||
|
||||
- `--fetch` alone is fine; combine as `--fetch --loose` or `--fetch --fused`.
|
||||
- `--loose` and `--fused` are **XOR** — pick one packaging path per run.
|
||||
- `--version X.Y.Z` sets the NACP / filename version (defaults to short git SHA).
|
||||
|
||||
### What `--fetch` downloads
|
||||
|
||||
Only the two pinned love-nx release assets (`love.nro`, `love.elf`). It does
|
||||
**not** install:
|
||||
|
||||
- devkitPro / `dkp-pacman` / `switch-dev`
|
||||
- Docker
|
||||
- ROMs, saves, or mods
|
||||
|
||||
---
|
||||
|
||||
## Native tools, then Docker
|
||||
|
||||
Fused packaging (`scripts/switch/build_fused.sh`):
|
||||
|
||||
1. Prefer native `nacptool` + `elf2nro` on `PATH` (or `$DEVKITPRO/tools/bin`).
|
||||
2. Else fall back to Docker using:
|
||||
- `GEN1_DKP_IMAGE` if set, otherwise
|
||||
- the image named in `scripts/switch/dkp-docker.image` (default
|
||||
`devkitpro/devkita64:latest`).
|
||||
|
||||
If neither native tools nor Docker work, the script exits non-zero with
|
||||
macOS / Linux / Windows / Docker hints and a pointer to this doc.
|
||||
|
||||
---
|
||||
|
||||
## Example commands
|
||||
|
||||
From the repo root:
|
||||
|
||||
```sh
|
||||
# Download pinned love-nx only
|
||||
scripts/build_switch.sh --fetch
|
||||
|
||||
# Loose pair for iteration (fetch + assemble)
|
||||
scripts/build_switch.sh --fetch --loose
|
||||
|
||||
# Single fused NRO + SD-ready zip for a release-like artifact
|
||||
scripts/build_switch.sh --fetch --fused --version 0.2.0
|
||||
```
|
||||
|
||||
Outputs land under `dist/switch/` (and `dist/switch/loose/` for loose mode).
|
||||
The fused path also writes `gen1recomp-<ver>-switch.nro.sha256` and
|
||||
`gen1recomp-<ver>-switch.zip` (+ `.sha256` sidecar for the zip).
|
||||
|
||||
Offline packaging smoke (no network, no nacptool required):
|
||||
|
||||
```sh
|
||||
bash scripts/switch/selftest_build_switch.sh
|
||||
bash scripts/switch/verify_payload.sh --self-test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI and release
|
||||
|
||||
Switch packaging has three automated surfaces (same policy as AD-010):
|
||||
|
||||
### Path-gated PR / push CI (`.github/workflows/ci.yml`)
|
||||
|
||||
When a change touches Switch packaging / Switch docs / NX runtime paths
|
||||
(`scripts/build_switch.sh`, `scripts/switch/**`, `docs/switch-*.md`,
|
||||
`tests/switch_ci_workflows_test.lua`, `tests/switch_transfer_docs_test.lua`,
|
||||
the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
|
||||
`src/core/GameVersion.lua`, `src/import/CacheFs.lua`, the NX engine suites
|
||||
`tests/engine/assets_version_fallback_test.lua`,
|
||||
`tests/engine/nx_generated_guard_test.lua`,
|
||||
`tests/engine/nx_yellow_boot_test.lua`,
|
||||
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
|
||||
or the Switch-related workflow YAML), CI runs:
|
||||
|
||||
1. **Offline selftest** on `ubuntu-latest` (forks **and** the canonical repo):
|
||||
`scripts/switch/selftest_build_switch.sh`,
|
||||
`scripts/switch/verify_payload.sh --self-test`,
|
||||
`luajit tests/switch_ci_workflows_test.lua`,
|
||||
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
|
||||
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
|
||||
`luajit tests/engine/nx_generated_guard_test.lua`,
|
||||
`luajit tests/engine/nx_yellow_boot_test.lua`).
|
||||
2. **Fused NRO build** only on the **canonical** repository
|
||||
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
|
||||
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
|
||||
head is that repo (same-repo push/PR). **Fork repository** CI never runs
|
||||
fused. **Fork → canonical PRs** also skip Switch fused (offline selftest
|
||||
still runs) so untrusted head code is not executed on the self-hosted Mac;
|
||||
iOS device build eligibility is unchanged. Fused also waits for a successful
|
||||
offline selftest before starting on the Mac runner.
|
||||
3. On successful PR fused builds, a follow-up workflow posts a PR comment
|
||||
linking the Actions artifact named `gen1recomp-switch-nro`
|
||||
(comment tag `switch-build-result`; see
|
||||
`.github/workflows/switch-artifact-comment.yml`).
|
||||
|
||||
Unrelated PRs do not burn the self-hosted Mac on Switch packaging.
|
||||
|
||||
### Release hard-fail (`.github/workflows/release.yml`)
|
||||
|
||||
GitHub Releases always build Switch on the same self-hosted Mac runner as the
|
||||
other platforms — this is a **hard gate** (no `continue-on-error`):
|
||||
|
||||
```sh
|
||||
scripts/build_switch.sh --fetch --fused --version "<release version>"
|
||||
```
|
||||
|
||||
A Switch packaging failure fails the entire release job. The release asset is
|
||||
`gen1recomp-<ver>-switch.zip` (SD-ready); the versioned `.nro` stays under
|
||||
`dist/switch/` for the packer and for PR CI artifacts.
|
||||
|
||||
### Runner provisioning
|
||||
|
||||
The self-hosted Mac runner must have **native switch-tools** (`nacptool` /
|
||||
`elf2nro`) **and/or Docker** available. CI and release do not silently run
|
||||
`dkp-pacman -S`; keep the runner image/host provisioned per this guide.
|
||||
|
||||
---
|
||||
|
||||
## Limitations / non-goals
|
||||
|
||||
These scripts and this guide do **not**:
|
||||
|
||||
- Push files to the console (no automated MTP / FTP / SD scripting)
|
||||
- Bundle or download any Pokémon ROM
|
||||
- Install `dkp-pacman` / `switch-dev` for you
|
||||
- Provide `nxlink` / netloader deploy (deferred — see [switch-transfer.md](switch-transfer.md))
|
||||
- Validate **Applet Mode** — use title override (hold **R**) for full memory
|
||||
|
||||
Player install steps: [switch-install.md](switch-install.md).
|
||||
Manual transfer (MTP / SD / FTP, macOS / Linux / Windows):
|
||||
[switch-transfer.md](switch-transfer.md).
|
||||
Hardware depth and evidence: [switch-development.md](switch-development.md),
|
||||
[switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
@@ -0,0 +1,528 @@
|
||||
# Nintendo Switch development (love-nx)
|
||||
|
||||
> Fused NRO support for issue [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
|
||||
> Releases ship `gen1recomp-*-switch.zip` (SD-ready tree). Console copy is
|
||||
> extract/merge at microSD root; title override required. See
|
||||
> [Known limitations](#known-limitations-read-before-reviewing).
|
||||
|
||||
**Canonical install / build / transfer docs** (start here unless you need hardware depth):
|
||||
|
||||
- Players → [switch-install.md](switch-install.md)
|
||||
- Builders → [switch-build.md](switch-build.md) (`scripts/build_switch.sh --fetch` downloads the pinned love-nx pair)
|
||||
- Transfer (MTP / SD / FTP on macOS, Linux, Windows) → [switch-transfer.md](switch-transfer.md)
|
||||
|
||||
This document covers what landed, known limitations, how hardware was tested,
|
||||
vendor layout, build/deploy, and the contributor transfer loop (detail lives in
|
||||
the transfer runbook).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- **Port / love-nx packaging:** [andrewqsantos](https://github.com/andrewqsantos)
|
||||
- **Community hardware testing** (Switch V1 / Erista boot): [booshankles](https://github.com/booshankles)
|
||||
- **Method guidance:** [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch) / love-nx
|
||||
- **Upstream project:** [bryanthaboi](https://github.com/bryanthaboi) / Gen1Recomp
|
||||
|
||||
## Status
|
||||
|
||||
| Area | State |
|
||||
| ---- | ----- |
|
||||
| Feature | **Available** — playable fused NRO path (issue #531) |
|
||||
| Runtime | Pinned love-nx **`11.5-nx1`** |
|
||||
| Product artifact | Releases: SD-ready `gen1recomp-*-switch.zip`; local/PR: fused `.nro`; loose `nro`+`game.love` for iteration |
|
||||
| Hardware | **OLED** validated (author, title override); **V1 / Erista** boot confirmed (community). Lite, docked soak, and Pro Controller matrices welcome |
|
||||
| Deploy / install | Releases publish SD-ready zip; **extract/merge at microSD root** (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)); no `nxlink` path yet |
|
||||
| Contributor transfer | Documented for **macOS, Linux, and Windows**; OpenMTP on Mac is one example, not the only contract |
|
||||
| Network features on NX | Self-update / remote mod download **disabled** (`networkValidated == false`) |
|
||||
| Community help | Welcome — especially HOS / love-nx packaging and broader hardware coverage |
|
||||
|
||||
### What landed
|
||||
|
||||
- Detect `NX` via `src/core/Platform.lua` without reusing Android flags
|
||||
- Writable ROM inbox under `getSaveDirectory()/imports/` + per-tab “Scan again” (SHA-1 match for the open game)
|
||||
- Joy-Con / gamepad mapping shared by launcher and gameplay (Nintendo A/B UX on NX)
|
||||
- Launcher L/R tab switch; gameplay L/R game-speed cycle; Select+face display chords
|
||||
- Focus loss / joystick reconnect recovery; opt-in `switch-debug.txt` diagnostics
|
||||
- Loose assemble + fused NRO build scripts (`scripts/build_switch.sh`, `scripts/switch/*`)
|
||||
- Payload gates so ROM / generated cache / saves never enter `game.love`
|
||||
- Community mod zip inbox at `imports/mods/` (rescan installs; FIND MODS stays network-gated)
|
||||
- Raw `.sav` inbox at `imports/saves/{red,blue,yellow}/` (**Import save** rescan) + export pull path `exports/{red,blue,yellow}/` (MTP hint; no openURL)
|
||||
- Hardware evidence for Phase 0 probe, ROM import, naming A/B, save/suspend, fused NRO — see `docs/switch-hardware-evidence.md`
|
||||
- Path-gated CI selftest + canonical fused PR artifact; release Switch hard-fail
|
||||
- Save editor pad/touch input (virtual cursor, A click, B close) — see `tools/save-editor/README.md`
|
||||
- Dynamic display size on NX only: handheld **1280×720**, docked/TV **1920×1080** (`src/core/NxDisplay.lua` + resizable conf so love-nx SDL can follow dock/undock at runtime)
|
||||
|
||||
### Known gaps / welcome contributions
|
||||
|
||||
- Docked vs handheld soak (≥30 min) and Lite coverage — resolution switch is implemented; long soak still welcome
|
||||
- Switch Lite and fuller Pro Controller / third-party pad matrices
|
||||
- Applet Mode remains unsupported by design (title override required)
|
||||
- `nxlink` / netloader contrib fast-loop (deferred — see [switch-transfer.md](switch-transfer.md))
|
||||
|
||||
Transfer runbooks for Linux/Windows (and SD/FTP alternatives) are in
|
||||
[switch-transfer.md](switch-transfer.md). Community mod zip install OLED smoke
|
||||
is **pass** — see NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
|
||||
## Design references (Dusklight)
|
||||
|
||||
This work borrowed method — not the native stack — from the [Dusklight Switch port](https://github.com/HayatoG/dusklight/tree/main/platforms/switch), especially [`LESSONS_AND_REUSE.md`](https://github.com/HayatoG/dusklight/blob/main/platforms/switch/LESSONS_AND_REUSE.md):
|
||||
|
||||
| Dusklight lesson | How Gen1Recomp applied it |
|
||||
| ---------------- | ------------------------- |
|
||||
| Emulators hide Tegra failures | Gate milestones on **real OLED hardware**, not Ryujinx/Yuzu alone |
|
||||
| Prove the lower layer first | `tools/switch-probe` before full launcher |
|
||||
| Know which binary ran | Embedded `build-info.json` (commit / love-nx tag) |
|
||||
| Cap continuous logs | Opt-in diagnostics, ≤1 Hz flush; Lua error log rotation |
|
||||
| Crash symbolization needs the exact ELF | Keep pinned `love.elf` with the NRO under test |
|
||||
| Full memory matters | Title override; Applet Mode is not the validation path |
|
||||
| Do not treat SD FS like desktop POSIX | Lua stays on `love.filesystem`; inbox + MTP for user files |
|
||||
| Isolate platform code | Capability module instead of Android flag overload |
|
||||
| NVK / WSI / `audren` stacks | **Not** copied — love-nx already supplies video/audio/input/FS |
|
||||
|
||||
The packaging goal matches Dusklight’s **single self-contained `.nro`**; contributor transfer stays multi-host (not Mac-only).
|
||||
|
||||
## Known limitations (read before reviewing)
|
||||
|
||||
1. **Transfer is manual and multi-method.** Runtime only needs files under the LÖVE save directory / NRO install folder. Use MTP, direct SD, or FTP per [switch-transfer.md](switch-transfer.md). macOS + OpenMTP is a documented example for OLED evidence — not “Switch requires a Mac.”
|
||||
2. **Deploy is manual.** There is no automated push to the console and no `nxlink` path yet. Operators build locally, transfer files, then title-override launch.
|
||||
3. **Hardware coverage.** Author P0/P1 pass rows were recorded on one Switch OLED; Switch V1 boot was confirmed independently. Treat Lite, docked soak, and other hosts as unknown until someone re-runs the checklist.
|
||||
4. **No ROM/save/mod zip bytes in git.** Legal dumps and third-party mods stay on the console (or local untracked folders).
|
||||
5. **AppleDouble sidecars** (`._*`) from some MTP clients can break zip/ROM/`.sav` scans — the launcher skips hidden `.*` names (including `._*.sav`); still prefer clean copies.
|
||||
|
||||
## How we tested
|
||||
|
||||
| Layer | What | Where |
|
||||
| ----- | ---- | ----- |
|
||||
| Unit / headless | Platform NX flags, RomImporter inbox, dual-path input, mod zip inbox, save `.sav` inbox, display chords, payload/self-tests | `tests/*`, `scripts/test.sh` |
|
||||
| Switch CI / packaging | Path-gated offline selftest (`selftest_build_switch.sh`, `verify_payload.sh --self-test`, `switch_ci_workflows_test.lua`); canonical fused PR artifact | `.github/workflows/ci.yml`, [switch-build.md](switch-build.md) § CI and release |
|
||||
| Probe on hardware | `getOS()==NX`, 1280×720, save path, Joy-Con events | `tools/switch-probe` → OLED |
|
||||
| Integration on hardware | MTP inbox ROM import, Play Red/Blue, naming A/B, quit/reopen save, suspend×10, reboot, fused NRO alone + NRO-only update | `docs/switch-hardware-evidence.md` |
|
||||
| Community hardware | Switch V1 / Erista boot with prebuilt NRO | [booshankles](https://github.com/booshankles) — see evidence log |
|
||||
| Known gaps | Docked soak, ≥30 min long-play, Lite, automated/`nxlink` deploy | Matrix deferred / absent rows |
|
||||
|
||||
Operator evidence must stay in `docs/switch-hardware-evidence.md`. **Do not invent passes** for hardware not run.
|
||||
|
||||
## love-nx 11.5-nx1 (pinned)
|
||||
|
||||
**Tag:** [11.5-nx1](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1)
|
||||
|
||||
**Local layout (not committed):**
|
||||
|
||||
```text
|
||||
.bazinga/love-nx/11.5-nx1/
|
||||
├── love.nro # homebrew launcher binary (loose mode: copied to gen1recomp.nro)
|
||||
└── love.elf # required for fused NRO builds (devkitPro nacptool/elf2nro)
|
||||
```
|
||||
|
||||
**Manifest:** `scripts/switch/love-nx-11.5-nx1.sha256` lists expected artifact names and SHA-256 checksums. Checksums are filled when binaries are fetched (`TBD_*` placeholders until then).
|
||||
|
||||
### Fetch instructions
|
||||
|
||||
Preferred (automated checksum verify):
|
||||
|
||||
```bash
|
||||
scripts/build_switch.sh --fetch
|
||||
```
|
||||
|
||||
That downloads pinned `love.nro` + `love.elf` into `.bazinga/love-nx/11.5-nx1/`
|
||||
and checks them against `scripts/switch/love-nx-11.5-nx1.sha256`. See
|
||||
[switch-build.md](switch-build.md) for the full mode glossary.
|
||||
|
||||
Manual fallback:
|
||||
|
||||
1. Open the [11.5-nx1 release](https://github.com/retronx-team/love-nx/releases/tag/11.5-nx1) and download `love.nro` and `love.elf`.
|
||||
2. Create the directory: `mkdir -p .bazinga/love-nx/11.5-nx1`
|
||||
3. Move both files into that directory.
|
||||
4. Confirm checksums match the manifest:
|
||||
|
||||
```bash
|
||||
shasum -a 256 .bazinga/love-nx/11.5-nx1/love.nro \
|
||||
.bazinga/love-nx/11.5-nx1/love.elf
|
||||
```
|
||||
|
||||
**Never commit** love-nx binaries, ROM dumps, or generated cache into git. The repo `.gitignore` excludes `.bazinga/` (vendor cache) and `/dist/` (build output).
|
||||
|
||||
## Loose-mode dist layout
|
||||
|
||||
Development builds place `gen1recomp.nro` and `game.love` side by side:
|
||||
|
||||
```text
|
||||
dist/switch/loose/
|
||||
├── gen1recomp.nro
|
||||
└── game.love
|
||||
```
|
||||
|
||||
Assemble with:
|
||||
|
||||
```bash
|
||||
scripts/build_switch.sh --loose
|
||||
```
|
||||
|
||||
(See `scripts/switch/assemble_loose.sh` for the underlying copy + checksum step.)
|
||||
|
||||
## Transfer & deploy (current contributor loop)
|
||||
|
||||
Detail for **macOS / Linux / Windows** and **MTP / SD / FTP** lives in
|
||||
[switch-transfer.md](switch-transfer.md). Summary:
|
||||
|
||||
| Layer | Intent |
|
||||
| ----- | ------ |
|
||||
| **Runtime / players** | Extract the release zip at microSD root (`switch/gen1recomp/`) and land ROMs/mods under the save-dir inboxes. The game does not hard-depend on OpenMTP or macOS. |
|
||||
| **Contributor loop** | Manual copy via MTP (DBI responder), direct SD (Hekate UMS / reader), or FTP. Fully manual — no CI deploy, no `nxlink` yet. |
|
||||
|
||||
The Mac + OpenMTP steps that remain below are the **OLED evidence reproduction** path; prefer the transfer runbook for day-to-day contrib on other hosts.
|
||||
|
||||
**Still avoided for routine evidence** (keeps SD handling honest):
|
||||
|
||||
- Treating `nxlink` / netloader as the release deploy story (deferred)
|
||||
- DBI `MicroSD install` / `NAND install` / NSP-style virtual folders for the `.love`/`.nro` pair
|
||||
|
||||
If MTP fails: check cable, USB port, DBI state, and that only one MTP client holds the device — then retry or switch to SD/FTP. Do not silently rewrite evidence using an untested path and claim parity with recorded SHA-256 round-trips.
|
||||
|
||||
### Manual deploy checklist (today)
|
||||
|
||||
1. Build on the contributor host (`scripts/build_switch.sh --loose` or fused).
|
||||
2. Close Gen1Recomp on the Switch; open DBI → `Run MTP responder`.
|
||||
3. Copy artifacts with your MTP client into `1: SD Card/switch/gen1recomp/` (and ROMs/mods into the save-dir inboxes when needed).
|
||||
4. Wait for the transfer queue; refresh; optionally round-trip SHA-256 on first artifacts of a type.
|
||||
5. Exit MTP; launch via **title override** (hold **R** on a title → hbmenu, not Applet Mode).
|
||||
|
||||
## OpenMTP + DBI transfer (loose build, Mac evidence example)
|
||||
|
||||
Full multi-OS / multi-method steps: [switch-transfer.md](switch-transfer.md).
|
||||
The numbered Mac loop below reproduces the OLED evidence path.
|
||||
|
||||
### On the Switch
|
||||
|
||||
1. Close Gen1Recomp if it is running.
|
||||
2. Open **DBI** from hbmenu.
|
||||
3. Select **`Run MTP responder`** (DBI documents `X` on the main screen).
|
||||
4. Keep DBI on that screen for the entire transfer.
|
||||
5. Connect the Switch to the Mac with a USB-C data cable.
|
||||
|
||||
### On the Mac
|
||||
|
||||
1. Close any other MTP clients.
|
||||
2. Open **OpenMTP** and select the DBI device.
|
||||
3. In the remote pane, open **`1: SD Card`**.
|
||||
4. Navigate to **`switch/`** and create **`gen1recomp/`** if needed.
|
||||
5. Enter **`1: SD Card/switch/gen1recomp/`**.
|
||||
6. Drag from the local pane:
|
||||
|
||||
```text
|
||||
dist/switch/loose/gen1recomp.nro
|
||||
dist/switch/loose/game.love
|
||||
```
|
||||
|
||||
7. Wait for the OpenMTP queue to finish completely.
|
||||
8. Refresh the remote listing and confirm file sizes match the local files.
|
||||
9. On the Switch, exit MTP responder normally in DBI before launching the app.
|
||||
|
||||
Expected layout on SD:
|
||||
|
||||
```text
|
||||
1: SD Card/
|
||||
└── switch/
|
||||
└── gen1recomp/
|
||||
├── gen1recomp.nro
|
||||
└── game.love
|
||||
```
|
||||
|
||||
## Round-trip SHA-256 verification
|
||||
|
||||
For the **first deploy** of each artifact type (loose pair, later fused NRO), verify MTP integrity:
|
||||
|
||||
1. **Before send** — record local hashes:
|
||||
|
||||
```bash
|
||||
shasum -a 256 dist/switch/loose/gen1recomp.nro \
|
||||
dist/switch/loose/game.love
|
||||
```
|
||||
|
||||
2. **After send** — in OpenMTP, copy the same files from `1: SD Card/switch/gen1recomp/` back to an empty local folder, e.g. `dist/switch/mtp-roundtrip/`.
|
||||
|
||||
3. **Compare** round-trip hashes:
|
||||
|
||||
```bash
|
||||
shasum -a 256 dist/switch/mtp-roundtrip/gen1recomp.nro \
|
||||
dist/switch/mtp-roundtrip/game.love
|
||||
```
|
||||
|
||||
4. Local pre-send and round-trip hashes **must match**. Record results in the test report template below.
|
||||
|
||||
Repeat whenever a cable glitch or interrupted transfer is suspected.
|
||||
|
||||
## Title override launch (full memory)
|
||||
|
||||
Applet Mode is **not** the primary validation path. Use **title override** so hbmenu runs with full memory:
|
||||
|
||||
1. Confirm the OpenMTP transfer queue finished.
|
||||
2. Exit MTP responder in DBI; disconnect USB if desired.
|
||||
3. Hold **`R`** while launching any legitimately installed title.
|
||||
4. Keep holding until **hbmenu** appears.
|
||||
5. Confirm hbmenu does **not** show **Applet Mode**.
|
||||
6. Launch **`gen1recomp`** (or the probe NRO during Phase 0).
|
||||
|
||||
Album / applet launches are only useful to document applet-specific limitations; P0/P1 gates use title override.
|
||||
|
||||
## Phase 0 hardware checklist
|
||||
|
||||
Complete **in order** on OLED hardware. Operator fills evidence fields — leave blank until tested.
|
||||
|
||||
| Step | Action | Pass | Evidence / notes |
|
||||
| ---- | ------ | ---- | ---------------- |
|
||||
| P0-0a | Fetch love-nx 11.5-nx1; record manifest SHA-256 | yes | See `scripts/switch/love-nx-11.5-nx1.sha256` |
|
||||
| P0-0b | Build `switch-probe.love` per `tools/switch-probe/README.md` | yes | |
|
||||
| P0-0c | Assemble loose probe (`game.love` = probe) to `dist/switch/loose/` | yes | |
|
||||
| P0-0d | MTP deploy to `1: SD Card/switch/gen1recomp/`; round-trip SHA-256 | yes | nro `8290ac15…5918f5`; love `9f198637…fa2e34f` |
|
||||
| P0-0e | Title override → probe boots; `getOS()` shows `NX` | yes | `getOS()`=`NX`, `love._os`=`NX` |
|
||||
| P0-0f | Probe lists 1280×720 (or documented dims), save path, gamepad/touch log | yes | save `sdmc:/switch/gen1recomp/switch-probe`; Joy-Con Y→#3 X→#4 |
|
||||
| P0-1a | Replace `game.love` with unpatched Gen1Recomp build | yes | feat/switch-nx inbox build |
|
||||
| P0-1b | MTP replace `game.love` only; round-trip SHA-256 | yes | |
|
||||
| P0-1c | Title override → launcher reaches import screen | yes | |
|
||||
| P0-1d | Joy-Con: can navigate launcher (no touch-only) | yes | Full report: `docs/switch-hardware-evidence.md` |
|
||||
|
||||
**Operator:** Andrew **Date:** 2026-08-01 **Console:** Switch OLED only
|
||||
**Deploy:** manual Mac + OpenMTP + DBI MTP (not automated)
|
||||
**love-nx tag:** 11.5-nx1 **gen1recomp commit:** `df7cea4`
|
||||
|
||||
## Phase 0 test report template
|
||||
|
||||
Copy this block into your hardware notes or PR evidence. **Do not commit ROM files or ROM hashes of private dumps.**
|
||||
|
||||
```markdown
|
||||
## Switch Phase 0 — hardware report
|
||||
|
||||
- Operator:
|
||||
- Date:
|
||||
- Console model:
|
||||
- Atmosphère / HOS version:
|
||||
- gen1recomp commit:
|
||||
- love-nx tag: 11.5-nx1
|
||||
- love.nro SHA-256 (local):
|
||||
- game.love SHA-256 (local, pre-send):
|
||||
- MTP round-trip SHA-256 (gen1recomp.nro):
|
||||
- MTP round-trip SHA-256 (game.love):
|
||||
- Title override used: yes / no
|
||||
- Applet Mode observed: yes / no (should be no for P0)
|
||||
- Probe getOS():
|
||||
- Probe dimensions:
|
||||
- Probe save directory shown:
|
||||
- Gamepad events logged: yes / no
|
||||
- Touch events logged: yes / no
|
||||
- Unpatched launcher boot: pass / fail
|
||||
- Joy-Con launcher navigation: pass / fail / not tested
|
||||
- Notes:
|
||||
```
|
||||
|
||||
## Fast dev loop (loose mode)
|
||||
|
||||
While iterating on Lua/assets:
|
||||
|
||||
1. Edit on Mac; run `scripts/test.sh --quick`.
|
||||
2. Rebuild `.bazinga/work/game.love` (`scripts/build.sh mac --no-notarize` or project pack step).
|
||||
3. Close Gen1Recomp on Switch.
|
||||
4. DBI → `Run MTP responder`.
|
||||
5. OpenMTP → `1: SD Card/switch/gen1recomp/`.
|
||||
6. Replace **only** `game.love`; wait for queue + refresh listing.
|
||||
7. Exit MTP responder; launch via title override.
|
||||
8. Keep `gen1recomp.nro` unchanged until the love-nx pin changes.
|
||||
|
||||
```bash
|
||||
scripts/test.sh --quick
|
||||
scripts/build.sh mac --no-notarize
|
||||
scripts/build_switch.sh --loose
|
||||
shasum -a 256 .bazinga/work/game.love
|
||||
```
|
||||
|
||||
## Controller input mapping (NX)
|
||||
|
||||
Measured on Switch OLED (`feat/switch-nx`, love-nx `11.5-nx1`, 1280×720). Both `joystickpressed` and `gamepadpressed` fire for Joy-Con; prefer the gamepad path when `joystick:isGamepad()` is true.
|
||||
|
||||
| Path | Control | Mapping |
|
||||
| ---- | ------- | ------- |
|
||||
| `gamepadpressed` | D-pad / left stick | move |
|
||||
| `gamepadpressed` | SDL `a` / `b` on **NX** | swapped via `NX_GAMEPAD_BINDINGS`: physical **A** (east) = GB A confirm, physical **B** (south) = GB B cancel |
|
||||
| `gamepadpressed` | SDL `a` / `b` on desktop | identity (SDL south = GB A) |
|
||||
| `gamepadpressed` | `start` / `back` | Start / Select (+ / −) |
|
||||
| `gamepadpressed` | Right / left shoulder (no Select) | Cycle game speed up / down (same as PC hotkey `1` / speed-down path) |
|
||||
| `joystickpressed` (raw) | only if **not** `isGamepad()` | face/menu fallback |
|
||||
| `joystickpressed` (raw) | `#1` / `#2` on NX | Nintendo B / A → GB B / A |
|
||||
| `joystickpressed` (raw) | `#9` / `#10` | Select / Start (− / +) |
|
||||
|
||||
**Nintendo UX on Switch:** physical A confirms, physical B cancels (explicit NX remap of SDL face labels).
|
||||
|
||||
**Launcher extras** (`RomImporter`): physical **A** clicks at the virtual cursor; **L** / **R** switch tabs; **Start** / **Select** start Play when a ROM is ready (else open Choose ROM). D-pad / left stick move the virtual cursor.
|
||||
|
||||
**Dual-path rule:** love-nx emits both `gamepadpressed` and `joystickpressed` for Joy-Con. When `joystick:isGamepad()` is true, Input and RomImporter **ignore raw** face/menu so NamingScreen does not see A+B in one frame. `NamingScreen` also prefers A over B if both edges still fire.
|
||||
|
||||
Implementation: `src/core/GamepadMap.lua` (`NX_RAW_*`, `ignoreRawForJoystick`, `displayChordDigit`), `src/core/Game.lua` (shoulder speed), `src/import/RomImporter.lua` (launcher tabs). Launcher and gameplay share the same converter.
|
||||
|
||||
## ROM inbox (NX)
|
||||
|
||||
Legal dumps land in a shared MTP inbox; **Scan again** is tab-scoped:
|
||||
|
||||
| Item | Value |
|
||||
| ---- | ----- |
|
||||
| Save-relative path | `imports/` (also accepts loose `.gb`/`.gbc` at the save-dir root) |
|
||||
| MTP destination | `1: SD Card/<save identity>/imports/` (see launcher notice for the live `getSaveDirectory()` path) |
|
||||
| Candidates | `*.gb` / `*.gbc` (hidden `.*` AppleDouble names skipped) |
|
||||
| Rescan | Game tab → **Scan again** — imports only the dump whose SHA-1 matches that tab (`GameVersion.forSha1`). Other known dumps stay for their own tabs |
|
||||
| Already ready | Same SHA already imported → “No new ROM found.” |
|
||||
|
||||
Players may drop Red, Blue, and Yellow into the same folder. Opening Yellow and pressing **Scan again** must not start a Red import.
|
||||
|
||||
## Mod zip inbox (NX)
|
||||
|
||||
Community mods install from a **separate** MTP inbox (not mixed into the ROM `imports/` scan):
|
||||
|
||||
| Item | Value |
|
||||
| ---- | ----- |
|
||||
| Save-relative path | `imports/mods/` |
|
||||
| MTP destination | `1: SD Card/<save identity>/imports/mods/` (see launcher notice for the live `getSaveDirectory()` path) |
|
||||
| Candidates | `*.zip` only |
|
||||
| Rescan | MODS tab → **Scan again** (installs each zip via `LauncherMods.installZip`; source zips are retained on success and failure) |
|
||||
| FIND MODS | Remains network-gated / hidden on NX (`networkValidated == false`) |
|
||||
|
||||
Do **not** commit third-party mod zip bytes into git. Drop the zip over MTP, rescan, enable in MODS, then Play.
|
||||
|
||||
**MTP tip (esp. macOS clients):** OpenMTP/Finder often creates AppleDouble sidecars named `._Something.zip` / `._cart.gb` / `._foo.sav`. Those are not real archives, ROMs, or saves — the launcher ignores hidden `.*` names under `imports/`, `imports/mods/`, and `imports/saves/<game>/`. If install still fails with “could not be opened” / “not a zip file”, delete any `._*` under the inbox and confirm the real zip starts with the `PK` magic (re-copy the release asset if unsure). This is a host-side annoyance of the current manual MTP loop, not something players should need forever.
|
||||
|
||||
Drop any community release `.zip` into `imports/mods/`, rescan, enable.
|
||||
Player-facing install steps: [switch-install.md](switch-install.md#community-mods).
|
||||
Mods own their OPTIONS / rebinds — do not duplicate third-party control tables here.
|
||||
|
||||
## Save `.sav` inbox (NX)
|
||||
|
||||
Raw Gen1 battery images use a **separate** MTP inbox (not mixed into ROM `imports/` or mod `imports/mods/`):
|
||||
|
||||
| Item | Value |
|
||||
| ---- | ----- |
|
||||
| Save-relative path | `imports/saves/red/`, `imports/saves/blue/`, `imports/saves/yellow/` |
|
||||
| MTP destination | `1: SD Card/<save identity>/imports/saves/<game>/` (see launcher notice for the live `getSaveDirectory()` path) |
|
||||
| Candidates | non-hidden `*.sav` only in **that game’s** folder |
|
||||
| Rescan | SAVE FILES → **Import save** on the matching game tab (scans only that folder) |
|
||||
| After success | Retire to `*.sav.imported` + append content hash to `imports/saves/<game>/.imported-sha1` |
|
||||
| Exports | **Export save** writes under `exports/<game>/gen1recomp-<game>-<slot>.sav`; NX shows an MTP path notice (no `openURL`) |
|
||||
|
||||
Do **not** commit `.sav` bytes into git. Drop the file into the matching game folder over MTP, press **Import save** on that tab, then play. Pull exports from `exports/<game>/`.
|
||||
|
||||
**MTP tip:** the same AppleDouble `._*.sav` rule applies — see the mod inbox tip above.
|
||||
|
||||
## Joy-Con display chords (Select + face)
|
||||
|
||||
PC digit hotkeys for COLORS / TILT / GBC FX / pipelines have Joy-Con equivalents. Hold **Select** (`back` / −) and press a face/shoulder button; the engine runs the same path as `Game:keypressed` for that digit (including `writeOptions` / Pipelines parity).
|
||||
|
||||
| Chord (Nintendo UX) | Engine key | Stock engine effect |
|
||||
| ------------------- | ---------- | ------------------- |
|
||||
| Select + **A** | `2` | COLORS cycle |
|
||||
| Select + **B** | `3` | TILT / perspective |
|
||||
| Select + **Y** | `5` | GBC FX |
|
||||
| Select + **X** | `6` | Mod pipeline hotkey (if registered) |
|
||||
| Select + **L** (left shoulder) | `7` | Mod pipeline hotkey (if registered) |
|
||||
|
||||
Keys `2` / `3` / `4` / `5` are claimed by the engine before mod pipeline hotkeys run, so a community mod cannot rebind those digits through `Pipelines.hotkey`. Mods that need their own controls should use OPTIONS rows or unclaimed hotkeys.
|
||||
|
||||
Without Select held, face buttons keep normal GB A/B gameplay mapping (no accidental color/tilt cycles). The **Options** menu remains available for the same settings — chords are optional shortcuts, not the only path.
|
||||
|
||||
On NX, A/B chords resolve through the Nintendo UX face remap so physical **A** → key `2` and physical **B** → key `3` match this table.
|
||||
|
||||
**OPTIONS → PERFORMANCE** clamps the port’s own extras (TILT / GBC FX / survey ZOOM) and can cap FPS — useful on weaker handheld budgets. Details: [new-features.md — Performance tier](new-features.md#performance-tier-low-end-devices).
|
||||
|
||||
Community mod zip install smoke (MODS inbox + Play): NXMOD-12 in [switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
|
||||
**Opt-in diagnostics:** create an empty `switch-debug.txt` in the save directory; events flush to `switch.log` at ≤1 Hz with build identity (no ROM/save bytes).
|
||||
|
||||
**NX asset probe (always on Play):** every Switch Play writes `nx-asset-probe.log` in the save directory (`pokemon-love2d/`). It lists whether `assets/generated/…` vs `yellow|blue/assets/generated/…` exist, what `Assets.resolve` returns, and whether `newImage` / `newImageData` open — for Yellow/Blue blank-sprite triage. No ROM bytes.
|
||||
|
||||
**Blue/Yellow cache overlay (NX):** fused love-nx cannot reliably mount `yellow|blue/assets/generated` onto the un-prefixed path, so `src/core/NxAssetOverlay.lua` wraps EVERY read-side love API that accepts a filesystem path (`filesystem.read/load/lines/newFileData/getInfo`, `graphics.newImage/newFont`, `image.newImageData`, `audio.newSource`, `sound.newSoundData`, `font.newFontData`) once at boot — only when `Platform.isNX()`. Covering the whole read surface (not just the loaders the boot needs today) keeps future states and mods inside the fallback automatically; write-side functions stay stock. Core code must NOT call love loaders on literal `assets/generated` paths (enforced by `tests/engine/nx_generated_guard_test.lua`); the chip-audio worker is a separate Lua state and gets the prefix explicitly via `audio.programPrefix` from `ChipAudio.slimAudio`.
|
||||
|
||||
**Hardware re-test:** T16 **pass** @ `2699c9a` (naming A=confirm / B=cancel). T19 **pass** (quit/reopen, suspend×10, reboot) — operator 2026-08-01.
|
||||
|
||||
**Suspend/resume audio:** after resume, chip music is stopped to avoid duplicate streams; confirm on hardware during P0-09/10 (T19).
|
||||
|
||||
## Lua error log (save directory)
|
||||
|
||||
On any uncaught Lua error, Gen1Recomp appends a redacted trace to `lua-error.log` in the LÖVE save directory (`love.filesystem.getSaveDirectory()`). The on-screen error overlay includes a hint pointing at that file. Logs rotate to `lua-error.log.1` when the active file exceeds 32 KiB. ROM/save bytes and non-printable data are stripped — never commit or share logs that might contain private paths without reviewing them first.
|
||||
|
||||
## Native crash triage (love-nx / Atmosphère)
|
||||
|
||||
love-nx native faults land under the console’s `crash_reports/` folder on SD (reachable via the same manual MTP workflow used for game deploys).
|
||||
|
||||
1. **Collect** — DBI → `Run MTP responder`; copy `sdmc:/crash_reports/*.bin` (or the dated subfolder) to the contributor host. Prefer keeping the microSD in-console for routine pulls.
|
||||
2. **Redact** — delete any attached screenshots or notes that mention ROM filenames, save paths, or private hashes before sharing logs publicly.
|
||||
3. **Symbolize** — use the **pinned** `love.elf` from `.bazinga/love-nx/11.5-nx1/` that matches `build-info.json` / `scripts/switch/love-nx-11.5-nx1.sha256`. Never use a “latest” download.
|
||||
|
||||
```bash
|
||||
# Example: aarch64-none-elf-addr2line from devkitPro
|
||||
aarch64-none-elf-addr2line -e .bazinga/love-nx/11.5-nx1/love.elf -f -C 0xADDRESS_FROM_CRASH_REPORT
|
||||
```
|
||||
|
||||
4. **Correlate** — compare `gitCommit` / `loveNxTag` from embedded `build-info.json` with the operator’s hardware notes.
|
||||
|
||||
If `addr2line` cannot resolve an address, archive the crash `.bin` with the exact `love.elf` SHA-256 used for the build — addresses are only meaningful against that ELF.
|
||||
|
||||
## P0 / P1 hardware matrix (ADR §9)
|
||||
|
||||
Operator evidence lives in `docs/switch-hardware-evidence.md`. **Do not invent passes** for rows that require hardware not yet run.
|
||||
|
||||
| ID | Requirement | Status | Evidence |
|
||||
| -- | ----------- | ------ | -------- |
|
||||
| P0-0a–f | love-nx pin, probe, MTP, title override | **pass** | Phase 0 checklist above; T4 |
|
||||
| P0-1a–d | Unpatched launcher boot + Joy-Con nav | **pass** | T4 / `docs/switch-hardware-evidence.md` |
|
||||
| P0-02 | MTP inbox import path shown | **pass** | T12 |
|
||||
| P0-03 | Rescan imports ROM | **pass** | T12 |
|
||||
| P0-04 | Canonical hash routes version | **pass** | T12 |
|
||||
| P0-05 | Source dump retained in inbox | **pass** | T12 |
|
||||
| P0-06 | Play reaches game after import | **pass** | T12 |
|
||||
| P0-07 | Joy-Con launcher navigation | **pass** | T16 @ `2699c9a` |
|
||||
| P0-08 | Joy-Con gameplay (incl. naming A/B) | **pass** | T16 @ `2699c9a` |
|
||||
| P0-09 | Save survives quit + reopen | **pass** | T19 |
|
||||
| P0-10 | ≥10 suspend cycles, no stuck input/dup audio | **pass** | T19 (operator 2026-08-01) |
|
||||
| P0-12 | Fused NRO boots without adjacent `game.love` | **pass** | T24 — `docs/switch-hardware-evidence.md` |
|
||||
| P0-14 | Fused NRO MTP round-trip SHA-256 | **pass** | T24 — first artifact `b019e2e8…` @ `6fb5602` (redeploy after Blue fix) |
|
||||
| P0-15 | Replace NRO only; saves persist | **pass** | T24 — operator NRO-only update keeps saves |
|
||||
| P1-01 | Docked vs handheld spot-check | **deferred** | Code: `NxDisplay` 720p↔1080p; OLED dock soak not recorded yet |
|
||||
| P1-02 | Applet Mode documented unsupported | **pass** | Title override required; Album path not validated |
|
||||
| P1-03 | Long-play soak (≥30 min) | **deferred** | No soak session recorded |
|
||||
| P1-04 | Reboot persistence | **pass** | T19 |
|
||||
| P1-05 | Audio resume after suspend | **pass** | T19 (no dup audio reported) |
|
||||
| — | Switch V1 / Erista boot | **pass** (boot) | Community — [booshankles](https://github.com/booshankles); see evidence log |
|
||||
| — | Switch Lite / docked soak | **untested** / **deferred** | Welcome contributions |
|
||||
| — | Automated / `nxlink` deploy | **absent** | Manual MTP / SD / FTP only (AD-009) |
|
||||
| — | Multi-OS transfer runbooks | **pass** | [switch-transfer.md](switch-transfer.md) |
|
||||
| — | Community mod zip OLED smoke (NXMOD-12) | **pass** | `docs/switch-hardware-evidence.md` |
|
||||
|
||||
## Review guidance
|
||||
|
||||
Maintainers may review as one PR or split later. Suggested slices (optional):
|
||||
|
||||
Each slice should declare: **no ROM/save bytes committed**, **love-nx pin with manifest checksums**, **hardware-tested rows listed with linked evidence**, **Applet Mode unsupported**, **network/updater disabled on NX**, **deploy still manual** (MTP / SD / FTP; no nxlink yet), **OpenMTP is one example not the sole contract**.
|
||||
|
||||
### Slice 1 — Platform + import (`platform/import`)
|
||||
|
||||
- `src/core/Platform.lua`, `conf.lua` NX branch
|
||||
- `src/import/RomImporter.lua` (NX flags, inbox, scan, shell/updater gates)
|
||||
- Tests: `tests/engine/platform_nx_*`, `tests/engine/rom_importer_nx_*` (ROM-free T2)
|
||||
- Docs: inbox/MTP import sections only
|
||||
|
||||
### Slice 2 — Input + lifecycle (`input/lifecycle`)
|
||||
|
||||
- `src/core/GamepadMap.lua`, `Input.lua`, `main.lua` focus/joystick hooks
|
||||
- `src/debug/SwitchDiagnostics.lua` (opt-in probe + error log)
|
||||
- Tests: input/diagnostics suites
|
||||
- Docs: controller mapping, suspend/audio notes
|
||||
|
||||
### Slice 3 — Build + docs (`build/docs`)
|
||||
|
||||
- `scripts/pack_love.sh`, `scripts/build_switch.sh`, `scripts/switch/*`
|
||||
- `assets/switch/icon.jpg`, `docs/switch-development.md`, hardware evidence templates
|
||||
- Gates: `pack_love.sh --dry-run`, `verify_payload.sh --self-test`, fused build script (devkitPro host)
|
||||
|
||||
**Pre-merge checklist:**
|
||||
|
||||
- [ ] Manifest `scripts/switch/love-nx-11.5-nx1.sha256` filled; binaries not in git
|
||||
- [ ] `verify_payload.sh` rejects generated cache / ROM / `.sav` / `.bak`
|
||||
- [ ] P0 matrix rows marked pass only with linked hardware evidence
|
||||
- [x] Fused NRO P0-12/14/15 pass with T24 evidence (`docs/switch-hardware-evidence.md`)
|
||||
- [ ] Updater / remote mod download hidden on NX (`networkValidated == false`)
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
# Switch hardware evidence (Phase 0 + import + input)
|
||||
|
||||
> **Hardware evidence log.** Author passes below were recorded on **one
|
||||
> Nintendo Switch OLED** with a **manual** Mac → DBI MTP deploy loop. A
|
||||
> separate community row records Switch V1 / Erista boot. These rows do
|
||||
> **not** claim Lite, docked soak, or automated install. See
|
||||
> `docs/switch-development.md` for status and limitations.
|
||||
|
||||
**love-nx:** `11.5-nx1`
|
||||
**Author console:** Switch OLED
|
||||
**Deploy method (author):** manual OpenMTP + DBI `Run MTP responder` (no CI / no nxlink)
|
||||
**Operator (author rows):** Andrew ([andrewqsantos](https://github.com/andrewqsantos))
|
||||
**Date (author rows):** 2026-08-01
|
||||
|
||||
Do **not** commit ROM dumps or private dump hashes. Do **not** mark a row **pass** without hardware notes for that row.
|
||||
|
||||
---
|
||||
|
||||
## Community — Switch V1 / Erista boot — pass (boot)
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Console | Nintendo Switch V1 (Erista) |
|
||||
| Check | Prebuilt fused NRO boots under title override |
|
||||
| Tester | [booshankles](https://github.com/booshankles) |
|
||||
| Notes | Community confirmation only — not a full P0/P1 matrix re-run on V1 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — probe (T4) — pass
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Commit (import era) | `df7cea4` |
|
||||
| `getOS()` / `love._os` | `NX` |
|
||||
| Dimensions | 1280×720 |
|
||||
| Save (probe) | `sdmc:/switch/gen1recomp/switch-probe` |
|
||||
| Joy-Con | `joystickpressed` + `gamepadpressed` (Y→`#3`, X→`#4`) |
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
| -------- | ------- |
|
||||
| `gen1recomp.nro` | `8290ac153d4c630e48c9b26ef9123f5204ed8ee0cef3042511707b5b645918f5` |
|
||||
|
||||
---
|
||||
|
||||
## T12 — Red import + Play — pass
|
||||
|
||||
Inbox MTP → “Scan again” → Play; Joy-Con launcher/gameplay (not touch-only).
|
||||
|
||||
---
|
||||
|
||||
# T16 — Joy-Con launcher + gameplay — pass (naming re-verify)
|
||||
|
||||
### Round 1 @ `7504753` — partial
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| Launcher / overworld (Joy-Con only) | **pass** |
|
||||
| Naming player/rival | **fail** (dual-path a+b; see below) |
|
||||
| Touch required | **no** |
|
||||
| `game.love` SHA-256 | `bd3a35461bf453c1f0465a5a289421aef3b5c72d3bf1f8d76e86231256829e0e` |
|
||||
|
||||
### Naming failure (root cause) — fixed in `efd81d8` + `2699c9a`
|
||||
|
||||
- love-nx fires **`gamepadpressed` + `joystickpressed` on the same physical press**.
|
||||
- `NamingScreen` tested `wasPressed("b")` before `"a"` → if both true in one frame, always deletes.
|
||||
- Dual-path fix: ignore raw when `isGamepad()` (`efd81d8`).
|
||||
- SDL-only UX then had physical B confirm / A erase; NX face remap (`2699c9a`) restores Nintendo A=confirm / B=cancel.
|
||||
|
||||
### Round 2 @ `2699c9a` — pass (Nintendo UX)
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Commit tested | `2699c9a` |
|
||||
| `game.love` SHA-256 | `a208b21e1f30b00e2e8c6fa6efe14f0e06d1db0ae1e50b810b16d9fb852926bc` |
|
||||
| Touch required | **no** |
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| Naming — player | **pass** — physical **A** confirms letter, **B** cancels/erases |
|
||||
| Naming — rival | **pass** (same) |
|
||||
| Launcher / overworld (prior round) | **pass** (unchanged mapping for d-pad/stick) |
|
||||
|
||||
T16 hardware gate: **closed**.
|
||||
|
||||
---
|
||||
|
||||
## T19 — save / suspend — pass
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| Save in-game → full quit → title-override reopen → load save | **pass** (@ `7504753` / retained) |
|
||||
| Suspend/resume ×10 (launcher / gameplay / mixed) | **pass** (operator 2026-08-01) |
|
||||
| Full console reboot persistence | **pass** (operator 2026-08-01) |
|
||||
|
||||
T19 hardware gate: **closed**. No stuck input, duplicate audio, or crash reported.
|
||||
|
||||
---
|
||||
|
||||
## T24 — fused NRO alone + NRO-only update — **pass**
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| First fused attempt | `6fb5602` (Blue Play failed — mount) |
|
||||
| Fix commits | `b1ad7c7` (logs/generated overlay), `ac6dfe7` (Blue/Yellow mount) |
|
||||
| Deploy | isolated folder, no adjacent `game.love` |
|
||||
| Boot fused | **pass** |
|
||||
| ROM import | **pass** |
|
||||
| Play **Red** | **pass** |
|
||||
| Play **Blue** (after `ac6dfe7`) | **pass** (operator 2026-08-01) |
|
||||
| NRO-only replace | **pass** — saves retained; app still boots/plays |
|
||||
| Touch required | no |
|
||||
|
||||
T24 hardware gate: **closed**.
|
||||
|
||||
---
|
||||
|
||||
## SWBLD — `build_switch.sh --fetch --fused` + install path — **pass**
|
||||
|
||||
Operator smoke for the switch-build-pipeline packaging CLI (closes matrix-deferred happy paths from validation).
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Command | `scripts/build_switch.sh --fetch --fused --version 0.0.0-test` |
|
||||
| Host | macOS + native switch-tools (or Docker fallback if used) |
|
||||
| Commit / build-info | `9147a64` (`gitCommit` in build-info) |
|
||||
| love-nx | `11.5-nx1` (manifest checksums match) |
|
||||
| Artifact | `dist/switch/gen1recomp-0.0.0-test-switch.nro` |
|
||||
| NRO SHA-256 | `210efb884a8d27443dc1c64ed8f071b0f862d8d0c9b140ad8185093c4e4027db` |
|
||||
| Install doc | `docs/switch-install.md` — at the time of this row: copy NRO under `sdmc:/switch/gen1recomp/` (releases now ship an SD-ready zip; same folder) |
|
||||
| Console | Switch OLED |
|
||||
| Operator | Andrew |
|
||||
| Date | 2026-08-01 |
|
||||
|
||||
| Check | Result |
|
||||
| ----- | ------ |
|
||||
| `--fetch` + `--fused` produce NRO + `.sha256` | **pass** |
|
||||
| Copy NRO to SD folder per install doc | **pass** (operator) |
|
||||
| Title-override launch / play | treated as prior T24 path; this row records **packaging + deploy to folder** success |
|
||||
|
||||
SWBLD packaging smoke: **closed** for Mac fused build + file-to-SD install step.
|
||||
|
||||
---
|
||||
|
||||
## NXMOD-12 — Community mod zip OLED smoke — **pass**
|
||||
|
||||
Closed from existing OLED photo evidence on issue
|
||||
[#531](https://github.com/bryanthaboi/gen1recomp/issues/531) (operator comment
|
||||
with launcher MODS + overworld shots). Photos live on the orphan branch
|
||||
[`switch-oled-photos`](https://github.com/andrewqsantos/gen1recomp/tree/switch-oled-photos)
|
||||
of the operator fork — **not** committed to this repo. Do **not** commit
|
||||
third-party mod `.zip` bytes. Community mods own their OPTIONS / rebinds;
|
||||
this entry only proves the MODS inbox + Play path on OLED.
|
||||
|
||||
| Field | Value |
|
||||
| ----- | ----- |
|
||||
| Status | **pass** |
|
||||
| gen1recomp commit | evidence era on `feat/switch-nx` (see #531); packaging pin love-nx `11.5-nx1` |
|
||||
| love-nx tag | `11.5-nx1` |
|
||||
| Console | Switch OLED |
|
||||
| Mod | community release `.zip` (not vendored; not named here) |
|
||||
| Zip committed to git? | **no** |
|
||||
| Photo evidence | [#531 comment](https://github.com/bryanthaboi/gen1recomp/issues/531) — MODS tab + overworld |
|
||||
| MODS tab photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1766.jpg |
|
||||
| Overworld photo | https://raw.githubusercontent.com/andrewqsantos/gen1recomp/switch-oled-photos/IMG_1771.jpg |
|
||||
| Operator | Andrew |
|
||||
| Date | 2026-08-01 |
|
||||
|
||||
### Checklist
|
||||
|
||||
| Step | Pass / fail / pending | Notes |
|
||||
| ---- | --------------------- | ----- |
|
||||
| MTP zip into save `imports/mods/` | **pass** | Photo evidence + prior inbox path |
|
||||
| MODS → Scan again → mod listed | **pass** | IMG_1766 — community mod installed |
|
||||
| Enable mod + Play Red boots without crash | **pass** | Overworld / Pallet / Oak lab photos on #531 |
|
||||
| Overworld Select+A → visible colors change | **pass** | Stock COLORS chord path exercised |
|
||||
| Overworld Select+B → visible tilt/perspective change | **pass** | Stock TILT chord path exercised (IMG_1771) |
|
||||
|
||||
### Evidence notes
|
||||
|
||||
```text
|
||||
Operator: Andrew
|
||||
Date: 2026-08-01
|
||||
Commit tested: feat/switch-nx era documented on issue #531
|
||||
Pass / fail summary: PASS — MODS zip install + Play on Switch OLED
|
||||
Photo branch: andrewqsantos/gen1recomp@switch-oled-photos
|
||||
```
|
||||
@@ -0,0 +1,164 @@
|
||||
# Install Gen1Recomp on Nintendo Switch
|
||||
|
||||
Every GitHub Release that includes Switch support ships an SD-ready zip:
|
||||
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
|
||||
**or** update — same steps), launch with **title override**, then import your
|
||||
own legal `.gb` ROM.
|
||||
|
||||
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
|
||||
> This project does not help you set that up. Tracks issue
|
||||
> [#531](https://github.com/bryanthaboi/gen1recomp/issues/531).
|
||||
> Hardware: **OLED** validated by the porter; **V1 / Erista** boot confirmed
|
||||
> by the community. Lite and other setups welcome more reports.
|
||||
> See [switch-development.md](switch-development.md) for limitations.
|
||||
|
||||
Prefer building from source? See [switch-build.md](switch-build.md).
|
||||
|
||||
Port by [andrewqsantos](https://github.com/andrewqsantos). Community testing
|
||||
help from [booshankles](https://github.com/booshankles).
|
||||
|
||||
## 1. Download the zip
|
||||
|
||||
1. Open
|
||||
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
|
||||
2. Download `gen1recomp-*-switch.zip` for the version you want.
|
||||
(Optional: verify against `sha256sums.txt` in the same release.)
|
||||
|
||||
## 2. Extract onto the microSD
|
||||
|
||||
Extract the zip at the **root** of the microSD so you get:
|
||||
|
||||
```text
|
||||
sdmc:/switch/gen1recomp/gen1recomp.nro
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/...
|
||||
```
|
||||
|
||||
Merge folders if your OS asks. Any method works: **MTP** (DBI → Run MTP
|
||||
responder + a client), **direct SD** (Hekate UMS or a card reader), or **FTP**.
|
||||
Exit MTP / unmount / stop FTP cleanly before launching. Step-by-step for
|
||||
macOS, Linux, and Windows: [switch-transfer.md](switch-transfer.md).
|
||||
|
||||
### Updating
|
||||
|
||||
Use the **same** extract/merge. It replaces `gen1recomp.nro` (and the small
|
||||
help `README.txt` / `INSTALL.txt` files). Saves, imported ROMs, mods, and
|
||||
options live under `pokemon-love2d/` — **do not delete that folder** when
|
||||
updating, or you will lose progress.
|
||||
|
||||
## 3. Launch with title override
|
||||
|
||||
**Applet Mode is not supported** for this game (not enough memory).
|
||||
|
||||
1. On the Switch HOME menu, highlight any installed title.
|
||||
2. Hold **R** and launch that title — this opens hbmenu with full memory
|
||||
(title override).
|
||||
3. From hbmenu, open `gen1recomp`.
|
||||
|
||||
Do **not** launch from the Album applet path for normal play.
|
||||
|
||||
## 4. Import your ROM
|
||||
|
||||
This project ships **no** game data. On first launch:
|
||||
|
||||
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), or Yellow
|
||||
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||
launcher also shows the live save-dir path). All three can sit in the
|
||||
same folder.
|
||||
2. Use **Scan again** on that game’s tab (Red / Blue / Yellow). Rescan
|
||||
matches by ROM SHA-1 for the open tab only — a Red dump never imports
|
||||
from the Yellow tab (and vice versa).
|
||||
|
||||
## 5. Import / Export a raw `.sav`
|
||||
|
||||
Continue a cart or PC battery save (or pull a slot off-console) via MTP /
|
||||
SD / FTP — same transfer methods as ROMs. Paths are **per game**:
|
||||
|
||||
| Game | Import inbox | Export folder |
|
||||
| ---- | ------------ | ------------- |
|
||||
| Red | `imports/saves/red/` | `exports/red/` |
|
||||
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
||||
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
||||
|
||||
(Under the save dir `pokemon-love2d/` — the zip already creates these folders.)
|
||||
|
||||
1. Copy a Gen1 `.sav` (32 KB) into that game’s inbox under the save dir
|
||||
([switch-transfer.md](switch-transfer.md)).
|
||||
2. With the game’s ROM already imported, open **that game’s tab** →
|
||||
**SAVE FILES** → **Import save**. Only that folder is scanned.
|
||||
3. A successful import retires the file to `*.sav.imported` and records its
|
||||
content hash so pressing **Import save** again does not clone slots.
|
||||
Failed imports leave the original `.sav` in place.
|
||||
4. To pull a slot off the console, use **Export save**, then copy the file
|
||||
from that game’s **`exports/<game>/`** folder via MTP / SD / FTP.
|
||||
|
||||
Do not put `.sav` files into git. Prefer clean copies — some MTP clients
|
||||
create `._*.sav` AppleDouble sidecars that are not real saves.
|
||||
|
||||
## Controls
|
||||
|
||||
### Gameplay
|
||||
|
||||
| Control | Action |
|
||||
| ------- | ------ |
|
||||
| D-pad / left stick | Move |
|
||||
| **A** | Confirm |
|
||||
| **B** | Cancel |
|
||||
| **+** (Start) | Start |
|
||||
| **−** (Select) | Select |
|
||||
| **R** (no Select held) | Cycle game speed up |
|
||||
| **L** (no Select held) | Cycle game speed down |
|
||||
|
||||
### Launcher
|
||||
|
||||
| Control | Action |
|
||||
| ------- | ------ |
|
||||
| D-pad / left stick | Move virtual cursor |
|
||||
| **A** | Click at cursor |
|
||||
| **L** / **R** | Previous / next tab |
|
||||
| **Start** / **Select** | Play if a ROM is ready; otherwise Choose ROM |
|
||||
|
||||
### System
|
||||
|
||||
| Control | Action |
|
||||
| ------- | ------ |
|
||||
| Hold **R** on HOME, then open from hbmenu | Title override (full memory) |
|
||||
|
||||
## Community mods
|
||||
|
||||
Mods install from a zip inbox (same transfer methods as ROMs):
|
||||
|
||||
1. Copy a release `.zip` into the save-dir **`imports/mods/`** path the
|
||||
launcher shows (MTP / SD / FTP — [switch-transfer.md](switch-transfer.md)).
|
||||
2. In the launcher, open **MODS** → **Scan again** → enable the mod →
|
||||
**Play**.
|
||||
|
||||
Remote **FIND MODS** / GitHub download stays **off** on Switch. Do not put
|
||||
mod zips into git. Community mods ship their own OPTIONS / rebinds — this port
|
||||
does not document third-party control tables.
|
||||
|
||||
### Joy-Con shortcuts (Select + face)
|
||||
|
||||
Hold **Select** (−) and press a face/shoulder button. Without Select, A/B stay
|
||||
normal gameplay confirm/cancel. These chords are the stock engine display
|
||||
hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
|
||||
|
||||
| Chord | Same as PC key | Stock engine effect |
|
||||
| ----- | -------------- | ------------------- |
|
||||
| Select + **A** | `2` | COLORS |
|
||||
| Select + **B** | `3` | TILT |
|
||||
| Select + **Y** | `5` | GBC FX |
|
||||
| Select + **X** | `6` | Mod pipeline hotkey (if a mod registers `6`) |
|
||||
| Select + **L** | `7` | Mod pipeline hotkey (if a mod registers `7`) |
|
||||
|
||||
If the handheld stutters with extras on, try **OPTIONS → PERFORMANCE** →
|
||||
`LOW` or `BALANCED`. Full chord notes for contributors:
|
||||
[switch-development.md](switch-development.md#joy-con-display-chords-select--face).
|
||||
|
||||
## Prefer building it yourself?
|
||||
|
||||
Building the fused NRO (and SD-ready zip) from source is covered in
|
||||
[switch-build.md](switch-build.md). Copying artifacts and inbox files
|
||||
(MTP / SD / FTP on macOS, Linux, Windows): [switch-transfer.md](switch-transfer.md).
|
||||
Status, limitations, and how we tested: [switch-development.md](switch-development.md).
|
||||
@@ -0,0 +1,168 @@
|
||||
# Switch file transfer (MTP / SD / FTP)
|
||||
|
||||
Canonical ways to put Gen1Recomp artifacts and inbox files onto a Nintendo
|
||||
Switch. **Any method is valid** if the bytes land in the destinations below.
|
||||
|
||||
This is the home runbook for contributors on **macOS, Linux, and Windows**.
|
||||
Player install (what to download, title override) stays in
|
||||
[switch-install.md](switch-install.md). Packaging stays in
|
||||
[switch-build.md](switch-build.md). Hardware evidence lives in
|
||||
[switch-hardware-evidence.md](switch-hardware-evidence.md).
|
||||
|
||||
> **Not supported yet:** `nxlink` / hbmenu netloader automation. Useful later
|
||||
> for a fast contrib rebuild loop; deferred on purpose (AD-009). Do not treat
|
||||
> netloader as the release or ROM/mod install path.
|
||||
|
||||
---
|
||||
|
||||
## Destinations (shared by every method)
|
||||
|
||||
| What | Where on the console |
|
||||
| ---- | -------------------- |
|
||||
| SD-ready release zip | Extract at microSD **root** → `sdmc:/switch/gen1recomp/gen1recomp.nro` plus `pokemon-love2d/` inbox folders. Install and update use the same merge; do **not** delete `pokemon-love2d/` |
|
||||
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
||||
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
||||
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game’s SAVE FILES → **Import save** |
|
||||
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
|
||||
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||
| Lua error log | `lua-error.log` in the save dir |
|
||||
|
||||
Saves persist across zip re-extract / NRO replacements as long as
|
||||
`pokemon-love2d/` is left in place. Never commit ROM dumps, `.sav`
|
||||
files, or third-party mod zips to git.
|
||||
|
||||
---
|
||||
|
||||
## Canonical methods
|
||||
|
||||
### 1. MTP (DBI responder + host client)
|
||||
|
||||
On the Switch: close Gen1Recomp → open **DBI** → **Run MTP responder** (often
|
||||
**X** on the main screen) → keep that screen up → USB-C data cable to the host.
|
||||
|
||||
On the host: open **one** MTP client, navigate to **`1: SD Card`**, then the
|
||||
paths above. Wait for the transfer queue; refresh; exit MTP on the Switch
|
||||
before launching.
|
||||
|
||||
#### macOS (example: OpenMTP)
|
||||
|
||||
[OpenMTP](https://github.com/ganeshrvel/openmtp) is the loop used for OLED
|
||||
hardware evidence — **one contributor example**, not a Mac-only product rule.
|
||||
|
||||
1. Quit other MTP clients.
|
||||
2. Open OpenMTP → select the DBI device → **`1: SD Card`**.
|
||||
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
||||
(or copy NRO / `game.love` for loose).
|
||||
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
||||
`imports/saves/<red|blue|yellow>/`, or `exports/<red|blue|yellow>/` path the
|
||||
launcher prints.
|
||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||
|
||||
macOS clients often create AppleDouble sidecars (`._Something.zip`,
|
||||
`._cart.gb`, `._foo.sav`). Those are not real archives or saves — the
|
||||
launcher skips hidden `.*` names. Delete `._*` junk if a zip/ROM/`.sav`
|
||||
fails to open.
|
||||
|
||||
#### Linux
|
||||
|
||||
1. Install desktop MTP support if needed (e.g. `gvfs-mtp` on GNOME/GTK
|
||||
desktops, or your distro’s KDE MTP stack).
|
||||
2. With DBI MTP active, open **Files** / **Dolphin** / **Thunar** and select
|
||||
the Switch / DBI device → **`1: SD Card`**.
|
||||
3. Extract the release zip at SD root (merge), or copy into `switch/gen1recomp/`
|
||||
and the save-dir inboxes as above.
|
||||
4. Use **only one** MTP accessor at a time. If `mtp-tools` / `mtpfs` reports
|
||||
“device is busy”, close the file manager’s MTP mount (or the CLI mount)
|
||||
and retry with a single client.
|
||||
5. Eject/unmount cleanly; exit MTP on the Switch; title-override launch.
|
||||
|
||||
If MTP is unavailable or flaky on Linux, use **direct SD** (Hekate UMS or a
|
||||
card reader) or **FTP** instead — same destinations in the table above.
|
||||
|
||||
#### Windows
|
||||
|
||||
1. With DBI MTP active, open **This PC** / **File Explorer** and look under
|
||||
**Portable Devices** for the Switch / DBI MTP volume → **`1: SD Card`**.
|
||||
2. Copy / extract into `switch\gen1recomp\` and the save-dir inboxes.
|
||||
3. Optional: [OpenMTP](https://github.com/ganeshrvel/openmtp) on Windows if
|
||||
Explorer is flaky.
|
||||
4. If Windows does not show an MTP device: Device Manager → find DBI / Switch
|
||||
→ Update driver → **MTP USB Device** (or Standard MTP Device). Prefer a
|
||||
data-capable USB-C cable and a direct port.
|
||||
5. Safely disconnect; exit MTP on the Switch; title-override launch.
|
||||
|
||||
If MTP is unavailable or flaky on Windows, use **direct SD** (Hekate UMS or a
|
||||
card reader) or **FTP** instead — same destinations in the table above.
|
||||
|
||||
### 2. Direct SD (Hekate UMS or card reader)
|
||||
|
||||
Same destinations; no MTP client required.
|
||||
|
||||
- **Hekate UMS** (preferred when available): expose the microSD to the host
|
||||
while the card stays in the console; mount the volume; copy files; **cleanly
|
||||
unmount** before leaving UMS.
|
||||
- **Physical reader**: power off / remove the microSD, copy on the host,
|
||||
**eject safely**, reinsert, boot CFW, title-override launch.
|
||||
|
||||
Do not yank the card or unplug UMS mid-write.
|
||||
|
||||
### 3. FTP (any SD-exposing Switch FTP)
|
||||
|
||||
Any homebrew FTP server that can write the microSD is fine — for example
|
||||
**DBI’s own FTP**, **sys-ftpd-light**, or **Sphaira** (names are illustrations
|
||||
only; pick what your CFW setup already uses).
|
||||
|
||||
1. Start the FTP server on the Switch; note IP/port/credentials from that app.
|
||||
2. From the host, connect with any FTP client and upload to the same
|
||||
`switch/gen1recomp/`, `imports/`, `imports/mods/`, `imports/saves/<game>/`,
|
||||
and `exports/<game>/` paths.
|
||||
3. Stop the FTP server cleanly before launching Gen1Recomp.
|
||||
|
||||
If credentials or chroots differ by app, trust the **destination paths**, not
|
||||
a single vendor tutorial.
|
||||
|
||||
---
|
||||
|
||||
## After every transfer
|
||||
|
||||
1. Exit MTP / unmount SD / stop FTP cleanly.
|
||||
2. Launch via **title override** (hold **R** on a title → hbmenu). **Applet
|
||||
Mode is not supported** (not enough memory).
|
||||
3. For ROMs: open the matching game tab → **Scan again** if the file was
|
||||
added after boot (SHA-1 must match that tab; other dumps in `imports/`
|
||||
stay for their own tabs). For mods: MODS → **Scan again** → enable →
|
||||
Play. For saves: SAVE FILES → **Import save** (rescans
|
||||
`imports/saves/<game>/`). Pull exported `.sav` files from
|
||||
`exports/<game>/`. Joy-Con display chords (stock engine):
|
||||
[switch-install.md](switch-install.md#joy-con-shortcuts-select--face).
|
||||
|
||||
### Optional NRO integrity check
|
||||
|
||||
For the first deploy of a given artifact (or after a flaky cable):
|
||||
|
||||
```bash
|
||||
shasum -a 256 path/to/gen1recomp.nro # or sha256sum
|
||||
```
|
||||
|
||||
Copy the file back from the SD and compare hashes. Round-trip must match.
|
||||
|
||||
---
|
||||
|
||||
## Failure modes (quick)
|
||||
|
||||
| Symptom | What to try |
|
||||
| ------- | ----------- |
|
||||
| Device busy / no MTP volume | One client only; different cable/port; Windows MTP USB Device driver; alternate method (SD or FTP) |
|
||||
| Zip/ROM/`.sav` “could not be opened” | Delete `._*` sidecars (including `._*.sav`); confirm real zip starts with `PK` |
|
||||
| Half-copied NRO / crash on boot | Re-copy; verify SHA-256; exit transfer mode before launch |
|
||||
| App opens in Applet Mode | Use title override (hold **R**), not Album |
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- Players: [switch-install.md](switch-install.md)
|
||||
- Builders: [switch-build.md](switch-build.md)
|
||||
- Status / hardware matrix: [switch-development.md](switch-development.md)
|
||||
- Evidence log: [switch-hardware-evidence.md](switch-hardware-evidence.md)
|
||||
@@ -0,0 +1,382 @@
|
||||
# Timing parity with the Game Boy
|
||||
|
||||
The port's clock is faithful: `src/core/FixedStep.lua` advances game logic in
|
||||
whole 1/60 s steps off wall-clock `dt`, the default speed multiplier is 1x
|
||||
(`src/core/GameSpeed.lua:22`), and audio runs on its own real-time accumulator
|
||||
so fast-forward cannot pitch it. Almost nothing in `src/` is seconds-based.
|
||||
|
||||
What diverges is the **frame budget of composed sequences**. The original
|
||||
spends a large fraction of its running time inside `DelayFrames` calls that
|
||||
produce no visible change - the pause after a page break, the beat before a
|
||||
status move resolves, the drain of an HP bar one point at a time. Those are
|
||||
invisible in a screenshot and easy to drop when porting behavior rather than
|
||||
timing. Dropping them is why the port reads as faster and snappier than
|
||||
hardware even though every individual animation is correct.
|
||||
|
||||
This document is the specification: what each sequence costs on hardware, and
|
||||
where that number comes from.
|
||||
|
||||
## Method
|
||||
|
||||
`tools/scan_pokered_delays.ps1` walks the disassembly and reports every
|
||||
frame-consuming wait with its enclosing routine label:
|
||||
|
||||
| Kind | Meaning | Frames |
|
||||
| --- | --- | --- |
|
||||
| `DelayFrames` | `ld c, N` + `call DelayFrames` (`home/delay.asm:1`) | N |
|
||||
| `DelayFrame` | one vblank wait (`home/vblank.asm:92`) | 1 |
|
||||
| `Delay3` | `home/palettes.asm:14`, three frames for a full bg-map update | 3 |
|
||||
| `Fade` | the `GBFade*` helpers, expanded to their totals below | 24 or 32 |
|
||||
|
||||
Current inventory against `pokered-master`: **450 sites** - 181 `DelayFrames`,
|
||||
164 `Delay3`, 65 `DelayFrame`, 31 `GBFade*`, and 9 `DelayFrames` calls whose
|
||||
count is computed at runtime.
|
||||
|
||||
The four fades all live in `home/fade.asm` and are loops of
|
||||
`ld c, 8 / call DelayFrames`:
|
||||
|
||||
| Routine | Iterations | Frames | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `GBFadeInFromBlack` | 4 | **32** | `home/fade.asm:21` |
|
||||
| `GBFadeOutToBlack` | 4 | **32** | `home/fade.asm:43` |
|
||||
| `GBFadeOutToWhite` | 3 | **24** | `home/fade.asm:26` |
|
||||
| `GBFadeInFromWhite` | 3 | **24** | `home/fade.asm:48` |
|
||||
|
||||
## The metric
|
||||
|
||||
For each catalog entry, `delta = |port - truth| / truth`; the entry passes at
|
||||
`delta <= 0.05`. The headline number is the **exposure-weighted** pass rate,
|
||||
weighting each sequence by how often it occurs in ordinary play - a 30-frame
|
||||
error on every page of dialogue matters more than a 30-frame error in the Hall
|
||||
of Fame. Weights are in the tier column: T1 sequences recur constantly, T2 are
|
||||
frequent, T3 are set pieces seen once or twice per playthrough.
|
||||
|
||||
## Tier 1 - constant exposure
|
||||
|
||||
These recur every few seconds of play and dominate perceived pacing. Both
|
||||
sides are verified.
|
||||
|
||||
### Overworld and transitions
|
||||
|
||||
| Sequence | Hardware | Source | Port | Port source | Delta |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Overworld loop iteration | 2 frames (two `DelayFrame`) | `home/overworld.asm:41-44` | 1 step | `src/core/Game.lua:174` | see note |
|
||||
| Warp / door: fade out | **32** | `home/overworld.asm:703` -> `GBFadeOutToBlack` | 32 | `src/render/Transition.lua` | **fixed** |
|
||||
| Warp / door: fade in | **0** (map is drawn under blacked palettes, no fade) | `home/overworld.asm:690-703` | 0 | `src/render/Transition.lua` | **fixed** |
|
||||
| Return to overworld after a battle | **10** hold, then `GBFadeInFromWhite` **24** | `home/overworld.asm:351-352`, `:22`, `:749-753` | 10 + 24 | `Transition.battleReturn` | **fixed** |
|
||||
| Special warp entry (fly / teleport / dungeon) | `Delay3` + `GBFadeInFromWhite` = **27** | `engine/overworld/player_animations.asm:5-7` | - | - | unmeasured |
|
||||
| Dungeon-warp arrival hold | **50** | `engine/overworld/player_animations.asm:43` | - | - | unmeasured |
|
||||
| Player step (walk) | 16 | 8 loop iterations x 2 frames | 16 | `src/world/Player.lua:14` | **ok** |
|
||||
| Turn in place | 2 | one extra loop pass | 2 | `src/world/Player.lua:18` | **ok** |
|
||||
|
||||
Note on the overworld loop: `OverworldLoop` calls `DelayFrame` and then falls
|
||||
through to `OverworldLoopLessDelay`, which calls it again - so a full pass
|
||||
costs 2 frames, and input is sampled every other frame. The port steps logic
|
||||
and samples input every frame. This does not change walking speed (the 2-frame
|
||||
loop moves 2 px, giving the same 16 frames per tile) but it does halve input
|
||||
latency versus hardware. Flagged rather than "wrong": matching it exactly would
|
||||
make the port feel less responsive than the original does on a modern display,
|
||||
and it is the one place where a deliberate divergence is defensible.
|
||||
|
||||
### Text
|
||||
|
||||
The typewriter cadence itself is already correct - 1/3/5 frames per character
|
||||
from `TextSpeedOptionData`, implemented at `src/render/TextBox.lua:267`. The
|
||||
gaps around it are missing.
|
||||
|
||||
| Sequence | Hardware | Source | Port | Port source | Delta |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `<CONT>` line scroll (after the A press) | `ProtectedDelay3` 3 + 2x `ScrollTextUpOneLine` 5 = **13** | `home/text.asm:262-277`, `:283-307` | 13 | `src/render/TextBox.lua` | **fixed** |
|
||||
| `<PARA>` paragraph break | `ProtectedDelay3` 3 + clear + **20** = **23** | `home/text.asm:230-243` | 23 | `src/render/TextBox.lua` | **fixed** |
|
||||
| Page break (`PageChar`) | 3 + **20** = **23** | `home/text.asm:245-260` | 23 | `src/render/TextBox.lua` | **fixed** |
|
||||
| `TextCommand_PAUSE` | **30** | `home/text.asm:500` | - | - | unmeasured |
|
||||
| `TextCommand_DOTS` | **10** per dot | `home/text.asm:576` | - | - | unmeasured |
|
||||
|
||||
The three ProtectedDelay3 frames are a *pre*-input hold: the arrow is already
|
||||
up and the button is ignored, because `ManualTextScroll` only starts watching
|
||||
the joypad after the delay returns. Mashing A through a long conversation
|
||||
therefore cannot go faster than 3 frames per line on hardware, and now cannot
|
||||
here either. `PromptText` (`home/text.asm:209-217`) has the same shape, so a
|
||||
finished page holds three frames before it can be dismissed too.
|
||||
|
||||
**Battle text is a second, separate engine.** `BattleState` types its own
|
||||
messages rather than going through `src/render/TextBox.lua`, so none of the
|
||||
fixes above reached it and it had drifted further than the overworld box:
|
||||
|
||||
| Sequence | Hardware | Source | Was | Now |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Per character | 1 glyph per `wOptions & $f` frames (1/3/5, default **3**) | `home/print_text.asm:4-45` | 2 glyphs **per frame**, option ignored | 3 |
|
||||
| Per character, A or B held | **1** frame | `print_text.asm:27-36` | 2 glyphs per frame | 1 |
|
||||
| `<CONT>` pre-input hold | **3** | `home/text.asm:263-267` | 0 | 3 |
|
||||
| `<CONT>` scroll after the press | **10** | `home/text.asm:280-305` | 0 | 10 |
|
||||
| Finished page, pre-input hold | **3** | `home/text.asm:213-217` | 0 | 3 |
|
||||
|
||||
At the default text speed the battle typewriter was running **six times**
|
||||
hardware speed, which is most of why battle text read as a blur, and it
|
||||
ignored the OPTION text-speed setting entirely.
|
||||
|
||||
`ScrollTextUpOneLine` is `ld b, 5` of `DelayFrame` (`home/text.asm:301-305`)
|
||||
and its own comment notes it is "always called twice in a row", so a CONT
|
||||
scroll blocks for 10 frames. The port's `scrollPx` slide at
|
||||
`src/render/TextBox.lua:305-307` is a cosmetic 8 px at 2 px/frame running in
|
||||
`draw()`, not on the logic step, and it does not gate the typewriter.
|
||||
|
||||
### Menus
|
||||
|
||||
| Sequence | Hardware | Source | Port | Port source | Delta |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Yes/no answer (either option) | **15** | `engine/menus/text_box.asm:322-323`, `:333-334` | 15 | `src/ui/ChoiceBox.lua` | **fixed** |
|
||||
| List menu open (bag, PC, party-as-list) | **10** | `home/list_menu.asm:55-56` | 0 | `src/ui/ListMenu.lua` | **-100%, open** |
|
||||
| List menu redraw per input | `Delay3` = **3** | `home/list_menu.asm:64` | 0 | - | **-100%, open** |
|
||||
| Field move from the party menu | `Delay3` = **3** | `engine/menus/start_sub_menus.asm:4,27,175-203` | 7 (white flash) | `src/render/Transition.lua:8` | see note |
|
||||
| Teleport from the party menu | **60** + `Delay3` | `engine/menus/start_sub_menus.asm:224-225` | - | - | unmeasured |
|
||||
|
||||
The port's 7-frame `white_flash` models `GBPalWhiteOutWithDelay3` plus the
|
||||
screen-tile restore, which is a defensible reading of the same sequence; it is
|
||||
listed here to be reconciled against the exact path rather than treated as a
|
||||
bug.
|
||||
|
||||
### Battle entry
|
||||
|
||||
The wipe into a battle and the silhouette slide behind it. Numbers here come
|
||||
from **pokered-c** (`C:\Users\Anthony\pokered`), whose `battle_transition.c`
|
||||
derives each wipe from `battle_transitions.asm` and then corrects it against a
|
||||
live side-by-side with the ROM. Where that project's measured value and a
|
||||
naive reading of the asm disagree, the measured value wins - see the inward
|
||||
spiral below.
|
||||
|
||||
| Sequence | Hardware | Was | Now |
|
||||
| --- | --- | --- | --- |
|
||||
| DoubleCircle wipe (wild, weak) | 10 x 3 = **30** | 40 | 30 |
|
||||
| Circle wipe (wild, strong) | 20 x 3 = **60** | 40 | 60 |
|
||||
| Spiral outward (trainer, strong) | 360 fills / 3 per frame = **120** | 40 | 120 |
|
||||
| Spiral inward (trainer, weak) | 7 tiles per `Delay3` = **~150** | 40 | 156 |
|
||||
| HStripes (dungeon wild, weak) | 20 x 3 = **60** | 24 | 60 |
|
||||
| VStripes (dungeon wild, strong) | 18 x 3 = **54** | 24 | 54 |
|
||||
| Shrink (dungeon trainer, weak) | 9 x 6 = **54** | 24 | 54 |
|
||||
| Split (dungeon trainer, strong) | 9 x 6 = **54** | 24 | 54 |
|
||||
| Flash before the circle wipes | 12 x 2 x 3 = **72** | 72 | 72 |
|
||||
| Black hold before the battle draws | not stated by pokered; ~30 floor, calibrated **60** | 30 | 60 |
|
||||
| Silhouette slide in | 144 px at 2 px/frame = **72** | 40 (160 px at 4 px/frame) | 72 |
|
||||
| Trainer intro, before balls + text | `WaitForSoundToFinish` + `DelayFrames 20` | 0 | sfx wait + 20 |
|
||||
|
||||
The trainer intro's sound is `SFX_Silph_Scope`, extracted here as
|
||||
`Trainer_Appeared` (`tools/rom_manifest.json` `audio.sfxHeaders`, bank 8 /
|
||||
`$42bb`). It was being extracted and never played by anything. It now plays
|
||||
into a clear window: `PrintBeginningBattleText .trainerBattle` does
|
||||
`PlaySound` then `WaitForSoundToFinish`, which **blocks**, and only then
|
||||
`DelayFrames 20` before `DrawAllPokeballs` and the text. `BattleState`'s
|
||||
message queue grew a `waitSound` row for that, since `WaitForSoundToFinish`
|
||||
waits on the sound actually stopping rather than on a fixed frame count.
|
||||
|
||||
**Scripted battles were skipping the transition entirely.** `BattleTransition`
|
||||
runs from `DoBattleTransitionAndInitBattleVariables`, which both
|
||||
`InitBattleCommon` (`core.asm:6680`, trainers) and `InitWildBattle` (`:6699`)
|
||||
call unconditionally - every battle on hardware enters through a wipe. In the
|
||||
port only `OverworldState:pushBattle` built one, and `Commands.start_battle`
|
||||
pushed the `BattleState` straight onto the stack. The trainer-*sight* path
|
||||
went through `pushBattle`, but every **script-driven** battle did not: gym
|
||||
leaders, the rival, Giovanni, and every scripted wild encounter cut straight
|
||||
to the battle screen with no transition at all. The catch tutorial
|
||||
(`old_man_demo`) had the same gap; `InitWildBattle` has no
|
||||
`BATTLE_TYPE_OLD_MAN` special case, so it gets a wipe too.
|
||||
|
||||
**Beyond 160x144.** Both halves of the transition used to stop at the classic
|
||||
letterbox. The flash filled the 160x144 UI canvas, and the wipe handed the
|
||||
surrounding window a generic centre-out square cascade
|
||||
(`Renderer:drawBattleCascade`) regardless of which of the eight styles was
|
||||
running - so at any zoom a spiral read as "a spiral in a box, with something
|
||||
else happening around it".
|
||||
|
||||
- The flash is a palette write (`rBGP`), and a palette register tints every
|
||||
pixel the LCD shows; there is no "outside the screen" for it to miss. It is
|
||||
now published to the renderer as a screen-space veil and painted over the
|
||||
finished composite, so it covers the whole surface at any zoom.
|
||||
- The spiral and circle walks are now generated for whatever grid the window
|
||||
works out to (`BattleTransition.gridOrder`), and the area outside the
|
||||
letterbox is filled in that order instead of the square cascade. The
|
||||
authentic 20x18 builders still own the letterbox itself, overrun and all,
|
||||
so nothing changes at 1x. The generic builders are deliberately *not* the
|
||||
ROM's walk - out there the hardware has no behaviour to be faithful to,
|
||||
only a shape to continue.
|
||||
- `shrink`, `split` and the two stripe styles are plain geometry rather than
|
||||
a tile order, so they keep the cascade for now. Extending them is
|
||||
rectangles, not a walk, and has not been done.
|
||||
|
||||
Note that the **flash is wild-only**: `BattleTransition_FlashScreen` is called
|
||||
from `BattleTransition_Circle` (`:585`) and `BattleTransition_DoubleCircle`
|
||||
(`:628`) and nowhere else. A trainer battle's transition is the spiral -
|
||||
inward against a weaker foe, outward against a stronger one
|
||||
(`wBattleTransitionSpiralDirection`, `:119-126`) - with no flash in front of
|
||||
it. The port's `flash` mapping was already correct; the transition simply
|
||||
never ran for those battles.
|
||||
|
||||
Two of these deserve their reasoning recorded:
|
||||
|
||||
**The inward spiral** writes one tile per iteration and calls
|
||||
`BattleTransition_TransferDelay3` every seventh tile. That helper is not a
|
||||
one-frame transfer - it is `ld a,1 / ldh [hAutoBGTransferEnabled] / call
|
||||
Delay3 / xor a / ldh [...]` (`battle_transitions.asm:619`), so the cadence is
|
||||
7 tiles per **three** frames. Reading it as one frame runs the whole wipe 3x
|
||||
too fast, ~46 frames against the ROM's ~150. pokered-c hit that exact bug and
|
||||
caught it on a live comparison.
|
||||
|
||||
**The black hold** is not a number pokered states anywhere; it is incidental
|
||||
load cost that a modern port does not pay. The derivable floor is ~13 frames
|
||||
(`LoadHpBarAndStatusTilePatterns` 4, `LoadHudTilePatterns` 2, `ClearScreen`'s
|
||||
`Delay3` 3, the `DisableLCD` LY wait 1, `Delay3` after `EnableLCD` 3), but the
|
||||
two sprite decompressors (`UncompressSpriteFromDE` for the 7x7 front pic and
|
||||
`LoadPlayerBackPic`'s uncompress + `ScaleSpriteByTwo`) are bit-level RLE/delta
|
||||
decoders that cannot be cycle-counted from the asm at all. So the derivation
|
||||
bottoms out around 25-30 with an unbounded remainder. pokered-c set 60 by ear
|
||||
against the real ROM and marked it ~95% right rather than frame-matched. The
|
||||
credible range is 30-60; it should not be "corrected" down toward the floor on
|
||||
the strength of the derivation, because the omitted decompressors are exactly
|
||||
the unbounded part. Pinning it exactly wants a frame-by-frame capture.
|
||||
|
||||
### Battle turns
|
||||
|
||||
| Sequence | Hardware | Source | Port | Port source | Delta |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Player HP bar drain of D HP over P pixels | **D + 2P + 6** | `engine/gfx/hp_bar.asm:81-135`, `:140-159`, `:234` | D + 2P + 6 | `src/battle/BattleState.lua` `stepHPDrain` | **fixed** |
|
||||
| Enemy HP bar drain over P pixels | **2P + 5** (no per-HP frame) | same, gated at `:207-209` | 2P + 5 | same | **fixed** |
|
||||
| Status move / missed move beat | **30** | `engine/battle/core.asm:3145,3158,3185-3186`; enemy `:5588` | 30 | `EffectRegistry` `missBeat`, `BattleState:performMove` | **fixed** |
|
||||
| Applying anim type 1, vertical shake b=8 | **48** | `animations.asm:500-503` | 48 | `BattleState:applyHitFx` | ok |
|
||||
| Applying anim type 2, fast horizontal b=8 | **72** | `animations.asm:505-508` | 72 | same | ok |
|
||||
| Applying anim type 3, slow horizontal 6,2 | **48** | `animations.asm:510-512,526-549` | 48 | same | ok |
|
||||
| Applying anim type 4, `AnimationBlinkMon` | **60** | `animations.asm:514-516`, `:1360-1376` | 60 | same | **fixed** |
|
||||
| Applying anim type 5, fast horizontal b=2 | **18** | `animations.asm:518-521` | 18 | same | ok |
|
||||
| Applying anim type 6, slow horizontal 3,2 | **24** | `animations.asm:523-525` | 24 | same | ok |
|
||||
| Faint slide-down | **14** (`PIC_HEIGHT` x `DelayFrames 2`) | `engine/battle/core.asm:1186-1222` | 14 | `BattleState:onFaint` | **fixed** |
|
||||
| Before every move animation | `Delay3` = **3** | `engine/battle/core.asm:6635-6640` | 3 | `BattleState:updateQueue` | **fixed** |
|
||||
| Battle start, after enemy send-out | **40** | `engine/battle/core.asm:152-156` | 40 | `BattleState:enter` | **fixed** |
|
||||
| Poison / burn / leech-seed tick | **20** | `engine/battle/core.asm:529-530` | 20 | `src/battle/BattleState.lua:1817` | **ok** |
|
||||
| Switch player mon | **50** | `engine/battle/core.asm:2421-2422` | 50 | `src/battle/BattleState.lua:1666-1668` | **ok** |
|
||||
| Post-hit hold (crit text or not) | **20** | `engine/battle/core.asm:3798-3814` | 20 | `EffectRegistry.runDamaging` | **fixed** |
|
||||
| Fainted mon slide-down | **2** per row | `engine/battle/core.asm:1216-1217` | - | - | unmeasured |
|
||||
| Trainer pic slide off | **2** per column | `engine/battle/core.asm:1267-1268` | - | - | unmeasured |
|
||||
| Trainer battle victory | **40** | `engine/battle/core.asm:940-941` | - | - | unmeasured |
|
||||
| Player blackout | **40** | `engine/battle/core.asm:1143-1144` | - | - | unmeasured |
|
||||
| No moves left (Struggle) | **60** | `engine/battle/core.asm:2753-2754` | - | - | unmeasured |
|
||||
| Send-out animation | `Delay3` + **4** + **5** | `engine/battle/core.asm:6814-6830` | - | - | unmeasured |
|
||||
|
||||
The HP bar was the single largest battle divergence. `UpdateHPBar` steps **one
|
||||
HP point per loop iteration**; each iteration pays 1 frame in
|
||||
`UpdateHPBar_PrintHPNumber` whenever `wHPBarType != 0` - the player's own HUD
|
||||
and the party menu, but not the enemy HUD - plus 2 frames for each pixel the
|
||||
bar actually moves. The tail (`.animateHPBarDone`, `:132-135`) prints the
|
||||
number once more, animates one last pixel and falls into `Delay3`, so it costs
|
||||
6 frames player-side and 5 enemy-side.
|
||||
|
||||
A 150 HP mon losing everything therefore costs 150 + 96 + 6 = **252 frames
|
||||
(4.2 s)** on the player's HUD, against 96 + 5 = 101 on the enemy's. The port
|
||||
used a flat `maxHP/96` - the enemy-side rate applied to both sides - and ran
|
||||
that same drain in 96 frames (1.6 s).
|
||||
|
||||
## Tier 2 - frequent
|
||||
|
||||
Status-effect failures (`engine/battle/effects.asm:161,1162,1205`) hold **50**
|
||||
frames each. `SwitchAndTeleportEffect` uses 50 and 20
|
||||
(`:834-901`). Evolution holds **50** then **40**
|
||||
(`engine/pokemon/evos_moves.asm:123,155`). The healing machine
|
||||
(`engine/overworld/healing_machine.asm`), item effects
|
||||
(`engine/items/item_effects.asm`, 259 frames across 8 sites), and the fishing
|
||||
animation (**10** then **100**, `engine/overworld/player_animations.asm:380,399`)
|
||||
are all in this tier.
|
||||
|
||||
## Tier 3 - set pieces
|
||||
|
||||
Highest total budgets in the scan, all seen rarely:
|
||||
`engine/movie/hall_of_fame.asm` (529 frames / 7 sites),
|
||||
`engine/link/cable_club.asm` (507 / 12), `engine/movie/credits.asm` (502 / 7),
|
||||
`engine/movie/trade.asm` (494 / 19), `engine/movie/intro.asm` (317 / 7),
|
||||
`engine/menus/main_menu.asm` (271 / 15), `engine/menus/save.asm` (250 / 3),
|
||||
`engine/battle/end_of_battle.asm` (200 / 1, the link-battle win/lose string).
|
||||
Several of these already have faithful implementations - see
|
||||
`src/ui/IntroMovie.lua`, `src/ui/Credits.lua`, `src/ui/HallOfFame.lua`, whose
|
||||
constants cite their asm sources directly.
|
||||
|
||||
## Status
|
||||
|
||||
Closed, and locked by `tests/engine/timing_parity.lua`:
|
||||
|
||||
1. **Text page and CONT breaks** - were 0 frames where hardware spends 13-23,
|
||||
on every page of every dialogue in the game. Now exact, including the
|
||||
three-frame pre-input hold that swallows a mashed A.
|
||||
2. **Player HP bar drain** - was ~2.5x too fast on a typical mon. Now steps
|
||||
one HP point at a time at the hardware rate, with the enemy HUD correctly
|
||||
cheaper than the player's.
|
||||
3. **Warp fade** - was a symmetric 12/12; now 32 out and no fade in, which is
|
||||
both the right duration and the right shape.
|
||||
4. **Yes/no answer** - was 0 frames where hardware spends 15, with the cursor
|
||||
snapping to NO on B for the duration as `.choseSecondMenuItem` does.
|
||||
|
||||
5. **Battle entry** - every wipe ran at a flat 40/24 against budgets of 30-156,
|
||||
the silhouette slide was 40 frames against 72, the black hold was half
|
||||
what pokered-c calibrated, and the trainer intro's 20-frame gap before the
|
||||
balls and text was missing entirely. This was the single most compressed
|
||||
stretch in the game.
|
||||
|
||||
6. **Battle turns** - the type-4 blink ran at 20 frames against 60. That is
|
||||
the applying animation for every plain damaging move the player uses, so
|
||||
it was the single most-repeated timing error in the game. The 30-frame
|
||||
beat that hardware spends on every status move and every miss was missing
|
||||
entirely. The faint slide, unusually, ran *slower* than hardware (30
|
||||
against 14).
|
||||
|
||||
Two things worth recording about that last batch, because both contradict a
|
||||
plausible reading:
|
||||
|
||||
- **`PrintCriticalOHKOText`'s 20-frame hold is not conditional.** The
|
||||
"no critical hit" early-out at `core.asm:3799` jumps to `.done`, and
|
||||
`.done` *is* the `ld c, 20 / jp DelayFrames`. Every landed hit pays it,
|
||||
which is where the beat before "It's super effective!" comes from.
|
||||
- **`StartBattle`'s 40-frame hold is not conditional either.** The `call nz`
|
||||
at `core.asm:154` gates only `EnemySendOutFirstMon`; the `DelayFrames 40`
|
||||
under it runs for wild battles too, and it lands *between* the enemy's
|
||||
send-out and `.playerSendOutFirstMon` (`:166`) rather than at the end of
|
||||
the intro.
|
||||
|
||||
Trainer victory (24-frame scroll-in + 40-frame hold) and the ball shake
|
||||
(`SFX_TINK` + `DelayFrames 40` per rock) were already correct -
|
||||
`BattleState.lua`'s `wait = 64` and `AnimPlayer.lua`'s `emit(40)`. Note that
|
||||
pokered-c's `BUI_TRAINER_VICTORY_SLIDE` comment calls the scroll-in 14
|
||||
frames; `_ScrollTrainerPicAfterBattle` is 6 loop passes of `DelayFrames 4`,
|
||||
so 24 is right and this port already had it.
|
||||
|
||||
**Leaving a battle was a cut, not a fade.** `EnterMap` checks
|
||||
`BIT_BATTLE_OVER_OR_BLACKOUT` and calls `MapEntryAfterBattle`
|
||||
(`home/overworld.asm:22`), which is `GBFadeInFromWhite` - so the map fades up
|
||||
from white over 24 frames, behind the 10-frame hold at `:351-352`. The port
|
||||
popped the battle and the overworld was simply there. `Transition.battleReturn`
|
||||
supplies both halves, and steps the veil in three palette stages of 8 frames
|
||||
rather than tweening it, because `GBFadeIncCommon` writes a palette and holds
|
||||
it with `ld c, 8 / call DelayFrames` (`home/fade.asm:30-41`) three times over.
|
||||
|
||||
It is wrapped around `battle.onFinish` in `OverworldState:pushBattle` - the
|
||||
one funnel every battle goes through - rather than living in `afterBattle`.
|
||||
That placement matters: a script-driven **win** defers `afterBattle` into
|
||||
`ctx.afterScript` so an evolution screen cannot be buried under the trainer's
|
||||
follow-up text (`Commands.start_battle`), and a fade inside `afterBattle`
|
||||
inherited that deferral. On a rival battle it fired after the post-battle
|
||||
dialogue *and* the rival's walk-off, rather than when the battle ended.
|
||||
|
||||
The rest of `onFinish` runs as the fade's `onDone`, which is also the hardware
|
||||
order: `MapEntryAfterBattle` fades the map back in and only then does the map
|
||||
script run. The overworld is frozen meanwhile - `StateStack` updates the top
|
||||
state only - so nothing moves under the white.
|
||||
|
||||
Hardware skips the fade on a dark map (`wMapPalOffset` nonzero takes the
|
||||
`LoadGBPal` branch at `:754`). This port has no `wMapPalOffset` equivalent -
|
||||
no map needs FLASH to be lit - so that branch is unreachable here;
|
||||
`battleReturn` accepts `opts.instant` for it if that ever changes.
|
||||
|
||||
Still open, hardware number confirmed but not yet wired:
|
||||
|
||||
- List menu open (10) and per-input redraw (`Delay3`).
|
||||
- Every battle-table row marked "unmeasured" above - the status/miss beat (30)
|
||||
is the highest-exposure of them, since it is paid on every status move and
|
||||
every miss.
|
||||
|
||||
Entries marked "unmeasured" have confirmed hardware numbers but the port side
|
||||
has not been traced; they are remaining work, not known-good.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Mike Freno
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,188 @@
|
||||
-- modules/Behavior.lua
|
||||
--
|
||||
-- Base module for the pluggable behavior system that drives the Behavior &
|
||||
-- Mode Unification refactor.
|
||||
--
|
||||
-- A *behavior* is a small, stateless table produced by `Behavior.new(spec)`
|
||||
-- that implements a fixed lifecycle hook set. Concrete behaviors (Clickable,
|
||||
-- Scrollable, TextEditable, Selectable, ...) each live in their own module and
|
||||
-- are attached to an Element. The Element's `update`/`draw`/save-restore paths
|
||||
-- iterate `element.behaviors` and dispatch to the appropriate hooks, replacing
|
||||
-- the swarm of `if self.scrollable` / immediate-mode-branch checks previously
|
||||
-- hard-coded in Element.lua.
|
||||
--
|
||||
-- Element.new iterates a registry of behavior prototypes and auto-attaches
|
||||
-- whichever return true from `shouldAttach(props)`. Element therefore never
|
||||
-- needs to know what an individual behavior does — only that it conforms to
|
||||
-- this interface.
|
||||
--
|
||||
-- Design constraints (locked — tasks 02-13 depend on this API):
|
||||
-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler.
|
||||
-- Stays fully stub-testable standalone (see testing/__tests__/behavior_test.lua).
|
||||
-- * Minimal interface — exactly 6 lifecycle hooks + a `shouldAttach` predicate.
|
||||
-- Do NOT add hooks "just in case"; new capabilities become new behaviors,
|
||||
-- not new hooks. Extending HOOK_NAMES is an architectural decision that must
|
||||
-- be mirrored by every concrete behavior.
|
||||
-- * Immutable instances — behavior tables are produced once and treated as
|
||||
-- read-only. Per-element runtime state lives on the element (or a subsystem
|
||||
-- the behavior attaches), NEVER on the behavior instance itself, so a single
|
||||
-- behavior instance can be shared across many elements.
|
||||
--
|
||||
-- Lifecycle hook contract (each receives the owning element as first argument):
|
||||
-- onAttach(element) — called once when the behavior is attached
|
||||
-- (element fully constructed). Allocate
|
||||
-- subsystems / register listeners here.
|
||||
-- onDetach(element) — called once when the behavior is detached
|
||||
-- (element destroyed / mode switch). Tear
|
||||
-- down anything onAttach created.
|
||||
-- onUpdate(element, dt) — called every frame from Element:update.
|
||||
-- onDraw(element, ctx) — called every frame from Element:draw; `ctx`
|
||||
-- is the draw context (viewport transform,
|
||||
-- scissor state, theme renderer, ...).
|
||||
-- saveState(element) -> state — called during Element save-state; returns
|
||||
-- a serializable snapshot (or nil) so the
|
||||
-- behavior's runtime state survives the
|
||||
-- immediate-mode recreation cycle.
|
||||
-- restoreState(element, state) — called after reconstruction with the
|
||||
-- snapshot previously returned by saveState.
|
||||
--
|
||||
-- shouldAttach(props) -> boolean — class-level predicate (not a hook): given
|
||||
-- an element's props table, return true if
|
||||
-- this behavior should be auto-attached.
|
||||
-- Defaults to false (opt-in).
|
||||
|
||||
--- A behavior instance: a frozen table of lifecycle hooks + a shouldAttach
|
||||
--- predicate. All hooks are always present (custom override or no-op default).
|
||||
---@class Behavior
|
||||
---@field onAttach fun(element:table)
|
||||
---@field onDetach fun(element:table)
|
||||
---@field onUpdate fun(element:table, dt:number)
|
||||
---@field onDraw fun(element:table, ctx:table)
|
||||
---@field saveState fun(element:table):any
|
||||
---@field restoreState fun(element:table, state:any)
|
||||
---@field shouldAttach fun(props:table):boolean
|
||||
|
||||
local Behavior = {}
|
||||
|
||||
-- The fixed, ordered lifecycle hook set. Order is preserved so downstream tasks
|
||||
-- (Element behavior iteration) can rely on a deterministic dispatch sequence.
|
||||
-- HOOK_NAMES is intentionally NOT extended casually — see file header.
|
||||
Behavior.HOOK_NAMES = {
|
||||
"onAttach",
|
||||
"onDetach",
|
||||
"onUpdate",
|
||||
"onDraw",
|
||||
"saveState",
|
||||
"restoreState",
|
||||
}
|
||||
|
||||
-- Allowlist of spec keys accepted by Behavior.new. Anything else is rejected so
|
||||
-- a typo (e.g. `onUpdat`) surfaces immediately instead of silently no-op'ing.
|
||||
-- Hook keys (HOOK_NAMES + shouldAttach) MUST be functions; metadata keys
|
||||
-- (drawLayer) may hold any value.
|
||||
local ALLOWED_KEYS = {
|
||||
onAttach = true,
|
||||
onDetach = true,
|
||||
onUpdate = true,
|
||||
onDraw = true,
|
||||
saveState = true,
|
||||
restoreState = true,
|
||||
shouldAttach = true,
|
||||
drawLayer = true,
|
||||
}
|
||||
|
||||
-- Spec keys whose values are NOT required to be functions (passive metadata
|
||||
-- consumed by dispatch sites, e.g. Element:draw's pre/post-children split).
|
||||
local NON_FUNCTION_KEYS = {
|
||||
drawLayer = true,
|
||||
}
|
||||
|
||||
-- Default no-op hook. Behaviors override only the hooks they need; every other
|
||||
-- hook resolves to this so dispatch sites never have to nil-check.
|
||||
local function noop() end
|
||||
|
||||
-- Default shouldAttach predicate: never auto-attach unless the behavior opts in
|
||||
-- by providing its own predicate. This is the safe default — a behavior with no
|
||||
-- opinion about which elements it applies to stays inert in the auto-attach
|
||||
-- pass (it can still be attached explicitly by name in a later task).
|
||||
local function defaultShouldAttach()
|
||||
return false
|
||||
end
|
||||
|
||||
-- Module-level default predicate exposed for callers/tests that want to
|
||||
-- reference the base default directly without constructing an instance.
|
||||
Behavior.shouldAttach = defaultShouldAttach
|
||||
|
||||
--- Factory: create a frozen behavior instance from a spec table.
|
||||
---
|
||||
--- `spec` is a table whose keys may be any subset of the 6 lifecycle hook names
|
||||
--- plus `shouldAttach`; each value (when present) must be a function. The
|
||||
--- returned table contains every lifecycle hook (custom override OR no-op) and
|
||||
--- a `shouldAttach` predicate (custom OR always-false default), so dispatch
|
||||
--- sites can call any hook unconditionally without nil-checking.
|
||||
---
|
||||
--- Unknown spec keys and non-function values raise an error immediately so
|
||||
--- mistakes fail fast at construction rather than as silent no-ops later.
|
||||
---
|
||||
---@param spec table|nil spec table overriding select hooks / shouldAttach
|
||||
---@return Behavior
|
||||
function Behavior.new(spec)
|
||||
spec = spec or {}
|
||||
|
||||
-- Validate spec keys up front so typos surface here, not as silent no-ops.
|
||||
for key, value in pairs(spec) do
|
||||
if not ALLOWED_KEYS[key] then
|
||||
error(string.format("Behavior.new: unknown spec key '%s'", tostring(key)), 2)
|
||||
end
|
||||
if not NON_FUNCTION_KEYS[key] and type(value) ~= "function" then
|
||||
error(string.format("Behavior.new: spec key '%s' must be a function, got %s", tostring(key), type(value)), 2)
|
||||
end
|
||||
end
|
||||
|
||||
local instance = {}
|
||||
|
||||
-- Populate every lifecycle hook: custom override when provided, no-op default
|
||||
-- otherwise. Guarantees `instance.hook` is always callable.
|
||||
for _, hook in ipairs(Behavior.HOOK_NAMES) do
|
||||
instance[hook] = spec[hook] or noop
|
||||
end
|
||||
|
||||
-- shouldAttach defaults to always-false; behaviors opt in by supplying one.
|
||||
instance.shouldAttach = spec.shouldAttach or defaultShouldAttach
|
||||
|
||||
-- drawLayer: optional metadata field (default nil = "background"/pre-children).
|
||||
-- Dispatch sites (Element:draw) use it to split rendering into pre-children
|
||||
-- (background layers) and post-children (overlay layers, e.g. scrollbars).
|
||||
instance.drawLayer = spec.drawLayer
|
||||
|
||||
-- Freeze: prevent adding new fields. Behavior instances are shared, stateless
|
||||
-- objects; runtime state belongs on the element, never on the behavior.
|
||||
-- (Reassigning an existing hook is still possible via direct index write —
|
||||
-- Lua metatables cannot intercept that — but the freeze communicates intent
|
||||
-- and catches accidental field additions.)
|
||||
local mt = {
|
||||
__newindex = function(_, key)
|
||||
error(string.format("Behavior: behavior instances are immutable (cannot set '%s')", tostring(key)), 2)
|
||||
end,
|
||||
--- Mark the metatable so consumers can detect a Behavior instance.
|
||||
---@return string
|
||||
__tostring = function()
|
||||
return "Behavior"
|
||||
end,
|
||||
__metatable = "Behavior",
|
||||
}
|
||||
setmetatable(instance, mt)
|
||||
|
||||
return instance
|
||||
end
|
||||
|
||||
--- Type guard: returns true if `value` is a Behavior instance produced by
|
||||
--- `Behavior.new`. Used by Element's attach path to validate registry entries
|
||||
--- without depending on identity.
|
||||
---@param value any
|
||||
---@return boolean
|
||||
function Behavior.isBehavior(value)
|
||||
return type(value) == "table" and getmetatable(value) == "Behavior"
|
||||
end
|
||||
|
||||
return Behavior
|
||||
@@ -0,0 +1,686 @@
|
||||
-- Lua 5.2+ compatibility for unpack
|
||||
local unpack = table.unpack or unpack
|
||||
|
||||
-- Warning cache to prevent duplicate warnings for the same element
|
||||
local warningCache = {}
|
||||
|
||||
local Cache = {
|
||||
canvases = {},
|
||||
quads = {},
|
||||
blurInstances = {}, -- Cache blur instances by quality
|
||||
blurredCanvases = {}, -- Cache pre-blurred canvases for immediate mode
|
||||
MAX_CANVAS_SIZE = 20,
|
||||
MAX_QUAD_SIZE = 20,
|
||||
MAX_BLURRED_CANVAS_CACHE = 50, -- Maximum cached blurred canvases
|
||||
RADIUS_THRESHOLD = 0.5, -- Skip blur below this radius
|
||||
LARGE_BLUR_THRESHOLD = 250 * 250, -- Warn if blur area exceeds this (250x250px)
|
||||
}
|
||||
|
||||
--- Round canvas size to nearest bucket for better reuse
|
||||
---@param size number Size to bucket
|
||||
---@return number bucketSize Bucketed size
|
||||
local function bucketSize(size)
|
||||
if size <= 128 then
|
||||
return math.ceil(size / 32) * 32
|
||||
elseif size <= 512 then
|
||||
return math.ceil(size / 64) * 64
|
||||
elseif size <= 1024 then
|
||||
return math.ceil(size / 128) * 128
|
||||
else
|
||||
return math.ceil(size / 256) * 256
|
||||
end
|
||||
end
|
||||
|
||||
--- Get or create a canvas from cache
|
||||
---@param width number Canvas width
|
||||
---@param height number Canvas height
|
||||
---@return love.Canvas canvas The cached or new canvas
|
||||
function Cache.getCanvas(width, height)
|
||||
-- Use bucketed sizes for better cache reuse
|
||||
local bucketedWidth = bucketSize(width)
|
||||
local bucketedHeight = bucketSize(height)
|
||||
local key = string.format("%dx%d", bucketedWidth, bucketedHeight)
|
||||
|
||||
if not Cache.canvases[key] then
|
||||
Cache.canvases[key] = {}
|
||||
end
|
||||
|
||||
local cache = Cache.canvases[key]
|
||||
|
||||
for i, entry in ipairs(cache) do
|
||||
if not entry.inUse then
|
||||
entry.inUse = true
|
||||
return entry.canvas
|
||||
end
|
||||
end
|
||||
|
||||
local canvas = love.graphics.newCanvas(bucketedWidth, bucketedHeight)
|
||||
table.insert(cache, { canvas = canvas, inUse = true })
|
||||
|
||||
if #cache > Cache.MAX_CANVAS_SIZE then
|
||||
local removed = table.remove(cache, 1)
|
||||
if removed and removed.canvas then
|
||||
removed.canvas:release()
|
||||
end
|
||||
end
|
||||
|
||||
return canvas
|
||||
end
|
||||
|
||||
--- Release a canvas back to the cache
|
||||
---@param canvas love.Canvas Canvas to release
|
||||
function Cache.releaseCanvas(canvas)
|
||||
for _, sizeCache in pairs(Cache.canvases) do
|
||||
for _, entry in ipairs(sizeCache) do
|
||||
if entry.canvas == canvas then
|
||||
entry.inUse = false
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Get or create a quad from cache
|
||||
---@param x number X position
|
||||
---@param y number Y position
|
||||
---@param width number Quad width
|
||||
---@param height number Quad height
|
||||
---@param sw number Source width
|
||||
---@param sh number Source height
|
||||
---@return love.Quad quad The cached or new quad
|
||||
function Cache.getQuad(x, y, width, height, sw, sh)
|
||||
local key = string.format("%d,%d,%d,%d,%d,%d", x, y, width, height, sw, sh)
|
||||
|
||||
if not Cache.quads[key] then
|
||||
Cache.quads[key] = {}
|
||||
end
|
||||
|
||||
local cache = Cache.quads[key]
|
||||
|
||||
for i, entry in ipairs(cache) do
|
||||
if not entry.inUse then
|
||||
entry.inUse = true
|
||||
return entry.quad
|
||||
end
|
||||
end
|
||||
|
||||
local quad = love.graphics.newQuad(x, y, width, height, sw, sh)
|
||||
table.insert(cache, { quad = quad, inUse = true })
|
||||
|
||||
if #cache > Cache.MAX_QUAD_SIZE then
|
||||
table.remove(cache, 1)
|
||||
end
|
||||
|
||||
return quad
|
||||
end
|
||||
|
||||
--- Release a quad back to the cache
|
||||
---@param quad love.Quad Quad to release
|
||||
function Cache.releaseQuad(quad)
|
||||
for _, keyCache in pairs(Cache.quads) do
|
||||
for _, entry in ipairs(keyCache) do
|
||||
if entry.quad == quad then
|
||||
entry.inUse = false
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Generate cache key for blurred canvas
|
||||
---@param elementId string Element ID
|
||||
---@param x number X position
|
||||
---@param y number Y position
|
||||
---@param width number Width
|
||||
---@param height number Height
|
||||
---@param radius number Blur radius
|
||||
---@param quality number Blur quality
|
||||
---@param isBackdrop boolean Whether this is backdrop blur
|
||||
---@return string key Cache key
|
||||
function Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, quality, isBackdrop)
|
||||
return string.format(
|
||||
"%s:%d:%d:%d:%d:%.1f:%d:%s",
|
||||
elementId,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
radius,
|
||||
quality,
|
||||
tostring(isBackdrop)
|
||||
)
|
||||
end
|
||||
|
||||
--- Get cached blurred canvas
|
||||
---@param key string Cache key
|
||||
---@return love.Canvas|nil canvas Cached canvas or nil
|
||||
function Cache.getBlurredCanvas(key)
|
||||
local entry = Cache.blurredCanvases[key]
|
||||
if entry then
|
||||
entry.lastUsed = os.time()
|
||||
return entry.canvas
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Store blurred canvas in cache
|
||||
---@param key string Cache key
|
||||
---@param canvas love.Canvas Canvas to cache
|
||||
function Cache.setBlurredCanvas(key, canvas)
|
||||
-- Limit cache size
|
||||
local count = 0
|
||||
for _ in pairs(Cache.blurredCanvases) do
|
||||
count = count + 1
|
||||
end
|
||||
|
||||
if count >= Cache.MAX_BLURRED_CANVAS_CACHE then
|
||||
-- Remove oldest entry
|
||||
local oldestKey = nil
|
||||
local oldestTime = math.huge
|
||||
for k, v in pairs(Cache.blurredCanvases) do
|
||||
if v.lastUsed < oldestTime then
|
||||
oldestTime = v.lastUsed
|
||||
oldestKey = k
|
||||
end
|
||||
end
|
||||
|
||||
if oldestKey then
|
||||
if Cache.blurredCanvases[oldestKey].canvas then
|
||||
Cache.blurredCanvases[oldestKey].canvas:release()
|
||||
end
|
||||
Cache.blurredCanvases[oldestKey] = nil
|
||||
end
|
||||
end
|
||||
|
||||
Cache.blurredCanvases[key] = {
|
||||
canvas = canvas,
|
||||
lastUsed = os.time(),
|
||||
}
|
||||
end
|
||||
|
||||
--- Clear blurred canvas cache for specific element
|
||||
---@param elementId string Element ID to clear cache for
|
||||
function Cache.clearBlurredCanvasesForElement(elementId)
|
||||
for key, entry in pairs(Cache.blurredCanvases) do
|
||||
if key:match("^" .. elementId .. ":") then
|
||||
if entry.canvas then
|
||||
entry.canvas:release()
|
||||
end
|
||||
Cache.blurredCanvases[key] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Clear all caches
|
||||
function Cache.clear()
|
||||
-- Release all blurred canvases
|
||||
for _, entry in pairs(Cache.blurredCanvases) do
|
||||
if entry.canvas then
|
||||
entry.canvas:release()
|
||||
end
|
||||
end
|
||||
|
||||
Cache.canvases = {}
|
||||
Cache.quads = {}
|
||||
Cache.blurInstances = {}
|
||||
Cache.blurredCanvases = {}
|
||||
warningCache = {} -- Clear warning cache on cache clear
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- SHADER BUILDER
|
||||
-- ============================================================================
|
||||
|
||||
local ShaderBuilder = {}
|
||||
|
||||
--- Build Gaussian blur shader with given parameters
|
||||
---@param taps number Number of samples (must be odd, >= 3)
|
||||
---@param offset number Offset value
|
||||
---@param offsetType string "weighted" or "center"
|
||||
---@param sigma number Sigma value for Gaussian distribution
|
||||
---@return love.Shader shader The compiled blur shader
|
||||
function ShaderBuilder.build(taps, offset, offsetType, sigma)
|
||||
taps = math.floor(taps)
|
||||
sigma = sigma >= 1 and sigma or (taps - 1) * offset / 6
|
||||
sigma = math.max(sigma, 1)
|
||||
|
||||
local steps = (taps + 1) / 2
|
||||
|
||||
local gOffsets = {}
|
||||
local gWeights = {}
|
||||
for i = 1, steps do
|
||||
gOffsets[i] = offset * (i - 1)
|
||||
gWeights[i] = math.exp(-0.5 * (gOffsets[i] - 0) ^ 2 * 1 / sigma ^ 2)
|
||||
end
|
||||
|
||||
local offsets = {}
|
||||
local weights = {}
|
||||
for i = #gWeights, 2, -2 do
|
||||
local oA, oB = gOffsets[i], gOffsets[i - 1]
|
||||
local wA, wB = gWeights[i], gWeights[i - 1]
|
||||
wB = oB == 0 and wB / 2 or wB
|
||||
local weight = wA + wB
|
||||
offsets[#offsets + 1] = offsetType == "center" and (oA + oB) / 2 or (oA * wA + oB * wB) / weight
|
||||
weights[#weights + 1] = weight
|
||||
end
|
||||
|
||||
local code = {
|
||||
[[
|
||||
extern vec2 direction;
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {]],
|
||||
}
|
||||
|
||||
local norm = 0
|
||||
if #gWeights % 2 == 0 then
|
||||
code[#code + 1] = "vec4 c = vec4( 0.0 );"
|
||||
else
|
||||
local weight = gWeights[1]
|
||||
norm = norm + weight
|
||||
code[#code + 1] = string.format("vec4 c = %f * texture2D(tex, tc);", weight)
|
||||
end
|
||||
|
||||
local template = "c += %f * ( texture2D(tex, tc + %f * direction)+ texture2D(tex, tc - %f * direction));\n"
|
||||
for i = 1, #offsets do
|
||||
local offset = offsets[i]
|
||||
local weight = weights[i]
|
||||
norm = norm + weight * 2
|
||||
code[#code + 1] = string.format(template, weight, offset, offset)
|
||||
end
|
||||
code[#code + 1] = string.format("return c * vec4(%f) * color; }", 1 / norm)
|
||||
|
||||
local shaderCode = table.concat(code)
|
||||
return love.graphics.newShader(shaderCode)
|
||||
end
|
||||
|
||||
--- Get or create a blur instance from cache
|
||||
---@param quality number Quality level (1-10)
|
||||
---@return table blurData Cached blur data {shader, taps}
|
||||
function Cache.getBlurInstance(quality)
|
||||
if not Cache.blurInstances[quality] then
|
||||
local taps = 3 + (quality - 1) * 1.5
|
||||
taps = math.floor(taps)
|
||||
if taps % 2 == 0 then
|
||||
taps = taps + 1
|
||||
end
|
||||
|
||||
local shader = ShaderBuilder.build(taps, 1.0, "weighted", -1)
|
||||
Cache.blurInstances[quality] = {
|
||||
shader = shader,
|
||||
taps = taps,
|
||||
}
|
||||
end
|
||||
|
||||
return Cache.blurInstances[quality]
|
||||
end
|
||||
|
||||
---@class BlurProps
|
||||
---@field quality number? Quality level (1-10, default: 5)
|
||||
|
||||
---@class Blur
|
||||
---@field shader love.Shader The blur shader
|
||||
---@field quality number Quality level (1-10)
|
||||
---@field taps number Number of shader taps
|
||||
---@field _ErrorHandler table? Reference to ErrorHandler module
|
||||
local Blur = {}
|
||||
Blur.__index = Blur
|
||||
|
||||
--- Check if we should warn about large blur area in immediate mode
|
||||
---@param elementId string|nil Element ID for caching warnings
|
||||
---@param width number Blur area width
|
||||
---@param height number Blur area height
|
||||
---@param blurType string "content" or "backdrop"
|
||||
local function checkLargeBlurWarning(elementId, width, height, blurType)
|
||||
-- Skip if no ErrorHandler available
|
||||
if not Blur._ErrorHandler then
|
||||
return
|
||||
end
|
||||
|
||||
-- Skip if not in immediate mode
|
||||
if not Blur._blurOptimizations then
|
||||
return
|
||||
end
|
||||
|
||||
-- Calculate blur area
|
||||
local area = width * height
|
||||
|
||||
-- Skip if area is below threshold
|
||||
if area <= Cache.LARGE_BLUR_THRESHOLD then
|
||||
return
|
||||
end
|
||||
|
||||
-- Generate warning key (use elementId if available, otherwise use dimensions)
|
||||
local warningKey = elementId or string.format("%dx%d:%s", width, height, blurType)
|
||||
|
||||
-- Skip if already warned for this element/area
|
||||
if warningCache[warningKey] then
|
||||
return
|
||||
end
|
||||
|
||||
-- Mark as warned
|
||||
warningCache[warningKey] = true
|
||||
|
||||
-- Issue warning
|
||||
local message =
|
||||
string.format("Large %s blur area detected (%dx%d = %d pixels) in immediate mode", blurType, width, height, area)
|
||||
|
||||
local suggestion =
|
||||
"Consider using retained mode for this component to avoid recreating blur effects every frame. Large blur operations are expensive and can cause performance issues in immediate mode."
|
||||
|
||||
Blur._ErrorHandler:warn("Blur", "PERF_003", {
|
||||
area = string.format("%.0fx%.0f", width or 0, height or 0),
|
||||
})
|
||||
end
|
||||
|
||||
--- Create a new blur effect instance
|
||||
---@param props BlurProps? Blur configuration
|
||||
---@return Blur blur The new blur instance
|
||||
function Blur.new(props)
|
||||
props = props or {}
|
||||
|
||||
local quality = props.quality or 5
|
||||
quality = math.max(1, math.min(10, quality))
|
||||
|
||||
-- Get cached blur instance for this quality level
|
||||
local blurData = Cache.getBlurInstance(quality)
|
||||
|
||||
local self = setmetatable({}, Blur)
|
||||
self.shader = blurData.shader
|
||||
self.quality = quality
|
||||
self.taps = blurData.taps
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
--- Apply blur to a region of the screen
|
||||
---@param radius number Blur radius in pixels
|
||||
---@param x number X position
|
||||
---@param y number Y position
|
||||
---@param width number Width of region
|
||||
---@param height number Height of region
|
||||
---@param drawFunc function Function to draw content to be blurred
|
||||
function Blur:applyToRegion(radius, x, y, width, height, drawFunc)
|
||||
if type(drawFunc) ~= "function" then
|
||||
if Blur._ErrorHandler then
|
||||
Blur._ErrorHandler:warn("Blur", "BLUR_001")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if radius <= 0 or width <= 0 or height <= 0 then
|
||||
drawFunc()
|
||||
return
|
||||
end
|
||||
|
||||
-- Early exit for very low radius (optimization)
|
||||
if radius < Cache.RADIUS_THRESHOLD then
|
||||
drawFunc()
|
||||
return
|
||||
end
|
||||
|
||||
-- Check for large blur area in immediate mode
|
||||
checkLargeBlurWarning(nil, width, height, "content")
|
||||
|
||||
-- Calculate offset multiplier based on radius and quality
|
||||
-- Higher quality = more samples = smaller steps for same radius
|
||||
local offsetMultiplier = radius / self.quality
|
||||
|
||||
local canvas1 = Cache.getCanvas(width, height)
|
||||
local canvas2 = Cache.getCanvas(width, height)
|
||||
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
local prevShader = love.graphics.getShader()
|
||||
local prevColor = { love.graphics.getColor() }
|
||||
local prevBlendMode = love.graphics.getBlendMode()
|
||||
|
||||
love.graphics.setCanvas(canvas1)
|
||||
love.graphics.clear()
|
||||
love.graphics.push()
|
||||
love.graphics.origin()
|
||||
love.graphics.translate(-x, -y)
|
||||
drawFunc()
|
||||
love.graphics.pop()
|
||||
|
||||
love.graphics.setShader(self.shader)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setBlendMode("alpha", "premultiplied")
|
||||
|
||||
-- Single pass with radius-controlled offset
|
||||
love.graphics.setCanvas(canvas2)
|
||||
love.graphics.clear()
|
||||
self.shader:send("direction", { offsetMultiplier / width, 0 })
|
||||
love.graphics.draw(canvas1, 0, 0)
|
||||
|
||||
love.graphics.setCanvas(canvas1)
|
||||
love.graphics.clear()
|
||||
self.shader:send("direction", { 0, offsetMultiplier / height })
|
||||
love.graphics.draw(canvas2, 0, 0)
|
||||
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
love.graphics.setShader()
|
||||
love.graphics.setBlendMode(prevBlendMode)
|
||||
love.graphics.draw(canvas1, x, y)
|
||||
|
||||
love.graphics.setShader(prevShader)
|
||||
love.graphics.setColor(unpack(prevColor))
|
||||
|
||||
Cache.releaseCanvas(canvas1)
|
||||
Cache.releaseCanvas(canvas2)
|
||||
end
|
||||
|
||||
--- Apply backdrop blur effect (blur content behind a region)
|
||||
---@param radius number Blur radius in pixels
|
||||
---@param x number X position
|
||||
---@param y number Y position
|
||||
---@param width number Width of region
|
||||
---@param height number Height of region
|
||||
---@param backdropCanvas love.Canvas Canvas containing the backdrop content
|
||||
function Blur:applyBackdrop(radius, x, y, width, height, backdropCanvas)
|
||||
if not backdropCanvas then
|
||||
if Blur._ErrorHandler then
|
||||
Blur._ErrorHandler:warn("Blur", "BLUR_002")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if radius <= 0 or width <= 0 or height <= 0 then
|
||||
return
|
||||
end
|
||||
|
||||
-- Early exit for very low radius (optimization)
|
||||
if radius < Cache.RADIUS_THRESHOLD then
|
||||
return
|
||||
end
|
||||
|
||||
-- Calculate offset multiplier based on radius and quality
|
||||
local offsetMultiplier = radius / self.quality
|
||||
|
||||
local canvas1 = Cache.getCanvas(width, height)
|
||||
local canvas2 = Cache.getCanvas(width, height)
|
||||
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
local prevShader = love.graphics.getShader()
|
||||
local prevColor = { love.graphics.getColor() }
|
||||
local prevBlendMode = love.graphics.getBlendMode()
|
||||
|
||||
love.graphics.setCanvas(canvas1)
|
||||
love.graphics.clear()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setBlendMode("alpha", "premultiplied")
|
||||
|
||||
local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
|
||||
local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight)
|
||||
love.graphics.draw(backdropCanvas, quad, 0, 0)
|
||||
|
||||
love.graphics.setShader(self.shader)
|
||||
|
||||
-- Single pass with radius-controlled offset
|
||||
love.graphics.setCanvas(canvas2)
|
||||
love.graphics.clear()
|
||||
self.shader:send("direction", { offsetMultiplier / width, 0 })
|
||||
love.graphics.draw(canvas1, 0, 0)
|
||||
|
||||
love.graphics.setCanvas(canvas1)
|
||||
love.graphics.clear()
|
||||
self.shader:send("direction", { 0, offsetMultiplier / height })
|
||||
love.graphics.draw(canvas2, 0, 0)
|
||||
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
love.graphics.setShader()
|
||||
love.graphics.setBlendMode(prevBlendMode)
|
||||
love.graphics.draw(canvas1, x, y)
|
||||
|
||||
love.graphics.setShader(prevShader)
|
||||
love.graphics.setColor(unpack(prevColor))
|
||||
|
||||
Cache.releaseCanvas(canvas1)
|
||||
Cache.releaseCanvas(canvas2)
|
||||
Cache.releaseQuad(quad)
|
||||
end
|
||||
|
||||
--- Get the current quality level
|
||||
---@return number quality Quality level (1-10)
|
||||
function Blur:getQuality()
|
||||
return self.quality
|
||||
end
|
||||
|
||||
--- Get the number of shader taps
|
||||
---@return number taps Number of shader taps
|
||||
function Blur:getTaps()
|
||||
return self.taps
|
||||
end
|
||||
|
||||
--- Clear all caches (call on window resize or memory cleanup)
|
||||
function Blur.clearCache()
|
||||
Cache.clear()
|
||||
end
|
||||
|
||||
--- Apply backdrop blur with caching support
|
||||
---@param radius number Blur radius in pixels
|
||||
---@param x number X position
|
||||
---@param y number Y position
|
||||
---@param width number Width of region
|
||||
---@param height number Height of region
|
||||
---@param backdropCanvas love.Canvas Canvas containing the backdrop content
|
||||
---@param elementId string|nil Element ID for caching (nil disables caching)
|
||||
function Blur:applyBackdropCached(radius, x, y, width, height, backdropCanvas, elementId)
|
||||
-- If caching is disabled or no element ID, fall back to regular apply
|
||||
if not Blur._blurOptimizations or not elementId then
|
||||
return self:applyBackdrop(radius, x, y, width, height, backdropCanvas)
|
||||
end
|
||||
|
||||
-- Generate cache key
|
||||
local cacheKey = Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, self.quality, true)
|
||||
|
||||
-- Check cache
|
||||
local cachedCanvas = Cache.getBlurredCanvas(cacheKey)
|
||||
if cachedCanvas then
|
||||
-- Draw cached blur
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
local prevShader = love.graphics.getShader()
|
||||
local prevColor = { love.graphics.getColor() }
|
||||
local prevBlendMode = love.graphics.getBlendMode()
|
||||
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
love.graphics.setShader()
|
||||
love.graphics.setBlendMode(prevBlendMode)
|
||||
love.graphics.draw(cachedCanvas, x, y)
|
||||
|
||||
love.graphics.setShader(prevShader)
|
||||
love.graphics.setColor(unpack(prevColor))
|
||||
return
|
||||
end
|
||||
|
||||
-- Not cached, render and cache
|
||||
if not backdropCanvas then
|
||||
if Blur._ErrorHandler then
|
||||
Blur._ErrorHandler:warn("Blur", "BLUR_002")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if radius <= 0 or width <= 0 or height <= 0 then
|
||||
return
|
||||
end
|
||||
|
||||
-- Early exit for very low radius (optimization)
|
||||
if radius < Cache.RADIUS_THRESHOLD then
|
||||
return
|
||||
end
|
||||
|
||||
-- Check for large blur area in immediate mode
|
||||
checkLargeBlurWarning(elementId, width, height, "backdrop")
|
||||
|
||||
-- Calculate offset multiplier based on radius and quality
|
||||
local offsetMultiplier = radius / self.quality
|
||||
|
||||
local canvas1 = Cache.getCanvas(width, height)
|
||||
local canvas2 = Cache.getCanvas(width, height)
|
||||
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
local prevShader = love.graphics.getShader()
|
||||
local prevColor = { love.graphics.getColor() }
|
||||
local prevBlendMode = love.graphics.getBlendMode()
|
||||
|
||||
love.graphics.setCanvas(canvas1)
|
||||
love.graphics.clear()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setBlendMode("alpha", "premultiplied")
|
||||
|
||||
local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
|
||||
local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight)
|
||||
love.graphics.draw(backdropCanvas, quad, 0, 0)
|
||||
|
||||
love.graphics.setShader(self.shader)
|
||||
|
||||
-- Single pass with radius-controlled offset
|
||||
love.graphics.setCanvas(canvas2)
|
||||
love.graphics.clear()
|
||||
self.shader:send("direction", { offsetMultiplier / width, 0 })
|
||||
love.graphics.draw(canvas1, 0, 0)
|
||||
|
||||
love.graphics.setCanvas(canvas1)
|
||||
love.graphics.clear()
|
||||
self.shader:send("direction", { 0, offsetMultiplier / height })
|
||||
love.graphics.draw(canvas2, 0, 0)
|
||||
|
||||
-- Cache the result
|
||||
local cachedResult = love.graphics.newCanvas(width, height)
|
||||
love.graphics.setCanvas(cachedResult)
|
||||
love.graphics.clear()
|
||||
love.graphics.setShader()
|
||||
love.graphics.setBlendMode("alpha", "premultiplied")
|
||||
love.graphics.draw(canvas1, 0, 0)
|
||||
Cache.setBlurredCanvas(cacheKey, cachedResult)
|
||||
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
love.graphics.setShader()
|
||||
love.graphics.setBlendMode(prevBlendMode)
|
||||
love.graphics.draw(canvas1, x, y)
|
||||
|
||||
love.graphics.setShader(prevShader)
|
||||
love.graphics.setColor(unpack(prevColor))
|
||||
|
||||
Cache.releaseCanvas(canvas1)
|
||||
Cache.releaseCanvas(canvas2)
|
||||
Cache.releaseQuad(quad)
|
||||
end
|
||||
|
||||
--- Clear blur cache for specific element
|
||||
---@param elementId string Element ID
|
||||
function Blur.clearElementCache(elementId)
|
||||
Cache.clearBlurredCanvasesForElement(elementId)
|
||||
end
|
||||
|
||||
--- Initialize Blur module with dependencies
|
||||
---@param deps table Dependencies: { ErrorHandler = ErrorHandler?, immediateModeOptimizations = boolean? }
|
||||
function Blur.init(deps)
|
||||
if type(deps) == "table" then
|
||||
Blur._ErrorHandler = deps.ErrorHandler
|
||||
Blur._blurOptimizations = deps.immediateModeOptimizations or false
|
||||
end
|
||||
end
|
||||
|
||||
Blur.Cache = Cache
|
||||
Blur.ShaderBuilder = ShaderBuilder
|
||||
|
||||
return Blur
|
||||
@@ -0,0 +1,385 @@
|
||||
--- Utility module for parsing and evaluating CSS-like calc() expressions
|
||||
--- Supports arithmetic operations (+, -, *, /) with mixed units (px, %, vw, vh)
|
||||
---@class Calc
|
||||
local Calc = {}
|
||||
|
||||
--- Initialize Calc module with dependencies
|
||||
---@param deps CalcDependencies Dependencies: { ErrorHandler = ErrorHandler? }
|
||||
function Calc.init(deps)
|
||||
Calc._ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
|
||||
--- Token types for lexical analysis
|
||||
local TokenType = {
|
||||
NUMBER = "NUMBER",
|
||||
UNIT = "UNIT",
|
||||
PLUS = "PLUS",
|
||||
MINUS = "MINUS",
|
||||
MULTIPLY = "MULTIPLY",
|
||||
DIVIDE = "DIVIDE",
|
||||
LPAREN = "LPAREN",
|
||||
RPAREN = "RPAREN",
|
||||
EOF = "EOF",
|
||||
}
|
||||
|
||||
--- Tokenize a calc expression string into tokens
|
||||
---@param expr string The expression to tokenize (e.g., "50% - 10vw")
|
||||
---@return CalcToken[]? tokens Array of tokens with type, value, unit
|
||||
---@return string? error Error message if tokenization fails
|
||||
local function tokenize(expr)
|
||||
local tokens = {}
|
||||
local i = 1
|
||||
local len = #expr
|
||||
|
||||
while i <= len do
|
||||
local char = expr:sub(i, i)
|
||||
|
||||
-- Skip whitespace
|
||||
if char:match("%s") then
|
||||
i = i + 1
|
||||
-- Number (including decimals, but NOT negative - handled separately below)
|
||||
elseif char:match("%d") or (char == "." and expr:sub(i + 1, i + 1):match("%d")) then
|
||||
local numStr = ""
|
||||
|
||||
-- Parse integer and decimal parts
|
||||
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
|
||||
numStr = numStr .. expr:sub(i, i)
|
||||
i = i + 1
|
||||
end
|
||||
|
||||
local num = tonumber(numStr)
|
||||
if not num then
|
||||
return nil, "Invalid number: " .. numStr
|
||||
end
|
||||
|
||||
-- Check for unit following the number
|
||||
local unitStr = ""
|
||||
while i <= len and expr:sub(i, i):match("[%a%%]") do
|
||||
unitStr = unitStr .. expr:sub(i, i)
|
||||
i = i + 1
|
||||
end
|
||||
|
||||
-- Default to px if no unit
|
||||
if unitStr == "" then
|
||||
unitStr = "px"
|
||||
end
|
||||
|
||||
-- Validate unit
|
||||
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
|
||||
if not validUnits[unitStr] then
|
||||
return nil, "Invalid unit: " .. unitStr
|
||||
end
|
||||
|
||||
table.insert(tokens, {
|
||||
type = TokenType.NUMBER,
|
||||
value = num,
|
||||
unit = unitStr,
|
||||
})
|
||||
-- Operators
|
||||
elseif char == "+" then
|
||||
table.insert(tokens, { type = TokenType.PLUS })
|
||||
i = i + 1
|
||||
elseif char == "-" then
|
||||
-- Check if this is a negative number or subtraction
|
||||
-- It's a negative number if previous token is an operator or opening paren
|
||||
local prevToken = tokens[#tokens]
|
||||
if
|
||||
not prevToken
|
||||
or prevToken.type == TokenType.PLUS
|
||||
or prevToken.type == TokenType.MINUS
|
||||
or prevToken.type == TokenType.MULTIPLY
|
||||
or prevToken.type == TokenType.DIVIDE
|
||||
or prevToken.type == TokenType.LPAREN
|
||||
then
|
||||
-- This is a negative number, continue to number parsing
|
||||
local numStr = "-"
|
||||
i = i + 1
|
||||
|
||||
-- Parse integer and decimal parts
|
||||
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
|
||||
numStr = numStr .. expr:sub(i, i)
|
||||
i = i + 1
|
||||
end
|
||||
|
||||
local num = tonumber(numStr)
|
||||
if not num then
|
||||
return nil, "Invalid number: " .. numStr
|
||||
end
|
||||
|
||||
-- Check for unit following the number
|
||||
local unitStr = ""
|
||||
while i <= len and expr:sub(i, i):match("[%a%%]") do
|
||||
unitStr = unitStr .. expr:sub(i, i)
|
||||
i = i + 1
|
||||
end
|
||||
|
||||
-- Default to px if no unit
|
||||
if unitStr == "" then
|
||||
unitStr = "px"
|
||||
end
|
||||
|
||||
-- Validate unit
|
||||
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
|
||||
if not validUnits[unitStr] then
|
||||
return nil, "Invalid unit: " .. unitStr
|
||||
end
|
||||
|
||||
table.insert(tokens, {
|
||||
type = TokenType.NUMBER,
|
||||
value = num,
|
||||
unit = unitStr,
|
||||
})
|
||||
else
|
||||
-- This is subtraction operator
|
||||
table.insert(tokens, { type = TokenType.MINUS })
|
||||
i = i + 1
|
||||
end
|
||||
elseif char == "*" then
|
||||
table.insert(tokens, { type = TokenType.MULTIPLY })
|
||||
i = i + 1
|
||||
elseif char == "/" then
|
||||
table.insert(tokens, { type = TokenType.DIVIDE })
|
||||
i = i + 1
|
||||
elseif char == "(" then
|
||||
table.insert(tokens, { type = TokenType.LPAREN })
|
||||
i = i + 1
|
||||
elseif char == ")" then
|
||||
table.insert(tokens, { type = TokenType.RPAREN })
|
||||
i = i + 1
|
||||
else
|
||||
return nil, "Unexpected character: " .. char
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(tokens, { type = TokenType.EOF })
|
||||
return tokens
|
||||
end
|
||||
|
||||
--- Parser for calc expressions using recursive descent
|
||||
---@class Parser
|
||||
---@field tokens CalcToken[] Array of tokens
|
||||
---@field pos number Current token position
|
||||
local Parser = {}
|
||||
Parser.__index = Parser
|
||||
|
||||
--- Create a new parser
|
||||
---@param tokens CalcToken[] Array of tokens
|
||||
---@return Parser
|
||||
function Parser.new(tokens)
|
||||
local self = setmetatable({}, Parser)
|
||||
self.tokens = tokens
|
||||
self.pos = 1
|
||||
return self
|
||||
end
|
||||
|
||||
--- Get current token
|
||||
---@return CalcToken token Current token
|
||||
function Parser:current()
|
||||
return self.tokens[self.pos]
|
||||
end
|
||||
|
||||
--- Advance to next token
|
||||
function Parser:advance()
|
||||
self.pos = self.pos + 1
|
||||
end
|
||||
|
||||
--- Parse expression (handles + and -)
|
||||
---@return CalcASTNode ast Abstract syntax tree node
|
||||
function Parser:parseExpression()
|
||||
local left = self:parseTerm()
|
||||
|
||||
while self:current().type == TokenType.PLUS or self:current().type == TokenType.MINUS do
|
||||
local op = self:current().type
|
||||
self:advance()
|
||||
local right = self:parseTerm()
|
||||
left = {
|
||||
type = op == TokenType.PLUS and "add" or "subtract",
|
||||
left = left,
|
||||
right = right,
|
||||
}
|
||||
end
|
||||
|
||||
return left
|
||||
end
|
||||
|
||||
--- Parse term (handles * and /)
|
||||
---@return CalcASTNode ast Abstract syntax tree node
|
||||
function Parser:parseTerm()
|
||||
local left = self:parseFactor()
|
||||
|
||||
while self:current().type == TokenType.MULTIPLY or self:current().type == TokenType.DIVIDE do
|
||||
local op = self:current().type
|
||||
self:advance()
|
||||
local right = self:parseFactor()
|
||||
left = {
|
||||
type = op == TokenType.MULTIPLY and "multiply" or "divide",
|
||||
left = left,
|
||||
right = right,
|
||||
}
|
||||
end
|
||||
|
||||
return left
|
||||
end
|
||||
|
||||
--- Parse factor (handles numbers and parentheses)
|
||||
---@return CalcASTNode ast Abstract syntax tree node
|
||||
function Parser:parseFactor()
|
||||
local token = self:current()
|
||||
|
||||
if token.type == TokenType.NUMBER then
|
||||
self:advance()
|
||||
return {
|
||||
type = "number",
|
||||
value = token.value,
|
||||
unit = token.unit,
|
||||
}
|
||||
elseif token.type == TokenType.LPAREN then
|
||||
self:advance()
|
||||
local expr = self:parseExpression()
|
||||
if self:current().type ~= TokenType.RPAREN then
|
||||
error("Expected closing parenthesis")
|
||||
end
|
||||
self:advance()
|
||||
return expr
|
||||
else
|
||||
error("Unexpected token: " .. token.type)
|
||||
end
|
||||
end
|
||||
|
||||
--- Parse the tokens into an AST
|
||||
---@return CalcASTNode ast Abstract syntax tree
|
||||
function Parser:parse()
|
||||
local ast = self:parseExpression()
|
||||
if self:current().type ~= TokenType.EOF then
|
||||
error("Unexpected tokens after expression")
|
||||
end
|
||||
return ast
|
||||
end
|
||||
|
||||
--- Create a calc expression object that can be resolved later
|
||||
--- This is the main API function that users call
|
||||
---@param expr string The calc expression (e.g., "50% - 10vw")
|
||||
---@return CalcObject calcObject A calc expression object with AST
|
||||
function Calc.new(expr)
|
||||
-- Tokenize
|
||||
local tokens, err = tokenize(expr)
|
||||
if not tokens then
|
||||
if Calc._ErrorHandler then
|
||||
Calc._ErrorHandler:warn("Calc", "VAL_006", {
|
||||
expression = expr,
|
||||
error = err,
|
||||
})
|
||||
end
|
||||
-- Return a fallback calc object that resolves to 0
|
||||
return {
|
||||
_isCalc = true,
|
||||
_expr = expr,
|
||||
_ast = nil,
|
||||
_error = err,
|
||||
}
|
||||
end
|
||||
|
||||
-- Parse
|
||||
local parser = Parser.new(tokens)
|
||||
local success, ast = pcall(function()
|
||||
return parser:parse()
|
||||
end)
|
||||
|
||||
if not success then
|
||||
if Calc._ErrorHandler then
|
||||
Calc._ErrorHandler:warn("Calc", "VAL_006", {
|
||||
expression = expr,
|
||||
error = ast, -- ast contains error message on failure
|
||||
})
|
||||
end
|
||||
-- Return a fallback calc object that resolves to 0
|
||||
return {
|
||||
_isCalc = true,
|
||||
_expr = expr,
|
||||
_ast = nil,
|
||||
_error = ast,
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
_isCalc = true,
|
||||
_expr = expr,
|
||||
_ast = ast,
|
||||
}
|
||||
end
|
||||
|
||||
--- Check if a value is a calc expression
|
||||
---@param value any The value to check
|
||||
---@return boolean isCalc True if value is a calc expression
|
||||
function Calc.isCalc(value)
|
||||
return type(value) == "table" and value._isCalc == true
|
||||
end
|
||||
|
||||
--- Resolve a calc expression to pixel value
|
||||
---@param calcObj CalcObject The calc expression object
|
||||
---@param viewportWidth number Viewport width in pixels
|
||||
---@param viewportHeight number Viewport height in pixels
|
||||
---@param parentSize number? Parent dimension for percentage units
|
||||
---@return number resolvedValue Resolved pixel value
|
||||
function Calc.resolve(calcObj, viewportWidth, viewportHeight, parentSize)
|
||||
if not calcObj._ast then
|
||||
-- Error during parsing, return 0
|
||||
return 0
|
||||
end
|
||||
|
||||
--- Evaluate AST node recursively
|
||||
---@param node table AST node
|
||||
---@return number value Evaluated value in pixels
|
||||
local function evaluate(node)
|
||||
if node.type == "number" then
|
||||
-- Convert unit to pixels
|
||||
local value = node.value
|
||||
local unit = node.unit
|
||||
|
||||
if unit == "px" then
|
||||
return value
|
||||
elseif unit == "%" then
|
||||
if not parentSize then
|
||||
if Calc._ErrorHandler then
|
||||
Calc._ErrorHandler:warn("Calc", "LAY_003", {
|
||||
unit = "%",
|
||||
issue = "parent dimension not available",
|
||||
})
|
||||
end
|
||||
return 0
|
||||
end
|
||||
return (value / 100) * parentSize
|
||||
elseif unit == "vw" then
|
||||
return (value / 100) * viewportWidth
|
||||
elseif unit == "vh" then
|
||||
return (value / 100) * viewportHeight
|
||||
else
|
||||
return 0
|
||||
end
|
||||
elseif node.type == "add" then
|
||||
return evaluate(node.left) + evaluate(node.right)
|
||||
elseif node.type == "subtract" then
|
||||
return evaluate(node.left) - evaluate(node.right)
|
||||
elseif node.type == "multiply" then
|
||||
return evaluate(node.left) * evaluate(node.right)
|
||||
elseif node.type == "divide" then
|
||||
local divisor = evaluate(node.right)
|
||||
if divisor == 0 then
|
||||
if Calc._ErrorHandler then
|
||||
Calc._ErrorHandler:warn("Calc", "VAL_006", {
|
||||
expression = calcObj._expr,
|
||||
error = "Division by zero",
|
||||
})
|
||||
end
|
||||
return 0
|
||||
end
|
||||
return evaluate(node.left) / divisor
|
||||
else
|
||||
return 0
|
||||
end
|
||||
end
|
||||
|
||||
return evaluate(calcObj._ast)
|
||||
end
|
||||
|
||||
return Calc
|
||||
@@ -0,0 +1,346 @@
|
||||
---@class Color
|
||||
local Color = {}
|
||||
Color.__index = Color
|
||||
|
||||
--- Initialize module with shared dependencies
|
||||
---@param deps table Dependencies {ErrorHandler}
|
||||
function Color.init(deps)
|
||||
if type(deps) == "table" then
|
||||
Color._ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
end
|
||||
|
||||
--- Build type-safe color objects with automatic validation and clamping
|
||||
--- Use this to avoid invalid color values and ensure consistent LÖVE-compatible colors (0-1 range)
|
||||
---@param r number? Red component (0-1), defaults to 0
|
||||
---@param g number? Green component (0-1), defaults to 0
|
||||
---@param b number? Blue component (0-1), defaults to 0
|
||||
---@param a number? Alpha component (0-1), defaults to 1
|
||||
---@return Color color The new color instance
|
||||
function Color.new(r, g, b, a)
|
||||
-- Sanitize and clamp color components
|
||||
local _, sanitizedR = Color.validateColorChannel(r or 0, 1)
|
||||
local _, sanitizedG = Color.validateColorChannel(g or 0, 1)
|
||||
local _, sanitizedB = Color.validateColorChannel(b or 0, 1)
|
||||
local _, sanitizedA = Color.validateColorChannel(a or 1, 1)
|
||||
|
||||
-- FFI structs don't support metatables/methods without wrapping
|
||||
-- The wrapping overhead negates the FFI benefits
|
||||
local self = setmetatable({}, Color)
|
||||
self.r = sanitizedR or 0
|
||||
self.g = sanitizedG or 0
|
||||
self.b = sanitizedB or 0
|
||||
self.a = sanitizedA or 1
|
||||
return self
|
||||
end
|
||||
|
||||
--- Extract individual color channels for use with love.graphics.setColor()
|
||||
--- Use this to pass colors to LÖVE's rendering functions
|
||||
---@return number r Red component (0-1)
|
||||
---@return number g Green component (0-1)
|
||||
---@return number b Blue component (0-1)
|
||||
---@return number a Alpha component (0-1)
|
||||
function Color:toRGBA()
|
||||
return self.r, self.g, self.b, self.a
|
||||
end
|
||||
|
||||
--- Parse CSS-style hex colors into Color objects for designer-friendly workflows
|
||||
--- Use this to work with colors from design tools that export hex values
|
||||
---@param hexWithTag string Hex color string (e.g. "#RRGGBB" or "#RRGGBBAA")
|
||||
---@return Color color The parsed color (returns white on error with warning)
|
||||
function Color.fromHex(hexWithTag)
|
||||
-- Validate input type
|
||||
if type(hexWithTag) ~= "string" then
|
||||
Color._ErrorHandler:warn("Color", "VAL_004", {
|
||||
input = tostring(hexWithTag),
|
||||
issue = "not a string",
|
||||
fallback = "white (#FFFFFF)",
|
||||
})
|
||||
return Color.new(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
local hex = hexWithTag:gsub("#", "")
|
||||
if #hex == 6 then
|
||||
local r = tonumber("0x" .. hex:sub(1, 2))
|
||||
local g = tonumber("0x" .. hex:sub(3, 4))
|
||||
local b = tonumber("0x" .. hex:sub(5, 6))
|
||||
if not r or not g or not b then
|
||||
Color._ErrorHandler:warn("Color", "VAL_004", {
|
||||
input = hexWithTag,
|
||||
issue = "invalid hex digits",
|
||||
fallback = "white (#FFFFFF)",
|
||||
})
|
||||
return Color.new(1, 1, 1, 1) -- Return white as fallback
|
||||
end
|
||||
return Color.new(r / 255, g / 255, b / 255, 1)
|
||||
elseif #hex == 8 then
|
||||
local r = tonumber("0x" .. hex:sub(1, 2))
|
||||
local g = tonumber("0x" .. hex:sub(3, 4))
|
||||
local b = tonumber("0x" .. hex:sub(5, 6))
|
||||
local a = tonumber("0x" .. hex:sub(7, 8))
|
||||
if not r or not g or not b or not a then
|
||||
Color._ErrorHandler:warn("Color", "VAL_004", {
|
||||
input = hexWithTag,
|
||||
issue = "invalid hex digits",
|
||||
fallback = "white (#FFFFFFFF)",
|
||||
})
|
||||
return Color.new(1, 1, 1, 1) -- Return white as fallback
|
||||
end
|
||||
return Color.new(r / 255, g / 255, b / 255, a / 255)
|
||||
else
|
||||
Color._ErrorHandler:warn("Color", "VAL_004", {
|
||||
input = hexWithTag,
|
||||
expected = "#RRGGBB or #RRGGBBAA",
|
||||
hexLength = #hex,
|
||||
fallback = "white (#FFFFFF)",
|
||||
})
|
||||
return Color.new(1, 1, 1, 1) -- Return white as fallback
|
||||
end
|
||||
end
|
||||
|
||||
--- Verify and sanitize individual color components to prevent rendering errors
|
||||
--- Use this to safely process user input or external color data
|
||||
---@param value any Value to validate
|
||||
---@param max number? Maximum value (255 for 0-255 range, 1 for 0-1 range), defaults to 1
|
||||
---@return boolean valid True if valid
|
||||
---@return number? clamped Clamped value in 0-1 range, nil if invalid
|
||||
function Color.validateColorChannel(value, max)
|
||||
max = max or 1
|
||||
|
||||
if type(value) ~= "number" then
|
||||
return false, nil
|
||||
end
|
||||
|
||||
-- Check for NaN
|
||||
if value ~= value then
|
||||
return false, nil
|
||||
end
|
||||
|
||||
-- Check for Infinity
|
||||
if value == math.huge or value == -math.huge then
|
||||
return false, nil
|
||||
end
|
||||
|
||||
-- Normalize to 0-1 range
|
||||
local normalized = value
|
||||
if max == 255 then
|
||||
normalized = value / 255
|
||||
end
|
||||
|
||||
-- Clamp to valid range
|
||||
normalized = math.max(0, math.min(1, normalized))
|
||||
|
||||
return true, normalized
|
||||
end
|
||||
|
||||
--- Validate hex color format
|
||||
---@param hex string Hex color string (with or without #)
|
||||
---@return boolean valid True if valid format
|
||||
---@return string? error Error message if invalid, nil if valid
|
||||
function Color.validateHexColor(hex)
|
||||
if type(hex) ~= "string" then
|
||||
return false, "Hex color must be a string"
|
||||
end
|
||||
|
||||
-- Remove # prefix
|
||||
local cleanHex = hex:gsub("^#", "")
|
||||
|
||||
-- Check length (3, 6, or 8 characters)
|
||||
if #cleanHex ~= 3 and #cleanHex ~= 6 and #cleanHex ~= 8 then
|
||||
return false, string.format("Invalid hex length: %d. Expected 3, 6, or 8 characters", #cleanHex)
|
||||
end
|
||||
|
||||
-- Check for valid hex characters
|
||||
if not cleanHex:match("^[0-9A-Fa-f]+$") then
|
||||
return false, "Invalid hex characters. Use only 0-9, A-F"
|
||||
end
|
||||
|
||||
return true, nil
|
||||
end
|
||||
|
||||
--- Validate RGB/RGBA color values
|
||||
---@param r number Red component
|
||||
---@param g number Green component
|
||||
---@param b number Blue component
|
||||
---@param a number? Alpha component (optional, defaults to max)
|
||||
---@param max number? Maximum value (255 or 1), defaults to 1
|
||||
---@return boolean valid True if valid
|
||||
---@return string? error Error message if invalid, nil if valid
|
||||
function Color.validateRGBColor(r, g, b, a, max)
|
||||
max = max or 1
|
||||
a = a or max
|
||||
|
||||
local rValid = Color.validateColorChannel(r, max)
|
||||
local gValid = Color.validateColorChannel(g, max)
|
||||
local bValid = Color.validateColorChannel(b, max)
|
||||
local aValid = Color.validateColorChannel(a, max)
|
||||
|
||||
if not rValid then
|
||||
return false, string.format("Invalid red channel: %s", tostring(r))
|
||||
end
|
||||
if not gValid then
|
||||
return false, string.format("Invalid green channel: %s", tostring(g))
|
||||
end
|
||||
if not bValid then
|
||||
return false, string.format("Invalid blue channel: %s", tostring(b))
|
||||
end
|
||||
if not aValid then
|
||||
return false, string.format("Invalid alpha channel: %s", tostring(a))
|
||||
end
|
||||
|
||||
return true, nil
|
||||
end
|
||||
|
||||
--- Check if a value is a valid color format
|
||||
---@param value any Value to check
|
||||
---@return string? format Format type ("hex", "named", "table"), nil if invalid
|
||||
function Color.isValidColorFormat(value)
|
||||
local valueType = type(value)
|
||||
|
||||
-- Check for hex string
|
||||
if valueType == "string" then
|
||||
if value:match("^#?[0-9A-Fa-f]+$") then
|
||||
local valid = Color.validateHexColor(value)
|
||||
if valid then
|
||||
return "hex"
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Check for table format
|
||||
if valueType == "table" then
|
||||
-- Check for Color instance
|
||||
if getmetatable(value) == Color then
|
||||
return "table"
|
||||
end
|
||||
|
||||
-- Check for array format {r, g, b, a}
|
||||
if value[1] and value[2] and value[3] then
|
||||
local valid = Color.validateRGBColor(value[1], value[2], value[3], value[4])
|
||||
if valid then
|
||||
return "table"
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for named format {r=, g=, b=, a=}
|
||||
if value.r and value.g and value.b then
|
||||
local valid = Color.validateRGBColor(value.r, value.g, value.b, value.a)
|
||||
if valid then
|
||||
return "table"
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Convert any color format to a valid Color object with graceful fallbacks
|
||||
--- Use this to robustly handle colors from any source without crashes
|
||||
---@param value any Color value to sanitize (hex, named, table, or Color instance)
|
||||
---@param default Color? Default color if invalid (defaults to black)
|
||||
---@return Color color Sanitized color instance (guaranteed non-nil)
|
||||
function Color.sanitizeColor(value, default)
|
||||
default = default or Color.new(0, 0, 0, 1)
|
||||
|
||||
local format = Color.isValidColorFormat(value)
|
||||
|
||||
if not format then
|
||||
return default
|
||||
end
|
||||
|
||||
-- Handle hex format
|
||||
if format == "hex" then
|
||||
local cleanHex = value:gsub("^#", "")
|
||||
|
||||
-- Expand 3-digit hex to 6-digit
|
||||
if #cleanHex == 3 then
|
||||
cleanHex = cleanHex:gsub("(.)", "%1%1")
|
||||
end
|
||||
|
||||
-- Try to parse
|
||||
local success, result = pcall(Color.fromHex, "#" .. cleanHex)
|
||||
if success then
|
||||
return result
|
||||
else
|
||||
return default
|
||||
end
|
||||
end
|
||||
|
||||
if format == "table" then
|
||||
-- Color instance
|
||||
if getmetatable(value) == Color then
|
||||
return value
|
||||
end
|
||||
|
||||
-- Array format
|
||||
if value[1] then
|
||||
local _, r = Color.validateColorChannel(value[1], 1)
|
||||
local _, g = Color.validateColorChannel(value[2], 1)
|
||||
local _, b = Color.validateColorChannel(value[3], 1)
|
||||
local _, a = Color.validateColorChannel(value[4] or 1, 1)
|
||||
|
||||
if r and g and b and a then
|
||||
return Color.new(r, g, b, a)
|
||||
end
|
||||
end
|
||||
|
||||
-- Named format
|
||||
if value.r then
|
||||
local _, r = Color.validateColorChannel(value.r, 1)
|
||||
local _, g = Color.validateColorChannel(value.g, 1)
|
||||
local _, b = Color.validateColorChannel(value.b, 1)
|
||||
local _, a = Color.validateColorChannel(value.a or 1, 1)
|
||||
|
||||
if r and g and b and a then
|
||||
return Color.new(r, g, b, a)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return default
|
||||
end
|
||||
|
||||
--- Universally convert any color format (hex, named, table) into a Color object
|
||||
--- Use this as your main color input handler to accept flexible color specifications
|
||||
---@param value any Color value (hex string, named color, table, or Color instance)
|
||||
---@return Color color Parsed color instance (defaults to black on error)
|
||||
function Color.parse(value)
|
||||
return Color.sanitizeColor(value, Color.new(0, 0, 0, 1))
|
||||
end
|
||||
|
||||
--- Smoothly transition between two colors for animations and gradients
|
||||
--- Use this to create color-based animations without manual channel calculations
|
||||
---@param colorA Color Starting color
|
||||
---@param colorB Color Ending color
|
||||
---@param t number Interpolation factor (0-1)
|
||||
---@return Color color Interpolated color
|
||||
function Color.lerp(colorA, colorB, t)
|
||||
-- Sanitize inputs
|
||||
if type(colorA) ~= "table" or getmetatable(colorA) ~= Color then
|
||||
colorA = Color.new(0, 0, 0, 1)
|
||||
end
|
||||
if type(colorB) ~= "table" or getmetatable(colorB) ~= Color then
|
||||
colorB = Color.new(0, 0, 0, 1)
|
||||
end
|
||||
if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then
|
||||
t = 0
|
||||
end
|
||||
|
||||
-- Clamp t to 0-1 range
|
||||
t = math.max(0, math.min(1, t))
|
||||
|
||||
-- Linear interpolation for each channel
|
||||
local oneMinusT = 1 - t
|
||||
local r = colorA.r * oneMinusT + colorB.r * t
|
||||
local g = colorA.g * oneMinusT + colorB.g * t
|
||||
local b = colorA.b * oneMinusT + colorB.b * t
|
||||
local a = colorA.a * oneMinusT + colorB.a * t
|
||||
|
||||
return Color.new(r, g, b, a)
|
||||
end
|
||||
|
||||
return Color
|
||||
@@ -0,0 +1,596 @@
|
||||
---@class Context
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local ZIndex = require(modulePath .. "ZIndex")
|
||||
local Element = require(modulePath .. "Element")
|
||||
local Context = {
|
||||
topElements = {},
|
||||
-- Base scale configuration
|
||||
baseScale = nil, -- {width: number, height: number}
|
||||
-- Current scale factors
|
||||
scaleFactors = { x = 1.0, y = 1.0 },
|
||||
defaultTheme = nil,
|
||||
_focusedElement = nil,
|
||||
_focusedElementId = nil, -- Stable id used to rehydrate focus across immediate-mode frames
|
||||
_activeEventElement = nil,
|
||||
_cachedViewport = { width = 0, height = 0 },
|
||||
-- Immediate mode state
|
||||
_immediateMode = false,
|
||||
_frameNumber = 0,
|
||||
_currentFrameElements = {},
|
||||
_immediateModeState = nil, -- Will be initialized if immediate mode is enabled
|
||||
_frameStarted = false,
|
||||
_autoBeganFrame = false,
|
||||
-- Z-index ordered element tracking for immediate mode
|
||||
_zIndexOrderedElements = {}, -- Array of elements sorted by z-index (lowest to highest)
|
||||
-- Focus management guard
|
||||
_settingFocus = false,
|
||||
-- Hook called whenever focus changes: function(element) or nil
|
||||
_onFocusChanged = nil,
|
||||
|
||||
-- Navigation state
|
||||
_navigationContext = {
|
||||
lastFocusedElement = nil, -- For returning from modals
|
||||
navigationMode = "sequential", -- "sequential" or "directional"
|
||||
containerElement = nil, -- Current navigation container
|
||||
},
|
||||
|
||||
initialized = false,
|
||||
|
||||
-- Expose internal hit-testing helpers for unit testing only.
|
||||
-- These are populated below after their local definitions. They are NOT part
|
||||
-- of the public API and must not be relied on by callers; they exist so the
|
||||
-- shared hit-test core (the single place display:none guarding lives) can be
|
||||
-- exercised directly by the test suite. Subsequent unified-event-routing
|
||||
-- tasks consume these locals through the mode-agnostic query functions.
|
||||
_test = {
|
||||
pointHitsElement = nil,
|
||||
elementHasScrollableOverflow = nil,
|
||||
},
|
||||
|
||||
-- Debug draw overlay
|
||||
_debugDraw = false,
|
||||
_debugDrawKey = nil,
|
||||
|
||||
-- Initialization state tracking
|
||||
---@type "uninitialized"|"initializing"|"ready"
|
||||
_initState = "uninitialized",
|
||||
---@type table[] Queue of {props: ElementProps, callback: function(element)|nil}
|
||||
_initQueue = {},
|
||||
|
||||
-- Per-frame cache for findInteractiveAtPosition so Clickable.onUpdate's
|
||||
-- per-element call (unified-event-routing task 05) doesn't re-walk the tree
|
||||
-- + realloc + sort for every interactive element sharing the same cursor.
|
||||
-- Invalidated explicitly by Context.clearInteractiveCache() at the start of
|
||||
-- each flexlove.update (both modes) and in clearFrameElements (immediate
|
||||
-- mid-frame rebuild). It also self-invalidates when the topElements table
|
||||
-- reference changes (tests replace it per-case; immediate-mode beginFrame
|
||||
-- reassigns it each frame), so direct callers that never go through
|
||||
-- flexlove.update still see fresh results across tree swaps.
|
||||
_interactiveLookupCache = {
|
||||
valid = false,
|
||||
x = nil,
|
||||
y = nil,
|
||||
result = nil,
|
||||
topElementsRef = nil,
|
||||
frameNumber = -1,
|
||||
},
|
||||
}
|
||||
|
||||
--- Check if a point hits an element, accounting for scroll offsets and display:none.
|
||||
--- All mode-agnostic query functions use this as their single hit-test entry point,
|
||||
--- ensuring fixes like display:none guarding apply everywhere.
|
||||
---
|
||||
--- This is the single canonical place where `element.display == false` short-
|
||||
--- circuits hit testing. Parent-chain clipping/scroll-offset accumulation is
|
||||
--- the caller's responsibility: callers walk the parent chain (using
|
||||
--- `elementHasScrollableOverflow` to decide which ancestors clip) and pass the
|
||||
--- accumulated scroll offset in here. Keeping the parent walk outside this core
|
||||
--- lets retained-mode (recursive tree descent) and immediate-mode (flat
|
||||
--- z-index list) callers share the exact same primitive bounds/display logic.
|
||||
---@param element Element
|
||||
---@param mx number Screen X coordinate
|
||||
---@param my number Screen Y coordinate
|
||||
---@param scrollOffsetX number? Accumulated scroll offset from parent chain
|
||||
---@param scrollOffsetY number? Accumulated scroll offset from parent chain
|
||||
---@return boolean hits
|
||||
local function pointHitsElement(element, mx, my, scrollOffsetX, scrollOffsetY)
|
||||
scrollOffsetX = scrollOffsetX or 0
|
||||
scrollOffsetY = scrollOffsetY or 0
|
||||
|
||||
-- Skip display:none elements entirely
|
||||
if element.display == false then
|
||||
return false
|
||||
end
|
||||
|
||||
local bx = element.x
|
||||
local by = element.y
|
||||
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||
|
||||
local adjustedX = mx + scrollOffsetX
|
||||
local adjustedY = my + scrollOffsetY
|
||||
|
||||
return adjustedX >= bx and adjustedX <= bx + bw and adjustedY >= by and adjustedY <= by + bh
|
||||
end
|
||||
|
||||
--- Check if an element has scrollable/clipped overflow (for scroll offset accumulation).
|
||||
--- Returns true for `scroll`, `auto`, and `hidden` on either axis. These are the
|
||||
--- overflow values that clip/translate descendant content and therefore require
|
||||
--- scroll-offset compensation when hit testing descendants.
|
||||
---@param element Element
|
||||
---@return boolean
|
||||
local function elementHasScrollableOverflow(element)
|
||||
local overflowX = element.overflowX or element.overflow
|
||||
local overflowY = element.overflowY or element.overflow
|
||||
return overflowX == "scroll"
|
||||
or overflowX == "auto"
|
||||
or overflowY == "scroll"
|
||||
or overflowY == "auto"
|
||||
or overflowX == "hidden"
|
||||
or overflowY == "hidden"
|
||||
end
|
||||
|
||||
-- Expose the two core helpers for unit testing only (see Context._test above).
|
||||
Context._test.pointHitsElement = pointHitsElement
|
||||
Context._test.elementHasScrollableOverflow = elementHasScrollableOverflow
|
||||
|
||||
-- Public exposure of the canonical hit-test primitive so other modules
|
||||
-- (e.g. FlexLove's `getElementAtPosition` / `_getTouchElementAtPosition`
|
||||
-- tree walks) can share the single implementation of bounds + display:none
|
||||
-- guarding instead of duplicating the `display == false` check inline.
|
||||
-- This keeps "display == false" in exactly one place for hit-testing.
|
||||
Context.pointHitsElement = pointHitsElement
|
||||
Context.elementHasScrollableOverflow = elementHasScrollableOverflow
|
||||
|
||||
--- Find the first scrollable element at a screen position, regardless of mode.
|
||||
--- This is the mode-agnostic successor to the two duplicated scrollable lookups
|
||||
--- that previously lived inline in `flexlove.wheelmoved`:
|
||||
--- * immediate mode — walked `Context._zIndexOrderedElements` in reverse and
|
||||
--- re-implemented bounds + parent-chain clipping + scroll-offset math; and
|
||||
--- * retained mode — recursed through `Context.topElements` with a private
|
||||
--- `findScrollableAtPosition(elements, x, y)` helper.
|
||||
--- Both paths now collapse into this single function, which routes every
|
||||
--- hit test through `pointHitsElement` (the single place `display == false`
|
||||
--- is guarded) and every scroll-offset decision through
|
||||
--- `elementHasScrollableOverflow`. As a result display:none elements are never
|
||||
--- returned in either mode, fixing the latent bug where the immediate-mode
|
||||
--- path's `isPointInElement` did not skip display:none elements.
|
||||
---
|
||||
--- The retained-mode branch intentionally mirrors the original
|
||||
--- `findScrollableAtPosition` helper's tree walk (deepest scrollable wins,
|
||||
--- children checked before self) but is upgraded to thread accumulated scroll
|
||||
--- offsets through `pointHitsElement` so nested scrolled containers are tested
|
||||
--- against their visible position. The original helper is removed once
|
||||
--- `flexlove.wheelmoved` is rerouted onto this function in task 04.
|
||||
---@param x number Screen X coordinate
|
||||
---@param y number Screen Y coordinate
|
||||
---@return Element|nil The scrollable element, or nil
|
||||
function Context.findScrollableAtPosition(x, y)
|
||||
if Context.isImmediateMode() then
|
||||
-- Immediate mode: iterate the z-index ordered list (reverse order =
|
||||
-- topmost first). pointHitsElement supplies the bounds + display guard.
|
||||
for i = #Context._zIndexOrderedElements, 1, -1 do
|
||||
local element = Context._zIndexOrderedElements[i]
|
||||
if pointHitsElement(element, x, y) then
|
||||
local overflowX = element.overflowX or element.overflow
|
||||
local overflowY = element.overflowY or element.overflow
|
||||
if
|
||||
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
|
||||
and (element._overflowX or element._overflowY)
|
||||
then
|
||||
return element
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
else
|
||||
-- Retained mode: recursive tree walk from topElements. Children are
|
||||
-- checked before self (deepest scrollable wins); accumulated scroll
|
||||
-- offsets are threaded through pointHitsElement so descendants of
|
||||
-- scrolled containers are hit-tested against their translated position.
|
||||
local function findInTree(elements, scrollOffsetX, scrollOffsetY)
|
||||
scrollOffsetX = scrollOffsetX or 0
|
||||
scrollOffsetY = scrollOffsetY or 0
|
||||
for i = #elements, 1, -1 do
|
||||
local element = elements[i]
|
||||
if pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
|
||||
if #element.children > 0 then
|
||||
local childScrollOffsetX = scrollOffsetX
|
||||
local childScrollOffsetY = scrollOffsetY
|
||||
if elementHasScrollableOverflow(element) then
|
||||
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
|
||||
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
|
||||
end
|
||||
local childResult = findInTree(element.children, childScrollOffsetX, childScrollOffsetY)
|
||||
if childResult then
|
||||
return childResult
|
||||
end
|
||||
end
|
||||
-- No descendant was scrollable — check self.
|
||||
local overflowX = element.overflowX or element.overflow
|
||||
local overflowY = element.overflowY or element.overflow
|
||||
if
|
||||
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
|
||||
and (element._overflowX or element._overflowY)
|
||||
then
|
||||
return element
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
return findInTree(Context.topElements)
|
||||
end
|
||||
end
|
||||
|
||||
--- Check whether immediate mode is active.
|
||||
--- This is the single canonical accessor for the mode flag consumed throughout
|
||||
--- the framework. Mode-aware branches elsewhere call this instead of reading
|
||||
--- `Context._immediateMode` directly, so the literal mode flag only appears
|
||||
--- here (its definition) and in StateManager (its mirrored storage) — never
|
||||
--- scattered across Element / behaviors / managers (behavior-mode-unification
|
||||
--- task 11).
|
||||
---@return boolean
|
||||
function Context.isImmediateMode()
|
||||
return Context._immediateMode
|
||||
end
|
||||
|
||||
---@return number, number -- scaleX, scaleY
|
||||
function Context.getScaleFactors()
|
||||
return Context.scaleFactors.x, Context.scaleFactors.y
|
||||
end
|
||||
|
||||
--- Register an element in the z-index ordered tree (for immediate mode)
|
||||
---@param element Element The element to register
|
||||
function Context.registerElement(element)
|
||||
if not Context.isImmediateMode() then
|
||||
return
|
||||
end
|
||||
|
||||
table.insert(Context._zIndexOrderedElements, element)
|
||||
end
|
||||
|
||||
function Context.clearFrameElements()
|
||||
Context._zIndexOrderedElements = {}
|
||||
Context.clearInteractiveCache()
|
||||
end
|
||||
|
||||
--- Compute the composite z-index key for an element.
|
||||
--- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ
|
||||
---
|
||||
--- ROOT_WEIGHT (10^10) gives the top-level ancestor's z-index 10 digits of significance.
|
||||
--- DEPTH_WEIGHT (10^3) gives nesting depth 3 digits, ensuring children always sort above
|
||||
--- their ancestors. The element's own z (capped to ±999 by ZIndex.clamp) fits within the
|
||||
--- remaining 3 digits without interfering with the depth component.
|
||||
---
|
||||
--- These weights assume |z| <= ZIndex.MAX_Z and practical tree depths (< 10^7), which
|
||||
--- keeps the composite key well within Lua's exact integer range (2^53 ≈ 9 × 10^15).
|
||||
---
|
||||
--- This is the SINGLE canonical z-index ordering function, used by both
|
||||
--- sortElementsByZIndex (the immediate-mode flat list sort) and
|
||||
--- findInteractiveAtPosition (the mode-agnostic occlusion sort). Keeping them
|
||||
--- on the same key ensures the interactive topmost element matches the visual
|
||||
--- draw order — a button in a z=50 MainMenu window must occlude a button in a
|
||||
--- z=0 BottomBar even when both buttons default to own z=0.
|
||||
local function getEffectiveZIndex(elem)
|
||||
local ownZ = elem.z or 0
|
||||
local rootZ = ownZ
|
||||
local depth = 0
|
||||
local current = elem.parent
|
||||
while current do
|
||||
rootZ = current.z or 0
|
||||
depth = depth + 1
|
||||
current = current.parent
|
||||
end
|
||||
return rootZ * ZIndex.ROOT_WEIGHT + depth * ZIndex.DEPTH_WEIGHT + ownZ
|
||||
end
|
||||
|
||||
-- Public exposure so FlexLove.getElementAtPosition shares the single
|
||||
-- implementation instead of duplicating the parent-chain walk as a closure.
|
||||
Context.getEffectiveZIndex = getEffectiveZIndex
|
||||
|
||||
--- Sort elements by z-index (called after all elements are registered)
|
||||
function Context.sortElementsByZIndex()
|
||||
-- Precompute the composite key ONCE per element so the sort comparator is a
|
||||
-- pure table lookup (O(1)) instead of re-walking the parent chain on every
|
||||
-- O(N log N) comparison. This function runs every frame in immediate mode.
|
||||
local elements = Context._zIndexOrderedElements
|
||||
local zIndices = {}
|
||||
for i = 1, #elements do
|
||||
zIndices[elements[i]] = getEffectiveZIndex(elements[i])
|
||||
end
|
||||
table.sort(elements, function(a, b)
|
||||
return zIndices[a] < zIndices[b]
|
||||
end)
|
||||
end
|
||||
|
||||
--- Find the topmost interactive element at a screen position, regardless of mode.
|
||||
--- Replaces the former immediate-mode-only `Context.getTopElementAt()` (removed
|
||||
--- in unified-event-routing task 05) and the retained-mode `_activeEventElement`
|
||||
--- mechanism — both are now funneled through this single entry point.
|
||||
---
|
||||
--- In immediate mode this replaces Context.getTopElementAt() (which only worked
|
||||
--- in immediate mode). In retained mode this provides the same role as the
|
||||
--- _activeEventElement set by flexlove.getElementAtPosition().
|
||||
---
|
||||
--- An element is "interactive" if it has an onEvent handler, themeComponent, or is editable.
|
||||
---@param x number Screen X coordinate
|
||||
---@param y number Screen Y coordinate
|
||||
---@return Element|nil The topmost interactive element, or nil
|
||||
function Context.findInteractiveAtPosition(x, y)
|
||||
-- Per-frame cache: Clickable.onUpdate runs this for every interactive
|
||||
-- element under the same cursor, but the result for a given (x,y) is
|
||||
-- identical across all of them within a single update pass. Returning a
|
||||
-- cached element restores the old 1x/frame cost of the _activeEventElement
|
||||
-- mechanism that task 05 replaced. Cache auto-invalidates when the
|
||||
-- topElements table reference changes (so tests and mid-frame rebuilds get
|
||||
-- fresh results) and is cleared explicitly per-frame in flexlove.update.
|
||||
local cache = Context._interactiveLookupCache
|
||||
if
|
||||
cache.valid
|
||||
and cache.x == x
|
||||
and cache.y == y
|
||||
and cache.topElementsRef == Context.topElements
|
||||
and cache.frameNumber == Context._frameNumber
|
||||
then
|
||||
return cache.result
|
||||
end
|
||||
|
||||
local interactiveCandidates = {}
|
||||
|
||||
local function collectInteractive(element, scrollOffsetX, scrollOffsetY)
|
||||
scrollOffsetX = scrollOffsetX or 0
|
||||
scrollOffsetY = scrollOffsetY or 0
|
||||
|
||||
if not pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
|
||||
return
|
||||
end
|
||||
|
||||
-- Check if this element is interactive
|
||||
if element.onEvent or element.themeComponent or element.editable then
|
||||
table.insert(interactiveCandidates, element)
|
||||
end
|
||||
|
||||
-- Recurse into children with accumulated scroll offset
|
||||
local childScrollOffsetX = scrollOffsetX
|
||||
local childScrollOffsetY = scrollOffsetY
|
||||
if elementHasScrollableOverflow(element) then
|
||||
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
|
||||
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
|
||||
end
|
||||
|
||||
for _, child in ipairs(element.children) do
|
||||
collectInteractive(child, childScrollOffsetX, childScrollOffsetY)
|
||||
end
|
||||
end
|
||||
|
||||
-- Always traverse the tree (works in both modes — topElements exists always)
|
||||
for _, element in ipairs(Context.topElements) do
|
||||
collectInteractive(element)
|
||||
end
|
||||
|
||||
-- Sort by composite z-index descending — topmost wins. The composite key
|
||||
-- (rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ) matches the ordering
|
||||
-- used by sortElementsByZIndex / _zIndexOrderedElements, so the interactive
|
||||
-- topmost element matches the visual draw order. This is critical for the
|
||||
-- game's multi-window layout: a button inside a z=50 MainMenu window must
|
||||
-- occlude a button inside a z=0 BottomBar even when both buttons default to
|
||||
-- own z=0. Sorting by own-z alone (the original implementation) couldn't
|
||||
-- distinguish them, so the wrong window's button could win, leaving the
|
||||
-- visible button's isActiveElement=false and clicks/hover dead.
|
||||
local zIndices = {}
|
||||
for _, el in ipairs(interactiveCandidates) do
|
||||
zIndices[el] = getEffectiveZIndex(el)
|
||||
end
|
||||
table.sort(interactiveCandidates, function(a, b)
|
||||
return zIndices[a] > zIndices[b]
|
||||
end)
|
||||
|
||||
local result = interactiveCandidates[1]
|
||||
|
||||
cache.x = x
|
||||
cache.y = y
|
||||
cache.result = result
|
||||
cache.topElementsRef = Context.topElements
|
||||
cache.frameNumber = Context._frameNumber
|
||||
cache.valid = true
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
--- Invalidate the per-frame `findInteractiveAtPosition` cache.
|
||||
--- Called once at the top of `flexlove.update` (the natural per-frame boundary
|
||||
--- in both modes) and from `clearFrameElements` (immediate-mode mid-frame
|
||||
--- rebuild). After invalidation the next lookup recomputes fresh.
|
||||
function Context.clearInteractiveCache()
|
||||
local cache = Context._interactiveLookupCache
|
||||
cache.valid = false
|
||||
cache.x = nil
|
||||
cache.y = nil
|
||||
cache.result = nil
|
||||
cache.topElementsRef = nil
|
||||
cache.frameNumber = -1
|
||||
end
|
||||
|
||||
--- Set the focused element (centralizes focus management)
|
||||
--- Automatically blurs the previously focused element if different
|
||||
---@param element Element|nil The element to focus (nil to clear focus)
|
||||
function Context.setFocused(element)
|
||||
if Context._focusedElement == element then
|
||||
return -- Already focused
|
||||
end
|
||||
|
||||
-- Prevent re-entry during focus change
|
||||
if Context._settingFocus then
|
||||
return
|
||||
end
|
||||
Context._settingFocus = true
|
||||
|
||||
-- Save reference to previously focused element before updating
|
||||
local oldFocusedElement = Context._focusedElement
|
||||
|
||||
-- Blur previously focused element
|
||||
if oldFocusedElement and oldFocusedElement ~= element then
|
||||
if oldFocusedElement._textEditor then
|
||||
oldFocusedElement._textEditor:blur(oldFocusedElement)
|
||||
end
|
||||
end
|
||||
|
||||
-- Set new focused element and persist its id for immediate-mode rehydration
|
||||
Context._focusedElement = element
|
||||
Context._focusedElementId = element and (element.id ~= "" and element.id or nil) or nil
|
||||
|
||||
-- Notify any registered focus change hook (e.g. FocusIndicator)
|
||||
if Context._onFocusChanged then
|
||||
Context._onFocusChanged(element)
|
||||
end
|
||||
|
||||
-- Focus the new element's text editor if it has one
|
||||
if element and element._textEditor then
|
||||
element._textEditor._focused = true
|
||||
end
|
||||
|
||||
Context._settingFocus = false
|
||||
end
|
||||
|
||||
--- Recursively search for an element by id in an element tree
|
||||
---@param root Element The root element to start searching from
|
||||
---@param targetId string The id to search for
|
||||
---@return Element|nil The element with the matching id, or nil if not found
|
||||
local function findElementById(root, targetId)
|
||||
if root.id == targetId then
|
||||
return root
|
||||
end
|
||||
for _, child in ipairs(root.children or {}) do
|
||||
local found = findElementById(child, targetId)
|
||||
if found then
|
||||
return found
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Rehydrate _focusedElement from _focusedElementId by scanning live elements.
|
||||
--- Called at the start of getFocused() in immediate mode so stale references
|
||||
--- are always replaced with the current-frame object before use.
|
||||
function Context._rehydrateFocus()
|
||||
if not Context._focusedElementId then
|
||||
Context._focusedElement = nil
|
||||
return
|
||||
end
|
||||
|
||||
-- First, try a fast linear search through all registered elements
|
||||
for _, elem in ipairs(Context._zIndexOrderedElements) do
|
||||
if elem.id == Context._focusedElementId then
|
||||
Context._focusedElement = elem
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- If not found, recursively search from top-level elements
|
||||
-- This handles cases where elements may not be in _zIndexOrderedElements
|
||||
for _, topLevel in ipairs(Context.topElements or {}) do
|
||||
local found = findElementById(topLevel, Context._focusedElementId)
|
||||
if found then
|
||||
Context._focusedElement = found
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- Element with that id is not present this frame (e.g. screen changed)
|
||||
Context._focusedElement = nil
|
||||
end
|
||||
|
||||
--- Get the currently focused element
|
||||
---@return Element|nil The focused element, or nil if none
|
||||
function Context.getFocused()
|
||||
if Context.isImmediateMode() then
|
||||
Context._rehydrateFocus()
|
||||
end
|
||||
return Context._focusedElement
|
||||
end
|
||||
|
||||
--- Clear focus from any element
|
||||
function Context.clearFocus()
|
||||
Context._focusedElementId = nil
|
||||
Context.setFocused(nil)
|
||||
end
|
||||
|
||||
--- Get all focusable elements in tab order, regardless of mode.
|
||||
--- In immediate mode this extracts from _zIndexOrderedElements (flat, z-sorted).
|
||||
--- In retained mode it walks the element tree (DOM order).
|
||||
--- In both modes, display:none elements are excluded.
|
||||
---@return table<Element> List of focusable elements in tab order
|
||||
function Context.getFocusableElements()
|
||||
local focusable = {}
|
||||
|
||||
local function isFocusable(elem)
|
||||
if elem.display == false then
|
||||
return false
|
||||
end
|
||||
-- Use Element:isFocusable() for consistent behavior
|
||||
return Element.isFocusable(elem)
|
||||
end
|
||||
|
||||
local function collectFromTree(elements)
|
||||
for _, elem in ipairs(elements) do
|
||||
if isFocusable(elem) then
|
||||
table.insert(focusable, elem)
|
||||
end
|
||||
if #elem.children > 0 then
|
||||
collectFromTree(elem.children)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if Context._immediateMode then
|
||||
-- Immediate mode: _zIndexOrderedElements is already in z-index order (lowest first),
|
||||
-- which approximates tab order for most UIs.
|
||||
for _, elem in ipairs(Context._zIndexOrderedElements) do
|
||||
if isFocusable(elem) then
|
||||
table.insert(focusable, elem)
|
||||
end
|
||||
end
|
||||
else
|
||||
-- Retained mode: walk the top element trees in DOM order
|
||||
collectFromTree(Context.topElements)
|
||||
end
|
||||
|
||||
return focusable
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Navigation Context
|
||||
-- ====================
|
||||
|
||||
--- Push current focus onto stack (for modals/dialogs)
|
||||
---@param element Element?
|
||||
function Context.pushFocusStack(element)
|
||||
Context._navigationContext.lastFocusedElement = Context._focusedElement
|
||||
if element then
|
||||
Context.setFocused(element)
|
||||
end
|
||||
end
|
||||
|
||||
--- Pop focus from stack (return from modal)
|
||||
---@return Element?
|
||||
function Context.popFocusStack()
|
||||
local previous = Context._navigationContext.lastFocusedElement
|
||||
Context._navigationContext.lastFocusedElement = nil
|
||||
Context.setFocused(previous)
|
||||
return previous
|
||||
end
|
||||
|
||||
--- Set navigation container (scope for tab navigation)
|
||||
---@param element Element?
|
||||
function Context.setNavigationContainer(element)
|
||||
Context._navigationContext.containerElement = element
|
||||
end
|
||||
|
||||
--- Get navigation container
|
||||
---@return Element?
|
||||
function Context.getNavigationContainer()
|
||||
return Context._navigationContext.containerElement
|
||||
end
|
||||
|
||||
return Context
|
||||
@@ -0,0 +1,171 @@
|
||||
-- Layout, flex, text, image, and ARIA enums used across FlexLove.
|
||||
-- Extracted from utils so utils stays under its LOC budget; re-exported as
|
||||
-- `utils.enums` for backward compatibility.
|
||||
|
||||
local enums = {
|
||||
---@enum TextAlign
|
||||
TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" },
|
||||
---@enum TextAlignVertical
|
||||
TextAlignVertical = { START = "start", CENTER = "center", END = "end" },
|
||||
---@enum Positioning
|
||||
Positioning = { ABSOLUTE = "absolute", RELATIVE = "relative", FLEX = "flex", GRID = "grid" },
|
||||
---@enum FlexDirection
|
||||
FlexDirection = {
|
||||
HORIZONTAL = "horizontal",
|
||||
VERTICAL = "vertical",
|
||||
ROW = "row",
|
||||
COLUMN = "column",
|
||||
HORIZONTAL_REVERSE = "horizontal-reverse",
|
||||
VERTICAL_REVERSE = "vertical-reverse",
|
||||
ROW_REVERSE = "row-reverse",
|
||||
COLUMN_REVERSE = "column-reverse",
|
||||
},
|
||||
---@enum JustifyContent
|
||||
JustifyContent = {
|
||||
FLEX_START = "flex-start",
|
||||
CENTER = "center",
|
||||
SPACE_AROUND = "space-around",
|
||||
FLEX_END = "flex-end",
|
||||
SPACE_EVENLY = "space-evenly",
|
||||
SPACE_BETWEEN = "space-between",
|
||||
},
|
||||
---@enum JustifySelf
|
||||
JustifySelf = {
|
||||
AUTO = "auto",
|
||||
FLEX_START = "flex-start",
|
||||
CENTER = "center",
|
||||
FLEX_END = "flex-end",
|
||||
SPACE_AROUND = "space-around",
|
||||
SPACE_EVENLY = "space-evenly",
|
||||
SPACE_BETWEEN = "space-between",
|
||||
},
|
||||
---@enum AlignItems
|
||||
AlignItems = {
|
||||
STRETCH = "stretch",
|
||||
FLEX_START = "flex-start",
|
||||
FLEX_END = "flex-end",
|
||||
CENTER = "center",
|
||||
BASELINE = "baseline",
|
||||
},
|
||||
---@enum AlignSelf
|
||||
AlignSelf = {
|
||||
AUTO = "auto",
|
||||
STRETCH = "stretch",
|
||||
FLEX_START = "flex-start",
|
||||
FLEX_END = "flex-end",
|
||||
CENTER = "center",
|
||||
BASELINE = "baseline",
|
||||
},
|
||||
---@enum AlignContent
|
||||
AlignContent = {
|
||||
STRETCH = "stretch",
|
||||
FLEX_START = "flex-start",
|
||||
FLEX_END = "flex-end",
|
||||
CENTER = "center",
|
||||
SPACE_BETWEEN = "space-between",
|
||||
SPACE_AROUND = "space-around",
|
||||
},
|
||||
---@enum FlexWrap
|
||||
FlexWrap = { NOWRAP = "nowrap", WRAP = "wrap", WRAP_REVERSE = "wrap-reverse" },
|
||||
---@enum TextSize
|
||||
TextSize = {
|
||||
XXS = "xxs",
|
||||
XS = "xs",
|
||||
SM = "sm",
|
||||
MD = "md",
|
||||
LG = "lg",
|
||||
XL = "xl",
|
||||
XXL = "xxl",
|
||||
XL3 = "3xl",
|
||||
XL4 = "4xl",
|
||||
},
|
||||
---@enum ImageRepeat
|
||||
ImageRepeat = {
|
||||
NO_REPEAT = "no-repeat",
|
||||
REPEAT = "repeat",
|
||||
REPEAT_X = "repeat-x",
|
||||
REPEAT_Y = "repeat-y",
|
||||
SPACE = "space",
|
||||
ROUND = "round",
|
||||
},
|
||||
|
||||
---@enum ARIA Role (accessibility roles for screen readers)
|
||||
ARIA = {
|
||||
-- Widget roles
|
||||
BUTTON = "button",
|
||||
CHECKBOX = "checkbox",
|
||||
LINK = "link",
|
||||
MENUITEM = "menuitem",
|
||||
MENUITEMCHECKBOX = "menuitemcheckbox",
|
||||
MENUITEMRADIO = "menuitemradio",
|
||||
PROGRESSBAR = "progressbar",
|
||||
RADIO = "radio",
|
||||
SCROLLBAR = "scrollbar",
|
||||
SLIDER = "slider",
|
||||
SPINBUTTON = "spinbutton",
|
||||
SWITCH = "switch",
|
||||
TAB = "tab",
|
||||
TABLIST = "tablist",
|
||||
TABPANEL = "tabpanel",
|
||||
TEXTBOX = "textbox",
|
||||
TOOLTIP = "tooltip",
|
||||
TREEITEM = "treeitem",
|
||||
COMBOBOX = "combobox",
|
||||
GRID = "grid",
|
||||
GRIDCELL = "gridcell",
|
||||
LISTBOX = "listbox",
|
||||
LISTITEM = "listitem",
|
||||
MENU = "menu",
|
||||
MENUBAR = "menubar",
|
||||
TREE = "tree",
|
||||
TREEGRID = "treegrid",
|
||||
WINDOW = "window",
|
||||
DIALOG = "dialog",
|
||||
ALERTDIALOG = "alertdialog",
|
||||
|
||||
-- Landmark roles
|
||||
BANNER = "banner",
|
||||
COMPLEMENTARY = "complementary",
|
||||
CONTENTINFO = "contentinfo",
|
||||
FORM = "form",
|
||||
MAIN = "main",
|
||||
NAVIGATION = "navigation",
|
||||
REGION = "region",
|
||||
SEARCH = "search",
|
||||
|
||||
-- Live region roles
|
||||
ALERT = "alert",
|
||||
LOG = "log",
|
||||
MARQUEE = "marquee",
|
||||
STATUS = "status",
|
||||
TIMERTIME = "timer",
|
||||
|
||||
-- Document structure roles
|
||||
ARTICLE = "article",
|
||||
BLOCKQUOTEBLOCKQUOTE = "blockquote",
|
||||
CAPTION = "caption",
|
||||
CODE = "code",
|
||||
DEFINITION = "definition",
|
||||
DELETED = "deletion",
|
||||
DIRECTORY = "directory",
|
||||
DIVISION = "division",
|
||||
EMphasis = "emphasis",
|
||||
HEADING = "heading",
|
||||
INSERTED = "insertion",
|
||||
LIST = "list",
|
||||
MARK = "mark",
|
||||
MATH = "math",
|
||||
NONE = "none",
|
||||
PARAGRAPH = "paragraph",
|
||||
PRESENTATION = "presentation",
|
||||
SEPARATOR = "separator",
|
||||
STRONG = "strong",
|
||||
SUBSCRIPT = "subscript",
|
||||
SUPERSCRIPT = "superscript",
|
||||
TERM = "term",
|
||||
TIME = "time",
|
||||
VARIABLE = "variable",
|
||||
},
|
||||
}
|
||||
|
||||
return { enums = enums }
|
||||
@@ -0,0 +1,843 @@
|
||||
---@class EventHandler
|
||||
---@field onEvent fun(element:Element, event:InputEvent)?
|
||||
---@field onEventDeferred boolean?
|
||||
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Touch-specific callback
|
||||
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent is deferred
|
||||
---@field onGesture fun(element:Element, gesture:table)? -- Gesture callback
|
||||
---@field onGestureDeferred boolean? -- Whether onGesture is deferred
|
||||
---@field touchEnabled boolean -- Whether touch events are processed (default: true)
|
||||
---@field multiTouchEnabled boolean -- Whether multi-touch is supported (default: false)
|
||||
---@field _pressed table<number, boolean>
|
||||
---@field _lastClickTime number?
|
||||
---@field _lastClickButton number?
|
||||
---@field _clickCount number
|
||||
---@field _dragStartX table<number, number>
|
||||
---@field _dragStartY table<number, number>
|
||||
---@field _lastMouseX table<number, number>
|
||||
---@field _lastMouseY table<number, number>
|
||||
---@field _touches table<string, table> -- Multi-touch state per touch ID
|
||||
---@field _touchStartPositions table<string, table> -- Touch start positions
|
||||
---@field _lastTouchPositions table<string, table> -- Last touch positions for delta
|
||||
---@field _touchHistory table<string, table> -- Touch position history for gestures (last 5)
|
||||
---@field _hovered boolean
|
||||
---@field _scrollbarPressHandled boolean
|
||||
---@field _InputEvent table
|
||||
---@field _utils table
|
||||
---@field _Performance Performance? Performance module dependency
|
||||
---@field _ErrorHandler ErrorHandler
|
||||
local EventHandler = {}
|
||||
EventHandler.__index = EventHandler
|
||||
|
||||
--- Initialize module with shared dependencies
|
||||
---@param deps table Dependencies {Performance, ErrorHandler, InputEvent, Context, utils}
|
||||
function EventHandler.init(deps)
|
||||
EventHandler._Performance = deps.Performance
|
||||
EventHandler._ErrorHandler = deps.ErrorHandler
|
||||
EventHandler._InputEvent = deps.InputEvent
|
||||
EventHandler._utils = deps.utils
|
||||
EventHandler._Context = deps.Context
|
||||
end
|
||||
|
||||
---@param config table Configuration options
|
||||
---@return EventHandler
|
||||
function EventHandler.new(config)
|
||||
config = config or {}
|
||||
local self = setmetatable({}, EventHandler)
|
||||
|
||||
self.onEvent = config.onEvent
|
||||
self.onEventDeferred = config.onEventDeferred
|
||||
self.onTouchEvent = config.onTouchEvent
|
||||
self.onTouchEventDeferred = config.onTouchEventDeferred or false
|
||||
self.onGesture = config.onGesture
|
||||
self.onGestureDeferred = config.onGestureDeferred or false
|
||||
self.touchEnabled = config.touchEnabled ~= false -- Default true
|
||||
self.multiTouchEnabled = config.multiTouchEnabled or false -- Default false
|
||||
|
||||
self._pressed = config._pressed or {}
|
||||
|
||||
self._lastClickTime = config._lastClickTime
|
||||
self._lastClickButton = config._lastClickButton
|
||||
self._clickCount = config._clickCount or 0
|
||||
|
||||
-- FocusIndicator reference (set after initialization)
|
||||
self._FocusIndicator = nil
|
||||
|
||||
self._dragStartX = config._dragStartX or {}
|
||||
self._dragStartY = config._dragStartY or {}
|
||||
self._lastMouseX = config._lastMouseX or {}
|
||||
self._lastMouseY = config._lastMouseY or {}
|
||||
|
||||
-- Multi-touch tracking
|
||||
self._touches = config._touches or {}
|
||||
self._touchStartPositions = config._touchStartPositions or {}
|
||||
self._lastTouchPositions = config._lastTouchPositions or {}
|
||||
self._touchHistory = config._touchHistory or {}
|
||||
|
||||
self._hovered = config._hovered or false
|
||||
|
||||
self._scrollbarPressHandled = false
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
--- Get state for persistence (for immediate mode)
|
||||
---@return table State data
|
||||
function EventHandler:getState()
|
||||
return {
|
||||
_pressed = self._pressed,
|
||||
_lastClickTime = self._lastClickTime,
|
||||
_lastClickButton = self._lastClickButton,
|
||||
_clickCount = self._clickCount,
|
||||
_dragStartX = self._dragStartX,
|
||||
_dragStartY = self._dragStartY,
|
||||
_lastMouseX = self._lastMouseX,
|
||||
_lastMouseY = self._lastMouseY,
|
||||
_touches = self._touches,
|
||||
_touchStartPositions = self._touchStartPositions,
|
||||
_lastTouchPositions = self._lastTouchPositions,
|
||||
_touchHistory = self._touchHistory,
|
||||
_hovered = self._hovered,
|
||||
}
|
||||
end
|
||||
|
||||
--- Restore state from persistence (for immediate mode)
|
||||
---@param state table State data
|
||||
function EventHandler:setState(state)
|
||||
if not state then
|
||||
return
|
||||
end
|
||||
|
||||
self._pressed = state._pressed or {}
|
||||
self._lastClickTime = state._lastClickTime
|
||||
self._lastClickButton = state._lastClickButton
|
||||
self._clickCount = state._clickCount or 0
|
||||
self._dragStartX = state._dragStartX or {}
|
||||
self._dragStartY = state._dragStartY or {}
|
||||
self._lastMouseX = state._lastMouseX or {}
|
||||
self._lastMouseY = state._lastMouseY or {}
|
||||
self._touches = state._touches or {}
|
||||
self._touchStartPositions = state._touchStartPositions or {}
|
||||
self._lastTouchPositions = state._lastTouchPositions or {}
|
||||
self._touchHistory = state._touchHistory or {}
|
||||
self._hovered = state._hovered or false
|
||||
end
|
||||
|
||||
--- Process mouse button events in the update cycle
|
||||
---@param element Element The parent element
|
||||
---@param mx number Mouse X position
|
||||
---@param my number Mouse Y position
|
||||
---@param isHovering boolean Whether mouse is over element
|
||||
---@param isActiveElement boolean Whether this is the top element at mouse position
|
||||
function EventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement)
|
||||
-- Start performance timing
|
||||
-- Performance accessed via EventHandler._Performance
|
||||
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||
EventHandler._Performance:startTimer("event_mouse")
|
||||
end
|
||||
|
||||
-- Check if currently dragging (allows drag continuation even if occluded)
|
||||
local isDragging = false
|
||||
for _, button in ipairs({ 1, 2, 3 }) do
|
||||
if self._pressed[button] and love.mouse.isDown(button) then
|
||||
isDragging = true
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
-- Check if any button is currently pressed (tracked state)
|
||||
local hasTrackedPress = false
|
||||
for _, button in ipairs({ 1, 2, 3 }) do
|
||||
if self._pressed[button] then
|
||||
hasTrackedPress = true
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
-- Can only process events if we have handler, element is enabled, and is active or dragging or has tracked press
|
||||
-- Read onEvent from element (source of truth), fallback to handler cache for backwards compat
|
||||
local canProcessEvents = (
|
||||
element.onEvent
|
||||
or self.onEvent
|
||||
or element.editable
|
||||
or element._selectState
|
||||
or element.selectOption
|
||||
)
|
||||
and element.visibility ~= "hidden"
|
||||
and not element.disabled
|
||||
and (isActiveElement or isDragging or hasTrackedPress)
|
||||
|
||||
if not canProcessEvents then
|
||||
-- If not hovering and no buttons are physically pressed, reset all pressed states
|
||||
-- This ensures the pressed state is cleared when mouse leaves without button held
|
||||
if not isHovering and not isDragging then
|
||||
for _, button in ipairs({ 1, 2, 3 }) do
|
||||
if self._pressed[button] and not love.mouse.isDown(button) then
|
||||
self._pressed[button] = false
|
||||
self._dragStartX[button] = nil
|
||||
self._dragStartY[button] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Track hover state changes even when events can't be processed
|
||||
-- Fire synthetic unhover when element becomes disabled while hovered
|
||||
if element.disabled and self._hovered then
|
||||
self._hovered = false
|
||||
if element.onEvent or self.onEvent then
|
||||
local modifiers = EventHandler._utils.getModifiers()
|
||||
local unhoverEvent = EventHandler._InputEvent.new({
|
||||
type = "unhover",
|
||||
button = 0,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = 0,
|
||||
})
|
||||
self:_invokeCallback(element, unhoverEvent)
|
||||
end
|
||||
elseif self._hovered and not isHovering then
|
||||
self._hovered = false
|
||||
if element.onEvent or self.onEvent then
|
||||
local modifiers = EventHandler._utils.getModifiers()
|
||||
local unhoverEvent = EventHandler._InputEvent.new({
|
||||
type = "unhover",
|
||||
button = 0,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = 0,
|
||||
})
|
||||
self:_invokeCallback(element, unhoverEvent)
|
||||
end
|
||||
end
|
||||
|
||||
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||
EventHandler._Performance:stopTimer("event_mouse")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Track hover state changes and fire hover/unhover events BEFORE button processing
|
||||
-- This ensures hover fires before press when mouse first enters element
|
||||
local wasHovered = self._hovered
|
||||
local isHoveringAndActive = isHovering and isActiveElement
|
||||
|
||||
if isHoveringAndActive and not wasHovered then
|
||||
-- Just started hovering - fire hover event
|
||||
self._hovered = true
|
||||
local modifiers = EventHandler._utils.getModifiers()
|
||||
local hoverEvent = EventHandler._InputEvent.new({
|
||||
type = "hover",
|
||||
button = 0,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = 0,
|
||||
})
|
||||
self:_invokeCallback(element, hoverEvent)
|
||||
elseif not isHoveringAndActive and wasHovered then
|
||||
-- Just stopped hovering - fire unhover event
|
||||
self._hovered = false
|
||||
local modifiers = EventHandler._utils.getModifiers()
|
||||
local unhoverEvent = EventHandler._InputEvent.new({
|
||||
type = "unhover",
|
||||
button = 0,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = 0,
|
||||
})
|
||||
self:_invokeCallback(element, unhoverEvent)
|
||||
end
|
||||
|
||||
-- Process all three mouse buttons
|
||||
local buttons = { 1, 2, 3 } -- left, right, middle
|
||||
|
||||
for _, button in ipairs(buttons) do
|
||||
-- Check if this button was tracked as pressed
|
||||
local wasPressed = self._pressed[button]
|
||||
local isPhysicallyPressed = love.mouse.isDown(button)
|
||||
|
||||
if isHovering or isDragging or wasPressed then
|
||||
if isPhysicallyPressed then
|
||||
-- Button is pressed down
|
||||
if not wasPressed then
|
||||
-- Just pressed - fire press event (only if hovering)
|
||||
if isHovering then
|
||||
self:_handleMousePress(element, mx, my, button)
|
||||
end
|
||||
else
|
||||
-- Button is still pressed - check for drag
|
||||
self:_handleMouseDrag(element, mx, my, button, isHovering)
|
||||
end
|
||||
elseif wasPressed then
|
||||
-- Button was just released
|
||||
-- Only fire click and release events if mouse is still hovering AND element is active
|
||||
-- (not occluded by another element)
|
||||
if isHovering and isActiveElement then
|
||||
self:_handleMouseRelease(element, mx, my, button)
|
||||
else
|
||||
-- Mouse left before release OR element is occluded - just clear the pressed state without firing events
|
||||
self._pressed[button] = false
|
||||
self._dragStartX[button] = nil
|
||||
self._dragStartY[button] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- After processing events, reset pressed states for buttons that are no longer held
|
||||
-- This handles the case where mouse leaves while button is held, then released
|
||||
if not isHovering and not isDragging then
|
||||
for _, button in ipairs({ 1, 2, 3 }) do
|
||||
if self._pressed[button] and not love.mouse.isDown(button) then
|
||||
self._pressed[button] = false
|
||||
self._dragStartX[button] = nil
|
||||
self._dragStartY[button] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Stop performance timing
|
||||
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||
EventHandler._Performance:stopTimer("event_mouse")
|
||||
end
|
||||
end
|
||||
|
||||
--- Handle mouse button press
|
||||
---@param element Element The parent element
|
||||
---@param mx number Mouse X position
|
||||
---@param my number Mouse Y position
|
||||
---@param button number Mouse button (1=left, 2=right, 3=middle)
|
||||
function EventHandler:_handleMousePress(element, mx, my, button)
|
||||
-- Check if press is on scrollbar first (skip if already handled)
|
||||
if button == 1 and not self._scrollbarPressHandled and element._handleScrollbarPress then
|
||||
if element:_handleScrollbarPress(mx, my, button) then
|
||||
-- Scrollbar consumed the event, mark as pressed to prevent onEvent
|
||||
self._pressed[button] = true
|
||||
self._scrollbarPressHandled = true
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- Fire press event
|
||||
local modifiers = EventHandler._utils.getModifiers()
|
||||
local pressEvent = EventHandler._InputEvent.new({
|
||||
type = "press",
|
||||
button = button,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = 1,
|
||||
})
|
||||
self:_invokeCallback(element, pressEvent)
|
||||
|
||||
self._pressed[button] = true
|
||||
|
||||
-- On left click, set keyboard focus to any focusable element (not just editable).
|
||||
-- Clear the focus indicator since mouse navigation doesn't use it.
|
||||
local isFocusable
|
||||
if type(element.isFocusable) == "function" then
|
||||
isFocusable = element:isFocusable()
|
||||
else
|
||||
isFocusable = (element.editable == true)
|
||||
or (type(element.onEvent) == "function")
|
||||
or element._selectState ~= nil
|
||||
or element.selectOption ~= nil
|
||||
end
|
||||
|
||||
if button == 1 and EventHandler._Context and isFocusable then
|
||||
EventHandler._Context.setFocused(element)
|
||||
-- Hide focus indicator - it's only for keyboard navigation
|
||||
if EventHandler._FocusIndicator then
|
||||
EventHandler._FocusIndicator.setFocused(nil)
|
||||
end
|
||||
end
|
||||
-- Set mouse down position for text selection on left click
|
||||
if button == 1 and element._textEditor then
|
||||
element._mouseDownPosition = element._textEditor:mouseToTextPosition(element, mx, my)
|
||||
element._textDragOccurred = false -- Reset drag flag on press
|
||||
end
|
||||
|
||||
-- Record drag start position per button
|
||||
self._dragStartX[button] = mx
|
||||
self._dragStartY[button] = my
|
||||
self._lastMouseX[button] = mx
|
||||
self._lastMouseY[button] = my
|
||||
end
|
||||
|
||||
--- Handle mouse drag (while button is pressed and mouse moves)
|
||||
---@param element Element The parent element
|
||||
---@param mx number Mouse X position
|
||||
---@param my number Mouse Y position
|
||||
---@param button number Mouse button
|
||||
---@param isHovering boolean Whether mouse is over element
|
||||
function EventHandler:_handleMouseDrag(element, mx, my, button, isHovering)
|
||||
local lastX = self._lastMouseX[button] or mx
|
||||
local lastY = self._lastMouseY[button] or my
|
||||
|
||||
if lastX ~= mx or lastY ~= my then
|
||||
-- Handle scrollbar drag if scrollbar was pressed
|
||||
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarDrag then
|
||||
element:_handleScrollbarDrag(mx, my)
|
||||
self._lastMouseX[button] = mx
|
||||
self._lastMouseY[button] = my
|
||||
return -- Don't process other drag events while dragging scrollbar
|
||||
end
|
||||
|
||||
-- Mouse has moved - fire drag event only if still hovering
|
||||
if isHovering then
|
||||
local modifiers = EventHandler._utils.getModifiers()
|
||||
local dx = mx - self._dragStartX[button]
|
||||
local dy = my - self._dragStartY[button]
|
||||
|
||||
local dragEvent = EventHandler._InputEvent.new({
|
||||
type = "drag",
|
||||
button = button,
|
||||
x = mx,
|
||||
y = my,
|
||||
dx = dx,
|
||||
dy = dy,
|
||||
modifiers = modifiers,
|
||||
clickCount = 1,
|
||||
})
|
||||
self:_invokeCallback(element, dragEvent)
|
||||
end
|
||||
|
||||
-- Handle text selection drag for editable elements
|
||||
if button == 1 and element.editable and element._focused and element._handleTextDrag then
|
||||
element:_handleTextDrag(mx, my)
|
||||
end
|
||||
|
||||
-- Update last known position for this button
|
||||
self._lastMouseX[button] = mx
|
||||
self._lastMouseY[button] = my
|
||||
end
|
||||
end
|
||||
|
||||
--- Handle mouse button release
|
||||
---@param mx number Mouse X position
|
||||
---@param my number Mouse Y position
|
||||
---@param button number Mouse button
|
||||
function EventHandler:_handleMouseRelease(element, mx, my, button)
|
||||
local currentTime = love.timer.getTime()
|
||||
local modifiers = EventHandler._utils.getModifiers()
|
||||
|
||||
-- Handle scrollbar release if scrollbar was pressed
|
||||
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarRelease then
|
||||
element:_handleScrollbarRelease(button)
|
||||
self._scrollbarPressHandled = false -- Reset flag
|
||||
self._pressed[button] = false
|
||||
self._dragStartX[button] = nil
|
||||
self._dragStartY[button] = nil
|
||||
return -- Don't process click events for scrollbar release
|
||||
end
|
||||
|
||||
-- Determine click count (double-click detection)
|
||||
local clickCount
|
||||
local doubleClickThreshold = 0.3 -- 300ms for double-click
|
||||
|
||||
if
|
||||
self._lastClickTime
|
||||
and self._lastClickButton == button
|
||||
and (currentTime - self._lastClickTime) < doubleClickThreshold
|
||||
then
|
||||
clickCount = self._clickCount + 1
|
||||
else
|
||||
clickCount = 1
|
||||
end
|
||||
|
||||
self._clickCount = clickCount
|
||||
self._lastClickTime = currentTime
|
||||
self._lastClickButton = button
|
||||
|
||||
-- Determine event type based on button
|
||||
local eventType = "click"
|
||||
if button == 2 then
|
||||
eventType = "rightclick"
|
||||
elseif button == 3 then
|
||||
eventType = "middleclick"
|
||||
end
|
||||
|
||||
-- Fire click event
|
||||
local clickEvent = EventHandler._InputEvent.new({
|
||||
type = eventType,
|
||||
button = button,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = clickCount,
|
||||
})
|
||||
self:_invokeCallback(element, clickEvent)
|
||||
|
||||
self._pressed[button] = false
|
||||
|
||||
-- Clean up drag tracking
|
||||
self._dragStartX[button] = nil
|
||||
self._dragStartY[button] = nil
|
||||
|
||||
-- Clean up text selection drag tracking
|
||||
if button == 1 then
|
||||
element._mouseDownPosition = nil
|
||||
end
|
||||
|
||||
-- Focus editable elements on left click
|
||||
if button == 1 and element.editable then
|
||||
-- Only focus if not already focused (to avoid moving cursor to end)
|
||||
local wasFocused = element:isFocused()
|
||||
if not wasFocused then
|
||||
element:focus()
|
||||
end
|
||||
|
||||
-- Handle text click for cursor positioning and word selection
|
||||
-- Only process click if no text drag occurred (to preserve drag selection)
|
||||
if element._handleTextClick and not element._textDragOccurred then
|
||||
element:_handleTextClick(mx, my, clickCount)
|
||||
end
|
||||
|
||||
-- Reset drag flag after release
|
||||
element._textDragOccurred = false
|
||||
end
|
||||
|
||||
-- Fire release event
|
||||
local releaseEvent = EventHandler._InputEvent.new({
|
||||
type = "release",
|
||||
button = button,
|
||||
x = mx,
|
||||
y = my,
|
||||
modifiers = modifiers,
|
||||
clickCount = clickCount,
|
||||
})
|
||||
self:_invokeCallback(element, releaseEvent)
|
||||
|
||||
if button == 1 and element._handleSelectRelease then
|
||||
element:_handleSelectRelease()
|
||||
end
|
||||
end
|
||||
|
||||
--- Process touch events in the update cycle
|
||||
---@param element Element The parent element
|
||||
function EventHandler:processTouchEvents(element)
|
||||
-- Start performance timing
|
||||
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||
EventHandler._Performance:startTimer("event_touch")
|
||||
end
|
||||
|
||||
-- Check if element can process events
|
||||
local canProcessEvents = (
|
||||
element.onEvent
|
||||
or self.onEvent
|
||||
or element.onTouchEvent
|
||||
or self.onTouchEvent
|
||||
or element.editable
|
||||
)
|
||||
and not element.disabled
|
||||
and self.touchEnabled
|
||||
|
||||
if not canProcessEvents then
|
||||
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||
EventHandler._Performance:stopTimer("event_touch")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local bx = element.x
|
||||
local by = element.y
|
||||
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
|
||||
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
|
||||
|
||||
-- Get current active touches from LÖVE
|
||||
local activeTouches = {}
|
||||
local touches = love.touch.getTouches()
|
||||
for _, id in ipairs(touches) do
|
||||
activeTouches[tostring(id)] = true
|
||||
end
|
||||
|
||||
-- Count active tracked touches for multi-touch filtering
|
||||
local trackedTouchCount = 0
|
||||
for _ in pairs(self._touches) do
|
||||
trackedTouchCount = trackedTouchCount + 1
|
||||
end
|
||||
|
||||
-- Process active touches
|
||||
for _, id in ipairs(touches) do
|
||||
local touchId = tostring(id)
|
||||
local tx, ty = love.touch.getPosition(id)
|
||||
local pressure = 1.0 -- LÖVE doesn't provide pressure by default
|
||||
|
||||
-- Check if touch is within element bounds
|
||||
local isInside = tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh
|
||||
|
||||
if isInside then
|
||||
if not self._touches[touchId] then
|
||||
-- Multi-touch filtering: reject new touches when multiTouchEnabled=false
|
||||
-- and we already have an active touch
|
||||
if self.multiTouchEnabled or trackedTouchCount == 0 then
|
||||
-- New touch began
|
||||
self:_handleTouchBegan(element, touchId, tx, ty, pressure)
|
||||
trackedTouchCount = trackedTouchCount + 1
|
||||
end
|
||||
else
|
||||
-- Touch moved
|
||||
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
|
||||
end
|
||||
elseif self._touches[touchId] then
|
||||
-- Touch moved outside or ended
|
||||
if activeTouches[touchId] then
|
||||
-- Still active but outside - fire moved event
|
||||
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
|
||||
else
|
||||
-- Touch ended
|
||||
self:_handleTouchEnded(element, touchId, tx, ty, pressure)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for ended touches (touches that were tracked but are no longer active)
|
||||
for touchId, _ in pairs(self._touches) do
|
||||
if not activeTouches[touchId] then
|
||||
-- Touch ended or cancelled
|
||||
local lastPos = self._lastTouchPositions[touchId]
|
||||
if lastPos then
|
||||
self:_handleTouchEnded(element, touchId, lastPos.x, lastPos.y, 1.0)
|
||||
else
|
||||
-- Cleanup orphaned touch
|
||||
self:_cleanupTouch(touchId)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Stop performance timing
|
||||
if EventHandler._Performance and EventHandler._Performance.enabled then
|
||||
EventHandler._Performance:stopTimer("event_touch")
|
||||
end
|
||||
end
|
||||
|
||||
--- Handle touch began event
|
||||
---@param element Element The parent element
|
||||
---@param touchId string Touch identifier
|
||||
---@param x number Touch X position
|
||||
---@param y number Touch Y position
|
||||
---@param pressure number Touch pressure (0-1)
|
||||
function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure)
|
||||
-- Create touch state
|
||||
self._touches[touchId] = {
|
||||
x = x,
|
||||
y = y,
|
||||
pressure = pressure,
|
||||
timestamp = love.timer.getTime(),
|
||||
phase = "began",
|
||||
}
|
||||
|
||||
-- Record start position
|
||||
self._touchStartPositions[touchId] = { x = x, y = y }
|
||||
self._lastTouchPositions[touchId] = { x = x, y = y }
|
||||
|
||||
-- Initialize touch history
|
||||
self._touchHistory[touchId] = { { x = x, y = y, timestamp = love.timer.getTime() } }
|
||||
|
||||
-- Create and fire touch press event
|
||||
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "began", pressure)
|
||||
touchEvent.type = "touchpress"
|
||||
touchEvent.dx = 0
|
||||
touchEvent.dy = 0
|
||||
self:_invokeCallback(element, touchEvent)
|
||||
self:_invokeTouchCallback(element, touchEvent)
|
||||
end
|
||||
|
||||
--- Handle touch moved event
|
||||
---@param element Element The parent element
|
||||
---@param touchId string Touch identifier
|
||||
---@param x number Touch X position
|
||||
---@param y number Touch Y position
|
||||
---@param pressure number Touch pressure (0-1)
|
||||
function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure)
|
||||
local touchState = self._touches[touchId]
|
||||
|
||||
if not touchState then
|
||||
-- Touch not tracked, ignore
|
||||
return
|
||||
end
|
||||
|
||||
local lastPos = self._lastTouchPositions[touchId]
|
||||
if not lastPos or lastPos.x ~= x or lastPos.y ~= y then
|
||||
-- Touch position changed
|
||||
local startPos = self._touchStartPositions[touchId]
|
||||
local dx = x - startPos.x
|
||||
local dy = y - startPos.y
|
||||
|
||||
-- Update touch state
|
||||
touchState.x = x
|
||||
touchState.y = y
|
||||
touchState.pressure = pressure
|
||||
touchState.phase = "moved"
|
||||
|
||||
-- Update last position
|
||||
self._lastTouchPositions[touchId] = { x = x, y = y }
|
||||
|
||||
-- Add to touch history (keep last 5 positions)
|
||||
local history = self._touchHistory[touchId] or {}
|
||||
table.insert(history, { x = x, y = y, timestamp = love.timer.getTime() })
|
||||
if #history > 5 then
|
||||
table.remove(history, 1)
|
||||
end
|
||||
self._touchHistory[touchId] = history
|
||||
|
||||
-- Create and fire touch move event
|
||||
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "moved", pressure)
|
||||
touchEvent.type = "touchmove"
|
||||
touchEvent.dx = dx
|
||||
touchEvent.dy = dy
|
||||
self:_invokeCallback(element, touchEvent)
|
||||
self:_invokeTouchCallback(element, touchEvent)
|
||||
end
|
||||
end
|
||||
|
||||
--- Handle touch ended event
|
||||
---@param element Element The parent element
|
||||
---@param touchId string Touch identifier
|
||||
---@param x number Touch X position
|
||||
---@param y number Touch Y position
|
||||
---@param pressure number Touch pressure (0-1)
|
||||
function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure)
|
||||
local touchState = self._touches[touchId]
|
||||
|
||||
if not touchState then
|
||||
-- Touch not tracked, ignore
|
||||
return
|
||||
end
|
||||
|
||||
local startPos = self._touchStartPositions[touchId]
|
||||
local dx = x - startPos.x
|
||||
local dy = y - startPos.y
|
||||
|
||||
-- Create and fire touch release event
|
||||
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "ended", pressure)
|
||||
touchEvent.type = "touchrelease"
|
||||
touchEvent.dx = dx
|
||||
touchEvent.dy = dy
|
||||
self:_invokeCallback(element, touchEvent)
|
||||
self:_invokeTouchCallback(element, touchEvent)
|
||||
|
||||
-- Cleanup touch state
|
||||
self:_cleanupTouch(touchId)
|
||||
end
|
||||
|
||||
--- Cleanup touch state
|
||||
---@param touchId string Touch ID
|
||||
function EventHandler:_cleanupTouch(touchId)
|
||||
self._touches[touchId] = nil
|
||||
self._touchStartPositions[touchId] = nil
|
||||
self._lastTouchPositions[touchId] = nil
|
||||
self._touchHistory[touchId] = nil
|
||||
end
|
||||
|
||||
--- Get active touches on this element
|
||||
---@return table<string, table> Active touches
|
||||
function EventHandler:getActiveTouches()
|
||||
return self._touches
|
||||
end
|
||||
|
||||
--- Reset scrollbar press flag (called each frame)
|
||||
function EventHandler:resetScrollbarPressFlag()
|
||||
self._scrollbarPressHandled = false
|
||||
end
|
||||
|
||||
--- Check if any mouse button is pressed
|
||||
---@return boolean True if any button is pressed
|
||||
function EventHandler:isAnyButtonPressed()
|
||||
for _, pressed in pairs(self._pressed) do
|
||||
if pressed then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Check if a specific button is pressed
|
||||
---@param button number Mouse button (1=left, 2=right, 3=middle)
|
||||
---@return boolean True if button is pressed
|
||||
function EventHandler:isButtonPressed(button)
|
||||
return self._pressed[button] == true
|
||||
end
|
||||
|
||||
--- Invoke the onEvent callback, optionally deferring it if onEventDeferred is true
|
||||
---@param element Element The element that triggered the event
|
||||
---@param event InputEvent The event data
|
||||
function EventHandler:_invokeCallback(element, event)
|
||||
-- Read onEvent from element (source of truth), fallback to handler cache for backwards compat
|
||||
local callback = element.onEvent or self.onEvent
|
||||
if not callback then
|
||||
return
|
||||
end
|
||||
|
||||
if self.onEventDeferred then
|
||||
-- Get FlexLove module to defer the callback
|
||||
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
|
||||
if FlexLove and FlexLove.deferCallback then
|
||||
FlexLove.deferCallback(function()
|
||||
callback(element, event)
|
||||
end)
|
||||
else
|
||||
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
|
||||
eventType = event.type,
|
||||
})
|
||||
end
|
||||
else
|
||||
callback(element, event)
|
||||
end
|
||||
end
|
||||
|
||||
--- Invoke the onTouchEvent callback, optionally deferring it
|
||||
---@param element Element The element that triggered the event
|
||||
---@param event InputEvent The touch event data
|
||||
function EventHandler:_invokeTouchCallback(element, event)
|
||||
-- Read onTouchEvent from element (source of truth), fallback to handler cache for backwards compat
|
||||
local callback = element.onTouchEvent or self.onTouchEvent
|
||||
if not callback then
|
||||
return
|
||||
end
|
||||
|
||||
if self.onTouchEventDeferred then
|
||||
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
|
||||
if FlexLove and FlexLove.deferCallback then
|
||||
FlexLove.deferCallback(function()
|
||||
callback(element, event)
|
||||
end)
|
||||
else
|
||||
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
|
||||
eventType = event.type,
|
||||
})
|
||||
end
|
||||
else
|
||||
callback(element, event)
|
||||
end
|
||||
end
|
||||
|
||||
--- Invoke the onGesture callback, optionally deferring it
|
||||
---@param element Element The element that triggered the event
|
||||
---@param gesture table The gesture data from GestureRecognizer
|
||||
function EventHandler:_invokeGestureCallback(element, gesture)
|
||||
-- Read onGesture from element (source of truth), fallback to handler cache for backwards compat
|
||||
local callback = element.onGesture or self.onGesture
|
||||
if not callback then
|
||||
return
|
||||
end
|
||||
|
||||
if self.onGestureDeferred then
|
||||
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
|
||||
if FlexLove and FlexLove.deferCallback then
|
||||
FlexLove.deferCallback(function()
|
||||
callback(element, gesture)
|
||||
end)
|
||||
else
|
||||
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
|
||||
gestureType = gesture.type,
|
||||
})
|
||||
end
|
||||
else
|
||||
callback(element, gesture)
|
||||
end
|
||||
end
|
||||
|
||||
return EventHandler
|
||||
@@ -0,0 +1,232 @@
|
||||
local packageName = ... or "FocusIndicator"
|
||||
local modulePath = packageName:match("(.-)[^%.]+$")
|
||||
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
local FocusIndicator = {}
|
||||
|
||||
--- Configuration
|
||||
---@type KeyboardNavigationFocusIndicatorConfig
|
||||
FocusIndicator.config = {
|
||||
enabled = true,
|
||||
|
||||
--- Custom draw function to override default rendering
|
||||
---@type function|nil
|
||||
--- Called with: element, bounds, style - return true to skip default drawing
|
||||
draw = nil,
|
||||
|
||||
-- Appearance
|
||||
color = { 0.2, 0.6, 1.0, 0.8 }, -- Blue with 80% opacity
|
||||
lineWidth = 2,
|
||||
inset = -3, -- Negative value extends beyond element
|
||||
borderRadius = 4,
|
||||
|
||||
-- Animation
|
||||
animationDuration = 0.15, -- Seconds for focus animation
|
||||
pulseEnabled = false, -- Enable pulsing animation
|
||||
pulseDuration = 1.0, -- Seconds per pulse cycle
|
||||
pulseScaleMin = 0.95, -- Minimum scale during pulse
|
||||
pulseScaleMax = 1.05, -- Maximum scale during pulse
|
||||
}
|
||||
|
||||
--- State
|
||||
FocusIndicator._focusedElement = nil
|
||||
FocusIndicator._animationProgress = 0
|
||||
FocusIndicator._pulsePhase = 0
|
||||
FocusIndicator._hidden = true
|
||||
FocusIndicator._deps = nil
|
||||
|
||||
--- Initialize FocusIndicator module
|
||||
---@param deps table Dependencies table containing Context and Color modules
|
||||
---@field deps.Context table Context module for getting focused element
|
||||
---@field deps.Color table Color module for color manipulation
|
||||
function FocusIndicator.init(deps)
|
||||
FocusIndicator._deps = deps
|
||||
FocusIndicator._Context = deps.Context
|
||||
FocusIndicator._Color = deps.Color
|
||||
end
|
||||
|
||||
--- Update animation state for entrance and pulse effects
|
||||
---@param dt number Delta time in seconds since last frame
|
||||
function FocusIndicator:update(dt)
|
||||
if not FocusIndicator.config.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- Update focus entrance animation
|
||||
if FocusIndicator._animationProgress < 1 then
|
||||
FocusIndicator._animationProgress =
|
||||
math.min(1, FocusIndicator._animationProgress + (dt / FocusIndicator.config.animationDuration))
|
||||
end
|
||||
|
||||
-- Update pulse animation
|
||||
if FocusIndicator.config.pulseEnabled then
|
||||
FocusIndicator._pulsePhase = (FocusIndicator._pulsePhase + dt) % FocusIndicator.config.pulseDuration
|
||||
end
|
||||
end
|
||||
|
||||
--- Set the focused element to render indicator around
|
||||
---@param element Element? The element to show focus indicator around, or nil to hide
|
||||
function FocusIndicator.setFocused(element)
|
||||
FocusIndicator._focusedElement = element
|
||||
FocusIndicator._hidden = element == nil
|
||||
-- Reset animation when focus changes
|
||||
if element then
|
||||
FocusIndicator._animationProgress = 0
|
||||
end
|
||||
end
|
||||
|
||||
--- Get the current scale factor for animations
|
||||
--- Combines entrance scale (0.8 to 1.0) with optional pulse scale
|
||||
---@return number Scale factor (typically 0.8-1.05 range)
|
||||
function FocusIndicator:getScale()
|
||||
local scale = 1
|
||||
|
||||
-- Apply entrance animation (scale up from 0.8)
|
||||
local entranceScale = 0.8 + (0.2 * FocusIndicator._animationProgress)
|
||||
scale = scale * entranceScale
|
||||
|
||||
-- Apply pulse animation
|
||||
if FocusIndicator.config.pulseEnabled then
|
||||
local pulseProgress = FocusIndicator._pulsePhase / FocusIndicator.config.pulseDuration
|
||||
-- Smooth sine wave pulse
|
||||
local pulseScale = FocusIndicator.config.pulseScaleMin
|
||||
+ (FocusIndicator.config.pulseScaleMax - FocusIndicator.config.pulseScaleMin)
|
||||
* (0.5 + 0.5 * math.sin(2 * math.pi * pulseProgress))
|
||||
scale = scale * pulseScale
|
||||
end
|
||||
|
||||
return scale
|
||||
end
|
||||
|
||||
--- Get the current opacity for the indicator
|
||||
--- Applies entrance animation fade-in to the configured alpha
|
||||
---@return number Alpha value (0-1 range)
|
||||
function FocusIndicator:getOpacity()
|
||||
-- Fade in on focus
|
||||
return FocusIndicator.config.color[4] * FocusIndicator._animationProgress
|
||||
end
|
||||
|
||||
--- Draw the focus indicator around the focused element
|
||||
--- Renders a rounded rectangle border, or calls custom draw function if configured
|
||||
--- Should be called from within love.draw() after all elements are drawn
|
||||
function FocusIndicator:draw()
|
||||
if not FocusIndicator.config.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
if FocusIndicator._hidden then
|
||||
return
|
||||
end
|
||||
|
||||
-- In immediate mode the stored element reference is stale (recreated every frame).
|
||||
-- Always resolve through Context so we get the live object with up-to-date positions.
|
||||
local element
|
||||
if FocusIndicator._Context then
|
||||
element = FocusIndicator._Context.getFocused()
|
||||
else
|
||||
element = FocusIndicator._focusedElement
|
||||
end
|
||||
|
||||
if not element then
|
||||
return
|
||||
end
|
||||
|
||||
-- Get element dimensions (use border-box size which includes padding)
|
||||
local x = element.x or 0
|
||||
local y = element.y or 0
|
||||
local w = element._borderBoxWidth
|
||||
or (element.width + (element.padding and (element.padding.left + element.padding.right) or 0))
|
||||
local h = element._borderBoxHeight
|
||||
or (element.height + (element.padding and (element.padding.top + element.padding.bottom) or 0))
|
||||
|
||||
if w == 0 or h == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
-- Calculate indicator dimensions with inset and scale
|
||||
local inset = FocusIndicator.config.inset
|
||||
local scale = self:getScale()
|
||||
|
||||
local indicatorX = x + inset
|
||||
local indicatorY = y + inset
|
||||
local indicatorW = w - 2 * inset
|
||||
local indicatorH = h - 2 * inset
|
||||
|
||||
-- Center the scale around the element
|
||||
local offsetX = (indicatorW * (1 - scale)) / 2
|
||||
local offsetY = (indicatorH * (1 - scale)) / 2
|
||||
|
||||
indicatorX = indicatorX + offsetX
|
||||
indicatorY = indicatorY + offsetY
|
||||
indicatorW = indicatorW * scale
|
||||
indicatorH = indicatorH * scale
|
||||
|
||||
-- Get color with animated opacity
|
||||
local r, g, b = FocusIndicator.config.color[1], FocusIndicator.config.color[2], FocusIndicator.config.color[3]
|
||||
local a = self:getOpacity()
|
||||
|
||||
-- Build style table for custom draw callback
|
||||
local bounds = {
|
||||
x = indicatorX,
|
||||
y = indicatorY,
|
||||
width = indicatorW,
|
||||
height = indicatorH,
|
||||
}
|
||||
|
||||
local style = {
|
||||
color = { r = r, g = g, b = b, a = a },
|
||||
lineWidth = FocusIndicator.config.lineWidth,
|
||||
borderRadius = FocusIndicator.config.borderRadius,
|
||||
scale = scale,
|
||||
opacity = a,
|
||||
}
|
||||
|
||||
-- Check for custom draw callback
|
||||
if FocusIndicator.config.draw then
|
||||
local skipDefault = FocusIndicator.config.draw(element, bounds, style)
|
||||
if skipDefault then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- Save current love.graphics state
|
||||
local prevBlend, prevAlphaMode = love.graphics.getBlendMode()
|
||||
local prevR, prevG, prevB, prevA = love.graphics.getColor()
|
||||
local prevLineWidth = love.graphics.getLineWidth()
|
||||
|
||||
-- Set blend mode for transparency
|
||||
love.graphics.setBlendMode("alpha")
|
||||
|
||||
-- Draw rounded rectangle border
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
love.graphics.setLineWidth(FocusIndicator.config.lineWidth)
|
||||
|
||||
-- Draw the rounded rectangle border
|
||||
local borderRadius = FocusIndicator.config.borderRadius
|
||||
love.graphics.rectangle("line", indicatorX, indicatorY, indicatorW, indicatorH, borderRadius)
|
||||
|
||||
-- Restore love.graphics state
|
||||
love.graphics.setBlendMode(prevBlend, prevAlphaMode)
|
||||
love.graphics.setColor(prevR, prevG, prevB, prevA)
|
||||
love.graphics.setLineWidth(prevLineWidth)
|
||||
end
|
||||
|
||||
--- Set the indicator color
|
||||
---@param r number Red component (0-1 range)
|
||||
---@param g number Green component (0-1 range)
|
||||
---@param b number Blue component (0-1 range)
|
||||
---@param a number|nil Alpha component (0-1 range), defaults to current alpha if omitted
|
||||
function FocusIndicator.setColor(r, g, b, a)
|
||||
FocusIndicator.config.color = { r, g, b, a or FocusIndicator.config.color[4] }
|
||||
end
|
||||
|
||||
--- Set the stroke width for the indicator border
|
||||
---@param width number Line width in pixels
|
||||
function FocusIndicator.setLineWidth(width)
|
||||
FocusIndicator.config.lineWidth = width
|
||||
end
|
||||
|
||||
return FocusIndicator
|
||||
@@ -0,0 +1,269 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
-- Font cache with LRU eviction, font resolution, and cache management.
|
||||
-- `ErrorHandler` and `resolveImagePath` are injected via init() to avoid
|
||||
-- a cross-import into utils (utils re-exports the cache via aliases).
|
||||
|
||||
-- Font cache with LRU eviction
|
||||
local FONT_CACHE = {}
|
||||
local FONT_CACHE_MAX_SIZE = 50
|
||||
local FONT_CACHE_STATS = {
|
||||
hits = 0,
|
||||
misses = 0,
|
||||
evictions = 0,
|
||||
size = 0,
|
||||
}
|
||||
|
||||
local ErrorHandler = nil
|
||||
local resolveImagePath = nil
|
||||
|
||||
--- Initialize dependencies
|
||||
---@param deps table Dependencies: { ErrorHandler = ErrorHandler, resolveImagePath = function }
|
||||
local function init(deps)
|
||||
if type(deps) == "table" then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
resolveImagePath = deps.resolveImagePath
|
||||
end
|
||||
end
|
||||
|
||||
-- LRU tracking: each entry has {font, lastUsed, accessCount}
|
||||
local function updateCacheAccess(cacheKey)
|
||||
local entry = FONT_CACHE[cacheKey]
|
||||
if entry then
|
||||
entry.lastUsed = love.timer.getTime()
|
||||
entry.accessCount = entry.accessCount + 1
|
||||
end
|
||||
end
|
||||
|
||||
local function evictLRU()
|
||||
local oldestKey = nil
|
||||
local oldestTime = math.huge
|
||||
|
||||
for key, entry in pairs(FONT_CACHE) do
|
||||
-- Skip methods (get, getFont) - only evict cache entries (tables with lastUsed)
|
||||
if type(entry) == "table" and entry.lastUsed then
|
||||
if entry.lastUsed < oldestTime then
|
||||
oldestTime = entry.lastUsed
|
||||
oldestKey = key
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if oldestKey then
|
||||
FONT_CACHE[oldestKey] = nil
|
||||
FONT_CACHE_STATS.evictions = FONT_CACHE_STATS.evictions + 1
|
||||
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size - 1
|
||||
end
|
||||
end
|
||||
|
||||
--- Create or get a font from cache
|
||||
---@param size number
|
||||
---@param fontPath string?
|
||||
---@return love.Font
|
||||
function FONT_CACHE.get(size, fontPath)
|
||||
-- Bucket font sizes for better cache reuse (reduces unique cache entries)
|
||||
-- Small sizes (< 20): round to nearest 2
|
||||
-- Medium sizes (20-40): round to nearest 4
|
||||
-- Large sizes (> 40): round to nearest 8
|
||||
if size < 20 then
|
||||
size = math.floor((size + 1) / 2) * 2
|
||||
elseif size < 40 then
|
||||
size = math.floor((size + 2) / 4) * 4
|
||||
else
|
||||
size = math.floor((size + 4) / 8) * 8
|
||||
end
|
||||
|
||||
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
|
||||
|
||||
if FONT_CACHE[cacheKey] then
|
||||
-- Cache hit
|
||||
FONT_CACHE_STATS.hits = FONT_CACHE_STATS.hits + 1
|
||||
updateCacheAccess(cacheKey)
|
||||
return FONT_CACHE[cacheKey].font
|
||||
end
|
||||
|
||||
-- Cache miss
|
||||
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
|
||||
|
||||
local font
|
||||
if fontPath then
|
||||
local resolvedPath = resolveImagePath(fontPath)
|
||||
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
|
||||
if success then
|
||||
font = result
|
||||
else
|
||||
if ErrorHandler then
|
||||
ErrorHandler:warn("utils", "RES_004", {
|
||||
resourceType = "font",
|
||||
path = fontPath,
|
||||
})
|
||||
end
|
||||
font = love.graphics.newFont(size)
|
||||
end
|
||||
else
|
||||
font = love.graphics.newFont(size)
|
||||
end
|
||||
|
||||
-- Add to cache with LRU metadata
|
||||
FONT_CACHE[cacheKey] = {
|
||||
font = font,
|
||||
lastUsed = love.timer.getTime(),
|
||||
accessCount = 1,
|
||||
}
|
||||
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
|
||||
|
||||
-- Evict if cache is full
|
||||
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
|
||||
evictLRU()
|
||||
end
|
||||
|
||||
return font
|
||||
end
|
||||
|
||||
--- Get font for text size (cached)
|
||||
---@param textSize number?
|
||||
---@param fontPath string?
|
||||
---@return love.Font
|
||||
function FONT_CACHE.getFont(textSize, fontPath)
|
||||
if textSize then
|
||||
return FONT_CACHE.get(textSize, fontPath)
|
||||
else
|
||||
return love.graphics.getFont()
|
||||
end
|
||||
end
|
||||
|
||||
-- Font resolution utilities
|
||||
|
||||
--- Resolve font path from fontFamily and theme
|
||||
---@param fontFamily string? Font family name or direct path
|
||||
---@param themeComponent string? Theme component name
|
||||
---@param themeManager table? ThemeManager instance
|
||||
---@return string? Resolved font path or nil
|
||||
local function resolveFontPath(fontFamily, themeComponent, themeManager)
|
||||
if fontFamily then
|
||||
-- Check if fontFamily is a theme font name
|
||||
local themeToUse = themeManager and themeManager:getTheme()
|
||||
if themeToUse and themeToUse.fonts and themeToUse.fonts[fontFamily] then
|
||||
return themeToUse.fonts[fontFamily]
|
||||
else
|
||||
-- Treat as direct path to font file
|
||||
return fontFamily
|
||||
end
|
||||
elseif themeComponent and themeManager then
|
||||
-- If using themeComponent but no fontFamily specified, check for default font in theme
|
||||
return themeManager:getDefaultFontFamily()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Get font for element (resolves from theme or fontFamily)
|
||||
---@param textSize number? Text size in pixels
|
||||
---@param fontFamily string? Font family name or direct path
|
||||
---@param themeComponent string? Theme component name
|
||||
---@param themeManager table? ThemeManager instance
|
||||
---@return love.Font
|
||||
local function getFont(textSize, fontFamily, themeComponent, themeManager)
|
||||
local fontPath = resolveFontPath(fontFamily, themeComponent, themeManager)
|
||||
return FONT_CACHE.getFont(textSize, fontPath)
|
||||
end
|
||||
|
||||
-- Font cache management
|
||||
|
||||
--- Get font cache statistics
|
||||
---@return table stats {hits, misses, evictions, size, hitRate}
|
||||
local function getFontCacheStats()
|
||||
local total = FONT_CACHE_STATS.hits + FONT_CACHE_STATS.misses
|
||||
local hitRate = total > 0 and (FONT_CACHE_STATS.hits / total) or 0
|
||||
return {
|
||||
hits = FONT_CACHE_STATS.hits,
|
||||
misses = FONT_CACHE_STATS.misses,
|
||||
evictions = FONT_CACHE_STATS.evictions,
|
||||
size = FONT_CACHE_STATS.size,
|
||||
hitRate = hitRate,
|
||||
}
|
||||
end
|
||||
|
||||
--- Set maximum font cache size
|
||||
---@param maxSize number Maximum number of fonts to cache
|
||||
local function setFontCacheSize(maxSize)
|
||||
FONT_CACHE_MAX_SIZE = math.max(1, maxSize)
|
||||
|
||||
-- Evict entries if cache is now over limit
|
||||
while FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE do
|
||||
evictLRU()
|
||||
end
|
||||
end
|
||||
|
||||
--- Clear font cache
|
||||
local function clearFontCache()
|
||||
-- Clear cache entries but preserve methods (get, getFont)
|
||||
for key, entry in pairs(FONT_CACHE) do
|
||||
if type(entry) == "table" and entry.lastUsed then
|
||||
FONT_CACHE[key] = nil
|
||||
end
|
||||
end
|
||||
FONT_CACHE_STATS.size = 0
|
||||
FONT_CACHE_STATS.evictions = 0
|
||||
end
|
||||
|
||||
--- Preload font at multiple sizes
|
||||
---@param fontPath string? Path to font file (nil for default font)
|
||||
---@param sizes table Array of font sizes to preload
|
||||
local function preloadFont(fontPath, sizes)
|
||||
for _, size in ipairs(sizes) do
|
||||
-- Round size to reduce cache entries
|
||||
size = math.floor(size + 0.5)
|
||||
|
||||
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
|
||||
|
||||
if not FONT_CACHE[cacheKey] then
|
||||
local font
|
||||
if fontPath then
|
||||
local resolvedPath = resolveImagePath(fontPath)
|
||||
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
|
||||
if success then
|
||||
font = result
|
||||
else
|
||||
font = love.graphics.newFont(size)
|
||||
end
|
||||
else
|
||||
font = love.graphics.newFont(size)
|
||||
end
|
||||
|
||||
FONT_CACHE[cacheKey] = {
|
||||
font = font,
|
||||
lastUsed = love.timer.getTime(),
|
||||
accessCount = 1,
|
||||
}
|
||||
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
|
||||
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
|
||||
|
||||
-- Evict if cache is full
|
||||
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
|
||||
evictLRU()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Reset font cache statistics
|
||||
local function resetFontCacheStats()
|
||||
FONT_CACHE_STATS.hits = 0
|
||||
FONT_CACHE_STATS.misses = 0
|
||||
FONT_CACHE_STATS.evictions = 0
|
||||
end
|
||||
|
||||
return {
|
||||
FONT_CACHE = FONT_CACHE,
|
||||
init = init,
|
||||
resolveFontPath = resolveFontPath,
|
||||
getFont = getFont,
|
||||
getFontCacheStats = getFontCacheStats,
|
||||
setFontCacheSize = setFontCacheSize,
|
||||
clearFontCache = clearFontCache,
|
||||
preloadFont = preloadFont,
|
||||
resetFontCacheStats = resetFontCacheStats,
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
---@class GestureRecognizer
|
||||
---@field _touches table<string, table> -- Current touch states
|
||||
---@field _gestureStates table -- Active gesture states
|
||||
---@field _config table -- Gesture configuration (thresholds, etc.)
|
||||
---@field _InputEvent table
|
||||
---@field _utils table
|
||||
local GestureRecognizer = {}
|
||||
GestureRecognizer.__index = GestureRecognizer
|
||||
|
||||
-- Gesture types enum
|
||||
local GestureType = {
|
||||
TAP = "tap",
|
||||
DOUBLE_TAP = "double_tap",
|
||||
LONG_PRESS = "long_press",
|
||||
SWIPE = "swipe",
|
||||
PAN = "pan",
|
||||
PINCH = "pinch",
|
||||
ROTATE = "rotate",
|
||||
}
|
||||
|
||||
-- Gesture states
|
||||
local GestureState = {
|
||||
POSSIBLE = "possible",
|
||||
BEGAN = "began",
|
||||
CHANGED = "changed",
|
||||
ENDED = "ended",
|
||||
CANCELLED = "cancelled",
|
||||
FAILED = "failed",
|
||||
}
|
||||
|
||||
-- Default configuration
|
||||
local defaultConfig = {
|
||||
-- Tap gesture
|
||||
tapMaxDuration = 0.3, -- seconds
|
||||
tapMaxMovement = 10, -- pixels
|
||||
|
||||
-- Double-tap gesture
|
||||
doubleTapInterval = 0.3, -- seconds between taps
|
||||
|
||||
-- Long-press gesture
|
||||
longPressMinDuration = 0.5, -- seconds
|
||||
longPressMaxMovement = 10, -- pixels
|
||||
|
||||
-- Swipe gesture
|
||||
swipeMinDistance = 50, -- pixels
|
||||
swipeMaxDuration = 0.2, -- seconds
|
||||
swipeMinVelocity = 200, -- pixels per second
|
||||
|
||||
-- Pan gesture
|
||||
panMinMovement = 5, -- pixels to start pan
|
||||
|
||||
-- Pinch gesture
|
||||
pinchMinScaleChange = 0.1, -- 10% scale change
|
||||
|
||||
-- Rotate gesture
|
||||
rotateMinAngleChange = 5, -- degrees
|
||||
}
|
||||
|
||||
--- Create a new GestureRecognizer instance
|
||||
---@param config table? Optional configuration options
|
||||
---@param deps table Dependencies {InputEvent, utils}
|
||||
---@return GestureRecognizer
|
||||
function GestureRecognizer.new(config, deps)
|
||||
config = config or {}
|
||||
|
||||
local self = setmetatable({}, GestureRecognizer)
|
||||
|
||||
self._InputEvent = deps.InputEvent
|
||||
self._utils = deps.utils
|
||||
|
||||
-- Merge configuration with defaults
|
||||
self._config = {}
|
||||
for key, value in pairs(defaultConfig) do
|
||||
self._config[key] = config[key] or value
|
||||
end
|
||||
|
||||
self._touches = {}
|
||||
self._gestureStates = {
|
||||
tap = nil,
|
||||
doubleTap = { lastTapTime = 0, tapCount = 0 },
|
||||
longPress = {},
|
||||
swipe = {},
|
||||
pan = {},
|
||||
pinch = {},
|
||||
rotate = {},
|
||||
}
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
--- Update gesture recognizer with touch event
|
||||
---@param event InputEvent Touch event
|
||||
function GestureRecognizer:processTouchEvent(event)
|
||||
if not event.touchId then
|
||||
return nil
|
||||
end
|
||||
|
||||
local touchId = event.touchId
|
||||
local gestures = {}
|
||||
|
||||
-- Update touch state
|
||||
if event.type == "touchpress" then
|
||||
self._touches[touchId] = {
|
||||
startX = event.x,
|
||||
startY = event.y,
|
||||
x = event.x,
|
||||
y = event.y,
|
||||
startTime = event.timestamp,
|
||||
lastTime = event.timestamp,
|
||||
phase = "began",
|
||||
}
|
||||
|
||||
-- Initialize gesture detection
|
||||
self:_detectTapBegan(touchId, event)
|
||||
self:_detectLongPressBegan(touchId, event)
|
||||
elseif event.type == "touchmove" then
|
||||
local touch = self._touches[touchId]
|
||||
if touch then
|
||||
touch.x = event.x
|
||||
touch.y = event.y
|
||||
touch.lastTime = event.timestamp
|
||||
touch.phase = "moved"
|
||||
|
||||
-- Update gesture detection
|
||||
local panGesture = self:_detectPan(touchId, event)
|
||||
if panGesture then
|
||||
table.insert(gestures, panGesture)
|
||||
end
|
||||
local swipeGesture = self:_detectSwipe(touchId, event)
|
||||
if swipeGesture then
|
||||
table.insert(gestures, swipeGesture)
|
||||
end
|
||||
|
||||
-- Multi-touch gestures
|
||||
if self:_getTouchCount() >= 2 then
|
||||
local pinchGesture = self:_detectPinch(event)
|
||||
if pinchGesture then
|
||||
table.insert(gestures, pinchGesture)
|
||||
end
|
||||
local rotateGesture = self:_detectRotate(event)
|
||||
if rotateGesture then
|
||||
table.insert(gestures, rotateGesture)
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif event.type == "touchrelease" then
|
||||
local touch = self._touches[touchId]
|
||||
if touch then
|
||||
touch.phase = "ended"
|
||||
|
||||
-- Finalize gesture detection
|
||||
local tapGesture = self:_detectTapEnded(touchId, event)
|
||||
if tapGesture then
|
||||
table.insert(gestures, tapGesture)
|
||||
end
|
||||
local swipeGesture = self:_detectSwipeEnded(touchId, event)
|
||||
if swipeGesture then
|
||||
table.insert(gestures, swipeGesture)
|
||||
end
|
||||
local panGesture = self:_detectPanEnded(touchId, event)
|
||||
if panGesture then
|
||||
table.insert(gestures, panGesture)
|
||||
end
|
||||
|
||||
-- Cleanup touch
|
||||
self._touches[touchId] = nil
|
||||
end
|
||||
elseif event.type == "touchcancel" then
|
||||
-- Cancel all active gestures for this touch
|
||||
self._touches[touchId] = nil
|
||||
self:_cancelAllGestures()
|
||||
end
|
||||
|
||||
return #gestures > 0 and gestures or nil
|
||||
end
|
||||
|
||||
--- Get number of active touches
|
||||
---@return number
|
||||
function GestureRecognizer:_getTouchCount()
|
||||
local count = 0
|
||||
for _ in pairs(self._touches) do
|
||||
count = count + 1
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
--- Detect tap gesture began
|
||||
---@param touchId string
|
||||
---@param event InputEvent
|
||||
function GestureRecognizer:_detectTapBegan(touchId, event)
|
||||
-- Tap detection happens on touch end
|
||||
-- Just record the touch for now
|
||||
end
|
||||
|
||||
--- Detect tap gesture ended
|
||||
---@param touchId string
|
||||
---@param event InputEvent
|
||||
function GestureRecognizer:_detectTapEnded(touchId, event)
|
||||
local touch = self._touches[touchId]
|
||||
if not touch then
|
||||
return
|
||||
end
|
||||
|
||||
local duration = event.timestamp - touch.startTime
|
||||
local dx = event.x - touch.startX
|
||||
local dy = event.y - touch.startY
|
||||
local distance = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
-- Check if it's a valid tap
|
||||
if duration < self._config.tapMaxDuration and distance < self._config.tapMaxMovement then
|
||||
local currentTime = event.timestamp
|
||||
local doubleTapState = self._gestureStates.doubleTap
|
||||
|
||||
-- Check for double-tap
|
||||
if currentTime - doubleTapState.lastTapTime < self._config.doubleTapInterval then
|
||||
doubleTapState.tapCount = doubleTapState.tapCount + 1
|
||||
|
||||
if doubleTapState.tapCount >= 2 then
|
||||
-- Fire double-tap gesture
|
||||
return {
|
||||
type = GestureType.DOUBLE_TAP,
|
||||
state = GestureState.ENDED,
|
||||
x = event.x,
|
||||
y = event.y,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
else
|
||||
doubleTapState.tapCount = 1
|
||||
end
|
||||
|
||||
doubleTapState.lastTapTime = currentTime
|
||||
|
||||
-- Fire tap gesture
|
||||
return {
|
||||
type = GestureType.TAP,
|
||||
state = GestureState.ENDED,
|
||||
x = event.x,
|
||||
y = event.y,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
--- Detect long-press gesture began
|
||||
---@param touchId string
|
||||
---@param event InputEvent
|
||||
function GestureRecognizer:_detectLongPressBegan(touchId, event)
|
||||
-- Long-press detection happens continuously during touch
|
||||
self._gestureStates.longPress[touchId] = {
|
||||
startX = event.x,
|
||||
startY = event.y,
|
||||
startTime = event.timestamp,
|
||||
triggered = false,
|
||||
}
|
||||
end
|
||||
|
||||
--- Detect pan gesture
|
||||
---@param touchId string
|
||||
---@param event InputEvent
|
||||
---@return table? Gesture event
|
||||
function GestureRecognizer:_detectPan(touchId, event)
|
||||
local touch = self._touches[touchId]
|
||||
if not touch then
|
||||
return nil
|
||||
end
|
||||
|
||||
local dx = event.x - touch.startX
|
||||
local dy = event.y - touch.startY
|
||||
local distance = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
local panState = self._gestureStates.pan[touchId]
|
||||
|
||||
if not panState then
|
||||
-- Check if pan should begin
|
||||
if distance >= self._config.panMinMovement then
|
||||
self._gestureStates.pan[touchId] = {
|
||||
active = true,
|
||||
lastX = touch.startX,
|
||||
lastY = touch.startY,
|
||||
}
|
||||
panState = self._gestureStates.pan[touchId]
|
||||
|
||||
return {
|
||||
type = GestureType.PAN,
|
||||
state = GestureState.BEGAN,
|
||||
x = event.x,
|
||||
y = event.y,
|
||||
dx = dx,
|
||||
dy = dy,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
else
|
||||
-- Pan is active, fire changed event
|
||||
local panDx = event.x - panState.lastX
|
||||
local panDy = event.y - panState.lastY
|
||||
|
||||
panState.lastX = event.x
|
||||
panState.lastY = event.y
|
||||
|
||||
return {
|
||||
type = GestureType.PAN,
|
||||
state = GestureState.CHANGED,
|
||||
x = event.x,
|
||||
y = event.y,
|
||||
dx = panDx,
|
||||
dy = panDy,
|
||||
totalDx = dx,
|
||||
totalDy = dy,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Detect pan ended
|
||||
---@param touchId string
|
||||
---@param event InputEvent
|
||||
---@return table? Gesture event
|
||||
function GestureRecognizer:_detectPanEnded(touchId, event)
|
||||
local panState = self._gestureStates.pan[touchId]
|
||||
if panState and panState.active then
|
||||
self._gestureStates.pan[touchId] = nil
|
||||
|
||||
local touch = self._touches[touchId]
|
||||
local dx = event.x - touch.startX
|
||||
local dy = event.y - touch.startY
|
||||
|
||||
return {
|
||||
type = GestureType.PAN,
|
||||
state = GestureState.ENDED,
|
||||
x = event.x,
|
||||
y = event.y,
|
||||
dx = dx,
|
||||
dy = dy,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Detect swipe gesture
|
||||
---@param touchId string
|
||||
---@param event InputEvent
|
||||
function GestureRecognizer:_detectSwipe(touchId, event)
|
||||
-- Swipe detection happens on touch end
|
||||
end
|
||||
|
||||
--- Detect swipe ended
|
||||
---@param touchId string
|
||||
---@param event InputEvent
|
||||
---@return table? Gesture event
|
||||
function GestureRecognizer:_detectSwipeEnded(touchId, event)
|
||||
local touch = self._touches[touchId]
|
||||
if not touch then
|
||||
return nil
|
||||
end
|
||||
|
||||
local duration = event.timestamp - touch.startTime
|
||||
local dx = event.x - touch.startX
|
||||
local dy = event.y - touch.startY
|
||||
local distance = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
-- Check if it's a valid swipe
|
||||
if distance >= self._config.swipeMinDistance and duration <= self._config.swipeMaxDuration then
|
||||
local velocity = distance / duration
|
||||
|
||||
if velocity >= self._config.swipeMinVelocity then
|
||||
-- Determine swipe direction
|
||||
local angle = math.atan2(dy, dx)
|
||||
local direction = "right"
|
||||
|
||||
if angle >= -math.pi / 4 and angle < math.pi / 4 then
|
||||
direction = "right"
|
||||
elseif angle >= math.pi / 4 and angle < 3 * math.pi / 4 then
|
||||
direction = "down"
|
||||
elseif angle >= -3 * math.pi / 4 and angle < -math.pi / 4 then
|
||||
direction = "up"
|
||||
else
|
||||
direction = "left"
|
||||
end
|
||||
|
||||
return {
|
||||
type = GestureType.SWIPE,
|
||||
state = GestureState.ENDED,
|
||||
x = event.x,
|
||||
y = event.y,
|
||||
dx = dx,
|
||||
dy = dy,
|
||||
direction = direction,
|
||||
velocity = velocity,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Detect pinch gesture
|
||||
---@param event InputEvent
|
||||
---@return table? Gesture event
|
||||
function GestureRecognizer:_detectPinch(event)
|
||||
-- Get two touches for pinch
|
||||
local touches = {}
|
||||
for touchId, touch in pairs(self._touches) do
|
||||
table.insert(touches, { id = touchId, touch = touch })
|
||||
if #touches >= 2 then
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if #touches < 2 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local t1 = touches[1].touch
|
||||
local t2 = touches[2].touch
|
||||
|
||||
-- Calculate current distance
|
||||
local currentDx = t2.x - t1.x
|
||||
local currentDy = t2.y - t1.y
|
||||
local currentDistance = math.sqrt(currentDx * currentDx + currentDy * currentDy)
|
||||
|
||||
-- Calculate initial distance
|
||||
local initialDx = t2.startX - t1.startX
|
||||
local initialDy = t2.startY - t1.startY
|
||||
local initialDistance = math.sqrt(initialDx * initialDx + initialDy * initialDy)
|
||||
|
||||
if initialDistance == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Calculate scale
|
||||
local scale = currentDistance / initialDistance
|
||||
local pinchState = self._gestureStates.pinch
|
||||
|
||||
if not pinchState.active then
|
||||
-- Check if pinch should begin
|
||||
if math.abs(scale - 1.0) >= self._config.pinchMinScaleChange then
|
||||
pinchState.active = true
|
||||
pinchState.initialScale = scale
|
||||
pinchState.lastScale = scale
|
||||
|
||||
-- Calculate center point
|
||||
local centerX = (t1.x + t2.x) / 2
|
||||
local centerY = (t1.y + t2.y) / 2
|
||||
|
||||
return {
|
||||
type = GestureType.PINCH,
|
||||
state = GestureState.BEGAN,
|
||||
scale = scale,
|
||||
centerX = centerX,
|
||||
centerY = centerY,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
else
|
||||
-- Pinch is active, fire changed event
|
||||
local centerX = (t1.x + t2.x) / 2
|
||||
local centerY = (t1.y + t2.y) / 2
|
||||
|
||||
local scaleChange = scale - pinchState.lastScale
|
||||
pinchState.lastScale = scale
|
||||
|
||||
return {
|
||||
type = GestureType.PINCH,
|
||||
state = GestureState.CHANGED,
|
||||
scale = scale,
|
||||
scaleChange = scaleChange,
|
||||
centerX = centerX,
|
||||
centerY = centerY,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Detect rotate gesture
|
||||
---@param event InputEvent
|
||||
---@return table? Gesture event
|
||||
function GestureRecognizer:_detectRotate(event)
|
||||
-- Get two touches for rotation
|
||||
local touches = {}
|
||||
for touchId, touch in pairs(self._touches) do
|
||||
table.insert(touches, { id = touchId, touch = touch })
|
||||
if #touches >= 2 then
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if #touches < 2 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local t1 = touches[1].touch
|
||||
local t2 = touches[2].touch
|
||||
|
||||
-- Calculate current angle
|
||||
local currentAngle = math.atan2(t2.y - t1.y, t2.x - t1.x)
|
||||
|
||||
-- Calculate initial angle
|
||||
local initialAngle = math.atan2(t2.startY - t1.startY, t2.startX - t1.startX)
|
||||
|
||||
-- Calculate rotation (in degrees)
|
||||
local rotation = (currentAngle - initialAngle) * 180 / math.pi
|
||||
|
||||
local rotateState = self._gestureStates.rotate
|
||||
|
||||
if not rotateState.active then
|
||||
-- Check if rotation should begin
|
||||
if math.abs(rotation) >= self._config.rotateMinAngleChange then
|
||||
rotateState.active = true
|
||||
rotateState.initialRotation = rotation
|
||||
rotateState.lastRotation = rotation
|
||||
|
||||
-- Calculate center point
|
||||
local centerX = (t1.x + t2.x) / 2
|
||||
local centerY = (t1.y + t2.y) / 2
|
||||
|
||||
return {
|
||||
type = GestureType.ROTATE,
|
||||
state = GestureState.BEGAN,
|
||||
rotation = rotation,
|
||||
centerX = centerX,
|
||||
centerY = centerY,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
else
|
||||
-- Rotation is active, fire changed event
|
||||
local centerX = (t1.x + t2.x) / 2
|
||||
local centerY = (t1.y + t2.y) / 2
|
||||
|
||||
local rotationChange = rotation - rotateState.lastRotation
|
||||
rotateState.lastRotation = rotation
|
||||
|
||||
return {
|
||||
type = GestureType.ROTATE,
|
||||
state = GestureState.CHANGED,
|
||||
rotation = rotation,
|
||||
rotationChange = rotationChange,
|
||||
centerX = centerX,
|
||||
centerY = centerY,
|
||||
timestamp = event.timestamp,
|
||||
}
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Cancel all active gestures
|
||||
function GestureRecognizer:_cancelAllGestures()
|
||||
for gestureType, state in pairs(self._gestureStates) do
|
||||
if type(state) == "table" and state.active then
|
||||
state.active = false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Reset gesture recognizer state
|
||||
function GestureRecognizer:reset()
|
||||
self._touches = {}
|
||||
self._gestureStates = {
|
||||
tap = nil,
|
||||
doubleTap = { lastTapTime = 0, tapCount = 0 },
|
||||
longPress = {},
|
||||
swipe = {},
|
||||
pan = {},
|
||||
pinch = { active = false },
|
||||
rotate = { active = false },
|
||||
}
|
||||
end
|
||||
|
||||
-- Export gesture types and states
|
||||
GestureRecognizer.GestureType = GestureType
|
||||
GestureRecognizer.GestureState = GestureState
|
||||
|
||||
return GestureRecognizer
|
||||
@@ -0,0 +1,336 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local utils = require(modulePath .. "utils")
|
||||
local enums = utils.enums
|
||||
local Units = require(modulePath .. "Units")
|
||||
|
||||
local Positioning = enums.Positioning
|
||||
local AlignItems = enums.AlignItems
|
||||
|
||||
--- Grid layout with variable column widths / row heights
|
||||
--- Supports px, %, fr, auto, vw, vh, and calc track sizes
|
||||
local Grid = {}
|
||||
|
||||
--- Parse a single track spec into {type, value}
|
||||
--- Uses the Units pipeline for standard CSS units (px, %, vw, vh, calc).
|
||||
--- Grid-specific types (fr, auto) are handled directly.
|
||||
---@param spec number|string Track specification: number (px), string ("100px", "50%", "10vw", "1fr", "auto")
|
||||
---@param availableSize number Container size for % resolution
|
||||
---@param viewportWidth number Viewport width for vw resolution
|
||||
---@param viewportHeight number Viewport height for vh resolution
|
||||
---@return table {type: "px"|"fr"|"auto", value: number}
|
||||
function Grid._parseTrack(spec, availableSize, viewportWidth, viewportHeight)
|
||||
-- Handle calc objects (tables with _isCalc flag from FlexLove.calc())
|
||||
if type(spec) == "table" then
|
||||
local resolved = Units.resolve(spec, "calc", viewportWidth, viewportHeight, availableSize)
|
||||
return { type = "px", value = resolved }
|
||||
end
|
||||
|
||||
if type(spec) == "number" then
|
||||
return { type = "px", value = spec }
|
||||
end
|
||||
|
||||
if type(spec) == "string" then
|
||||
if spec == "auto" then
|
||||
return { type = "auto", value = 0 }
|
||||
end
|
||||
|
||||
-- Check for fr unit (grid-specific, not in Units pipeline)
|
||||
local numStr, unit = spec:match("^([%-]?[%d%.]+)(.*)$")
|
||||
if numStr and unit == "fr" then
|
||||
local num = tonumber(numStr)
|
||||
if num then
|
||||
return { type = "fr", value = num }
|
||||
end
|
||||
end
|
||||
|
||||
-- Delegate all other units to the Units pipeline (px, %, vw, vh, calc)
|
||||
local parsedVal, parsedUnit = Units.parse(spec)
|
||||
local resolved = Units.resolve(parsedVal, parsedUnit, viewportWidth, viewportHeight, availableSize)
|
||||
return { type = "px", value = resolved }
|
||||
end
|
||||
|
||||
-- Default: 1fr
|
||||
return { type = "fr", value = 1 }
|
||||
end
|
||||
|
||||
--- Build track list from gridColumns/gridRows or fall back to equal 1fr tracks
|
||||
---@param spec number|table? Track count (number = equal 1fr tracks) or array of track specs (e.g., {"1fr", "2fr", "100px"})
|
||||
---@param availableSize number Container size for % resolution
|
||||
---@param viewportWidth number Viewport width for vw resolution
|
||||
---@param viewportHeight number Viewport height for vh resolution
|
||||
---@return table Array of {type, value} track descriptors
|
||||
function Grid._buildTracks(spec, availableSize, viewportWidth, viewportHeight)
|
||||
if type(spec) == "table" and #spec > 0 then
|
||||
local tracks = {}
|
||||
for i, s in ipairs(spec) do
|
||||
tracks[i] = Grid._parseTrack(s, availableSize, viewportWidth, viewportHeight)
|
||||
end
|
||||
return tracks
|
||||
end
|
||||
-- Fallback: equal 1fr tracks
|
||||
local count = (type(spec) == "number" and spec > 0) and spec or 1
|
||||
local tracks = {}
|
||||
for i = 1, count do
|
||||
tracks[i] = { type = "fr", value = 1 }
|
||||
end
|
||||
return tracks
|
||||
end
|
||||
|
||||
--- Measure intrinsic content sizes for auto tracks
|
||||
--- Maps children to their tracks and computes each child's max-content contribution.
|
||||
--- For children with explicit dimensions (units unit ~= "auto"), uses the original
|
||||
--- explicit size. For auto-sized children, uses calculated content size.
|
||||
--- Stores the max per auto track. Matches CSS Grid auto sizing where tracks size
|
||||
--- to the max-content contribution of their grid items.
|
||||
---@param tracks table Array of {type, value} track descriptors
|
||||
---@param children table Array of grid child elements
|
||||
---@param axis "width"|"height" Dimension axis to measure
|
||||
function Grid._measureAutoTracks(tracks, children, axis)
|
||||
local trackSizes = {}
|
||||
local numTracks = #tracks
|
||||
|
||||
for i, child in ipairs(children) do
|
||||
local index = i - 1
|
||||
local trackIdx = (index % numTracks) + 1
|
||||
|
||||
local intrinsicSize
|
||||
if axis == "width" then
|
||||
local unit = child.units and child.units.width and child.units.width.unit
|
||||
if unit and unit ~= "auto" then
|
||||
-- Explicit width: use original value + padding (not stretched border-box)
|
||||
intrinsicSize = (child.units.width.value or 0) + child.padding.left + child.padding.right
|
||||
else
|
||||
-- Auto-sized: use calculated content size
|
||||
intrinsicSize = child:calculateAutoWidth()
|
||||
end
|
||||
else
|
||||
local unit = child.units and child.units.height and child.units.height.unit
|
||||
if unit and unit ~= "auto" then
|
||||
intrinsicSize = (child.units.height.value or 0) + child.padding.top + child.padding.bottom
|
||||
else
|
||||
intrinsicSize = child:calculateAutoHeight()
|
||||
end
|
||||
end
|
||||
|
||||
if intrinsicSize > 0 then
|
||||
trackSizes[trackIdx] = math.max(trackSizes[trackIdx] or 0, intrinsicSize)
|
||||
end
|
||||
end
|
||||
|
||||
-- Apply measured sizes to auto tracks
|
||||
for i, track in ipairs(tracks) do
|
||||
if track.type == "auto" and trackSizes[i] then
|
||||
track.value = trackSizes[i]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Resolve track sizes: auto (content) first, then px (fixed), then fr (remaining)
|
||||
--- CSS Grid algorithm:
|
||||
--- 1. auto tracks size to their content (max-content) — measured by _measureAutoTracks
|
||||
--- 2. px tracks consume their fixed size
|
||||
--- 3. fr tracks consume remaining free space proportionally
|
||||
--- 4. If no fr tracks exist, auto tracks share remaining space equally
|
||||
--- Mutates tracks in-place, converting all to {type="px", value=number}
|
||||
---@param tracks table Array of {type, value} track descriptors
|
||||
---@param availableSize number Total space available for tracks
|
||||
---@param gap number Gap between tracks
|
||||
function Grid._resolveTracks(tracks, availableSize, gap)
|
||||
local count = #tracks
|
||||
local totalGaps = (count > 1 and (count - 1) * gap) or 0
|
||||
local remaining = math.max(0, availableSize - totalGaps)
|
||||
|
||||
-- Pass 1: Treat auto tracks as fixed (content-measured) and subtract
|
||||
for _, track in ipairs(tracks) do
|
||||
if track.type == "px" then
|
||||
remaining = remaining - track.value
|
||||
elseif track.type == "auto" then
|
||||
remaining = remaining - math.max(0, track.value)
|
||||
end
|
||||
end
|
||||
|
||||
remaining = math.max(0, remaining)
|
||||
|
||||
-- Pass 2: Count fr shares
|
||||
local totalFr = 0
|
||||
local autoCount = 0
|
||||
for _, track in ipairs(tracks) do
|
||||
if track.type == "fr" then
|
||||
totalFr = totalFr + track.value
|
||||
elseif track.type == "auto" then
|
||||
autoCount = autoCount + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Pass 3: Distribute remaining space
|
||||
if totalFr > 0 then
|
||||
-- fr tracks consume all remaining free space
|
||||
local frUnit = remaining / totalFr
|
||||
for _, track in ipairs(tracks) do
|
||||
if track.type == "fr" then
|
||||
track.value = frUnit * track.value
|
||||
track.type = "px"
|
||||
end
|
||||
end
|
||||
elseif autoCount > 0 then
|
||||
-- No fr tracks: auto tracks share remaining space equally (grow beyond content)
|
||||
local extraPerAuto = math.max(0, remaining) / autoCount
|
||||
for _, track in ipairs(tracks) do
|
||||
if track.type == "auto" then
|
||||
track.value = track.value + extraPerAuto
|
||||
track.type = "px"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Layout grid items within a grid container
|
||||
--- Supports variable column widths and row heights via gridColumns/gridRows (number or track specs)
|
||||
--- Falls back to equal-sized 1fr tracks when nil
|
||||
---@param element Element -- Grid container element
|
||||
function Grid.layoutGridItems(element)
|
||||
-- Calculate space reserved by absolutely positioned siblings
|
||||
local reservedLeft = 0
|
||||
local reservedRight = 0
|
||||
local reservedTop = 0
|
||||
local reservedBottom = 0
|
||||
|
||||
for _, child in ipairs(element.children) do
|
||||
-- Only consider absolutely positioned children with explicit positioning and display != false
|
||||
if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute and child.display ~= false then
|
||||
-- BORDER-BOX MODEL: Use border-box dimensions for space calculations
|
||||
local childBorderBoxWidth = child:getBorderBoxWidth()
|
||||
local childBorderBoxHeight = child:getBorderBoxHeight()
|
||||
|
||||
if child.left then
|
||||
reservedLeft = math.max(reservedLeft, child.left + childBorderBoxWidth)
|
||||
end
|
||||
if child.right then
|
||||
reservedRight = math.max(reservedRight, child.right + childBorderBoxWidth)
|
||||
end
|
||||
if child.top then
|
||||
reservedTop = math.max(reservedTop, child.top + childBorderBoxHeight)
|
||||
end
|
||||
if child.bottom then
|
||||
reservedBottom = math.max(reservedBottom, child.bottom + childBorderBoxHeight)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Calculate available space (accounting for padding and reserved space)
|
||||
-- BORDER-BOX MODEL: element.width and element.height are already content dimensions
|
||||
local availableWidth = math.max(0, element.width - reservedLeft - reservedRight)
|
||||
local availableHeight = math.max(0, element.height - reservedTop - reservedBottom)
|
||||
|
||||
-- Get gaps
|
||||
local columnGap = element.columnGap or 0
|
||||
local rowGap = element.rowGap or 0
|
||||
|
||||
-- Collect grid children (exclude explicitly absolute and display=false)
|
||||
local gridChildren = {}
|
||||
for _, child in ipairs(element.children) do
|
||||
if not (child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute) and child.display ~= false then
|
||||
table.insert(gridChildren, child)
|
||||
end
|
||||
end
|
||||
|
||||
-- Get viewport dimensions for unit resolution (vw, vh, %)
|
||||
local vpw, vph = Units.getViewport()
|
||||
|
||||
-- Build tracks, measure auto tracks by content, then resolve sizes
|
||||
local colTracks = Grid._buildTracks(element.gridColumns, availableWidth, vpw, vph)
|
||||
local rowTracks = Grid._buildTracks(element.gridRows, availableHeight, vpw, vph)
|
||||
|
||||
Grid._measureAutoTracks(colTracks, gridChildren, "width")
|
||||
Grid._measureAutoTracks(rowTracks, gridChildren, "height")
|
||||
|
||||
Grid._resolveTracks(colTracks, availableWidth, columnGap)
|
||||
Grid._resolveTracks(rowTracks, availableHeight, rowGap)
|
||||
|
||||
-- Compute column start positions (for positioning)
|
||||
local colStarts = {}
|
||||
local currentX = element.x + element.padding.left + reservedLeft
|
||||
for col = 1, #colTracks do
|
||||
colStarts[col] = currentX
|
||||
currentX = currentX + colTracks[col].value + columnGap
|
||||
end
|
||||
|
||||
local rowStarts = {}
|
||||
local currentY = element.y + element.padding.top + reservedTop
|
||||
for row = 1, #rowTracks do
|
||||
rowStarts[row] = currentY
|
||||
currentY = currentY + rowTracks[row].value + rowGap
|
||||
end
|
||||
|
||||
local effectiveAlignItems = element.alignItems or AlignItems.STRETCH
|
||||
|
||||
for i, child in ipairs(gridChildren) do
|
||||
-- Calculate row and column (0-indexed for calculation)
|
||||
local index = i - 1
|
||||
local col = index % #colTracks
|
||||
local row = math.floor(index / #colTracks)
|
||||
|
||||
if row >= #rowTracks then
|
||||
break
|
||||
end
|
||||
|
||||
-- Get resolved cell position and size
|
||||
local colIdx = col + 1
|
||||
local rowIdx = row + 1
|
||||
local cellX = colStarts[colIdx]
|
||||
local cellY = rowStarts[rowIdx]
|
||||
local cellWidth = colTracks[colIdx].value
|
||||
local cellHeight = rowTracks[rowIdx].value
|
||||
|
||||
-- Apply alignment within grid cell (default to stretch)
|
||||
-- BORDER-BOX MODEL: Set border-box dimensions, content area adjusts automatically
|
||||
if effectiveAlignItems == AlignItems.STRETCH or effectiveAlignItems == "stretch" then
|
||||
child.x = cellX
|
||||
child.y = cellY
|
||||
child._borderBoxWidth = cellWidth
|
||||
child._borderBoxHeight = cellHeight
|
||||
child.width = math.max(0, cellWidth - child.padding.left - child.padding.right)
|
||||
child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom)
|
||||
-- Disable auto-sizing when stretched by grid
|
||||
child.autosizing.width = false
|
||||
child.autosizing.height = false
|
||||
elseif effectiveAlignItems == AlignItems.CENTER or effectiveAlignItems == "center" then
|
||||
local childBorderBoxWidth = child:getBorderBoxWidth()
|
||||
local childBorderBoxHeight = child:getBorderBoxHeight()
|
||||
child.x = cellX + (cellWidth - childBorderBoxWidth) / 2
|
||||
child.y = cellY + (cellHeight - childBorderBoxHeight) / 2
|
||||
elseif
|
||||
effectiveAlignItems == AlignItems.FLEX_START
|
||||
or effectiveAlignItems == "flex-start"
|
||||
or effectiveAlignItems == "start"
|
||||
then
|
||||
child.x = cellX
|
||||
child.y = cellY
|
||||
elseif
|
||||
effectiveAlignItems == AlignItems.FLEX_END
|
||||
or effectiveAlignItems == "flex-end"
|
||||
or effectiveAlignItems == "end"
|
||||
then
|
||||
local childBorderBoxWidth = child:getBorderBoxWidth()
|
||||
local childBorderBoxHeight = child:getBorderBoxHeight()
|
||||
child.x = cellX + cellWidth - childBorderBoxWidth
|
||||
child.y = cellY + cellHeight - childBorderBoxHeight
|
||||
else
|
||||
child.x = cellX
|
||||
child.y = cellY
|
||||
child._borderBoxWidth = cellWidth
|
||||
child._borderBoxHeight = cellHeight
|
||||
child.width = math.max(0, cellWidth - child.padding.left - child.padding.right)
|
||||
child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom)
|
||||
-- Disable auto-sizing when stretched by grid
|
||||
child.autosizing.width = false
|
||||
child.autosizing.height = false
|
||||
end
|
||||
|
||||
if #child.children > 0 then
|
||||
child:layoutChildren()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return Grid
|
||||
@@ -0,0 +1,160 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
local utils = req("utils")
|
||||
|
||||
-- ErrorHandler will be injected via init
|
||||
local ErrorHandler = nil
|
||||
|
||||
---@class ImageCache
|
||||
---@field _cache table<string, {image: love.Image, imageData: love.ImageData?}>
|
||||
local ImageCache = {}
|
||||
ImageCache._cache = {}
|
||||
|
||||
--- Initialize ImageCache with dependencies
|
||||
---@param deps table Dependencies table with ErrorHandler
|
||||
function ImageCache.init(deps)
|
||||
if deps and deps.ErrorHandler then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
end
|
||||
|
||||
--- Load an image from file path with caching
|
||||
--- Returns cached image if already loaded, otherwise loads and caches it
|
||||
---@param imagePath string -- Path to image file
|
||||
---@param loadImageData boolean? -- Optional: also load ImageData for pixel access (default: false)
|
||||
---@return love.Image|nil -- Image object or nil on error
|
||||
---@return string|nil -- Error message if loading failed
|
||||
function ImageCache.load(imagePath, loadImageData)
|
||||
if not imagePath or type(imagePath) ~= "string" or imagePath == "" then
|
||||
return nil, "Invalid image path: path must be a non-empty string"
|
||||
end
|
||||
|
||||
local normalizedPath = utils.normalizePath(imagePath)
|
||||
|
||||
if ImageCache._cache[normalizedPath] then
|
||||
return ImageCache._cache[normalizedPath].image, nil
|
||||
end
|
||||
|
||||
local success, imageOrError = pcall(love.graphics.newImage, normalizedPath)
|
||||
if not success then
|
||||
if ErrorHandler then
|
||||
ErrorHandler:warn("ImageCache", "RES_004", {
|
||||
resourceType = "image",
|
||||
path = imagePath,
|
||||
error = tostring(imageOrError),
|
||||
})
|
||||
end
|
||||
return nil, string.format("Failed to load image '%s': %s", imagePath, tostring(imageOrError))
|
||||
end
|
||||
|
||||
local image = imageOrError
|
||||
local imgData = nil
|
||||
|
||||
if loadImageData then
|
||||
local dataSuccess, dataOrError = pcall(love.image.newImageData, normalizedPath)
|
||||
if dataSuccess then
|
||||
imgData = dataOrError
|
||||
elseif ErrorHandler then
|
||||
ErrorHandler:warn("ImageCache", "RES_004", {
|
||||
resourceType = "image data",
|
||||
path = imagePath,
|
||||
error = tostring(dataOrError),
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
ImageCache._cache[normalizedPath] = {
|
||||
image = image,
|
||||
imageData = imgData,
|
||||
}
|
||||
|
||||
return image, nil
|
||||
end
|
||||
|
||||
--- Get a cached image without loading
|
||||
---@param imagePath string -- Path to image file
|
||||
---@return love.Image|nil -- Cached image or nil if not found
|
||||
function ImageCache.get(imagePath)
|
||||
if not imagePath or type(imagePath) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local normalizedPath = utils.normalizePath(imagePath)
|
||||
local cached = ImageCache._cache[normalizedPath]
|
||||
return cached and cached.image or nil
|
||||
end
|
||||
|
||||
--- Get cached ImageData for an image
|
||||
---@param imagePath string -- Path to image file
|
||||
---@return love.ImageData|nil -- Cached ImageData or nil if not found
|
||||
function ImageCache.getImageData(imagePath)
|
||||
if not imagePath or type(imagePath) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local normalizedPath = utils.normalizePath(imagePath)
|
||||
local cached = ImageCache._cache[normalizedPath]
|
||||
return cached and cached.imageData or nil
|
||||
end
|
||||
|
||||
--- Remove a specific image from cache
|
||||
---@param imagePath string -- Path to image file to remove
|
||||
---@return boolean -- True if image was removed, false if not found
|
||||
function ImageCache.remove(imagePath)
|
||||
if not imagePath or type(imagePath) ~= "string" then
|
||||
return false
|
||||
end
|
||||
|
||||
local normalizedPath = utils.normalizePath(imagePath)
|
||||
if ImageCache._cache[normalizedPath] then
|
||||
local cached = ImageCache._cache[normalizedPath]
|
||||
if cached.image then
|
||||
cached.image:release()
|
||||
end
|
||||
if cached.imageData then
|
||||
cached.imageData:release()
|
||||
end
|
||||
ImageCache._cache[normalizedPath] = nil
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
--- Clear all cached images
|
||||
function ImageCache.clear()
|
||||
for path, cached in pairs(ImageCache._cache) do
|
||||
if cached.image then
|
||||
cached.image:release()
|
||||
end
|
||||
if cached.imageData then
|
||||
cached.imageData:release()
|
||||
end
|
||||
end
|
||||
ImageCache._cache = {}
|
||||
end
|
||||
|
||||
--- Get cache statistics
|
||||
---@return {count: number, memoryEstimate: number} -- Cache stats
|
||||
function ImageCache.getStats()
|
||||
local count = 0
|
||||
local memoryEstimate = 0
|
||||
|
||||
for path, cached in pairs(ImageCache._cache) do
|
||||
count = count + 1
|
||||
if cached.image then
|
||||
local w, h = cached.image:getDimensions()
|
||||
-- Estimate: 4 bytes per pixel (RGBA)
|
||||
memoryEstimate = memoryEstimate + (w * h * 4)
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
count = count,
|
||||
memoryEstimate = memoryEstimate,
|
||||
}
|
||||
end
|
||||
|
||||
return ImageCache
|
||||
@@ -0,0 +1,380 @@
|
||||
---@class ImageRenderer
|
||||
local ImageRenderer = {}
|
||||
|
||||
-- ErrorHandler and utils will be injected via init
|
||||
local ErrorHandler = nil
|
||||
local utils = nil
|
||||
|
||||
--- Initialize ImageRenderer with dependencies
|
||||
---@param deps table Dependencies table with ErrorHandler and utils
|
||||
function ImageRenderer.init(deps)
|
||||
if deps and deps.ErrorHandler then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
if deps and deps.utils then
|
||||
utils = deps.utils
|
||||
end
|
||||
end
|
||||
|
||||
--- Calculate rendering parameters for object-fit modes
|
||||
--- Returns source and destination rectangles for rendering
|
||||
---@param imageWidth number -- Natural width of the image
|
||||
---@param imageHeight number -- Natural height of the image
|
||||
---@param boundsWidth number -- Width of the bounds to fit within
|
||||
---@param boundsHeight number -- Height of the bounds to fit within
|
||||
---@param fitMode string? -- One of: "fill", "contain", "cover", "scale-down", "none" (default: "fill")
|
||||
---@param objectPosition string? -- Position like "center center", "top left", "50% 50%" (default: "center center")
|
||||
---@return {sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number, scaleX: number, scaleY: number}
|
||||
function ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, fitMode, objectPosition)
|
||||
fitMode = fitMode or "fill"
|
||||
objectPosition = objectPosition or "center center"
|
||||
|
||||
if imageWidth <= 0 or imageHeight <= 0 or boundsWidth <= 0 or boundsHeight <= 0 then
|
||||
ErrorHandler:error("ImageRenderer", "VAL_002", {
|
||||
imageWidth = imageWidth,
|
||||
imageHeight = imageHeight,
|
||||
boundsWidth = boundsWidth,
|
||||
boundsHeight = boundsHeight,
|
||||
})
|
||||
end
|
||||
|
||||
local result = {
|
||||
sx = 0, -- Source X
|
||||
sy = 0, -- Source Y
|
||||
sw = imageWidth, -- Source width
|
||||
sh = imageHeight, -- Source height
|
||||
dx = 0, -- Destination X
|
||||
dy = 0, -- Destination Y
|
||||
dw = boundsWidth, -- Destination width
|
||||
dh = boundsHeight, -- Destination height
|
||||
scaleX = 1, -- Scale factor X
|
||||
scaleY = 1, -- Scale factor Y
|
||||
}
|
||||
|
||||
if fitMode == "fill" then
|
||||
-- Stretch to fill bounds (may distort)
|
||||
result.scaleX = boundsWidth / imageWidth
|
||||
result.scaleY = boundsHeight / imageHeight
|
||||
result.dw = boundsWidth
|
||||
result.dh = boundsHeight
|
||||
elseif fitMode == "contain" then
|
||||
-- Scale to fit within bounds (preserves aspect ratio)
|
||||
local scale = math.min(boundsWidth / imageWidth, boundsHeight / imageHeight)
|
||||
result.scaleX = scale
|
||||
result.scaleY = scale
|
||||
result.dw = imageWidth * scale
|
||||
result.dh = imageHeight * scale
|
||||
|
||||
-- Apply object-position for letterbox alignment
|
||||
local posX, posY = ImageRenderer._parsePosition(objectPosition)
|
||||
result.dx = (boundsWidth - result.dw) * posX
|
||||
result.dy = (boundsHeight - result.dh) * posY
|
||||
elseif fitMode == "cover" then
|
||||
-- Scale to cover bounds (preserves aspect ratio, may crop)
|
||||
local scale = math.max(boundsWidth / imageWidth, boundsHeight / imageHeight)
|
||||
result.scaleX = scale
|
||||
result.scaleY = scale
|
||||
|
||||
local scaledWidth = imageWidth * scale
|
||||
local scaledHeight = imageHeight * scale
|
||||
|
||||
-- Apply object-position for crop alignment
|
||||
local posX, posY = ImageRenderer._parsePosition(objectPosition)
|
||||
|
||||
-- Calculate which part of the scaled image to show
|
||||
local cropX = (scaledWidth - boundsWidth) * posX
|
||||
local cropY = (scaledHeight - boundsHeight) * posY
|
||||
|
||||
-- Convert back to source coordinates
|
||||
result.sx = cropX / scale
|
||||
result.sy = cropY / scale
|
||||
result.sw = boundsWidth / scale
|
||||
result.sh = boundsHeight / scale
|
||||
|
||||
result.dx = 0
|
||||
result.dy = 0
|
||||
result.dw = boundsWidth
|
||||
result.dh = boundsHeight
|
||||
elseif fitMode == "none" then
|
||||
-- Use natural size (no scaling)
|
||||
result.scaleX = 1
|
||||
result.scaleY = 1
|
||||
result.dw = imageWidth
|
||||
result.dh = imageHeight
|
||||
|
||||
-- Apply object-position
|
||||
local posX, posY = ImageRenderer._parsePosition(objectPosition)
|
||||
result.dx = (boundsWidth - imageWidth) * posX
|
||||
result.dy = (boundsHeight - imageHeight) * posY
|
||||
elseif fitMode == "scale-down" then
|
||||
-- Use none or contain, whichever is smaller
|
||||
if imageWidth <= boundsWidth and imageHeight <= boundsHeight then
|
||||
-- Image fits naturally, use "none"
|
||||
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "none", objectPosition)
|
||||
else
|
||||
-- Image too large, use "contain"
|
||||
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "contain", objectPosition)
|
||||
end
|
||||
else
|
||||
ErrorHandler:warn("ImageRenderer", "VAL_007", {
|
||||
fitMode = fitMode,
|
||||
fallback = "fill",
|
||||
})
|
||||
-- Use 'fill' as fallback
|
||||
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "fill", objectPosition)
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
--- Parse object-position string into normalized coordinates (0-1)
|
||||
--- Supports keywords (center, top, bottom, left, right) and percentages
|
||||
---@param position string -- Position string like "center center", "top left", "50% 50%"
|
||||
---@return number, number -- Normalized X and Y positions (0-1)
|
||||
function ImageRenderer._parsePosition(position)
|
||||
if not position or type(position) ~= "string" then
|
||||
return 0.5, 0.5 -- Default to center
|
||||
end
|
||||
|
||||
-- Split into X and Y components
|
||||
local parts = {}
|
||||
for part in position:gmatch("%S+") do
|
||||
table.insert(parts, part:lower())
|
||||
end
|
||||
|
||||
-- If only one value, use it for both axes (with special handling)
|
||||
if #parts == 1 then
|
||||
local val = parts[1]
|
||||
if val == "left" or val == "right" then
|
||||
parts = { val, "center" }
|
||||
elseif val == "top" or val == "bottom" then
|
||||
parts = { "center", val }
|
||||
else
|
||||
parts = { val, val }
|
||||
end
|
||||
elseif #parts == 0 then
|
||||
return 0.5, 0.5 -- Default to center
|
||||
end
|
||||
|
||||
local function parseValue(val)
|
||||
-- Handle keywords
|
||||
if val == "center" then
|
||||
return 0.5
|
||||
elseif val == "left" or val == "top" then
|
||||
return 0
|
||||
elseif val == "right" or val == "bottom" then
|
||||
return 1
|
||||
end
|
||||
|
||||
-- Handle percentages
|
||||
local percent = val:match("^([%d%.]+)%%$")
|
||||
if percent then
|
||||
return tonumber(percent) / 100
|
||||
end
|
||||
|
||||
-- Handle plain numbers (treat as percentage)
|
||||
local num = tonumber(val)
|
||||
if num then
|
||||
return num / 100
|
||||
end
|
||||
|
||||
-- Invalid value, default to center
|
||||
return 0.5
|
||||
end
|
||||
|
||||
local x = parseValue(parts[1])
|
||||
local y = parseValue(parts[2] or parts[1])
|
||||
|
||||
-- Clamp to 0-1 range
|
||||
x = math.max(0, math.min(1, x))
|
||||
y = math.max(0, math.min(1, y))
|
||||
|
||||
return x, y
|
||||
end
|
||||
|
||||
--- Draw an image with specified object-fit mode
|
||||
---@param image love.Image -- Image to draw
|
||||
---@param x number -- X position of bounds
|
||||
---@param y number -- Y position of bounds
|
||||
---@param width number -- Width of bounds
|
||||
---@param height number -- Height of bounds
|
||||
---@param fitMode string? -- Object-fit mode (default: "fill")
|
||||
---@param objectPosition string? -- Object-position (default: "center center")
|
||||
---@param opacity number? -- Opacity 0-1 (default: 1)
|
||||
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
|
||||
function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, opacity, tintColor)
|
||||
if not image then
|
||||
return -- Nothing to draw
|
||||
end
|
||||
|
||||
opacity = opacity or 1
|
||||
fitMode = fitMode or "fill"
|
||||
objectPosition = objectPosition or "center center"
|
||||
|
||||
local imgWidth, imgHeight = image:getDimensions()
|
||||
local params = ImageRenderer.calculateFit(imgWidth, imgHeight, width, height, fitMode, objectPosition)
|
||||
|
||||
-- Save current color
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
|
||||
-- Apply opacity and tint
|
||||
if tintColor then
|
||||
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, opacity)
|
||||
end
|
||||
|
||||
-- Draw image
|
||||
if params.sx ~= 0 or params.sy ~= 0 or params.sw ~= imgWidth or params.sh ~= imgHeight then
|
||||
-- Need to use a quad for cropping
|
||||
local quad = love.graphics.newQuad(params.sx, params.sy, params.sw, params.sh, imgWidth, imgHeight)
|
||||
love.graphics.draw(image, quad, x + params.dx, y + params.dy, 0, params.dw / params.sw, params.dh / params.sh)
|
||||
else
|
||||
-- Simple draw with scaling
|
||||
love.graphics.draw(image, x + params.dx, y + params.dy, 0, params.scaleX, params.scaleY)
|
||||
end
|
||||
|
||||
-- Restore color
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
end
|
||||
|
||||
--- Draw an image with tiling/repeat mode
|
||||
---@param image love.Image -- Image to draw
|
||||
---@param x number -- X position of bounds
|
||||
---@param y number -- Y position of bounds
|
||||
---@param width number -- Width of bounds
|
||||
---@param height number -- Height of bounds
|
||||
---@param repeatMode string? -- Repeat mode: "repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round" (default: "no-repeat")
|
||||
---@param opacity number? -- Opacity 0-1 (default: 1)
|
||||
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
|
||||
function ImageRenderer.drawTiled(image, x, y, width, height, repeatMode, opacity, tintColor)
|
||||
if not image then
|
||||
return -- Nothing to draw
|
||||
end
|
||||
|
||||
opacity = opacity or 1
|
||||
repeatMode = repeatMode or "no-repeat"
|
||||
|
||||
local imgWidth, imgHeight = image:getDimensions()
|
||||
|
||||
-- Save current color
|
||||
local r, g, b, a = love.graphics.getColor()
|
||||
|
||||
-- Apply opacity and tint
|
||||
if tintColor then
|
||||
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
|
||||
else
|
||||
love.graphics.setColor(1, 1, 1, opacity)
|
||||
end
|
||||
|
||||
if repeatMode == "no-repeat" then
|
||||
-- Just draw once, no tiling
|
||||
love.graphics.draw(image, x, y)
|
||||
elseif repeatMode == "repeat" then
|
||||
-- Tile in both directions
|
||||
local tilesX = math.ceil(width / imgWidth)
|
||||
local tilesY = math.ceil(height / imgHeight)
|
||||
|
||||
for tileY = 0, tilesY - 1 do
|
||||
for tileX = 0, tilesX - 1 do
|
||||
local drawX = x + (tileX * imgWidth)
|
||||
local drawY = y + (tileY * imgHeight)
|
||||
|
||||
-- Calculate how much of the tile to draw (for partial tiles at edges)
|
||||
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
|
||||
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
|
||||
|
||||
if drawWidth < imgWidth or drawHeight < imgHeight then
|
||||
-- Use quad for partial tile
|
||||
local quad = love.graphics.newQuad(0, 0, drawWidth, drawHeight, imgWidth, imgHeight)
|
||||
love.graphics.draw(image, quad, drawX, drawY)
|
||||
else
|
||||
-- Draw full tile
|
||||
love.graphics.draw(image, drawX, drawY)
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif repeatMode == "repeat-x" then
|
||||
-- Tile horizontally only
|
||||
local tilesX = math.ceil(width / imgWidth)
|
||||
|
||||
for tileX = 0, tilesX - 1 do
|
||||
local drawX = x + (tileX * imgWidth)
|
||||
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
|
||||
|
||||
if drawWidth < imgWidth then
|
||||
-- Use quad for partial tile
|
||||
local quad = love.graphics.newQuad(0, 0, drawWidth, imgHeight, imgWidth, imgHeight)
|
||||
love.graphics.draw(image, quad, drawX, y)
|
||||
else
|
||||
-- Draw full tile
|
||||
love.graphics.draw(image, drawX, y)
|
||||
end
|
||||
end
|
||||
elseif repeatMode == "repeat-y" then
|
||||
-- Tile vertically only
|
||||
local tilesY = math.ceil(height / imgHeight)
|
||||
|
||||
for tileY = 0, tilesY - 1 do
|
||||
local drawY = y + (tileY * imgHeight)
|
||||
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
|
||||
|
||||
if drawHeight < imgHeight then
|
||||
-- Use quad for partial tile
|
||||
local quad = love.graphics.newQuad(0, 0, imgWidth, drawHeight, imgWidth, imgHeight)
|
||||
love.graphics.draw(image, quad, x, drawY)
|
||||
else
|
||||
-- Draw full tile
|
||||
love.graphics.draw(image, x, drawY)
|
||||
end
|
||||
end
|
||||
elseif repeatMode == "space" then
|
||||
-- Distribute tiles with even spacing
|
||||
local tilesX = math.floor(width / imgWidth)
|
||||
local tilesY = math.floor(height / imgHeight)
|
||||
|
||||
if tilesX < 1 then
|
||||
tilesX = 1
|
||||
end
|
||||
if tilesY < 1 then
|
||||
tilesY = 1
|
||||
end
|
||||
|
||||
local spaceX = tilesX > 1 and (width - (tilesX * imgWidth)) / (tilesX - 1) or 0
|
||||
local spaceY = tilesY > 1 and (height - (tilesY * imgHeight)) / (tilesY - 1) or 0
|
||||
|
||||
for tileY = 0, tilesY - 1 do
|
||||
for tileX = 0, tilesX - 1 do
|
||||
local drawX = x + (tileX * (imgWidth + spaceX))
|
||||
local drawY = y + (tileY * (imgHeight + spaceY))
|
||||
love.graphics.draw(image, drawX, drawY)
|
||||
end
|
||||
end
|
||||
elseif repeatMode == "round" then
|
||||
-- Scale tiles to fit bounds exactly
|
||||
local tilesX = math.max(1, utils.round(width / imgWidth))
|
||||
local tilesY = math.max(1, utils.round(height / imgHeight))
|
||||
|
||||
local scaleX = width / (tilesX * imgWidth)
|
||||
local scaleY = height / (tilesY * imgHeight)
|
||||
|
||||
for tileY = 0, tilesY - 1 do
|
||||
for tileX = 0, tilesX - 1 do
|
||||
local drawX = x + (tileX * imgWidth * scaleX)
|
||||
local drawY = y + (tileY * imgHeight * scaleY)
|
||||
love.graphics.draw(image, drawX, drawY, 0, scaleX, scaleY)
|
||||
end
|
||||
end
|
||||
else
|
||||
ErrorHandler:warn("ImageRenderer", "VAL_007", {
|
||||
repeatMode = repeatMode,
|
||||
fallback = "no-repeat",
|
||||
})
|
||||
love.graphics.draw(image, x, y)
|
||||
end
|
||||
|
||||
-- Restore color
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
end
|
||||
|
||||
return ImageRenderer
|
||||
@@ -0,0 +1,174 @@
|
||||
-- ====================
|
||||
-- ImageScaler
|
||||
-- ====================
|
||||
|
||||
local ImageScaler = {}
|
||||
|
||||
-- ErrorHandler will be injected via init
|
||||
local ErrorHandler = nil
|
||||
|
||||
--- Initialize ImageScaler with dependencies
|
||||
---@param deps table Dependencies table with ErrorHandler
|
||||
function ImageScaler.init(deps)
|
||||
if deps and deps.ErrorHandler then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
end
|
||||
|
||||
--- Scale an ImageData region using nearest-neighbor sampling
|
||||
--- Produces sharp, pixelated scaling - ideal for pixel art
|
||||
---@param sourceImageData love.ImageData -- Source image data
|
||||
---@param srcX number -- Source region X (0-based)
|
||||
---@param srcY number -- Source region Y (0-based)
|
||||
---@param srcW number -- Source region width
|
||||
---@param srcH number -- Source region height
|
||||
---@param destW number -- Destination width
|
||||
---@param destH number -- Destination height
|
||||
---@return love.ImageData -- Scaled image data
|
||||
function ImageScaler.scaleNearest(sourceImageData, srcX, srcY, srcW, srcH, destW, destH)
|
||||
if not sourceImageData then
|
||||
ErrorHandler:error("ImageScaler", "VAL_001", {
|
||||
parameter = "sourceImageData",
|
||||
})
|
||||
end
|
||||
|
||||
if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then
|
||||
ErrorHandler:warn("ImageScaler", "VAL_002", {
|
||||
srcW = srcW,
|
||||
srcH = srcH,
|
||||
destW = destW,
|
||||
destH = destH,
|
||||
fallback = "1x1 transparent image",
|
||||
})
|
||||
-- Return a minimal 1x1 transparent image as fallback
|
||||
local fallbackImageData = love.image.newImageData(1, 1)
|
||||
fallbackImageData:setPixel(0, 0, 0, 0, 0, 0)
|
||||
return fallbackImageData
|
||||
end
|
||||
|
||||
-- Create destination ImageData
|
||||
local destImageData = love.image.newImageData(destW, destH)
|
||||
|
||||
-- Calculate scale ratios (cached outside loops for performance)
|
||||
local scaleX = srcW / destW
|
||||
local scaleY = srcH / destH
|
||||
|
||||
-- Nearest-neighbor sampling
|
||||
for destY = 0, destH - 1 do
|
||||
for destX = 0, destW - 1 do
|
||||
-- Calculate source pixel coordinates using floor (nearest-neighbor)
|
||||
local srcPixelX = math.floor(destX * scaleX) + srcX
|
||||
local srcPixelY = math.floor(destY * scaleY) + srcY
|
||||
|
||||
-- Clamp to source bounds (safety check)
|
||||
srcPixelX = math.min(srcPixelX, srcX + srcW - 1)
|
||||
srcPixelY = math.min(srcPixelY, srcY + srcH - 1)
|
||||
|
||||
-- Sample source pixel
|
||||
local r, g, b, a = sourceImageData:getPixel(srcPixelX, srcPixelY)
|
||||
|
||||
-- Write to destination
|
||||
destImageData:setPixel(destX, destY, r, g, b, a)
|
||||
end
|
||||
end
|
||||
|
||||
return destImageData
|
||||
end
|
||||
|
||||
--- Linear interpolation helper
|
||||
--- Blends between two values based on interpolation factor
|
||||
---@param a number -- Start value
|
||||
---@param b number -- End value
|
||||
---@param t number -- Interpolation factor [0, 1]
|
||||
---@return number -- Interpolated value
|
||||
local function lerp(a, b, t)
|
||||
return a + (b - a) * t
|
||||
end
|
||||
|
||||
--- Scale an ImageData region using bilinear interpolation
|
||||
--- Produces smooth, filtered scaling - ideal for high-quality upscaling
|
||||
---@param sourceImageData love.ImageData -- Source image data
|
||||
---@param srcX number -- Source region X (0-based)
|
||||
---@param srcY number -- Source region Y (0-based)
|
||||
---@param srcW number -- Source region width
|
||||
---@param srcH number -- Source region height
|
||||
---@param destW number -- Destination width
|
||||
---@param destH number -- Destination height
|
||||
---@return love.ImageData -- Scaled image data
|
||||
function ImageScaler.scaleBilinear(sourceImageData, srcX, srcY, srcW, srcH, destW, destH)
|
||||
if not sourceImageData then
|
||||
ErrorHandler:error("ImageScaler", "VAL_001", {
|
||||
parameter = "sourceImageData",
|
||||
})
|
||||
end
|
||||
|
||||
if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then
|
||||
ErrorHandler:warn("ImageScaler", "VAL_002", {
|
||||
srcW = srcW,
|
||||
srcH = srcH,
|
||||
destW = destW,
|
||||
destH = destH,
|
||||
fallback = "1x1 transparent image",
|
||||
})
|
||||
-- Return a minimal 1x1 transparent image as fallback
|
||||
local fallbackImageData = love.image.newImageData(1, 1)
|
||||
fallbackImageData:setPixel(0, 0, 0, 0, 0, 0)
|
||||
return fallbackImageData
|
||||
end
|
||||
|
||||
-- Create destination ImageData
|
||||
local destImageData = love.image.newImageData(destW, destH)
|
||||
|
||||
-- Calculate scale ratios
|
||||
local scaleX = srcW / destW
|
||||
local scaleY = srcH / destH
|
||||
|
||||
-- Bilinear interpolation
|
||||
for destY = 0, destH - 1 do
|
||||
for destX = 0, destW - 1 do
|
||||
-- Calculate fractional source position
|
||||
local srcXf = destX * scaleX
|
||||
local srcYf = destY * scaleY
|
||||
|
||||
-- Get integer coordinates for 2x2 sampling grid
|
||||
local x0 = math.floor(srcXf)
|
||||
local y0 = math.floor(srcYf)
|
||||
local x1 = math.min(x0 + 1, srcW - 1)
|
||||
local y1 = math.min(y0 + 1, srcH - 1)
|
||||
|
||||
-- Get fractional parts for interpolation
|
||||
local fx = srcXf - x0
|
||||
local fy = srcYf - y0
|
||||
|
||||
-- Sample 4 neighboring pixels (with source offset)
|
||||
local r00, g00, b00, a00 = sourceImageData:getPixel(srcX + x0, srcY + y0)
|
||||
local r10, g10, b10, a10 = sourceImageData:getPixel(srcX + x1, srcY + y0)
|
||||
local r01, g01, b01, a01 = sourceImageData:getPixel(srcX + x0, srcY + y1)
|
||||
local r11, g11, b11, a11 = sourceImageData:getPixel(srcX + x1, srcY + y1)
|
||||
|
||||
-- Interpolate horizontally (top and bottom rows)
|
||||
local rTop = lerp(r00, r10, fx)
|
||||
local gTop = lerp(g00, g10, fx)
|
||||
local bTop = lerp(b00, b10, fx)
|
||||
local aTop = lerp(a00, a10, fx)
|
||||
|
||||
local rBottom = lerp(r01, r11, fx)
|
||||
local gBottom = lerp(g01, g11, fx)
|
||||
local bBottom = lerp(b01, b11, fx)
|
||||
local aBottom = lerp(a01, a11, fx)
|
||||
|
||||
-- Interpolate vertically (final result)
|
||||
local r = lerp(rTop, rBottom, fy)
|
||||
local g = lerp(gTop, gBottom, fy)
|
||||
local b = lerp(bTop, bBottom, fy)
|
||||
local a = lerp(aTop, aBottom, fy)
|
||||
|
||||
-- Write to destination
|
||||
destImageData:setPixel(destX, destY, r, g, b, a)
|
||||
end
|
||||
end
|
||||
|
||||
return destImageData
|
||||
end
|
||||
|
||||
return ImageScaler
|
||||
@@ -0,0 +1,88 @@
|
||||
---@class InputEvent
|
||||
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel"
|
||||
---@field button number -- Mouse button: 1 (left), 2 (right), 3 (middle)
|
||||
---@field x number -- Mouse/Touch X position
|
||||
---@field y number -- Mouse/Touch Y position
|
||||
---@field dx number? -- Delta X from drag/touch start (only for drag/touch events)
|
||||
---@field dy number? -- Delta Y from drag/touch start (only for drag/touch events)
|
||||
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
|
||||
---@field clickCount number -- Number of clicks (for double/triple click detection)
|
||||
---@field timestamp number -- Time when event occurred
|
||||
---@field touchId string? -- Touch identifier (for multi-touch)
|
||||
---@field pressure number? -- Touch pressure (0-1, defaults to 1.0)
|
||||
---@field phase string? -- Touch phase: "began", "moved", "ended", "cancelled"
|
||||
local InputEvent = {}
|
||||
InputEvent.__index = InputEvent
|
||||
|
||||
---@class InputEventProps
|
||||
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel"
|
||||
---@field button number
|
||||
---@field x number
|
||||
---@field y number
|
||||
---@field dx number?
|
||||
---@field dy number?
|
||||
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
|
||||
---@field clickCount number?
|
||||
---@field timestamp number?
|
||||
---@field touchId string?
|
||||
---@field pressure number?
|
||||
---@field phase string?
|
||||
|
||||
--- Create a new input event
|
||||
---@param props InputEventProps
|
||||
---@return InputEvent
|
||||
function InputEvent.new(props)
|
||||
local self = setmetatable({}, InputEvent)
|
||||
self.type = props.type
|
||||
self.button = props.button
|
||||
self.x = props.x
|
||||
self.y = props.y
|
||||
self.dx = props.dx
|
||||
self.dy = props.dy
|
||||
self.modifiers = props.modifiers
|
||||
self.clickCount = props.clickCount or 1
|
||||
self.timestamp = props.timestamp or love.timer.getTime()
|
||||
|
||||
-- Touch-specific properties
|
||||
self.touchId = props.touchId
|
||||
self.pressure = props.pressure or 1.0
|
||||
self.phase = props.phase
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
--- Create an InputEvent from LÖVE touch data
|
||||
---@param id userdata Touch ID from LÖVE
|
||||
---@param x number Touch X position
|
||||
---@param y number Touch Y position
|
||||
---@param phase string Touch phase: "began", "moved", "ended", "cancelled"
|
||||
---@param pressure number? Touch pressure (0-1, defaults to 1.0)
|
||||
---@return InputEvent
|
||||
function InputEvent.fromTouch(id, x, y, phase, pressure)
|
||||
local touchIdStr = tostring(id)
|
||||
local eventType = "touchpress"
|
||||
if phase == "moved" then
|
||||
eventType = "touchmove"
|
||||
elseif phase == "ended" then
|
||||
eventType = "touchrelease"
|
||||
elseif phase == "cancelled" then
|
||||
eventType = "touchcancel"
|
||||
end
|
||||
|
||||
return InputEvent.new({
|
||||
type = eventType,
|
||||
button = 1, -- Treat touch as left button
|
||||
x = x,
|
||||
y = y,
|
||||
dx = 0,
|
||||
dy = 0,
|
||||
modifiers = { shift = false, ctrl = false, alt = false, super = false },
|
||||
clickCount = 1,
|
||||
timestamp = love.timer.getTime(),
|
||||
touchId = touchIdStr,
|
||||
pressure = pressure or 1.0,
|
||||
phase = phase,
|
||||
})
|
||||
end
|
||||
|
||||
return InputEvent
|
||||
@@ -0,0 +1,748 @@
|
||||
local packageName = ... or "KeyboardNavigation"
|
||||
local modulePath = packageName:match("(.-)[^%.]+$")
|
||||
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
---@class KeyboardNavigation
|
||||
---@field config KeyboardNavigationConfig
|
||||
local KeyboardNavigation = {
|
||||
|
||||
config = {
|
||||
-- Global settings
|
||||
enabled = true,
|
||||
debugMode = false,
|
||||
|
||||
-- Key bindings
|
||||
keys = {
|
||||
next = "tab",
|
||||
previous = "shifttab",
|
||||
up = "up",
|
||||
down = "down",
|
||||
left = "left",
|
||||
right = "right",
|
||||
activate = { "return", "space" },
|
||||
dismiss = "escape",
|
||||
toggleDebug = "f12",
|
||||
inspect = "i",
|
||||
},
|
||||
|
||||
-- Navigation behavior
|
||||
wrapAround = true,
|
||||
directionalNavigation = true,
|
||||
focusVisible = true,
|
||||
autofocusOnCreate = false,
|
||||
|
||||
--- Drop focus after pressing Enter/Space to activate an element
|
||||
--- When false, focus remains on the element after activation
|
||||
dropFocusOnSelection = true,
|
||||
|
||||
-- Developer tools
|
||||
developerTools = {
|
||||
enabled = true,
|
||||
showProperties = true,
|
||||
highlightColor = { 1, 0.8, 0, 0.5 },
|
||||
},
|
||||
|
||||
-- Focus indicator style
|
||||
focusIndicator = {
|
||||
color = { 0.2, 0.6, 1.0, 0.8 },
|
||||
lineWidth = 2,
|
||||
inset = -3,
|
||||
borderRadius = 4,
|
||||
animationDuration = 0.15,
|
||||
},
|
||||
},
|
||||
|
||||
-- State
|
||||
_navigationStack = {},
|
||||
_lastNavigationTime = 0,
|
||||
_inspectMode = false,
|
||||
_deps = nil,
|
||||
|
||||
-- Spatial index for directional navigation (performance optimization)
|
||||
_spatialIndex = {
|
||||
enabled = false,
|
||||
cellSize = 100, -- Grid cell size in pixels
|
||||
grid = {}, -- Grid storing element references
|
||||
elementPositions = {}, -- Cache of element positions {element = {x, y, w, h}}
|
||||
lastUpdateFrame = 0,
|
||||
},
|
||||
}
|
||||
|
||||
--- Initialize KeyboardNavigation module
|
||||
---@param deps table {Context, Element, ErrorHandler, utils, InputEvent}
|
||||
function KeyboardNavigation.init(deps)
|
||||
-- Validate required dependencies
|
||||
local required = { Context = true, Element = true, ErrorHandler = true, utils = true, InputEvent = true }
|
||||
for depName, _ in pairs(required) do
|
||||
if not deps[depName] then
|
||||
error(string.format("KeyboardNavigation.init: Missing required dependency: %s", depName))
|
||||
end
|
||||
end
|
||||
|
||||
KeyboardNavigation._deps = deps
|
||||
KeyboardNavigation._ErrorHandler = deps.ErrorHandler
|
||||
KeyboardNavigation._InputEvent = deps.InputEvent
|
||||
KeyboardNavigation._Context = deps.Context
|
||||
KeyboardNavigation._Element = deps.Element
|
||||
KeyboardNavigation._utils = deps.utils
|
||||
end
|
||||
|
||||
--- Handle keyboard press for navigation
|
||||
---@param key string
|
||||
---@param scancode string
|
||||
---@param isrepeat boolean
|
||||
---@return boolean handled
|
||||
function KeyboardNavigation:handleKeyPress(key, scancode, isrepeat)
|
||||
if not KeyboardNavigation._Context then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Debug logging
|
||||
if KeyboardNavigation.config.debugMode then
|
||||
print(
|
||||
string.format(
|
||||
"[KeyboardNavigation] Key pressed: %s (scancode: %s, repeat: %s)",
|
||||
key,
|
||||
scancode,
|
||||
tostring(isrepeat)
|
||||
)
|
||||
)
|
||||
print(string.format("[KeyboardNavigation] Enabled: %s", tostring(KeyboardNavigation.config.enabled)))
|
||||
end
|
||||
|
||||
local config = KeyboardNavigation.config
|
||||
local keys = config.keys
|
||||
|
||||
-- Check for activation keys
|
||||
for _, activateKey in ipairs(keys.activate) do
|
||||
if key == activateKey then
|
||||
return self:activateElement()
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for dismiss key
|
||||
if key == keys.dismiss then
|
||||
return self:dismissElement()
|
||||
end
|
||||
|
||||
-- Check for next/previous navigation
|
||||
-- Tab with shift held = previous; Tab without shift = next
|
||||
if key == keys.next then
|
||||
if love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift") then
|
||||
return self:previousFocusable()
|
||||
end
|
||||
return self:nextFocusable()
|
||||
end
|
||||
|
||||
if key == keys.previous then
|
||||
return self:previousFocusable()
|
||||
end
|
||||
|
||||
-- Check for directional navigation
|
||||
if config.directionalNavigation then
|
||||
if key == keys.up then
|
||||
return self:navigateDirectional("up")
|
||||
elseif key == keys.down then
|
||||
return self:navigateDirectional("down")
|
||||
elseif key == keys.left then
|
||||
return self:navigateDirectional("left")
|
||||
elseif key == keys.right then
|
||||
return self:navigateDirectional("right")
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Find next focusable element in the focusable list
|
||||
---@param focusableList table<Element> List of focusable elements in tab order
|
||||
---@param current Element? Currently focused element
|
||||
---@return Element?
|
||||
function KeyboardNavigation:_findNextInList(focusableList, current)
|
||||
local currentIndex = 0
|
||||
if current then
|
||||
for i, elem in ipairs(focusableList) do
|
||||
if elem.id == current.id then
|
||||
currentIndex = i
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Search forward
|
||||
if currentIndex < #focusableList then
|
||||
return focusableList[currentIndex + 1]
|
||||
end
|
||||
|
||||
-- Wrap around if enabled
|
||||
if KeyboardNavigation.config.wrapAround and #focusableList > 0 then
|
||||
return focusableList[1]
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Get the focusable element list scoped to the navigation container
|
||||
---@return Element[]
|
||||
function KeyboardNavigation:_getScopedFocusableList()
|
||||
local Context = KeyboardNavigation._Context
|
||||
local container = Context.getNavigationContainer()
|
||||
if container then
|
||||
return container:getFocusableChildren()
|
||||
end
|
||||
return Context.getFocusableElements()
|
||||
end
|
||||
|
||||
--- Navigate to next focusable element (Tab)
|
||||
---@return boolean success
|
||||
function KeyboardNavigation:nextFocusable()
|
||||
local Context = KeyboardNavigation._Context
|
||||
|
||||
local current = Context.getFocused()
|
||||
if KeyboardNavigation.config.debugMode then
|
||||
print(
|
||||
string.format("[KeyboardNavigation] Tab pressed - Current focus: %s", tostring(current and current.id or "nil"))
|
||||
)
|
||||
end
|
||||
|
||||
local focusableList = self:_getScopedFocusableList()
|
||||
local nextElem = self:_findNextInList(focusableList, current)
|
||||
|
||||
if nextElem then
|
||||
self:_focusElement(nextElem)
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Find previous focusable element in the focusable list
|
||||
---@param focusableList table<Element> List of focusable elements in tab order
|
||||
---@param current Element? Currently focused element
|
||||
---@return Element?
|
||||
function KeyboardNavigation:_findPreviousInList(focusableList, current)
|
||||
local currentIndex = #focusableList + 1
|
||||
if current then
|
||||
for i, elem in ipairs(focusableList) do
|
||||
if elem.id == current.id then
|
||||
currentIndex = i
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Search backward
|
||||
if currentIndex - 1 >= 1 then
|
||||
return focusableList[currentIndex - 1]
|
||||
end
|
||||
|
||||
-- Wrap around if enabled
|
||||
if KeyboardNavigation.config.wrapAround and #focusableList > 0 then
|
||||
return focusableList[#focusableList]
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--- Navigate to previous focusable element (Shift+Tab)
|
||||
---@return boolean success
|
||||
function KeyboardNavigation:previousFocusable()
|
||||
local Context = KeyboardNavigation._Context
|
||||
|
||||
local current = Context.getFocused()
|
||||
|
||||
local focusableList = self:_getScopedFocusableList()
|
||||
local prevElem = self:_findPreviousInList(focusableList, current)
|
||||
|
||||
if prevElem then
|
||||
self:_focusElement(prevElem)
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Navigate using arrow keys
|
||||
---@param direction "up"|"down"|"left"|"right"
|
||||
---@return boolean success
|
||||
function KeyboardNavigation:navigateDirectional(direction)
|
||||
local Context = KeyboardNavigation._Context
|
||||
local current = Context.getFocused()
|
||||
|
||||
if not current then
|
||||
return false
|
||||
end
|
||||
|
||||
local nextElem = KeyboardNavigation:_findDirectionalNeighbor(current, direction)
|
||||
|
||||
if nextElem then
|
||||
self:_focusElement(nextElem)
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Find closest focusable element in the given direction
|
||||
---@param current Element
|
||||
---@param direction "up"|"down"|"left"|"right"
|
||||
---@return Element?
|
||||
function KeyboardNavigation:_findDirectionalNeighbor(current, direction)
|
||||
-- Try spatial index first if enabled
|
||||
if KeyboardNavigation._spatialIndex.enabled then
|
||||
local spatialResult = self:_findDirectionalNeighborSpatial(current, direction)
|
||||
if spatialResult then
|
||||
return spatialResult
|
||||
end
|
||||
end
|
||||
|
||||
-- Collect all focusable elements visible this frame
|
||||
local Context = KeyboardNavigation._Context
|
||||
local focusable = {}
|
||||
|
||||
local function collectFocusable(elem)
|
||||
if elem:isFocusable() and elem ~= current then
|
||||
table.insert(focusable, elem)
|
||||
end
|
||||
for _, child in ipairs(elem.children) do
|
||||
collectFocusable(child)
|
||||
end
|
||||
end
|
||||
|
||||
-- Mode-agnostic: collect from Context's focusable list
|
||||
local allFocusable = Context.getFocusableElements()
|
||||
for _, elem in ipairs(allFocusable) do
|
||||
if elem ~= current then
|
||||
table.insert(focusable, elem)
|
||||
end
|
||||
end
|
||||
|
||||
if #focusable == 0 then
|
||||
return nil
|
||||
end
|
||||
|
||||
local currentRect = {
|
||||
x = current.x,
|
||||
y = current.y,
|
||||
width = current.width or 0,
|
||||
height = current.height or 0,
|
||||
}
|
||||
|
||||
local closest = nil
|
||||
local closestDistance = math.huge
|
||||
|
||||
for _, elem in ipairs(focusable) do
|
||||
local elemRect = {
|
||||
x = elem.x,
|
||||
y = elem.y,
|
||||
width = elem.width or 0,
|
||||
height = elem.height or 0,
|
||||
}
|
||||
|
||||
local distance, isInDirection = self:_calculateDirectionalDistance(currentRect, elemRect, direction)
|
||||
|
||||
if isInDirection and distance < closestDistance then
|
||||
closest = elem
|
||||
closestDistance = distance
|
||||
end
|
||||
end
|
||||
|
||||
-- If no element found in exact direction, try with looser criteria
|
||||
if not closest then
|
||||
closest = self:_findClosestInDirection(current, focusable, direction)
|
||||
end
|
||||
|
||||
return closest
|
||||
end
|
||||
|
||||
--- Calculate distance and direction between elements
|
||||
---@param from table {x, y, width, height}
|
||||
---@param to table {x, y, width, height}
|
||||
---@param direction string
|
||||
---@return number distance, boolean isInDirection
|
||||
function KeyboardNavigation:_calculateDirectionalDistance(from, to, direction)
|
||||
-- Calculate bounding box edges
|
||||
local fromLeft = from.x
|
||||
local fromRight = from.x + from.width
|
||||
local fromTop = from.y
|
||||
local fromBottom = from.y + from.height
|
||||
|
||||
local toLeft = to.x
|
||||
local toRight = to.x + to.width
|
||||
local toTop = to.y
|
||||
local toBottom = to.y + to.height
|
||||
|
||||
local distance = math.huge
|
||||
local isInDirection = false
|
||||
|
||||
if direction == "up" then
|
||||
if toBottom < fromTop then
|
||||
isInDirection = true
|
||||
distance = fromTop - toBottom
|
||||
end
|
||||
elseif direction == "down" then
|
||||
if toTop > fromBottom then
|
||||
isInDirection = true
|
||||
distance = toTop - fromBottom
|
||||
end
|
||||
elseif direction == "left" then
|
||||
if toRight < fromLeft then
|
||||
isInDirection = true
|
||||
distance = fromLeft - toRight
|
||||
end
|
||||
elseif direction == "right" then
|
||||
if toLeft > fromRight then
|
||||
isInDirection = true
|
||||
distance = toLeft - fromRight
|
||||
end
|
||||
end
|
||||
|
||||
return distance, isInDirection
|
||||
end
|
||||
|
||||
--- Find closest element in direction using center-to-center distance
|
||||
---@param current Element
|
||||
---@param focusable Element[]
|
||||
---@param direction string
|
||||
---@return Element?
|
||||
function KeyboardNavigation:_findClosestInDirection(current, focusable, direction)
|
||||
local currentCenterX = current.x + (current.width or 0) / 2
|
||||
local currentCenterY = current.y + (current.height or 0) / 2
|
||||
|
||||
local closest = nil
|
||||
local closestDistance = math.huge
|
||||
|
||||
for _, elem in ipairs(focusable) do
|
||||
if elem ~= current then
|
||||
local elemCenterX = elem.x + (elem.width or 0) / 2
|
||||
local elemCenterY = elem.y + (elem.height or 0) / 2
|
||||
|
||||
local dx = elemCenterX - currentCenterX
|
||||
local dy = elemCenterY - currentCenterY
|
||||
|
||||
-- Check if element is generally in the right direction
|
||||
local isInDirection = false
|
||||
|
||||
if direction == "up" and dy < 0 then
|
||||
isInDirection = true
|
||||
elseif direction == "down" and dy > 0 then
|
||||
isInDirection = true
|
||||
elseif direction == "left" and dx < 0 then
|
||||
isInDirection = true
|
||||
elseif direction == "right" and dx > 0 then
|
||||
isInDirection = true
|
||||
end
|
||||
|
||||
if isInDirection then
|
||||
local distance = math.sqrt(dx * dx + dy * dy)
|
||||
if distance < closestDistance then
|
||||
closest = elem
|
||||
closestDistance = distance
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return closest
|
||||
end
|
||||
|
||||
--- Focus an element
|
||||
---@param element Element
|
||||
function KeyboardNavigation:_focusElement(element)
|
||||
local Context = KeyboardNavigation._Context
|
||||
|
||||
if element and element:isFocusable() then
|
||||
if KeyboardNavigation.config.debugMode then
|
||||
print(
|
||||
string.format(
|
||||
"[KeyboardNavigation] Focusing element: %s (id: %s)",
|
||||
element.themeComponent or "unknown",
|
||||
tostring(element.id)
|
||||
)
|
||||
)
|
||||
end
|
||||
Context.setFocused(element)
|
||||
|
||||
-- Update focus indicator
|
||||
if KeyboardNavigation.FocusIndicator then
|
||||
KeyboardNavigation.FocusIndicator.setFocused(element)
|
||||
end
|
||||
|
||||
-- Call onFocus callback if it exists
|
||||
if element.onFocus then
|
||||
local success, err = pcall(function()
|
||||
if element.onFocusDeferred then
|
||||
table.insert(Context._deferredCallbacks or {}, function()
|
||||
element:onFocus(element)
|
||||
end)
|
||||
else
|
||||
element:onFocus(element)
|
||||
end
|
||||
end)
|
||||
|
||||
if not success then
|
||||
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_001", {
|
||||
elementId = element.id or "unknown",
|
||||
error = tostring(err),
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@return boolean
|
||||
function KeyboardNavigation:_shouldDropFocusOnSelection(element)
|
||||
if element and element.dropFocusOnSelection ~= nil then
|
||||
return element.dropFocusOnSelection == true
|
||||
end
|
||||
|
||||
return KeyboardNavigation.config.dropFocusOnSelection == true
|
||||
end
|
||||
|
||||
--- Activate currently focused element
|
||||
---@return boolean success
|
||||
function KeyboardNavigation:activateElement()
|
||||
local Context = KeyboardNavigation._Context
|
||||
local focused = Context.getFocused()
|
||||
|
||||
if not focused then
|
||||
return false
|
||||
end
|
||||
|
||||
if focused.disabled then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Fire press and release events
|
||||
if focused.onEvent then
|
||||
local modifiers = KeyboardNavigation._utils.getModifiers()
|
||||
local pressEvent = KeyboardNavigation._InputEvent.new({
|
||||
type = "press",
|
||||
button = 1,
|
||||
x = focused.x,
|
||||
y = focused.y,
|
||||
modifiers = modifiers,
|
||||
clickCount = 1,
|
||||
})
|
||||
|
||||
local releaseEvent = KeyboardNavigation._InputEvent.new({
|
||||
type = "release",
|
||||
button = 1,
|
||||
x = focused.x,
|
||||
y = focused.y,
|
||||
modifiers = modifiers,
|
||||
clickCount = 1,
|
||||
})
|
||||
|
||||
local success, err = pcall(function()
|
||||
focused.onEvent(focused, pressEvent)
|
||||
focused.onEvent(focused, releaseEvent)
|
||||
end)
|
||||
|
||||
if not success then
|
||||
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_002", {
|
||||
elementId = focused.id or "unknown",
|
||||
error = tostring(err),
|
||||
})
|
||||
end
|
||||
|
||||
-- Drop focus after selection based on per-element override or global config.
|
||||
if KeyboardNavigation:_shouldDropFocusOnSelection(focused) then
|
||||
Context.clearFocus()
|
||||
if KeyboardNavigation.FocusIndicator then
|
||||
KeyboardNavigation.FocusIndicator.setFocused(nil)
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--- Dismiss currently focused element
|
||||
---@return boolean success
|
||||
function KeyboardNavigation:dismissElement()
|
||||
local Context = KeyboardNavigation._Context
|
||||
local focused = Context.getFocused()
|
||||
|
||||
if not focused then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Check if element has a dismiss handler
|
||||
if focused.onDismiss then
|
||||
local success, err = pcall(function()
|
||||
if focused.onDismissDeferred then
|
||||
table.insert(Context._deferredCallbacks or {}, function()
|
||||
focused:onDismiss(focused)
|
||||
end)
|
||||
else
|
||||
focused:onDismiss(focused)
|
||||
end
|
||||
end)
|
||||
|
||||
if not success then
|
||||
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_003", {
|
||||
elementId = focused.id or "unknown",
|
||||
error = tostring(err),
|
||||
})
|
||||
end
|
||||
|
||||
return true -- Handler took care of dismissal
|
||||
end
|
||||
|
||||
-- Default behavior: blur the element (only if no onDismiss handler)
|
||||
Context.clearFocus()
|
||||
return true
|
||||
end
|
||||
|
||||
--- Update keyboard navigation (for animations, etc.)
|
||||
---@param dt number
|
||||
function KeyboardNavigation:update(dt)
|
||||
-- Update focus indicator if it exists
|
||||
if KeyboardNavigation.FocusIndicator then
|
||||
KeyboardNavigation.FocusIndicator:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
--- Push current focus onto stack (for modals/dialogs)
|
||||
--- Saves current focus and sets new focus to the given element
|
||||
---@param element Element? The element to focus (e.g., modal dialog)
|
||||
function KeyboardNavigation:pushFocus(element)
|
||||
local Context = KeyboardNavigation._Context
|
||||
|
||||
table.insert(KeyboardNavigation._navigationStack, Context.getFocused())
|
||||
Context.pushFocusStack(element)
|
||||
end
|
||||
|
||||
--- Pop focus from stack (return from modal)
|
||||
--- Restores previously focused element from the stack
|
||||
---@return Element? The previously focused element, or nil if stack was empty
|
||||
function KeyboardNavigation:popFocus()
|
||||
local Context = KeyboardNavigation._Context
|
||||
|
||||
local previous = Context.popFocusStack()
|
||||
if #KeyboardNavigation._navigationStack > 0 then
|
||||
previous = table.remove(KeyboardNavigation._navigationStack)
|
||||
end
|
||||
|
||||
return previous
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Spatial Index (Performance Optimization)
|
||||
-- ====================
|
||||
|
||||
--- Enable spatial index for faster directional navigation
|
||||
---@param enabled boolean
|
||||
function KeyboardNavigation.enableSpatialIndex(enabled)
|
||||
KeyboardNavigation._spatialIndex.enabled = enabled
|
||||
if not enabled then
|
||||
KeyboardNavigation:_clearSpatialIndex()
|
||||
end
|
||||
end
|
||||
|
||||
--- Clear spatial index
|
||||
function KeyboardNavigation:_clearSpatialIndex()
|
||||
KeyboardNavigation._spatialIndex.grid = {}
|
||||
KeyboardNavigation._spatialIndex.elementPositions = {}
|
||||
end
|
||||
|
||||
--- Find directional neighbor using spatial index
|
||||
---@param current Element
|
||||
---@param direction "up"|"down"|"left"|"right"
|
||||
---@return Element?
|
||||
function KeyboardNavigation:_findDirectionalNeighborSpatial(current, direction)
|
||||
local index = KeyboardNavigation._spatialIndex
|
||||
local cellSize = index.cellSize
|
||||
|
||||
-- Get current element's grid position
|
||||
local currentPos = index.elementPositions[current]
|
||||
if not currentPos then
|
||||
return nil
|
||||
end
|
||||
|
||||
local centerX = currentPos.x + currentPos.w / 2
|
||||
local centerY = currentPos.y + currentPos.h / 2
|
||||
local currentCellX = math.floor(centerX / cellSize)
|
||||
local currentCellY = math.floor(centerY / cellSize)
|
||||
|
||||
-- Search in direction, expanding outward
|
||||
local maxSearchRadius = 20 -- Maximum cells to search
|
||||
local visited = {}
|
||||
|
||||
for radius = 1, maxSearchRadius do
|
||||
local candidates = {}
|
||||
|
||||
-- Get cells in the search ring
|
||||
if direction == "up" then
|
||||
table.insert(candidates, { currentCellX, currentCellY - radius })
|
||||
if radius > 1 then
|
||||
table.insert(candidates, { currentCellX - 1, currentCellY - radius })
|
||||
table.insert(candidates, { currentCellX + 1, currentCellY - radius })
|
||||
end
|
||||
elseif direction == "down" then
|
||||
table.insert(candidates, { currentCellX, currentCellY + radius })
|
||||
if radius > 1 then
|
||||
table.insert(candidates, { currentCellX - 1, currentCellY + radius })
|
||||
table.insert(candidates, { currentCellX + 1, currentCellY + radius })
|
||||
end
|
||||
elseif direction == "left" then
|
||||
table.insert(candidates, { currentCellX - radius, currentCellY })
|
||||
if radius > 1 then
|
||||
table.insert(candidates, { currentCellX - radius, currentCellY - 1 })
|
||||
table.insert(candidates, { currentCellX - radius, currentCellY + 1 })
|
||||
end
|
||||
elseif direction == "right" then
|
||||
table.insert(candidates, { currentCellX + radius, currentCellY })
|
||||
if radius > 1 then
|
||||
table.insert(candidates, { currentCellX + radius, currentCellY - 1 })
|
||||
table.insert(candidates, { currentCellX + radius, currentCellY + 1 })
|
||||
end
|
||||
end
|
||||
|
||||
-- Check each candidate cell
|
||||
for _, cell in ipairs(candidates) do
|
||||
local cellKey = string.format("%d,%d", cell[1], cell[2])
|
||||
local cellElements = index.grid[cellKey]
|
||||
|
||||
if cellElements then
|
||||
for _, elem in ipairs(cellElements) do
|
||||
if elem ~= current and not visited[elem] then
|
||||
visited[elem] = true
|
||||
local elemPos = index.elementPositions[elem]
|
||||
if elemPos then
|
||||
local elemCenterX = elemPos.x + elemPos.w / 2
|
||||
local elemCenterY = elemPos.y + elemPos.h / 2
|
||||
|
||||
-- Check if element is in the correct direction
|
||||
local isInDirection = false
|
||||
if direction == "up" and elemCenterY < centerY then
|
||||
isInDirection = true
|
||||
elseif direction == "down" and elemCenterY > centerY then
|
||||
isInDirection = true
|
||||
elseif direction == "left" and elemCenterX < centerX then
|
||||
isInDirection = true
|
||||
elseif direction == "right" and elemCenterX > centerX then
|
||||
isInDirection = true
|
||||
end
|
||||
|
||||
if isInDirection then
|
||||
return elem
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
return KeyboardNavigation
|
||||
@@ -0,0 +1,697 @@
|
||||
---@class MemoryScanner
|
||||
---@field _StateManager table
|
||||
---@field _Context table
|
||||
---@field _ImageCache table
|
||||
---@field _ErrorHandler table
|
||||
local MemoryScanner = {}
|
||||
|
||||
---Initialize MemoryScanner with dependencies
|
||||
---@param deps {StateManager: table, Context: table, ImageCache: table, ErrorHandler: table}
|
||||
function MemoryScanner.init(deps)
|
||||
MemoryScanner._StateManager = deps.StateManager
|
||||
MemoryScanner._Context = deps.Context
|
||||
MemoryScanner._ImageCache = deps.ImageCache
|
||||
MemoryScanner._ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
|
||||
---Count items in a table
|
||||
---@param tbl table
|
||||
---@return number
|
||||
local function countTable(tbl)
|
||||
local count = 0
|
||||
for _ in pairs(tbl) do
|
||||
count = count + 1
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
---Calculate memory size estimate for a table (recursive)
|
||||
---@param tbl table
|
||||
---@param visited table? Tracking table to prevent circular references
|
||||
---@param depth number? Current recursion depth
|
||||
---@return number bytes Estimated memory usage in bytes
|
||||
local function estimateTableSize(tbl, visited, depth)
|
||||
if type(tbl) ~= "table" then
|
||||
return 0
|
||||
end
|
||||
|
||||
visited = visited or {}
|
||||
depth = depth or 0
|
||||
|
||||
-- Limit recursion depth to prevent stack overflow
|
||||
if depth > 10 then
|
||||
return 0
|
||||
end
|
||||
|
||||
-- Check for circular references
|
||||
if visited[tbl] then
|
||||
return 0
|
||||
end
|
||||
visited[tbl] = true
|
||||
|
||||
local size = 40 -- Base table overhead (approximate)
|
||||
|
||||
for k, v in pairs(tbl) do
|
||||
-- Key size
|
||||
if type(k) == "string" then
|
||||
size = size + #k + 24 -- String overhead
|
||||
elseif type(k) == "number" then
|
||||
size = size + 8
|
||||
else
|
||||
size = size + 8 -- Reference
|
||||
end
|
||||
|
||||
-- Value size
|
||||
if type(v) == "string" then
|
||||
size = size + #v + 24
|
||||
elseif type(v) == "number" then
|
||||
size = size + 8
|
||||
elseif type(v) == "boolean" then
|
||||
size = size + 4
|
||||
elseif type(v) == "table" then
|
||||
size = size + estimateTableSize(v, visited, depth + 1)
|
||||
elseif type(v) == "function" then
|
||||
size = size + 16 -- Function reference
|
||||
else
|
||||
size = size + 8 -- Other references
|
||||
end
|
||||
end
|
||||
|
||||
return size
|
||||
end
|
||||
|
||||
---Scan StateManager for memory issues
|
||||
---@return table report Detailed report of StateManager memory usage
|
||||
function MemoryScanner.scanStateManager()
|
||||
local report = {
|
||||
stateCount = 0,
|
||||
stateStoreSize = 0,
|
||||
metadataSize = 0,
|
||||
callSiteCounterSize = 0,
|
||||
orphanedStates = {},
|
||||
staleStates = {},
|
||||
largeStates = {},
|
||||
issues = {},
|
||||
}
|
||||
|
||||
if not MemoryScanner._StateManager then
|
||||
table.insert(report.issues, {
|
||||
severity = "error",
|
||||
message = "StateManager not initialized",
|
||||
})
|
||||
return report
|
||||
end
|
||||
|
||||
local internal = MemoryScanner._StateManager._getInternalState()
|
||||
local stateStore = internal.stateStore
|
||||
local stateMetadata = internal.stateMetadata
|
||||
local callSiteCounters = internal.callSiteCounters
|
||||
local currentFrame = MemoryScanner._StateManager.getFrameNumber()
|
||||
|
||||
-- Count states
|
||||
report.stateCount = countTable(stateStore)
|
||||
|
||||
-- Estimate sizes
|
||||
report.stateStoreSize = estimateTableSize(stateStore)
|
||||
report.metadataSize = estimateTableSize(stateMetadata)
|
||||
report.callSiteCounterSize = estimateTableSize(callSiteCounters)
|
||||
|
||||
-- Check for orphaned states (metadata without state)
|
||||
for id, _ in pairs(stateMetadata) do
|
||||
if not stateStore[id] then
|
||||
table.insert(report.orphanedStates, id)
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for stale states (not accessed in many frames)
|
||||
local staleThreshold = 120 -- 2 seconds at 60fps
|
||||
for id, meta in pairs(stateMetadata) do
|
||||
local framesSinceAccess = currentFrame - meta.lastFrame
|
||||
if framesSinceAccess > staleThreshold then
|
||||
table.insert(report.staleStates, {
|
||||
id = id,
|
||||
framesSinceAccess = framesSinceAccess,
|
||||
createdFrame = meta.createdFrame,
|
||||
accessCount = meta.accessCount,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for large states (may indicate memory bloat)
|
||||
for id, state in pairs(stateStore) do
|
||||
local stateSize = estimateTableSize(state)
|
||||
if stateSize > 1024 then -- More than 1KB
|
||||
table.insert(report.largeStates, {
|
||||
id = id,
|
||||
size = stateSize,
|
||||
keyCount = countTable(state),
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Check callSiteCounters (should be near 0 after frame cleanup)
|
||||
local callSiteCount = countTable(callSiteCounters)
|
||||
if callSiteCount > 100 then
|
||||
table.insert(report.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("callSiteCounters has %d entries (expected near 0)", callSiteCount),
|
||||
suggestion = "incrementFrame() may not be called properly, or counters aren't being reset",
|
||||
})
|
||||
end
|
||||
|
||||
-- Check for excessive state count
|
||||
if report.stateCount > 500 then
|
||||
table.insert(report.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("High state count: %d states", report.stateCount),
|
||||
suggestion = "Consider reducing element count or implementing more aggressive cleanup",
|
||||
})
|
||||
end
|
||||
|
||||
-- Check for orphaned states
|
||||
if #report.orphanedStates > 0 then
|
||||
table.insert(report.issues, {
|
||||
severity = "error",
|
||||
message = string.format("Found %d orphaned states (metadata without state)", #report.orphanedStates),
|
||||
suggestion = "This indicates a bug in state management - metadata should be cleaned up with state",
|
||||
})
|
||||
end
|
||||
|
||||
-- Check for stale states
|
||||
if #report.staleStates > 10 then
|
||||
table.insert(report.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("Found %d stale states (not accessed in 2+ seconds)", #report.staleStates),
|
||||
suggestion = "Cleanup may not be aggressive enough - consider reducing stateRetentionFrames",
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
---Scan Context for memory issues
|
||||
---@return table report Detailed report of Context memory usage
|
||||
function MemoryScanner.scanContext()
|
||||
local report = {
|
||||
topElementCount = 0,
|
||||
zIndexElementCount = 0,
|
||||
frameElementCount = 0,
|
||||
issues = {},
|
||||
}
|
||||
|
||||
if not MemoryScanner._Context then
|
||||
table.insert(report.issues, {
|
||||
severity = "error",
|
||||
message = "Context not initialized",
|
||||
})
|
||||
return report
|
||||
end
|
||||
|
||||
-- Count elements
|
||||
report.topElementCount = #MemoryScanner._Context.topElements
|
||||
report.zIndexElementCount = #MemoryScanner._Context._zIndexOrderedElements
|
||||
report.frameElementCount = #MemoryScanner._Context._currentFrameElements
|
||||
|
||||
-- Check for stale z-index elements (should be cleared each frame)
|
||||
if MemoryScanner._Context.isImmediateMode() then
|
||||
-- In immediate mode, _zIndexOrderedElements should be cleared at frame start
|
||||
-- If it has elements outside of frame rendering, that's a leak
|
||||
if not MemoryScanner._Context._frameStarted and report.zIndexElementCount > 0 then
|
||||
table.insert(report.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("Z-index array has %d elements outside of frame", report.zIndexElementCount),
|
||||
suggestion = "clearFrameElements() may not be called properly in beginFrame()",
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for excessive element count
|
||||
if report.topElementCount > 100 then
|
||||
table.insert(report.issues, {
|
||||
severity = "info",
|
||||
message = string.format("High top-level element count: %d", report.topElementCount),
|
||||
suggestion = "Consider consolidating elements or using fewer top-level containers",
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
---Scan ImageCache for memory issues
|
||||
---@return table report Detailed report of ImageCache memory usage
|
||||
function MemoryScanner.scanImageCache()
|
||||
local report = {
|
||||
imageCount = 0,
|
||||
estimatedMemory = 0,
|
||||
issues = {},
|
||||
}
|
||||
|
||||
if not MemoryScanner._ImageCache then
|
||||
table.insert(report.issues, {
|
||||
severity = "error",
|
||||
message = "ImageCache not initialized",
|
||||
})
|
||||
return report
|
||||
end
|
||||
|
||||
local stats = MemoryScanner._ImageCache.getStats()
|
||||
report.imageCount = stats.count
|
||||
report.estimatedMemory = stats.memoryEstimate
|
||||
|
||||
-- Check for excessive memory usage (>100MB)
|
||||
if report.estimatedMemory > 100 * 1024 * 1024 then
|
||||
table.insert(report.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("ImageCache using ~%.2f MB", report.estimatedMemory / 1024 / 1024),
|
||||
suggestion = "Consider implementing cache eviction or clearing unused images",
|
||||
})
|
||||
end
|
||||
|
||||
-- Check for excessive image count
|
||||
if report.imageCount > 50 then
|
||||
table.insert(report.issues, {
|
||||
severity = "info",
|
||||
message = string.format("ImageCache has %d images", report.imageCount),
|
||||
suggestion = "Review if all cached images are necessary",
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
---Check if a circular reference is intentional (parent-child, module, or metatable)
|
||||
---@param path string The current path where circular ref was detected
|
||||
---@param originalPath string The original path where the table was first seen
|
||||
---@return boolean True if this is an intentional circular reference
|
||||
local function isIntentionalCircularReference(path, originalPath)
|
||||
-- Pattern 1: child.parent points back to parent
|
||||
-- Example: "topElements.1.children.1.parent" -> "topElements.1"
|
||||
if path:match("%.parent$") then
|
||||
local parentPath = path:match("^(.+)%.children%.[^.]+%.parent$")
|
||||
if parentPath == originalPath then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 2: parent.children[n] points to child, child points back somewhere in parent tree
|
||||
-- Example: "topElements.1" -> "topElements.1.children.1.parent"
|
||||
if originalPath:match("%.parent$") then
|
||||
local childParentPath = originalPath:match("^(.+)%.children%.[^.]+%.parent$")
|
||||
if childParentPath == path then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 3: Check for nested parent-child cycles
|
||||
-- child.children[n].parent -> child
|
||||
local segments = {}
|
||||
for segment in path:gmatch("[^.]+") do
|
||||
table.insert(segments, segment)
|
||||
end
|
||||
|
||||
-- Look for .children.N.parent pattern
|
||||
for i = 1, #segments - 2 do
|
||||
if segments[i] == "children" and segments[i + 2] == "parent" then
|
||||
-- Reconstruct path without the .children.N.parent suffix
|
||||
local reconstructedPath = table.concat(segments, ".", 1, i - 1)
|
||||
if reconstructedPath == originalPath then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 4: Metatable __index self-references (modules)
|
||||
-- Example: "element._renderer._Theme.__index" -> "element._renderer._Theme"
|
||||
if path:match("%.__index$") then
|
||||
local basePath = path:match("^(.+)%.__index$")
|
||||
if basePath == originalPath then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 5: Shared module references (elements sharing same module instances)
|
||||
-- Example: Multiple elements referencing _utils, _Theme, _Blur, etc.
|
||||
-- These start with _ and are typically modules
|
||||
local pathModuleName = path:match("%.(_[%w]+)%.")
|
||||
local originalModuleName = originalPath:match("%.(_[%w]+)%.")
|
||||
|
||||
if pathModuleName and originalModuleName then
|
||||
-- If both paths reference the same internal module (starting with _), it's intentional
|
||||
if pathModuleName == originalModuleName then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 6: Shared Color/Transform objects between elements
|
||||
-- These are value objects that can be safely shared
|
||||
if path:match("Color") and originalPath:match("Color") then
|
||||
return true
|
||||
end
|
||||
if path:match("Transform") and originalPath:match("Transform") then
|
||||
return true
|
||||
end
|
||||
|
||||
-- Pattern 7: LayoutEngine holding reference to its element
|
||||
-- Example: "element._layoutEngine.element" -> "element"
|
||||
if path:match("%._layoutEngine%.element$") then
|
||||
local elementPath = path:match("^(.+)%._layoutEngine%.element$")
|
||||
if elementPath == originalPath then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 8: Renderer holding references to element properties
|
||||
-- Example: "element._renderer.cornerRadius" -> "element.cornerRadius"
|
||||
if path:match("%._renderer%.") then
|
||||
local rendererBasePath = path:match("^(.+)%._renderer%.")
|
||||
local originalBasePath = originalPath:match("^(.+)%.")
|
||||
if rendererBasePath == originalBasePath then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Pattern 9: Context reference from layout engine (shared singleton)
|
||||
-- Example: "element._layoutEngine._Context.topElements" -> "topElements"
|
||||
if path:match("%._layoutEngine%._Context%.") and originalPath == "topElements" then
|
||||
return true
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
---Detect circular references in a table
|
||||
---@param tbl table Table to check
|
||||
---@param path string? Current path (for reporting)
|
||||
---@param visited table? Tracking table
|
||||
---@return table[] circularRefs Array of circular reference paths
|
||||
---@return table[] intentionalRefs Array of intentional parent-child refs
|
||||
local function detectCircularReferences(tbl, path, visited)
|
||||
if type(tbl) ~= "table" then
|
||||
return {}, {}
|
||||
end
|
||||
|
||||
path = path or "root"
|
||||
visited = visited or {}
|
||||
local circularRefs = {}
|
||||
local intentionalRefs = {}
|
||||
|
||||
-- Check if we've seen this table before
|
||||
if visited[tbl] then
|
||||
local ref = {
|
||||
path = path,
|
||||
originalPath = visited[tbl],
|
||||
}
|
||||
|
||||
-- Determine if this is an intentional circular reference
|
||||
if isIntentionalCircularReference(path, visited[tbl]) then
|
||||
table.insert(intentionalRefs, ref)
|
||||
else
|
||||
table.insert(circularRefs, ref)
|
||||
end
|
||||
|
||||
return circularRefs, intentionalRefs
|
||||
end
|
||||
|
||||
-- Mark as visited
|
||||
visited[tbl] = path
|
||||
|
||||
-- Recursively check children
|
||||
for k, v in pairs(tbl) do
|
||||
if type(v) == "table" then
|
||||
local childPath = path .. "." .. tostring(k)
|
||||
local childRefs, childIntentionalRefs = detectCircularReferences(v, childPath, visited)
|
||||
for _, ref in ipairs(childRefs) do
|
||||
table.insert(circularRefs, ref)
|
||||
end
|
||||
for _, ref in ipairs(childIntentionalRefs) do
|
||||
table.insert(intentionalRefs, ref)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return circularRefs, intentionalRefs
|
||||
end
|
||||
|
||||
---Scan for circular references in immediate mode
|
||||
---@return table report Detailed report of circular references
|
||||
function MemoryScanner.scanCircularReferences()
|
||||
local report = {
|
||||
stateStoreCircularRefs = {},
|
||||
stateStoreIntentionalRefs = {},
|
||||
contextCircularRefs = {},
|
||||
contextIntentionalRefs = {},
|
||||
issues = {},
|
||||
}
|
||||
|
||||
if MemoryScanner._StateManager then
|
||||
local internal = MemoryScanner._StateManager._getInternalState()
|
||||
report.stateStoreCircularRefs, report.stateStoreIntentionalRefs =
|
||||
detectCircularReferences(internal.stateStore, "stateStore")
|
||||
end
|
||||
|
||||
if MemoryScanner._Context then
|
||||
report.contextCircularRefs, report.contextIntentionalRefs =
|
||||
detectCircularReferences(MemoryScanner._Context.topElements, "topElements")
|
||||
end
|
||||
|
||||
-- Report issues only for cross-module circular references
|
||||
if #report.stateStoreCircularRefs > 0 then
|
||||
table.insert(report.issues, {
|
||||
severity = "info",
|
||||
message = string.format(
|
||||
"Found %d cross-module circular references in StateManager",
|
||||
#report.stateStoreCircularRefs
|
||||
),
|
||||
suggestion = "These are typically architectural dependencies between modules, not memory leaks",
|
||||
})
|
||||
end
|
||||
|
||||
if #report.contextCircularRefs > 0 then
|
||||
table.insert(report.issues, {
|
||||
severity = "info",
|
||||
message = string.format("Found %d cross-module circular references in Context", #report.contextCircularRefs),
|
||||
suggestion = "These are typically architectural dependencies (e.g., layout engine ↔ renderer), not memory leaks",
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
---Run comprehensive memory scan
|
||||
---@return table report Complete memory analysis report
|
||||
function MemoryScanner.scan()
|
||||
local startMemory = collectgarbage("count")
|
||||
|
||||
local report = {
|
||||
timestamp = os.time(),
|
||||
startMemory = startMemory / 1024, -- MB
|
||||
stateManager = MemoryScanner.scanStateManager(),
|
||||
context = MemoryScanner.scanContext(),
|
||||
imageCache = MemoryScanner.scanImageCache(),
|
||||
circularRefs = MemoryScanner.scanCircularReferences(),
|
||||
summary = {
|
||||
totalIssues = 0,
|
||||
criticalIssues = 0,
|
||||
warnings = 0,
|
||||
info = 0,
|
||||
},
|
||||
}
|
||||
|
||||
-- Count issues by severity
|
||||
local function countIssues(subReport)
|
||||
for _, issue in ipairs(subReport.issues or {}) do
|
||||
report.summary.totalIssues = report.summary.totalIssues + 1
|
||||
if issue.severity == "error" then
|
||||
report.summary.criticalIssues = report.summary.criticalIssues + 1
|
||||
elseif issue.severity == "warning" then
|
||||
report.summary.warnings = report.summary.warnings + 1
|
||||
elseif issue.severity == "info" then
|
||||
report.summary.info = report.summary.info + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
countIssues(report.stateManager)
|
||||
countIssues(report.context)
|
||||
countIssues(report.imageCache)
|
||||
countIssues(report.circularRefs)
|
||||
|
||||
-- Force GC and measure freed memory
|
||||
local beforeGC = collectgarbage("count")
|
||||
collectgarbage("collect")
|
||||
collectgarbage("collect")
|
||||
local afterGC = collectgarbage("count")
|
||||
|
||||
report.gcAnalysis = {
|
||||
beforeGC = beforeGC / 1024, -- MB
|
||||
afterGC = afterGC / 1024, -- MB
|
||||
freed = (beforeGC - afterGC) / 1024, -- MB
|
||||
freedPercent = ((beforeGC - afterGC) / beforeGC) * 100,
|
||||
}
|
||||
|
||||
-- Analyze GC effectiveness
|
||||
if report.gcAnalysis.freedPercent < 5 then
|
||||
table.insert(report.stateManager.issues, {
|
||||
severity = "info",
|
||||
message = string.format("GC freed only %.1f%% of memory", report.gcAnalysis.freedPercent),
|
||||
suggestion = "Most memory is still referenced - this is normal if UI is active",
|
||||
})
|
||||
elseif report.gcAnalysis.freedPercent > 30 then
|
||||
table.insert(report.stateManager.issues, {
|
||||
severity = "warning",
|
||||
message = string.format("GC freed %.1f%% of memory", report.gcAnalysis.freedPercent),
|
||||
suggestion = "Significant memory was unreferenced - may indicate cleanup issues",
|
||||
})
|
||||
end
|
||||
|
||||
return report
|
||||
end
|
||||
|
||||
---Format report as human-readable string
|
||||
---@param report table Memory scan report
|
||||
---@return string formatted Formatted report
|
||||
function MemoryScanner.formatReport(report)
|
||||
local lines = {}
|
||||
|
||||
table.insert(lines, "=== FlexLöve Memory Scanner Report ===")
|
||||
table.insert(lines, string.format("Timestamp: %s", os.date("%Y-%m-%d %H:%M:%S", report.timestamp)))
|
||||
table.insert(lines, string.format("Memory: %.2f MB", report.startMemory))
|
||||
table.insert(lines, "")
|
||||
|
||||
-- Summary
|
||||
table.insert(lines, "--- Summary ---")
|
||||
table.insert(lines, string.format("Total Issues: %d", report.summary.totalIssues))
|
||||
table.insert(lines, string.format(" Critical: %d", report.summary.criticalIssues))
|
||||
table.insert(lines, string.format(" Warnings: %d", report.summary.warnings))
|
||||
table.insert(lines, string.format(" Info: %d", report.summary.info))
|
||||
table.insert(lines, "")
|
||||
|
||||
-- StateManager
|
||||
table.insert(lines, "--- StateManager ---")
|
||||
table.insert(lines, string.format("State Count: %d", report.stateManager.stateCount))
|
||||
table.insert(lines, string.format("State Store Size: %.2f KB", report.stateManager.stateStoreSize / 1024))
|
||||
table.insert(lines, string.format("Metadata Size: %.2f KB", report.stateManager.metadataSize / 1024))
|
||||
table.insert(lines, string.format("CallSite Counters: %.2f KB", report.stateManager.callSiteCounterSize / 1024))
|
||||
table.insert(lines, string.format("Orphaned States: %d", #report.stateManager.orphanedStates))
|
||||
table.insert(lines, string.format("Stale States: %d", #report.stateManager.staleStates))
|
||||
table.insert(lines, string.format("Large States: %d", #report.stateManager.largeStates))
|
||||
|
||||
if #report.stateManager.issues > 0 then
|
||||
table.insert(lines, "Issues:")
|
||||
for _, issue in ipairs(report.stateManager.issues) do
|
||||
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||
if issue.suggestion then
|
||||
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||
end
|
||||
end
|
||||
end
|
||||
table.insert(lines, "")
|
||||
|
||||
-- Context
|
||||
table.insert(lines, "--- Context ---")
|
||||
table.insert(lines, string.format("Top Elements: %d", report.context.topElementCount))
|
||||
table.insert(lines, string.format("Z-Index Elements: %d", report.context.zIndexElementCount))
|
||||
table.insert(lines, string.format("Frame Elements: %d", report.context.frameElementCount))
|
||||
|
||||
if #report.context.issues > 0 then
|
||||
table.insert(lines, "Issues:")
|
||||
for _, issue in ipairs(report.context.issues) do
|
||||
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||
if issue.suggestion then
|
||||
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||
end
|
||||
end
|
||||
end
|
||||
table.insert(lines, "")
|
||||
|
||||
-- ImageCache
|
||||
table.insert(lines, "--- ImageCache ---")
|
||||
table.insert(lines, string.format("Image Count: %d", report.imageCache.imageCount))
|
||||
table.insert(lines, string.format("Estimated Memory: %.2f MB", report.imageCache.estimatedMemory / 1024 / 1024))
|
||||
|
||||
if #report.imageCache.issues > 0 then
|
||||
table.insert(lines, "Issues:")
|
||||
for _, issue in ipairs(report.imageCache.issues) do
|
||||
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||
if issue.suggestion then
|
||||
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||
end
|
||||
end
|
||||
end
|
||||
table.insert(lines, "")
|
||||
|
||||
-- Circular References
|
||||
table.insert(lines, "--- Circular References ---")
|
||||
table.insert(lines, string.format("StateStore (Cross-module refs): %d", #report.circularRefs.stateStoreCircularRefs))
|
||||
table.insert(
|
||||
lines,
|
||||
string.format(
|
||||
"StateStore (Intentional - parent-child, modules, metatables): %d",
|
||||
#report.circularRefs.stateStoreIntentionalRefs
|
||||
)
|
||||
)
|
||||
table.insert(lines, string.format("Context (Cross-module refs): %d", #report.circularRefs.contextCircularRefs))
|
||||
table.insert(
|
||||
lines,
|
||||
string.format(
|
||||
"Context (Intentional - parent-child, modules, metatables): %d",
|
||||
#report.circularRefs.contextIntentionalRefs
|
||||
)
|
||||
)
|
||||
|
||||
if #report.circularRefs.issues > 0 then
|
||||
table.insert(lines, "Issues:")
|
||||
for _, issue in ipairs(report.circularRefs.issues) do
|
||||
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
|
||||
if issue.suggestion then
|
||||
table.insert(lines, string.format(" → %s", issue.suggestion))
|
||||
end
|
||||
end
|
||||
else
|
||||
table.insert(lines, " ✓ No unexpected circular references detected")
|
||||
end
|
||||
table.insert(lines, " Note: Cross-module refs are typically architectural dependencies, not memory leaks")
|
||||
table.insert(lines, "")
|
||||
|
||||
-- GC Analysis
|
||||
table.insert(lines, "--- Garbage Collection Analysis ---")
|
||||
table.insert(lines, string.format("Before GC: %.2f MB", report.gcAnalysis.beforeGC))
|
||||
table.insert(lines, string.format("After GC: %.2f MB", report.gcAnalysis.afterGC))
|
||||
table.insert(lines, string.format("Freed: %.2f MB (%.1f%%)", report.gcAnalysis.freed, report.gcAnalysis.freedPercent))
|
||||
table.insert(lines, "")
|
||||
|
||||
table.insert(lines, "=== End Report ===")
|
||||
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
|
||||
---Save report to file
|
||||
---@param report table Memory scan report
|
||||
---@param filename string? Output filename (default: memory_report.txt)
|
||||
function MemoryScanner.saveReport(report, filename)
|
||||
filename = filename or "memory_report.txt"
|
||||
local formatted = MemoryScanner.formatReport(report)
|
||||
|
||||
local file = io.open(filename, "w")
|
||||
if file then
|
||||
file:write(formatted)
|
||||
file:close()
|
||||
if MemoryScanner._ErrorHandler then
|
||||
MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", {
|
||||
resourceType = "report",
|
||||
path = filename,
|
||||
status = "saved",
|
||||
})
|
||||
end
|
||||
else
|
||||
if MemoryScanner._ErrorHandler then
|
||||
MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", {
|
||||
resourceType = "report",
|
||||
path = filename,
|
||||
status = "failed to save",
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return MemoryScanner
|
||||
@@ -0,0 +1,202 @@
|
||||
---@class ModuleLoader
|
||||
local ModuleLoader = {}
|
||||
|
||||
-- Module registry to track loaded vs. stub modules
|
||||
ModuleLoader._registry = {}
|
||||
ModuleLoader._ErrorHandler = nil
|
||||
|
||||
--- Initialize ModuleLoader with dependencies
|
||||
---@param deps table
|
||||
function ModuleLoader.init(deps)
|
||||
ModuleLoader._ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
|
||||
--- Create a null-object stub for a missing optional module
|
||||
--- Provides safe defaults that won't cause runtime errors
|
||||
---@param moduleName string
|
||||
---@return table
|
||||
local function createNullObject(moduleName)
|
||||
local stub = {
|
||||
_isStub = true,
|
||||
_moduleName = moduleName,
|
||||
}
|
||||
|
||||
-- Common method stubs that return safe defaults
|
||||
local metatable = {
|
||||
__index = function(_, key)
|
||||
-- Common initialization method
|
||||
if key == "init" then
|
||||
return function()
|
||||
return stub
|
||||
end
|
||||
end
|
||||
|
||||
-- Common constructor method
|
||||
if key == "new" then
|
||||
return function()
|
||||
return stub
|
||||
end
|
||||
end
|
||||
|
||||
-- Common draw method
|
||||
if key == "draw" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common update method
|
||||
if key == "update" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common render method
|
||||
if key == "render" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common cleanup method
|
||||
if key == "destroy" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common cleanup method
|
||||
if key == "cleanup" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common clear method
|
||||
if key == "clear" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common reset method
|
||||
if key == "reset" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common get method
|
||||
if key == "get" then
|
||||
return function()
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Common set method
|
||||
if key == "set" then
|
||||
return function() end
|
||||
end
|
||||
|
||||
-- Common load method
|
||||
if key == "load" then
|
||||
return function()
|
||||
return stub
|
||||
end
|
||||
end
|
||||
|
||||
-- Common cache-related methods
|
||||
if key == "cache" or key == "getCache" or key == "clearCache" then
|
||||
return function()
|
||||
return {}
|
||||
end
|
||||
end
|
||||
|
||||
-- For any unknown method, return a no-op function that accepts any arguments
|
||||
-- This allows safe method calls on stub objects (e.g., Performance:startFrame())
|
||||
return function()
|
||||
return stub
|
||||
end
|
||||
end,
|
||||
|
||||
-- Make function calls safe (in case the stub itself is called)
|
||||
__call = function()
|
||||
return stub
|
||||
end,
|
||||
}
|
||||
|
||||
setmetatable(stub, metatable)
|
||||
return stub
|
||||
end
|
||||
|
||||
--- Safely require a module with graceful fallback for optional modules
|
||||
--- Returns the module if it exists, or a null-object stub if it's optional and missing
|
||||
--- Throws an error if a required module is missing
|
||||
---@param modulePath string Full path to the module (e.g., "modules.Performance")
|
||||
---@param isOptional boolean If true, returns null-object on failure; if false, throws error
|
||||
---@return table module The loaded module or a null-object stub
|
||||
function ModuleLoader.safeRequire(modulePath, isOptional)
|
||||
-- Check if already loaded
|
||||
if ModuleLoader._registry[modulePath] then
|
||||
return ModuleLoader._registry[modulePath]
|
||||
end
|
||||
|
||||
-- Attempt to load the module
|
||||
local success, result = pcall(require, modulePath)
|
||||
|
||||
if success then
|
||||
-- Module loaded successfully
|
||||
ModuleLoader._registry[modulePath] = result
|
||||
return result
|
||||
else
|
||||
-- Module failed to load
|
||||
if isOptional then
|
||||
-- Create null-object stub for optional module
|
||||
local stub = createNullObject(modulePath)
|
||||
ModuleLoader._registry[modulePath] = stub
|
||||
|
||||
-- Log warning about missing optional module
|
||||
if ModuleLoader._ErrorHandler then
|
||||
ModuleLoader._ErrorHandler:warn("ModuleLoader", "MOD_001", {
|
||||
modulePath = modulePath,
|
||||
})
|
||||
end
|
||||
|
||||
return stub
|
||||
else
|
||||
-- Required module is missing - throw error
|
||||
error(string.format("Required module '%s' not found: %s", modulePath, tostring(result)))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Check if a module is actually loaded (not a stub)
|
||||
---@param modulePath string Full path to the module
|
||||
---@return boolean isLoaded True if module is loaded, false if it's a stub or not loaded
|
||||
function ModuleLoader.isModuleLoaded(modulePath)
|
||||
local module = ModuleLoader._registry[modulePath]
|
||||
if not module then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Check if it's a stub
|
||||
return not module._isStub
|
||||
end
|
||||
|
||||
--- Get list of all loaded modules
|
||||
---@return table modules List of module paths that are actually loaded (not stubs)
|
||||
function ModuleLoader.getLoadedModules()
|
||||
local loaded = {}
|
||||
for path, module in pairs(ModuleLoader._registry) do
|
||||
if not module._isStub then
|
||||
table.insert(loaded, path)
|
||||
end
|
||||
end
|
||||
return loaded
|
||||
end
|
||||
|
||||
--- Get list of all stub modules
|
||||
---@return table stubs List of module paths that are stubs
|
||||
function ModuleLoader.getStubModules()
|
||||
local stubs = {}
|
||||
for path, module in pairs(ModuleLoader._registry) do
|
||||
if module._isStub then
|
||||
table.insert(stubs, path)
|
||||
end
|
||||
end
|
||||
return stubs
|
||||
end
|
||||
|
||||
--- Clear the module registry (useful for testing)
|
||||
function ModuleLoader._clearRegistry()
|
||||
ModuleLoader._registry = {}
|
||||
end
|
||||
|
||||
return ModuleLoader
|
||||
@@ -0,0 +1,217 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local ImageScaler = require(modulePath .. "ImageScaler")
|
||||
|
||||
local NinePatch = {}
|
||||
|
||||
-- ErrorHandler will be injected via init
|
||||
local ErrorHandler = nil
|
||||
|
||||
--- Initialize NinePatch with dependencies
|
||||
---@param deps table Dependencies table with ErrorHandler
|
||||
function NinePatch.init(deps)
|
||||
if deps and deps.ErrorHandler then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
-- Also initialize ImageScaler since it's a dependency
|
||||
if ImageScaler.init then
|
||||
ImageScaler.init(deps)
|
||||
end
|
||||
end
|
||||
|
||||
--- Draw a 9-patch component using Android-style rendering
|
||||
--- Corners are scaled by scaleCorners multiplier, edges stretch in one dimension only
|
||||
---@param component ThemeComponent
|
||||
---@param atlas love.Image
|
||||
---@param x number -- X position (top-left corner)
|
||||
---@param y number -- Y position (top-left corner)
|
||||
---@param width number -- Total width (border-box)
|
||||
---@param height number -- Total height (border-box)
|
||||
---@param opacity number?
|
||||
---@param elementScaleCorners number? -- Element-level override for scaleCorners (scale multiplier)
|
||||
---@param elementScalingAlgorithm "nearest"|"bilinear"? -- Element-level override for scalingAlgorithm
|
||||
function NinePatch.draw(component, atlas, x, y, width, height, opacity, elementScaleCorners, elementScalingAlgorithm)
|
||||
if not component or not atlas then
|
||||
return
|
||||
end
|
||||
|
||||
opacity = opacity or 1
|
||||
love.graphics.setColor(1, 1, 1, opacity)
|
||||
|
||||
local regions = component.regions
|
||||
|
||||
-- Extract border dimensions from regions (in pixels)
|
||||
local left = regions.topLeft.w
|
||||
local right = regions.topRight.w
|
||||
local top = regions.topLeft.h
|
||||
local bottom = regions.bottomLeft.h
|
||||
local centerW = regions.middleCenter.w
|
||||
local centerH = regions.middleCenter.h
|
||||
|
||||
-- Calculate content area (space remaining after borders)
|
||||
local contentWidth = width - left - right
|
||||
local contentHeight = height - top - bottom
|
||||
|
||||
-- Clamp to prevent negative dimensions
|
||||
contentWidth = math.max(0, contentWidth)
|
||||
contentHeight = math.max(0, contentHeight)
|
||||
|
||||
-- Calculate stretch scales for edges and center
|
||||
local scaleX = contentWidth / centerW
|
||||
local scaleY = contentHeight / centerH
|
||||
|
||||
-- Create quads for each region
|
||||
local atlasWidth, atlasHeight = atlas:getDimensions()
|
||||
|
||||
local function makeQuad(region)
|
||||
return love.graphics.newQuad(region.x, region.y, region.w, region.h, atlasWidth, atlasHeight)
|
||||
end
|
||||
|
||||
-- Get corner scale multiplier
|
||||
-- Priority: element-level override > component setting > default (nil = no scaling)
|
||||
local scaleCorners = elementScaleCorners
|
||||
if scaleCorners == nil then
|
||||
scaleCorners = component.scaleCorners
|
||||
end
|
||||
|
||||
-- Priority: element-level override > component setting > default ("bilinear")
|
||||
local scalingAlgorithm = elementScalingAlgorithm
|
||||
if scalingAlgorithm == nil then
|
||||
scalingAlgorithm = component.scalingAlgorithm or "bilinear"
|
||||
end
|
||||
|
||||
if scaleCorners and type(scaleCorners) == "number" and scaleCorners > 0 then
|
||||
-- Initialize cache if needed
|
||||
if not component._scaledRegionCache then
|
||||
component._scaledRegionCache = {}
|
||||
end
|
||||
|
||||
-- Use the numeric scale multiplier directly
|
||||
local scaleFactor = scaleCorners
|
||||
|
||||
-- Helper to get or create scaled region
|
||||
local function getScaledRegion(regionName, region, targetWidth, targetHeight)
|
||||
local cacheKey = string.format("%s_%.2f_%s", regionName, scaleFactor, scalingAlgorithm)
|
||||
|
||||
if component._scaledRegionCache[cacheKey] then
|
||||
return component._scaledRegionCache[cacheKey]
|
||||
end
|
||||
|
||||
-- Get ImageData from component (stored during theme loading)
|
||||
local atlasData = component._loadedAtlasData
|
||||
if not atlasData then
|
||||
ErrorHandler.error(
|
||||
"NinePatch",
|
||||
"REN_007",
|
||||
"No ImageData available for atlas. Image must be loaded with safeLoadImage.",
|
||||
{
|
||||
componentType = component.type,
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
local scaledData
|
||||
|
||||
if scalingAlgorithm == "nearest" then
|
||||
scaledData =
|
||||
ImageScaler.scaleNearest(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight)
|
||||
else
|
||||
scaledData =
|
||||
ImageScaler.scaleBilinear(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight)
|
||||
end
|
||||
|
||||
-- Convert to image and cache
|
||||
local scaledImage = love.graphics.newImage(scaledData)
|
||||
component._scaledRegionCache[cacheKey] = scaledImage
|
||||
|
||||
return scaledImage
|
||||
end
|
||||
|
||||
-- Calculate scaled dimensions for corners
|
||||
local scaledLeft = math.floor(left * scaleFactor + 0.5)
|
||||
local scaledRight = math.floor(right * scaleFactor + 0.5)
|
||||
local scaledTop = math.floor(top * scaleFactor + 0.5)
|
||||
local scaledBottom = math.floor(bottom * scaleFactor + 0.5)
|
||||
|
||||
-- CORNERS (scaled using algorithm)
|
||||
local topLeftScaled = getScaledRegion("topLeft", regions.topLeft, scaledLeft, scaledTop)
|
||||
local topRightScaled = getScaledRegion("topRight", regions.topRight, scaledRight, scaledTop)
|
||||
local bottomLeftScaled = getScaledRegion("bottomLeft", regions.bottomLeft, scaledLeft, scaledBottom)
|
||||
local bottomRightScaled = getScaledRegion("bottomRight", regions.bottomRight, scaledRight, scaledBottom)
|
||||
|
||||
love.graphics.draw(topLeftScaled, x, y)
|
||||
love.graphics.draw(topRightScaled, x + width - scaledRight, y)
|
||||
love.graphics.draw(bottomLeftScaled, x, y + height - scaledBottom)
|
||||
love.graphics.draw(bottomRightScaled, x + width - scaledRight, y + height - scaledBottom)
|
||||
|
||||
-- Update content dimensions to account for scaled borders
|
||||
local adjustedContentWidth = width - scaledLeft - scaledRight
|
||||
local adjustedContentHeight = height - scaledTop - scaledBottom
|
||||
adjustedContentWidth = math.max(0, adjustedContentWidth)
|
||||
adjustedContentHeight = math.max(0, adjustedContentHeight)
|
||||
|
||||
-- Recalculate stretch scales
|
||||
local adjustedScaleX = adjustedContentWidth / centerW
|
||||
local adjustedScaleY = adjustedContentHeight / centerH
|
||||
|
||||
-- TOP/BOTTOM EDGES (stretch horizontally, scale vertically)
|
||||
if adjustedContentWidth > 0 then
|
||||
local topCenterScaled = getScaledRegion("topCenter", regions.topCenter, regions.topCenter.w, scaledTop)
|
||||
local bottomCenterScaled =
|
||||
getScaledRegion("bottomCenter", regions.bottomCenter, regions.bottomCenter.w, scaledBottom)
|
||||
|
||||
love.graphics.draw(topCenterScaled, x + scaledLeft, y, 0, adjustedScaleX, 1)
|
||||
love.graphics.draw(bottomCenterScaled, x + scaledLeft, y + height - scaledBottom, 0, adjustedScaleX, 1)
|
||||
end
|
||||
|
||||
-- LEFT/RIGHT EDGES (stretch vertically, scale horizontally)
|
||||
if adjustedContentHeight > 0 then
|
||||
local middleLeftScaled = getScaledRegion("middleLeft", regions.middleLeft, scaledLeft, regions.middleLeft.h)
|
||||
local middleRightScaled = getScaledRegion("middleRight", regions.middleRight, scaledRight, regions.middleRight.h)
|
||||
|
||||
love.graphics.draw(middleLeftScaled, x, y + scaledTop, 0, 1, adjustedScaleY)
|
||||
love.graphics.draw(middleRightScaled, x + width - scaledRight, y + scaledTop, 0, 1, adjustedScaleY)
|
||||
end
|
||||
|
||||
-- CENTER (stretch both dimensions, no scaling)
|
||||
if adjustedContentWidth > 0 and adjustedContentHeight > 0 then
|
||||
love.graphics.draw(
|
||||
atlas,
|
||||
makeQuad(regions.middleCenter),
|
||||
x + scaledLeft,
|
||||
y + scaledTop,
|
||||
0,
|
||||
adjustedScaleX,
|
||||
adjustedScaleY
|
||||
)
|
||||
end
|
||||
else
|
||||
-- Original rendering logic (no scaling)
|
||||
-- CORNERS (no scaling - 1:1 pixel perfect)
|
||||
love.graphics.draw(atlas, makeQuad(regions.topLeft), x, y)
|
||||
love.graphics.draw(atlas, makeQuad(regions.topRight), x + left + contentWidth, y)
|
||||
love.graphics.draw(atlas, makeQuad(regions.bottomLeft), x, y + top + contentHeight)
|
||||
love.graphics.draw(atlas, makeQuad(regions.bottomRight), x + left + contentWidth, y + top + contentHeight)
|
||||
|
||||
-- TOP/BOTTOM EDGES (stretch horizontally only)
|
||||
if contentWidth > 0 then
|
||||
love.graphics.draw(atlas, makeQuad(regions.topCenter), x + left, y, 0, scaleX, 1)
|
||||
love.graphics.draw(atlas, makeQuad(regions.bottomCenter), x + left, y + top + contentHeight, 0, scaleX, 1)
|
||||
end
|
||||
|
||||
-- LEFT/RIGHT EDGES (stretch vertically only)
|
||||
if contentHeight > 0 then
|
||||
love.graphics.draw(atlas, makeQuad(regions.middleLeft), x, y + top, 0, 1, scaleY)
|
||||
love.graphics.draw(atlas, makeQuad(regions.middleRight), x + left + contentWidth, y + top, 0, 1, scaleY)
|
||||
end
|
||||
|
||||
-- CENTER (stretch both dimensions)
|
||||
if contentWidth > 0 and contentHeight > 0 then
|
||||
love.graphics.draw(atlas, makeQuad(regions.middleCenter), x + left, y + top, 0, scaleX, scaleY)
|
||||
end
|
||||
end
|
||||
|
||||
-- Reset color
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return NinePatch
|
||||
@@ -0,0 +1,351 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
-- All numeric, range, type, and enum validation lives here.
|
||||
-- `clamp` is injected via init() to avoid a cross-import into utils.
|
||||
-- `ErrorHandler` is injected via init() so error reporting routes through
|
||||
-- the shared handler (matching the pre-split behavior of utils.validate*).
|
||||
|
||||
local ErrorHandler = nil
|
||||
local clamp = nil
|
||||
|
||||
--- Initialize dependencies
|
||||
---@param deps table Dependencies: { ErrorHandler = table, clamp = function }
|
||||
local function init(deps)
|
||||
if type(deps) == "table" then
|
||||
ErrorHandler = deps.ErrorHandler or ErrorHandler
|
||||
clamp = deps.clamp or clamp
|
||||
end
|
||||
end
|
||||
|
||||
-- Numeric validation utilities
|
||||
|
||||
--- Check if a value is NaN (not-a-number)
|
||||
--- @param value any Value to check
|
||||
--- @return boolean
|
||||
local function isNaN(value)
|
||||
return type(value) == "number" and value ~= value
|
||||
end
|
||||
|
||||
--- Check if a value is Infinity
|
||||
--- @param value any Value to check
|
||||
--- @return boolean
|
||||
local function isInfinity(value)
|
||||
return type(value) == "number" and (value == math.huge or value == -math.huge)
|
||||
end
|
||||
|
||||
--- Validate a numeric value with comprehensive checks
|
||||
--- @param value any Value to validate
|
||||
--- @param options table? Validation options
|
||||
--- @return boolean, string?, number? Returns valid, errorMessage, sanitizedValue
|
||||
local function validateNumber(value, options)
|
||||
options = options or {}
|
||||
|
||||
-- Check if value is a number type
|
||||
if type(value) ~= "number" then
|
||||
if options.default ~= nil then
|
||||
return true, nil, options.default
|
||||
end
|
||||
return false, string.format("Value must be a number, got %s", type(value)), nil
|
||||
end
|
||||
|
||||
-- Check for NaN
|
||||
if isNaN(value) then
|
||||
if not options.allowNaN then
|
||||
if options.default ~= nil then
|
||||
return true, nil, options.default
|
||||
end
|
||||
return false, "Value is NaN (not-a-number)", nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for Infinity
|
||||
if isInfinity(value) then
|
||||
if not options.allowInfinity then
|
||||
if options.default ~= nil then
|
||||
return true, nil, options.default
|
||||
end
|
||||
return false, "Value is Infinity", nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for integer requirement
|
||||
if options.integer and math.floor(value) ~= value then
|
||||
return false, string.format("Value must be an integer, got %s", value), nil
|
||||
end
|
||||
|
||||
-- Check for positive requirement
|
||||
if options.positive and value <= 0 then
|
||||
return false, string.format("Value must be positive, got %s", value), nil
|
||||
end
|
||||
|
||||
-- Check bounds
|
||||
if options.min and value < options.min then
|
||||
return false, string.format("Value %s is below minimum %s", value, options.min), nil
|
||||
end
|
||||
|
||||
if options.max and value > options.max then
|
||||
return false, string.format("Value %s is above maximum %s", value, options.max), nil
|
||||
end
|
||||
|
||||
return true, nil, value
|
||||
end
|
||||
|
||||
--- Sanitize a numeric value (never errors, always returns valid number)
|
||||
--- @param value any Value to sanitize
|
||||
--- @param min number? Minimum value
|
||||
--- @param max number? Maximum value
|
||||
--- @param default number? Default value for invalid inputs
|
||||
--- @return number Sanitized value
|
||||
local function sanitizeNumber(value, min, max, default)
|
||||
default = default or 0
|
||||
min = min or -math.huge
|
||||
max = max or math.huge
|
||||
|
||||
-- Convert to number if possible
|
||||
if type(value) == "string" then
|
||||
value = tonumber(value)
|
||||
end
|
||||
|
||||
-- Handle non-numeric
|
||||
if type(value) ~= "number" then
|
||||
return default
|
||||
end
|
||||
|
||||
-- Handle NaN
|
||||
if isNaN(value) then
|
||||
return default
|
||||
end
|
||||
|
||||
-- Handle Infinity
|
||||
if value == math.huge then
|
||||
return max
|
||||
end
|
||||
if value == -math.huge then
|
||||
return min
|
||||
end
|
||||
|
||||
-- Clamp to range
|
||||
return clamp(value, min, max)
|
||||
end
|
||||
|
||||
--- Validate and convert to integer
|
||||
--- @param value any Value to validate
|
||||
--- @param min number? Minimum value
|
||||
--- @param max number? Maximum value
|
||||
--- @return boolean, string?, number? Returns valid, errorMessage, integerValue
|
||||
local function validateInteger(value, min, max)
|
||||
local valid, err, sanitized = validateNumber(value, {
|
||||
min = min,
|
||||
max = max,
|
||||
integer = true,
|
||||
})
|
||||
|
||||
if not valid then
|
||||
return false, err, nil
|
||||
end
|
||||
|
||||
return true, nil, math.floor(sanitized or value)
|
||||
end
|
||||
|
||||
--- Validate and normalize percentage value
|
||||
--- @param value any Value to validate (can be "50%", 0.5, or 50)
|
||||
--- @return boolean, string?, number? Returns valid, errorMessage, normalizedValue (0-1)
|
||||
local function validatePercentage(value)
|
||||
-- Handle string percentage
|
||||
if type(value) == "string" then
|
||||
local num = value:match("^(%d+%.?%d*)%%$")
|
||||
if num then
|
||||
value = tonumber(num)
|
||||
if value then
|
||||
value = value / 100
|
||||
end
|
||||
else
|
||||
value = tonumber(value)
|
||||
end
|
||||
end
|
||||
|
||||
if type(value) ~= "number" then
|
||||
return false, "Percentage must be a number", nil
|
||||
end
|
||||
|
||||
if isNaN(value) or isInfinity(value) then
|
||||
return false, "Percentage cannot be NaN or Infinity", nil
|
||||
end
|
||||
|
||||
-- If value is > 1, assume it's 0-100 range
|
||||
if value > 1 then
|
||||
value = value / 100
|
||||
end
|
||||
|
||||
-- Clamp to 0-1
|
||||
value = clamp(value, 0, 1)
|
||||
|
||||
return true, nil, value
|
||||
end
|
||||
|
||||
--- Validate opacity value (0-1)
|
||||
--- @param value any Value to validate
|
||||
--- @return boolean, string?, number? Returns valid, errorMessage, opacityValue
|
||||
local function validateOpacity(value)
|
||||
return validateNumber(value, { min = 0, max = 1, default = 1 })
|
||||
end
|
||||
|
||||
--- Validate degree value (0-360)
|
||||
--- @param value any Value to validate
|
||||
--- @return boolean, string?, number? Returns valid, errorMessage, degreeValue
|
||||
local function validateDegrees(value)
|
||||
local valid, err, sanitized = validateNumber(value)
|
||||
if not valid then
|
||||
return false, err, nil
|
||||
end
|
||||
|
||||
-- Normalize to 0-360 range
|
||||
local degrees = sanitized or value
|
||||
degrees = degrees % 360
|
||||
if degrees < 0 then
|
||||
degrees = degrees + 360
|
||||
end
|
||||
|
||||
return true, nil, degrees
|
||||
end
|
||||
|
||||
--- Validate coordinate value (pixel position)
|
||||
--- @param value any Value to validate
|
||||
--- @return boolean, string?, number? Returns valid, errorMessage, coordinateValue
|
||||
local function validateCoordinate(value)
|
||||
return validateNumber(value, {
|
||||
allowNaN = false,
|
||||
allowInfinity = false,
|
||||
})
|
||||
end
|
||||
|
||||
--- Validate dimension value (width/height, must be non-negative)
|
||||
--- @param value any Value to validate
|
||||
--- @return boolean, string?, number? Returns valid, errorMessage, dimensionValue
|
||||
local function validateDimension(value)
|
||||
return validateNumber(value, {
|
||||
min = 0,
|
||||
allowNaN = false,
|
||||
allowInfinity = false,
|
||||
})
|
||||
end
|
||||
|
||||
--- Validate that a value is in an enum table
|
||||
---@param value any Value to validate
|
||||
---@param enumTable table Enum table with valid values
|
||||
---@param propName string Property name for error messages
|
||||
---@param moduleName string? Module name for error messages (default: "Element")
|
||||
---@return boolean True if valid
|
||||
local function validateEnum(value, enumTable, propName, moduleName)
|
||||
if value == nil then
|
||||
return true
|
||||
end
|
||||
|
||||
for _, validValue in pairs(enumTable) do
|
||||
if value == validValue then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- Build list of valid options
|
||||
local validOptions = {}
|
||||
for _, v in pairs(enumTable) do
|
||||
table.insert(validOptions, "'" .. v .. "'")
|
||||
end
|
||||
table.sort(validOptions)
|
||||
|
||||
if ErrorHandler then
|
||||
ErrorHandler:error(moduleName or "Element", "VAL_007", {
|
||||
property = propName,
|
||||
expected = table.concat(validOptions, ", "),
|
||||
got = tostring(value),
|
||||
})
|
||||
else
|
||||
error(
|
||||
string.format("%s must be one of: %s. Got: '%s'", propName, table.concat(validOptions, ", "), tostring(value))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
--- Validate that a numeric value is within a range
|
||||
---@param value any Value to validate
|
||||
---@param min number Minimum allowed value
|
||||
---@param max number Maximum allowed value
|
||||
---@param propName string Property name for error messages
|
||||
---@param moduleName string? Module name for error messages (default: "Element")
|
||||
---@return boolean True if valid
|
||||
local function validateRange(value, min, max, propName, moduleName)
|
||||
if value == nil then
|
||||
return true
|
||||
end
|
||||
if type(value) ~= "number" then
|
||||
if ErrorHandler then
|
||||
ErrorHandler:error(moduleName or "Element", "VAL_001", {
|
||||
property = propName,
|
||||
expected = "number",
|
||||
got = type(value),
|
||||
})
|
||||
else
|
||||
error(string.format("%s must be a number, got %s", propName, type(value)))
|
||||
end
|
||||
elseif value < min or value > max then
|
||||
if ErrorHandler then
|
||||
ErrorHandler:error(moduleName or "Element", "VAL_002", {
|
||||
property = propName,
|
||||
min = tostring(min),
|
||||
max = tostring(max),
|
||||
value = tostring(value),
|
||||
})
|
||||
else
|
||||
error(
|
||||
string.format("%s must be between %s and %s, got %s", propName, tostring(min), tostring(max), tostring(value))
|
||||
)
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
--- Validate that a value is of the expected type
|
||||
---@param value any Value to validate
|
||||
---@param expectedType string Expected type name
|
||||
---@param propName string Property name for error messages
|
||||
---@param moduleName string? Module name for error messages (default: "Element")
|
||||
---@return boolean True if valid
|
||||
local function validateType(value, expectedType, propName, moduleName)
|
||||
if value == nil then
|
||||
return true
|
||||
end
|
||||
local actualType = type(value)
|
||||
if actualType ~= expectedType then
|
||||
if ErrorHandler then
|
||||
ErrorHandler:error(moduleName or "Element", "VAL_001", {
|
||||
property = propName,
|
||||
expected = expectedType,
|
||||
got = actualType,
|
||||
})
|
||||
else
|
||||
error(string.format("%s must be %s, got %s", propName, expectedType, actualType))
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return {
|
||||
init = init,
|
||||
isNaN = isNaN,
|
||||
isInfinity = isInfinity,
|
||||
validateNumber = validateNumber,
|
||||
sanitizeNumber = sanitizeNumber,
|
||||
validateInteger = validateInteger,
|
||||
validatePercentage = validatePercentage,
|
||||
validateOpacity = validateOpacity,
|
||||
validateDegrees = validateDegrees,
|
||||
validateCoordinate = validateCoordinate,
|
||||
validateDimension = validateDimension,
|
||||
validateEnum = validateEnum,
|
||||
validateRange = validateRange,
|
||||
validateType = validateType,
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
-- Path sanitization, validation, and file-extension helpers.
|
||||
-- Uses love.filesystem when available (optional) for existence checks.
|
||||
|
||||
--- Normalize a file path for consistent cache keys
|
||||
---@param path string File path to normalize
|
||||
---@return string Normalized path
|
||||
local function normalizePath(path)
|
||||
path = path:match("^%s*(.-)%s*$")
|
||||
path = path:gsub("\\", "/")
|
||||
path = path:gsub("/+", "/")
|
||||
return path
|
||||
end
|
||||
|
||||
--- Sanitize a file path
|
||||
--- @param path string Path to sanitize
|
||||
--- @return string Sanitized path
|
||||
local function sanitizePath(path)
|
||||
if path == nil then
|
||||
return ""
|
||||
end
|
||||
path = tostring(path)
|
||||
|
||||
-- Trim whitespace
|
||||
path = path:match("^%s*(.-)%s*$") or ""
|
||||
|
||||
-- Normalize separators to forward slash
|
||||
path = path:gsub("\\", "/")
|
||||
|
||||
-- Remove duplicate slashes
|
||||
path = path:gsub("/+", "/")
|
||||
|
||||
-- Remove trailing slash (except for root)
|
||||
if #path > 1 and path:sub(-1) == "/" then
|
||||
path = path:sub(1, -2)
|
||||
end
|
||||
|
||||
return path
|
||||
end
|
||||
|
||||
--- Check if a path is safe (no traversal attacks)
|
||||
--- @param path string Path to check
|
||||
--- @param baseDir string? Base directory to check against (optional)
|
||||
--- @return boolean, string? Returns true if safe, or false with reason
|
||||
local function isPathSafe(path, baseDir)
|
||||
if path == nil or path == "" then
|
||||
return false, "Path is empty"
|
||||
end
|
||||
|
||||
-- Sanitize the path
|
||||
path = sanitizePath(path)
|
||||
|
||||
-- Check for suspicious patterns
|
||||
if path:match("%.%.") then
|
||||
return false, "Path contains '..' (parent directory reference)"
|
||||
end
|
||||
|
||||
-- Check for null bytes
|
||||
if path:match("%z") then
|
||||
return false, "Path contains null bytes"
|
||||
end
|
||||
|
||||
-- Check for encoded traversal attempts (including double-encoding)
|
||||
local lowerPath = path:lower()
|
||||
if
|
||||
lowerPath:match("%%2e")
|
||||
or lowerPath:match("%%2f")
|
||||
or lowerPath:match("%%5c")
|
||||
or lowerPath:match("%%252e")
|
||||
or lowerPath:match("%%252f")
|
||||
or lowerPath:match("%%255c")
|
||||
then
|
||||
return false, "Path contains URL-encoded directory separators"
|
||||
end
|
||||
|
||||
-- If baseDir is provided, ensure path is within it
|
||||
if baseDir then
|
||||
baseDir = sanitizePath(baseDir)
|
||||
|
||||
-- For relative paths, prepend baseDir
|
||||
local fullPath = path
|
||||
if not path:match("^/") and not path:match("^%a:") then
|
||||
fullPath = baseDir .. "/" .. path
|
||||
end
|
||||
fullPath = sanitizePath(fullPath)
|
||||
|
||||
-- Check if fullPath starts with baseDir
|
||||
if not fullPath:match("^" .. baseDir:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")) then
|
||||
return false, "Path is outside allowed directory"
|
||||
end
|
||||
end
|
||||
|
||||
return true, nil
|
||||
end
|
||||
|
||||
--- Validate a file path with comprehensive checks
|
||||
--- @param path string Path to validate
|
||||
--- @param options table? Validation options
|
||||
--- @return boolean, string? Returns true if valid, or false with error message
|
||||
local function validatePath(path, options)
|
||||
options = options or {}
|
||||
|
||||
-- Check path is not nil/empty
|
||||
if path == nil or path == "" then
|
||||
return false, "Path is empty"
|
||||
end
|
||||
|
||||
path = tostring(path)
|
||||
|
||||
-- Check maximum length
|
||||
local maxLength = options.maxLength or 4096
|
||||
if #path > maxLength then
|
||||
return false, string.format("Path exceeds maximum length of %d characters", maxLength)
|
||||
end
|
||||
|
||||
-- Sanitize path
|
||||
path = sanitizePath(path)
|
||||
|
||||
-- Check for safety (traversal attacks)
|
||||
local safe, reason = isPathSafe(path, options.baseDir)
|
||||
if not safe then
|
||||
return false, reason
|
||||
end
|
||||
|
||||
-- Check allowed extensions
|
||||
if options.allowedExtensions then
|
||||
local ext = path:match("%.([^%.]+)$")
|
||||
if not ext then
|
||||
return false, "Path has no file extension"
|
||||
end
|
||||
|
||||
ext = ext:lower()
|
||||
local allowed = false
|
||||
for _, allowedExt in ipairs(options.allowedExtensions) do
|
||||
if ext == allowedExt:lower() then
|
||||
allowed = true
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if not allowed then
|
||||
return false, string.format("File extension '%s' is not allowed", ext)
|
||||
end
|
||||
end
|
||||
|
||||
-- Check if file must exist
|
||||
if options.mustExist and love and love.filesystem then
|
||||
local info = love.filesystem.getInfo(path)
|
||||
if not info then
|
||||
return false, "File does not exist"
|
||||
end
|
||||
end
|
||||
|
||||
return true, nil
|
||||
end
|
||||
|
||||
--- Get file extension from path
|
||||
--- @param path string File path
|
||||
--- @return string? extension File extension (lowercase) or nil
|
||||
local function getFileExtension(path)
|
||||
if not path then
|
||||
return nil
|
||||
end
|
||||
local ext = path:match("%.([^%.]+)$")
|
||||
return ext and ext:lower() or nil
|
||||
end
|
||||
|
||||
--- Check if path has allowed extension
|
||||
--- @param path string File path
|
||||
--- @param allowedExtensions table Array of allowed extensions
|
||||
--- @return boolean
|
||||
local function hasAllowedExtension(path, allowedExtensions)
|
||||
local ext = getFileExtension(path)
|
||||
if not ext then
|
||||
return false
|
||||
end
|
||||
|
||||
for _, allowedExt in ipairs(allowedExtensions) do
|
||||
if ext == allowedExt:lower() then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
return {
|
||||
normalizePath = normalizePath,
|
||||
sanitizePath = sanitizePath,
|
||||
isPathSafe = isPathSafe,
|
||||
validatePath = validatePath,
|
||||
getFileExtension = getFileExtension,
|
||||
hasAllowedExtension = hasAllowedExtension,
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
---@class Performance
|
||||
---@field enabled boolean
|
||||
---@field hudEnabled boolean
|
||||
---@field hudToggleKey string
|
||||
---@field hudPosition {x: number, y: number}
|
||||
---@field warningThresholdMs number
|
||||
---@field criticalThresholdMs number
|
||||
---@field logToConsole boolean
|
||||
---@field logWarnings boolean
|
||||
---@field warningsEnabled boolean
|
||||
---@field _ErrorHandler table?
|
||||
---@field _timers table
|
||||
---@field _metrics table
|
||||
---@field _lastMetricsCleanup number
|
||||
---@field _frameMetrics table
|
||||
---@field _memoryMetrics table
|
||||
---@field _warnings table
|
||||
---@field _lastFrameStart number?
|
||||
---@field _shownWarnings table
|
||||
---@field _memoryProfiler table
|
||||
local Performance = {}
|
||||
Performance.__index = Performance
|
||||
|
||||
---@type Performance|nil
|
||||
local instance = nil
|
||||
|
||||
local METRICS_CLEANUP_INTERVAL = 30
|
||||
local METRICS_RETENTION_TIME = 10
|
||||
local MAX_METRICS_COUNT = 500
|
||||
local CORE_METRICS = { frame = true, layout = true, render = true }
|
||||
|
||||
---@param config {enabled?: boolean, hudEnabled?: boolean, hudToggleKey?: string, hudPosition?: {x: number, y: number}, warningThresholdMs?: number, criticalThresholdMs?: number, logToConsole?: boolean, logWarnings?: boolean, warningsEnabled?: boolean, memoryProfiling?: boolean}?
|
||||
---@param deps {ErrorHandler: ErrorHandler}
|
||||
---@return Performance
|
||||
function Performance.init(config, deps)
|
||||
if instance == nil then
|
||||
local self = setmetatable({}, Performance)
|
||||
|
||||
-- Configuration
|
||||
self.enabled = config and config.enabled or false
|
||||
self.hudEnabled = config and config.hudEnabled or false
|
||||
self.hudToggleKey = config and config.hudToggleKey or "f3"
|
||||
self.hudPosition = config and config.hudPosition or { x = 10, y = 10 }
|
||||
self.warningThresholdMs = config and config.warningThresholdMs or 13.0
|
||||
self.criticalThresholdMs = config and config.criticalThresholdMs or 16.67
|
||||
self.logToConsole = config and config.logToConsole or false
|
||||
self.logWarnings = config and config.logWarnings or true
|
||||
self.warningsEnabled = config and config.warningsEnabled or true
|
||||
|
||||
self._timers = {}
|
||||
self._metrics = {}
|
||||
self._lastMetricsCleanup = 0
|
||||
self._frameMetrics = {
|
||||
frameCount = 0,
|
||||
totalTime = 0,
|
||||
lastFrameTime = 0,
|
||||
minFrameTime = math.huge,
|
||||
maxFrameTime = 0,
|
||||
fps = 0,
|
||||
lastFpsUpdate = 0,
|
||||
fpsUpdateInterval = 0.5,
|
||||
}
|
||||
self._memoryMetrics = {
|
||||
current = 0,
|
||||
peak = 0,
|
||||
gcCount = 0,
|
||||
lastGcCheck = 0,
|
||||
}
|
||||
self._warnings = {}
|
||||
self._lastFrameStart = nil
|
||||
self._shownWarnings = {}
|
||||
self._memoryProfiler = {
|
||||
enabled = config and config.memoryProfiling or false,
|
||||
sampleInterval = 60,
|
||||
framesSinceLastSample = 0,
|
||||
samples = {},
|
||||
maxSamples = 20,
|
||||
monitoredTables = {},
|
||||
}
|
||||
self._ErrorHandler = deps and deps.ErrorHandler
|
||||
instance = self
|
||||
end
|
||||
return instance
|
||||
end
|
||||
|
||||
--- Toggle HUD visibility
|
||||
function Performance:toggleHUD()
|
||||
self.hudEnabled = not self.hudEnabled
|
||||
end
|
||||
|
||||
function Performance:startTimer(name)
|
||||
if not self.enabled then
|
||||
return
|
||||
end
|
||||
self._timers[name] = love.timer.getTime()
|
||||
end
|
||||
|
||||
function Performance:stopTimer(name)
|
||||
if not self.enabled then
|
||||
return nil
|
||||
end
|
||||
|
||||
local startTime = self._timers[name]
|
||||
if not startTime then
|
||||
-- Silently return nil if timer wasn't started
|
||||
-- This can happen legitimately when Performance is toggled mid-frame
|
||||
-- or when layout functions have early returns
|
||||
return nil
|
||||
end
|
||||
|
||||
local elapsed = (love.timer.getTime() - startTime) * 1000
|
||||
self._timers[name] = nil
|
||||
|
||||
-- Update metrics
|
||||
if not self._metrics[name] then
|
||||
self._metrics[name] = {
|
||||
total = 0,
|
||||
count = 0,
|
||||
min = math.huge,
|
||||
max = 0,
|
||||
average = 0,
|
||||
lastUsed = love.timer.getTime(),
|
||||
}
|
||||
end
|
||||
|
||||
local m = self._metrics[name]
|
||||
m.total = m.total + elapsed
|
||||
m.count = m.count + 1
|
||||
m.min = math.min(m.min, elapsed)
|
||||
m.max = math.max(m.max, elapsed)
|
||||
m.average = m.total / m.count
|
||||
m.lastUsed = love.timer.getTime()
|
||||
|
||||
-- Check for warnings
|
||||
if elapsed > self.criticalThresholdMs then
|
||||
self:_addWarning(name, elapsed, "critical")
|
||||
elseif elapsed > self.warningThresholdMs then
|
||||
self:_addWarning(name, elapsed, "warning")
|
||||
end
|
||||
|
||||
if self.logToConsole then
|
||||
-- Use ErrorHandler if available, otherwise fall back to print
|
||||
if self._ErrorHandler and self._ErrorHandler.warn then
|
||||
self._ErrorHandler:warn("Performance", "PERF_001", {
|
||||
metric = name,
|
||||
elapsed = string.format("%.3fms", elapsed),
|
||||
})
|
||||
else
|
||||
print(string.format("[Performance] %s: %.3fms", name, elapsed))
|
||||
end
|
||||
end
|
||||
|
||||
return elapsed
|
||||
end
|
||||
|
||||
--- Update with actual delta time from LÖVE (call from love.update)
|
||||
---@param dt number Delta time in seconds
|
||||
function Performance:updateDeltaTime(dt)
|
||||
if not self.enabled then
|
||||
return
|
||||
end
|
||||
local now = love.timer.getTime()
|
||||
if now - self._frameMetrics.lastFpsUpdate >= self._frameMetrics.fpsUpdateInterval then
|
||||
if dt > 0 then
|
||||
self._frameMetrics.fps = math.floor(1 / dt + 0.5)
|
||||
end
|
||||
self._frameMetrics.lastFpsUpdate = now
|
||||
end
|
||||
end
|
||||
|
||||
--- Start frame timing (call at beginning of frame)
|
||||
function Performance:startFrame()
|
||||
if not self.enabled then
|
||||
return
|
||||
end
|
||||
self._lastFrameStart = love.timer.getTime()
|
||||
self:_updateMemory()
|
||||
end
|
||||
|
||||
function Performance:endFrame()
|
||||
if not self.enabled or not self._lastFrameStart then
|
||||
return
|
||||
end
|
||||
|
||||
local now = love.timer.getTime()
|
||||
local frameTime = (now - self._lastFrameStart) * 1000
|
||||
|
||||
self._frameMetrics.lastFrameTime = frameTime
|
||||
self._frameMetrics.totalTime = self._frameMetrics.totalTime + frameTime
|
||||
self._frameMetrics.frameCount = self._frameMetrics.frameCount + 1
|
||||
self._frameMetrics.minFrameTime = math.min(self._frameMetrics.minFrameTime, frameTime)
|
||||
self._frameMetrics.maxFrameTime = math.max(self._frameMetrics.maxFrameTime, frameTime)
|
||||
|
||||
if frameTime > self.criticalThresholdMs then
|
||||
self:_addWarning("frame", frameTime, "critical")
|
||||
end
|
||||
|
||||
self:updateMemoryProfiling()
|
||||
|
||||
-- Periodic metrics cleanup
|
||||
if now - self._lastMetricsCleanup >= METRICS_CLEANUP_INTERVAL then
|
||||
local cleanupTime = now - METRICS_RETENTION_TIME
|
||||
for name, data in pairs(self._metrics) do
|
||||
if not CORE_METRICS[name] and data.lastUsed and data.lastUsed < cleanupTime then
|
||||
self._metrics[name] = nil
|
||||
end
|
||||
end
|
||||
self._lastMetricsCleanup = now
|
||||
end
|
||||
|
||||
-- Enforce max metrics limit
|
||||
local metricsCount = 0
|
||||
for _ in pairs(self._metrics) do
|
||||
metricsCount = metricsCount + 1
|
||||
end
|
||||
|
||||
if metricsCount > MAX_METRICS_COUNT then
|
||||
local sortedMetrics = {}
|
||||
for name, data in pairs(self._metrics) do
|
||||
if not CORE_METRICS[name] then
|
||||
table.insert(sortedMetrics, { name = name, lastUsed = data.lastUsed or 0 })
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(sortedMetrics, function(a, b)
|
||||
return a.lastUsed < b.lastUsed
|
||||
end)
|
||||
|
||||
local toRemove = metricsCount - MAX_METRICS_COUNT
|
||||
for i = 1, math.min(toRemove, #sortedMetrics) do
|
||||
self._metrics[sortedMetrics[i].name] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Update memory metrics
|
||||
function Performance:_updateMemory()
|
||||
if not self.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local memKb = collectgarbage("count")
|
||||
self._memoryMetrics.current = memKb
|
||||
self._memoryMetrics.peak = math.max(self._memoryMetrics.peak, memKb)
|
||||
|
||||
local now = love.timer.getTime()
|
||||
if now - self._memoryMetrics.lastGcCheck >= 1.0 then
|
||||
self._memoryMetrics.gcCount = self._memoryMetrics.gcCount + 1
|
||||
self._memoryMetrics.lastGcCheck = now
|
||||
end
|
||||
end
|
||||
|
||||
--- Add a performance warning (private)
|
||||
--- @param name string Metric name
|
||||
--- @param value number Metric value
|
||||
--- @param level "warning"|"critical" Warning level
|
||||
function Performance:_addWarning(name, value, level)
|
||||
if not self.logWarnings then
|
||||
return
|
||||
end
|
||||
|
||||
local warning = {
|
||||
name = name,
|
||||
value = value,
|
||||
level = level,
|
||||
time = love.timer.getTime(),
|
||||
}
|
||||
|
||||
table.insert(self._warnings, warning)
|
||||
|
||||
if #self._warnings > 100 then
|
||||
table.remove(self._warnings, 1)
|
||||
end
|
||||
|
||||
if self.logToConsole or self.warningsEnabled then
|
||||
local warningKey = name .. "_" .. level
|
||||
local lastWarningTime = self._shownWarnings[warningKey] or 0
|
||||
local now = love.timer.getTime()
|
||||
|
||||
if now - lastWarningTime >= 60 then
|
||||
if self._ErrorHandler and self._ErrorHandler.warn then
|
||||
local code = level == "critical" and "PERF_002" or "PERF_001"
|
||||
|
||||
self._ErrorHandler:warn("Performance", code, {
|
||||
metric = name,
|
||||
value = string.format("%.2fms", value),
|
||||
threshold = level == "critical" and self.criticalThresholdMs or self.warningThresholdMs,
|
||||
})
|
||||
end
|
||||
|
||||
self._shownWarnings[warningKey] = now
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Render performance HUD
|
||||
--- @param x number? X position (default: 10)
|
||||
--- @param y number? Y position (default: 10)
|
||||
function Performance:renderHUD(x, y)
|
||||
if not self.hudEnabled then
|
||||
return
|
||||
end
|
||||
|
||||
x = x or self.hudPosition.x
|
||||
y = y or self.hudPosition.y
|
||||
|
||||
self:_updateMemory()
|
||||
|
||||
local fm = self._frameMetrics
|
||||
local mm = self._memoryMetrics
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 0.8)
|
||||
love.graphics.rectangle("fill", x, y, 300, 220)
|
||||
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local lineHeight = 18
|
||||
local currentY = y + 10
|
||||
|
||||
-- FPS
|
||||
local fpsColor = { 1, 1, 1 }
|
||||
if fm.lastFrameTime > self.criticalThresholdMs then
|
||||
fpsColor = { 1, 0, 0 }
|
||||
elseif fm.lastFrameTime > self.warningThresholdMs then
|
||||
fpsColor = { 1, 1, 0 }
|
||||
end
|
||||
love.graphics.setColor(fpsColor)
|
||||
love.graphics.print(string.format("FPS: %d (%.2fms)", fm.fps, fm.lastFrameTime), x + 10, currentY)
|
||||
currentY = currentY + lineHeight
|
||||
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local avgFrame = fm.frameCount > 0 and fm.totalTime / fm.frameCount or 0
|
||||
love.graphics.print(string.format("Avg Frame: %.2fms", avgFrame), x + 10, currentY)
|
||||
currentY = currentY + lineHeight
|
||||
love.graphics.print(string.format("Min/Max: %.2f/%.2fms", fm.minFrameTime, fm.maxFrameTime), x + 10, currentY)
|
||||
currentY = currentY + lineHeight
|
||||
|
||||
local currentMb = mm.current / 1024
|
||||
local peakMb = mm.peak / 1024
|
||||
love.graphics.print(string.format("Memory: %.2f MB (peak: %.2f MB)", currentMb, peakMb), x + 10, currentY)
|
||||
currentY = currentY + lineHeight
|
||||
|
||||
local metricsCount = 0
|
||||
for _ in pairs(self._metrics) do
|
||||
metricsCount = metricsCount + 1
|
||||
end
|
||||
local metricsColor = metricsCount > MAX_METRICS_COUNT * 0.8 and { 1, 0.5, 0 } or { 1, 1, 1 }
|
||||
love.graphics.setColor(metricsColor)
|
||||
love.graphics.print(string.format("Metrics: %d/%d", metricsCount, MAX_METRICS_COUNT), x + 10, currentY)
|
||||
currentY = currentY + lineHeight + 5
|
||||
|
||||
-- Top timings
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local sortedMetrics = {}
|
||||
for name, data in pairs(self._metrics) do
|
||||
table.insert(sortedMetrics, { name = name, average = data.average })
|
||||
end
|
||||
table.sort(sortedMetrics, function(a, b)
|
||||
return a.average > b.average
|
||||
end)
|
||||
|
||||
love.graphics.print("Top Timings:", x + 10, currentY)
|
||||
currentY = currentY + lineHeight
|
||||
|
||||
for i = 1, math.min(5, #sortedMetrics) do
|
||||
local m = sortedMetrics[i]
|
||||
love.graphics.print(string.format(" %s: %.3fms", m.name, m.average), x + 10, currentY)
|
||||
currentY = currentY + lineHeight
|
||||
end
|
||||
|
||||
if #self._warnings > 0 then
|
||||
love.graphics.setColor(1, 0.5, 0, 1)
|
||||
love.graphics.print(string.format("Warnings: %d", #self._warnings), x + 10, currentY)
|
||||
end
|
||||
end
|
||||
|
||||
--- Handle keyboard input for HUD toggle
|
||||
--- @param key string Key pressed
|
||||
function Performance:keypressed(key)
|
||||
if key == self.hudToggleKey then
|
||||
self:toggleHUD()
|
||||
end
|
||||
end
|
||||
|
||||
--- Log a performance warning (only once per warning key)
|
||||
--- @param warningKey string Unique key for this warning type
|
||||
--- @param module string Module name (e.g., "LayoutEngine", "Element")
|
||||
--- @param message string Warning message
|
||||
--- @param details table? Additional details
|
||||
--- @param suggestion string? Optimization suggestion
|
||||
function Performance:logWarning(warningKey, module, message, details, suggestion)
|
||||
if not self.warningsEnabled then
|
||||
return
|
||||
end
|
||||
|
||||
if self._shownWarnings[warningKey] then
|
||||
return
|
||||
end
|
||||
|
||||
self._shownWarnings[warningKey] = true
|
||||
|
||||
local count = 0
|
||||
for _ in pairs(self._shownWarnings) do
|
||||
count = count + 1
|
||||
end
|
||||
if count > 1000 then
|
||||
self._shownWarnings = { [warningKey] = true }
|
||||
end
|
||||
|
||||
if self._ErrorHandler and self._ErrorHandler.warn then
|
||||
self._ErrorHandler:warn(module, "PERF_001", details or {})
|
||||
end
|
||||
end
|
||||
|
||||
--- Track a counter metric (increments per frame)
|
||||
--- @param name string Counter name
|
||||
--- @param value number? Value to add (default: 1)
|
||||
function Performance:incrementCounter(name, value)
|
||||
if not self.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
value = value or 1
|
||||
|
||||
if not self._metrics[name] then
|
||||
self._metrics[name] = {
|
||||
total = 0,
|
||||
count = 0,
|
||||
min = math.huge,
|
||||
max = 0,
|
||||
average = 0,
|
||||
frameValue = 0,
|
||||
lastUsed = love.timer.getTime(),
|
||||
}
|
||||
end
|
||||
|
||||
local m = self._metrics[name]
|
||||
m.frameValue = (m.frameValue or 0) + value
|
||||
m.lastUsed = love.timer.getTime()
|
||||
end
|
||||
|
||||
--- Reset frame counters (call at end of frame)
|
||||
function Performance:resetFrameCounters()
|
||||
if not self.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
local now = love.timer.getTime()
|
||||
local toRemove = {}
|
||||
|
||||
for name, data in pairs(self._metrics) do
|
||||
if data.frameValue then
|
||||
if data.frameValue > 0 then
|
||||
data.total = data.total + data.frameValue
|
||||
data.count = data.count + 1
|
||||
data.min = math.min(data.min, data.frameValue)
|
||||
data.max = math.max(data.max, data.frameValue)
|
||||
data.average = data.total / data.count
|
||||
data.lastUsed = now
|
||||
end
|
||||
|
||||
data.frameValue = 0
|
||||
|
||||
if data.count == 0 and not CORE_METRICS[name] then
|
||||
table.insert(toRemove, name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for _, name in ipairs(toRemove) do
|
||||
self._metrics[name] = nil
|
||||
end
|
||||
end
|
||||
|
||||
--- Register a table for memory leak monitoring
|
||||
--- @param name string Friendly name for the table
|
||||
--- @param tableRef table Reference to the table to monitor
|
||||
function Performance:registerTableForMonitoring(name, tableRef)
|
||||
self._memoryProfiler.monitoredTables[name] = tableRef
|
||||
end
|
||||
|
||||
function Performance:_sampleMemory()
|
||||
local sample = {
|
||||
time = love.timer.getTime(),
|
||||
memory = collectgarbage("count") / 1024, -- MB
|
||||
tableSizes = {},
|
||||
}
|
||||
local function getTableSize(tbl)
|
||||
local count = 0
|
||||
for _ in pairs(tbl) do
|
||||
count = count + 1
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
for name, tableRef in pairs(self._memoryProfiler.monitoredTables) do
|
||||
sample.tableSizes[name] = getTableSize(tableRef)
|
||||
end
|
||||
|
||||
table.insert(self._memoryProfiler.samples, sample)
|
||||
|
||||
-- Keep only maxSamples
|
||||
if #self._memoryProfiler.samples > self._memoryProfiler.maxSamples then
|
||||
table.remove(self._memoryProfiler.samples, 1)
|
||||
end
|
||||
|
||||
-- Check for memory leaks (consistent growth)
|
||||
if #self._memoryProfiler.samples >= 5 then
|
||||
for name, _ in pairs(self._memoryProfiler.monitoredTables) do
|
||||
local sizes = {}
|
||||
for i = math.max(1, #self._memoryProfiler.samples - 4), #self._memoryProfiler.samples do
|
||||
table.insert(sizes, self._memoryProfiler.samples[i].tableSizes[name])
|
||||
end
|
||||
|
||||
-- Check if table is consistently growing
|
||||
local growing = true
|
||||
for i = 2, #sizes do
|
||||
if sizes[i] <= sizes[i - 1] then
|
||||
growing = false
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if growing and sizes[#sizes] > sizes[1] * 1.5 then
|
||||
self:_addWarning("memory_leak", sizes[#sizes], "warning")
|
||||
|
||||
if not self._shownWarnings[name] then
|
||||
local message = string.format("Table '%s' growing consistently", name)
|
||||
if self._ErrorHandler and self._ErrorHandler.warn then
|
||||
self._ErrorHandler:warn("Performance", "MEM_001", {
|
||||
table = name,
|
||||
initialSize = sizes[1],
|
||||
currentSize = sizes[#sizes],
|
||||
growthPercent = math.floor(((sizes[#sizes] / sizes[1]) - 1) * 100),
|
||||
})
|
||||
end
|
||||
|
||||
self._shownWarnings[name] = true
|
||||
end
|
||||
elseif not growing then
|
||||
self._shownWarnings[name] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Update memory profiling (call from endFrame)
|
||||
function Performance:updateMemoryProfiling()
|
||||
if not self._memoryProfiler.enabled then
|
||||
return
|
||||
end
|
||||
|
||||
self._memoryProfiler.framesSinceLastSample = self._memoryProfiler.framesSinceLastSample + 1
|
||||
|
||||
if self._memoryProfiler.framesSinceLastSample >= self._memoryProfiler.sampleInterval then
|
||||
self:_sampleMemory()
|
||||
self._memoryProfiler.framesSinceLastSample = 0
|
||||
end
|
||||
end
|
||||
|
||||
return Performance
|
||||