Compare commits
245 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 | |||
| 0bb0518093 | |||
| 41f549b271 | |||
| 6fb5aa08ff | |||
| ba8aa7b263 | |||
| 243cb6a477 | |||
| c987fefede | |||
| 2a038f7112 | |||
| 548e75a15e | |||
| 339c85b590 | |||
| 0f0420314b | |||
| a08b5e65f7 | |||
| 99b89c4e5e | |||
| 20586e0c48 | |||
| b54d0c605a | |||
| 2f75af902c | |||
| 9147a6413e | |||
| 9a467d4bf1 | |||
| e40f9337b6 | |||
| 1f0712d2d5 | |||
| 345bc9519a | |||
| 0b30bb2eff | |||
| f0e88aa581 | |||
| 317893bd61 | |||
| bd181271d9 | |||
| 4df09010a8 | |||
| 1a5b2b96dc | |||
| 43d89813fc | |||
| 5bc76ce6fe | |||
| 1fe35335a5 | |||
| 8654444d1a | |||
| 772c39edb8 | |||
| 74e7474349 | |||
| 3aefe41ea1 | |||
| 2223c31e93 | |||
| 568f0fa9eb | |||
| e91cf0f0c2 | |||
| 8a1f583d88 | |||
| 7518319c80 | |||
| 304df44742 | |||
| e92bab6f4f | |||
| 43755c5457 | |||
| d4455c508f | |||
| 7d01242cd1 | |||
| 9d77189cec | |||
| c8adbdcc64 | |||
| ac6dfe7134 | |||
| b1ad7c7254 | |||
| 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 |
|
After Width: | Height: | Size: 626 KiB |
|
After Width: | Height: | Size: 582 KiB |
|
After Width: | Height: | Size: 472 KiB |
|
After Width: | Height: | Size: 730 KiB |
@@ -44,7 +44,7 @@ jobs:
|
||||
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$|\.github/workflows/(ci|release)\.yml$)'; then
|
||||
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"
|
||||
@@ -102,6 +102,85 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
@@ -28,16 +29,27 @@ jobs:
|
||||
[ -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: |
|
||||
#### iOS Release IPA
|
||||
[gen1recomp.ipa](${{ steps.artifact.outputs.artifact_url }})
|
||||
|
||||
- [Download 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>Automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
|
||||
<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, an iOS
|
||||
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on
|
||||
# the self-hosted Mac runner, and publishes them as a GitHub Release.
|
||||
# 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:
|
||||
@@ -195,6 +197,17 @@ jobs:
|
||||
--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
|
||||
@@ -257,6 +270,12 @@ jobs:
|
||||
[ -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"
|
||||
@@ -368,22 +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}-ios.ipa" \
|
||||
"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() && 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 }}
|
||||
@@ -31,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/)
|
||||
|
||||
@@ -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,7 +4,7 @@ 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.
|
||||
|
||||
> [!WARNING]
|
||||
> [!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>
|
||||
@@ -112,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) |
|
||||
@@ -121,8 +122,8 @@ 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
|
||||
|
||||
@@ -211,6 +212,16 @@ Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
|
||||
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
|
||||
@@ -218,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,
|
||||
@@ -265,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" \
|
||||
|
||||
@@ -58,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
|
||||
@@ -70,6 +80,10 @@ function love.conf(t)
|
||||
-- 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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither
|
||||
-- tile gates on facing.
|
||||
|
||||
local Menu = require("src.ui.Menu")
|
||||
local 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):
|
||||
@@ -25,33 +26,112 @@ local STATUS_LABELS = {
|
||||
{ " 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 items, showMenu, askHeading
|
||||
function showMenu()
|
||||
game.stack:push(Menu.new(game, items,
|
||||
{ tx = 0, ty = 0, tw = 12, th = 8, rowStep = 1 }))
|
||||
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
|
||||
-- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop
|
||||
-- pass, immediately before HandleMenuInput
|
||||
function askHeading()
|
||||
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?",
|
||||
showMenu))
|
||||
nil, { stay = { onShown = function()
|
||||
board = board or StatusBoard.new(game, pick, closeBoard)
|
||||
game.stack:push(board)
|
||||
end } }))
|
||||
end
|
||||
items = {}
|
||||
for i, row in ipairs(STATUS_LABELS) do
|
||||
local label, key = row[1], row[2]
|
||||
items[i] = { label = label, onSelect = function()
|
||||
game.stack:push(TextBox.new(game, text[key] or label, askHeading))
|
||||
end }
|
||||
end
|
||||
-- no onSelect: Menu's own pop closes the box, matching .exitBlackboard
|
||||
items[#items + 1] = { label = " QUIT" }
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._ViridianSchoolBlackboardText1
|
||||
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
|
||||
askHeading))
|
||||
openBoard))
|
||||
end
|
||||
|
||||
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -22,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", 22 }, -- 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;
|
||||
@@ -37,33 +37,41 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
-- 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", "end" }, -- 18
|
||||
{ "jump", "end" }, -- 19 (spacer)
|
||||
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" }, -- 20
|
||||
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 21
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 22
|
||||
{ "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
|
||||
|
||||
@@ -71,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" },
|
||||
@@ -160,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 =
|
||||
@@ -297,9 +325,14 @@ 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" })
|
||||
-- restore the lab theme once he's walked out, same as the Yellow port
|
||||
table.insert(rows, { "play_music", "Music_OaksLab" })
|
||||
ow.runner:run(rows, { npc = rival })
|
||||
return true
|
||||
|
||||
@@ -287,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,16 +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
|
||||
-- #535: previously jumped to the same silent-end target as the
|
||||
-- give-then-thank fallthrough (row 13), so the warden said nothing
|
||||
-- on every visit after the trade. pokered's .got_item branch
|
||||
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText,
|
||||
-- so route here to the new row 17 that does the same.
|
||||
{ "jump_if_true", 17 }, -- 3
|
||||
-- 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
|
||||
@@ -663,15 +694,27 @@ M.WARDENS_HOUSE = {
|
||||
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
|
||||
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
|
||||
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
|
||||
{ "jump", 18 }, -- 13 (already got it this convo; jp .done)
|
||||
{ "jump", 18 }, -- 14 (unused)
|
||||
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
|
||||
{ "jump", 18 }, -- 16 (#535: skip the new explanation row below)
|
||||
{ "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.
|
||||
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 17
|
||||
{ "label", "got_hm04" }, -- 22
|
||||
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 23
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -723,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
|
||||
@@ -749,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)
|
||||
@@ -899,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
|
||||
@@ -937,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 = {
|
||||
|
||||
@@ -549,21 +549,57 @@ M.GAME_CORNER = {
|
||||
-- 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,
|
||||
line("DoYouNeedSomeGameCoinsText",
|
||||
"Do you need some\ngame coins?\f¥1000 for 50."), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
"Do you need some\ngame coins?\f¥1000 for 50."),
|
||||
nil, { choice = function(yes)
|
||||
if not yes then
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("PleaseComePlaySometimeText",
|
||||
"No? Please come\nplay sometime!"), done))
|
||||
"No? Please come\nplay sometime!"), finish))
|
||||
return
|
||||
end
|
||||
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
|
||||
@@ -571,29 +607,30 @@ M.GAME_CORNER = {
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("DontHaveCoinCaseText",
|
||||
"You don't have a\nCOIN CASE!"), done))
|
||||
"You don't have a\nCOIN CASE!"), finish))
|
||||
return
|
||||
end
|
||||
if (game.save.coins or 0) >= 9990 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("CoinCaseIsFullText",
|
||||
"Oops! Your COIN\nCASE is full."), done))
|
||||
"Oops! Your COIN\nCASE is full."), finish))
|
||||
return
|
||||
end
|
||||
if game.save.money < 1000 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
line("CantAffordTheCoinsText",
|
||||
"You can't afford\nthe coins!"), done))
|
||||
"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,
|
||||
line("ThanksHereAre50CoinsText",
|
||||
"Thanks! Here are\nyour 50 coins!")
|
||||
.. ("\fCOINS: %d"):format(game.save.coins), done))
|
||||
end))
|
||||
end))
|
||||
"Thanks! Here are\nyour 50 coins!"), finish))
|
||||
end }))
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -606,99 +643,188 @@ 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),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
-- 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). After it, the same text pointer
|
||||
-- (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
|
||||
@@ -63,7 +64,12 @@ end
|
||||
local function oldMan2Rows(game, ow, npc)
|
||||
local rows = {
|
||||
{ "show_text", "_ViridianCityOldManHadMyCoffeeNowText" },
|
||||
{ "old_man_demo" },
|
||||
-- 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" },
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -209,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
|
||||
@@ -314,12 +319,29 @@ 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, **Disable** to hide the overlay permanently (for
|
||||
controllers / emulation handhelds -- distinct from the temporary
|
||||
gamepad auto-hide), **Reset** for defaults, **Done** to save into
|
||||
`options.lua` as normalized window fractions so rotation keeps the
|
||||
relative placement. In-game, Options → **TOUCH PAD** toggles the same
|
||||
on/off flag without leaving a play session.
|
||||
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
|
||||
|
||||
@@ -354,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)
|
||||
@@ -382,7 +426,9 @@ semantics - so the two windows read as one app. Six tabs:
|
||||
- **Items**: money, a searchable item picker (replacing the arrows that
|
||||
cycled one id at a time through ~250 items), the configurable bag (20 slots
|
||||
by default), PC storage
|
||||
with no slot cap, and the eight badges as toggle chips.
|
||||
with no slot cap, and the eight badges as toggle chips. 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,
|
||||
@@ -460,3 +506,38 @@ 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,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
|
||||
@@ -0,0 +1,505 @@
|
||||
-- modules/PropertySchema.lua
|
||||
--
|
||||
-- Declarative source of truth for every Element prop.
|
||||
--
|
||||
-- Each entry describes one prop that Element.new / Element:setProperty currently
|
||||
-- handles inline. Downstream tasks (03 data-driven prop binding, 05 registry-driven
|
||||
-- setProperty dispatch) read this metadata instead of hardcoding property names.
|
||||
--
|
||||
-- Design constraints (locked — tasks 03/05 depend on this API):
|
||||
-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler.
|
||||
-- Normalizers/validators are small, dependency-free closures so the module is
|
||||
-- unit-testable standalone. Color/^/unit/enum *defaults* that require those
|
||||
-- modules are left as `nil` here and applied by construction-time special
|
||||
-- handlers in Task 03; only defaults expressible as literals are stored.
|
||||
-- * O(1) lookup — `get(name)` is a single table index into a pre-built registry;
|
||||
-- no per-call construction.
|
||||
-- * Additive — `define(specs)` merges entries by name so build profiles can
|
||||
-- extend/override without rebuilding the whole table.
|
||||
--
|
||||
-- Metadata shape per prop (all fields present, false/nil when not applicable):
|
||||
-- type string — type tag for tooling ("number"|"string"|"boolean"|
|
||||
-- "table"|"function"|"color"|"any")
|
||||
-- default any|nil — literal default value applied when prop is absent
|
||||
-- normalizer fn|nil — pure fn(value) -> value; transforms input before
|
||||
-- storage (e.g. single-value padding -> 4-side table)
|
||||
-- validator fn|nil — pure fn(value) -> bool; returns false for invalid
|
||||
-- input (Task 03 warns + falls back on false)
|
||||
-- isDimension boolean — true for width/height: setProperty routes these
|
||||
-- through _resolveDimensionProperty (unit-string
|
||||
-- resolution + border-box sync). Other unit-accepting
|
||||
-- props (x/y/gap/padding/etc.) are resolved at
|
||||
-- construction via special handlers, NOT via this flag.
|
||||
-- affectsLayout boolean — true for props in the legacy setProperty
|
||||
-- `layoutProperties` table; setting one invalidates
|
||||
-- layout (matches baseline behavior exactly).
|
||||
-- syncsTheme boolean — true for props whose setProperty path must reach
|
||||
-- ThemeManager/Renderer (disabled/active/themeComponent)
|
||||
-- hasDeferred boolean — true for callbacks that have an `on<Name>Deferred`
|
||||
-- boolean companion prop (auto-wired by Task 03)
|
||||
-- storageKey string|nil— when set, the prop is stored on the element under
|
||||
-- this key instead of its own name (prop aliases, e.g.
|
||||
-- isDisabled -> stored as `disabled`)
|
||||
|
||||
local PropertySchema = {}
|
||||
|
||||
---@type table<string, table>
|
||||
local registry = {}
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Pure normalizers (small + dependency-free; hot-pathed during construction)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
--- Expand a single value to a 4-side table. Leaves tables unchanged. nil passthrough.
|
||||
--- Used by padding/margin: `padding = 5` -> `{top=5,right=5,bottom=5,left=5}`.
|
||||
local function expandSides(value)
|
||||
if value == nil then
|
||||
return nil
|
||||
end
|
||||
if type(value) == "table" then
|
||||
return value
|
||||
end
|
||||
return { top = value, right = value, bottom = value, left = value }
|
||||
end
|
||||
|
||||
--- Normalize flex direction aliases to internal enum names.
|
||||
--- "row" -> "horizontal", "column" -> "vertical",
|
||||
--- "row-reverse" -> "horizontal-reverse", "column-reverse" -> "vertical-reverse";
|
||||
--- everything else passes through.
|
||||
local function normalizeFlexDirection(value)
|
||||
if value == "row" then
|
||||
return "horizontal"
|
||||
elseif value == "column" then
|
||||
return "vertical"
|
||||
elseif value == "row-reverse" then
|
||||
return "horizontal-reverse"
|
||||
elseif value == "column-reverse" then
|
||||
return "vertical-reverse"
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
--- Replicate Element.new's border-shape normalization (pure).
|
||||
--- * table with sides: true -> 1, number -> value, false/nil -> false; nil if no
|
||||
--- truthy side remains.
|
||||
--- * number / other truthy scalar: kept as-is.
|
||||
--- * nil / false: nil.
|
||||
local function normalizeBorder(value)
|
||||
if value == nil or value == false then
|
||||
return nil
|
||||
end
|
||||
if type(value) == "table" then
|
||||
local function side(v)
|
||||
if v == true then
|
||||
return 1
|
||||
elseif type(v) == "number" then
|
||||
return v
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
local t = side(value.top)
|
||||
local r = side(value.right)
|
||||
local b = side(value.bottom)
|
||||
local l = side(value.left)
|
||||
if not (t or r or b or l) then
|
||||
return nil
|
||||
end
|
||||
return { top = t, right = r, bottom = b, left = l }
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
--- Replicate Element.new's cornerRadius-shape normalization (pure).
|
||||
--- * number: 0 -> nil, else the number.
|
||||
--- * table: nil if all four sides are zero/absent, else fill zeros for absent sides.
|
||||
--- * nil -> nil.
|
||||
local function normalizeCornerRadius(value)
|
||||
if value == nil then
|
||||
return nil
|
||||
end
|
||||
if type(value) == "number" then
|
||||
if value == 0 then
|
||||
return nil
|
||||
end
|
||||
return value
|
||||
end
|
||||
if type(value) == "table" then
|
||||
-- Mirrors Element.new: `or` truthiness (0 is truthy in Lua). Only an all-
|
||||
-- nil/false table collapses to nil; any present side — including 0 — yields
|
||||
-- the 4-side table with zero-filled absent sides.
|
||||
local hasAny = value.topLeft or value.topRight or value.bottomLeft or value.bottomRight
|
||||
if not hasAny then
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
topLeft = value.topLeft or 0,
|
||||
topRight = value.topRight or 0,
|
||||
bottomLeft = value.bottomLeft or 0,
|
||||
bottomRight = value.bottomRight or 0,
|
||||
}
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Pure validators (dependency-free; return boolean)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
--- Range validator factory: returns fn(v) -> bool. nil is treated as valid
|
||||
--- (absence handling is the default mechanism's job).
|
||||
local function rangeValidator(min, max)
|
||||
return function(v)
|
||||
if v == nil then
|
||||
return true
|
||||
end
|
||||
return type(v) == "number" and v >= min and v <= max
|
||||
end
|
||||
end
|
||||
|
||||
--- Enum validator factory: returns fn(v) -> bool for membership in `set` (set may
|
||||
--- be an array or a map of value->truthy).
|
||||
local function enumValidator(set)
|
||||
local lookup = {}
|
||||
if type(set) == "table" then
|
||||
for k, v in pairs(set) do
|
||||
if type(k) == "number" then
|
||||
lookup[v] = true
|
||||
else
|
||||
lookup[k] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
return function(v)
|
||||
if v == nil then
|
||||
return true
|
||||
end
|
||||
return lookup[v] == true
|
||||
end
|
||||
end
|
||||
|
||||
--- Boolean validator: nil is valid (absence); otherwise must be a boolean.
|
||||
local function booleanValidator(v)
|
||||
return v == nil or type(v) == "boolean"
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Registry construction
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
--- Build a fully-populated metadata entry, filling omitted fields with defaults.
|
||||
local function entry(spec)
|
||||
return {
|
||||
type = spec.type or "any",
|
||||
default = spec.default,
|
||||
normalizer = spec.normalizer,
|
||||
validator = spec.validator,
|
||||
isDimension = spec.isDimension == true,
|
||||
affectsLayout = spec.affectsLayout == true,
|
||||
syncsTheme = spec.syncsTheme == true,
|
||||
hasDeferred = spec.hasDeferred == true,
|
||||
storageKey = spec.storageKey,
|
||||
}
|
||||
end
|
||||
|
||||
--- Merge prop specs into the registry (additive; later entries override earlier).
|
||||
---@param specs table<string, table> map of prop-name -> spec
|
||||
---@return table registry the live registry table (for chaining/inspection)
|
||||
function PropertySchema.define(specs)
|
||||
for name, spec in pairs(specs) do
|
||||
registry[name] = entry(spec)
|
||||
end
|
||||
return registry
|
||||
end
|
||||
|
||||
--- O(1) metadata lookup.
|
||||
---@param name string prop name
|
||||
---@return table|nil metadata nil for unknown props (no error)
|
||||
function PropertySchema.get(name)
|
||||
return registry[name]
|
||||
end
|
||||
|
||||
--- Return the live registry (for inspection / coverage assertions only — not for
|
||||
--- per-call construction).
|
||||
---@return table
|
||||
function PropertySchema.all()
|
||||
return registry
|
||||
end
|
||||
|
||||
--- True if a prop is registered.
|
||||
---@param name string
|
||||
---@return boolean
|
||||
function PropertySchema.has(name)
|
||||
return registry[name] ~= nil
|
||||
end
|
||||
|
||||
--- True if setting this prop invalidates layout (legacy `layoutProperties` set).
|
||||
--- O(1) registry lookup — no per-call table construction. Unknown props return false,
|
||||
--- matching the legacy `layoutProperties[name]` nil-lookup behavior exactly.
|
||||
---@param name string prop name
|
||||
---@return boolean
|
||||
function PropertySchema.affectsLayout(name)
|
||||
local meta = registry[name]
|
||||
return meta ~= nil and meta.affectsLayout == true
|
||||
end
|
||||
|
||||
--- True for dimension props (width/height) that `setProperty` routes through
|
||||
--- `_resolveDimensionProperty` (unit-string resolution + border-box sync).
|
||||
--- O(1) registry lookup — no per-call table construction. Unknown props return false,
|
||||
--- matching the legacy `dimensionProperties[name]` nil-lookup behavior exactly.
|
||||
---@param name string prop name
|
||||
---@return boolean
|
||||
function PropertySchema.isDimension(name)
|
||||
local meta = registry[name]
|
||||
return meta ~= nil and meta.isDimension == true
|
||||
end
|
||||
|
||||
--- True for props whose setProperty path must reach ThemeManager/Renderer
|
||||
--- (disabled/active/themeComponent). O(1) registry lookup — no per-call table
|
||||
--- construction. Unknown props return false, matching a legacy nil-lookup exactly.
|
||||
---@param name string prop name
|
||||
---@return boolean
|
||||
function PropertySchema.syncsTheme(name)
|
||||
local meta = registry[name]
|
||||
return meta ~= nil and meta.syncsTheme == true
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Default schema (covers every prop handled in Element.new lines 259-1909 and
|
||||
-- Element:setProperty lines 4291-4417 of the Task-01 baseline).
|
||||
-- ---------------------------------------------------------------------------
|
||||
local function defineDefaults()
|
||||
PropertySchema.define({
|
||||
-- ------------------------------------------------------------------ identity
|
||||
id = { type = "string" },
|
||||
userdata = { type = "any" },
|
||||
parent = { type = "table", affectsLayout = true },
|
||||
children = { type = "table" },
|
||||
|
||||
-- ------------------------------------------------------------------ callbacks
|
||||
onEvent = { type = "function", hasDeferred = true },
|
||||
onFocus = { type = "function", hasDeferred = true },
|
||||
onBlur = { type = "function", hasDeferred = true },
|
||||
onTextInput = { type = "function", hasDeferred = true },
|
||||
onTextChange = { type = "function", hasDeferred = true },
|
||||
onEnter = { type = "function", hasDeferred = true },
|
||||
onCreate = { type = "function", hasDeferred = true },
|
||||
onTouchEvent = { type = "function", hasDeferred = true },
|
||||
onGesture = { type = "function", hasDeferred = true },
|
||||
onImageLoad = { type = "function", hasDeferred = true },
|
||||
onImageError = { type = "function", hasDeferred = true },
|
||||
|
||||
-- Deferred companion flags (stored directly; no further Deferred companion)
|
||||
onEventDeferred = { type = "boolean", default = false },
|
||||
onFocusDeferred = { type = "boolean", default = false },
|
||||
onBlurDeferred = { type = "boolean", default = false },
|
||||
onTextInputDeferred = { type = "boolean", default = false },
|
||||
onTextChangeDeferred = { type = "boolean", default = false },
|
||||
onEnterDeferred = { type = "boolean", default = false },
|
||||
onCreateDeferred = { type = "boolean", default = false },
|
||||
onTouchEventDeferred = { type = "boolean", default = false },
|
||||
onGestureDeferred = { type = "boolean", default = false },
|
||||
onImageLoadDeferred = { type = "boolean", default = false },
|
||||
onImageErrorDeferred = { type = "boolean", default = false },
|
||||
|
||||
-- focus / touch behavior
|
||||
dropFocusOnSelection = { type = "boolean" },
|
||||
customDraw = { type = "function" },
|
||||
touchEnabled = { type = "boolean", default = true },
|
||||
multiTouchEnabled = { type = "boolean", default = false },
|
||||
|
||||
-- ------------------------------------------------------------------ theme
|
||||
theme = { type = "table" },
|
||||
themeComponent = { type = "string", syncsTheme = true },
|
||||
disabled = { type = "boolean", default = false, syncsTheme = true },
|
||||
isDisabled = {
|
||||
type = "boolean",
|
||||
default = false,
|
||||
syncsTheme = true,
|
||||
storageKey = "disabled",
|
||||
},
|
||||
active = { type = "boolean", default = false, syncsTheme = true },
|
||||
disableHighlight = { type = "boolean" },
|
||||
themeStateLock = { type = "boolean" },
|
||||
themeComponentDisabledStates = { type = "table" },
|
||||
scaleCorners = { type = "boolean" },
|
||||
scalingAlgorithm = { type = "string" },
|
||||
contentAutoSizingMultiplier = { type = "table" },
|
||||
contentBlur = { type = "table" },
|
||||
backdropBlur = { type = "table" },
|
||||
|
||||
-- ------------------------------------------------------------------ text editing
|
||||
editable = { type = "boolean", default = false },
|
||||
multiline = { type = "boolean", default = false },
|
||||
passwordMode = { type = "boolean", default = false },
|
||||
textWrap = { type = "string" }, -- default computed from multiline
|
||||
maxLines = { type = "number" },
|
||||
maxLength = { type = "number" },
|
||||
placeholder = { type = "string" },
|
||||
inputType = { type = "string", default = "text" },
|
||||
textOverflow = { type = "string", default = "clip" },
|
||||
scrollable = { type = "boolean" }, -- default = multiline
|
||||
autoGrow = { type = "boolean" }, -- default = multiline
|
||||
selectOnFocus = { type = "boolean", default = false },
|
||||
cursorColor = { type = "color" },
|
||||
selectionColor = { type = "color" },
|
||||
cursorBlinkRate = { type = "number", default = 0.5 },
|
||||
text = { type = "string" },
|
||||
textAlign = {
|
||||
type = "string",
|
||||
default = "start",
|
||||
validator = enumValidator({ "start", "center", "end", "justify" }),
|
||||
},
|
||||
-- textAlignVertical is a derived storage field split out from textAlign
|
||||
-- (bindVisualState resolves table/compound-string input into H + V). Its
|
||||
-- validator is exposed for bindVisualState to validate the V component; the
|
||||
-- prop itself stays in SPECIAL_PROPS because compound parsing needs
|
||||
-- ErrorHandler warnings (schema is pure-Lua, cannot warn).
|
||||
textAlignVertical = {
|
||||
type = "string",
|
||||
default = "start",
|
||||
validator = enumValidator({ "start", "center", "end" }),
|
||||
},
|
||||
textColor = { type = "color" },
|
||||
fontFamily = { type = "string" },
|
||||
textSize = { type = "any" }, -- number | preset string; resolved by special handler
|
||||
minTextSize = { type = "number" },
|
||||
maxTextSize = { type = "number" },
|
||||
autoScaleText = { type = "boolean", default = true },
|
||||
|
||||
-- ------------------------------------------------------------------ dimensions / box model
|
||||
width = { type = "any", isDimension = true, affectsLayout = true },
|
||||
height = { type = "any", isDimension = true, affectsLayout = true },
|
||||
x = { type = "any", affectsLayout = false },
|
||||
y = { type = "any", affectsLayout = false },
|
||||
minWidth = { type = "any" },
|
||||
maxWidth = { type = "any" },
|
||||
minHeight = { type = "any" },
|
||||
maxHeight = { type = "any" },
|
||||
gap = { type = "any", affectsLayout = true },
|
||||
padding = {
|
||||
type = "any",
|
||||
affectsLayout = true,
|
||||
normalizer = expandSides,
|
||||
},
|
||||
margin = {
|
||||
type = "any",
|
||||
affectsLayout = true,
|
||||
normalizer = expandSides,
|
||||
},
|
||||
flexDirection = {
|
||||
type = "string",
|
||||
default = "horizontal",
|
||||
affectsLayout = true,
|
||||
normalizer = normalizeFlexDirection,
|
||||
},
|
||||
flexWrap = { type = "string", default = "nowrap", affectsLayout = true },
|
||||
justifyContent = { type = "string", default = "flex-start", affectsLayout = true },
|
||||
alignItems = { type = "string", default = "stretch", affectsLayout = true },
|
||||
alignContent = { type = "string", default = "stretch", affectsLayout = true },
|
||||
positioning = { type = "string", default = "relative", affectsLayout = true },
|
||||
gridRows = { type = "number", affectsLayout = true },
|
||||
gridColumns = { type = "number", affectsLayout = true },
|
||||
top = { type = "any", affectsLayout = true },
|
||||
right = { type = "any", affectsLayout = true },
|
||||
bottom = { type = "any", affectsLayout = true },
|
||||
left = { type = "any", affectsLayout = true },
|
||||
columnGap = { type = "any" },
|
||||
rowGap = { type = "any" },
|
||||
flex = { type = "any" }, -- shorthand: expands to flexGrow/flexShrink/flexBasis
|
||||
flexGrow = { type = "number", default = 0, validator = rangeValidator(0, math.huge) },
|
||||
flexShrink = { type = "number", default = 1, validator = rangeValidator(0, math.huge) },
|
||||
flexBasis = { type = "any", default = "auto" },
|
||||
alignSelf = { type = "string", default = "auto" },
|
||||
justifySelf = { type = "string" },
|
||||
z = { type = "number", default = 0 },
|
||||
tabIndex = { type = "number" },
|
||||
|
||||
-- ------------------------------------------------------------------ border / background / visual
|
||||
border = { type = "any", normalizer = normalizeBorder },
|
||||
borderColor = { type = "color" }, -- default Color.new(0,0,0,1) via special handler
|
||||
backgroundColor = { type = "color" }, -- default transparent via special handler
|
||||
opacity = {
|
||||
type = "number",
|
||||
default = 1,
|
||||
validator = rangeValidator(0, 1),
|
||||
},
|
||||
visibility = { type = "string", default = "visible" },
|
||||
display = {
|
||||
type = "boolean",
|
||||
default = true,
|
||||
validator = booleanValidator,
|
||||
},
|
||||
transform = { type = "table" },
|
||||
cornerRadius = { type = "any", normalizer = normalizeCornerRadius },
|
||||
|
||||
-- ------------------------------------------------------------------ image
|
||||
imagePath = { type = "string" },
|
||||
image = { type = "table" },
|
||||
objectFit = {
|
||||
type = "string",
|
||||
default = "fill",
|
||||
validator = enumValidator({ "fill", "contain", "cover", "scale-down", "none" }),
|
||||
},
|
||||
objectPosition = { type = "string", default = "center center" },
|
||||
imageOpacity = {
|
||||
type = "number",
|
||||
default = 1,
|
||||
validator = rangeValidator(0, 1),
|
||||
},
|
||||
imageRepeat = {
|
||||
type = "string",
|
||||
default = "no-repeat",
|
||||
validator = enumValidator({
|
||||
"no-repeat",
|
||||
"repeat",
|
||||
"repeat-x",
|
||||
"repeat-y",
|
||||
"space",
|
||||
"round",
|
||||
}),
|
||||
},
|
||||
imageTint = { type = "color" },
|
||||
|
||||
-- ------------------------------------------------------------------ scroll / scrollbar
|
||||
overflow = { type = "string" },
|
||||
overflowX = { type = "string" },
|
||||
overflowY = { type = "string" },
|
||||
scrollbarWidth = { type = "number" },
|
||||
scrollbarColor = { type = "color" },
|
||||
scrollbarTrackColor = { type = "color" },
|
||||
scrollbarRadius = { type = "number" },
|
||||
scrollbarPadding = { type = "number" },
|
||||
scrollSpeed = { type = "number" },
|
||||
invertScroll = { type = "boolean" },
|
||||
smoothScrollEnabled = { type = "boolean" },
|
||||
scrollBarStyle = { type = "string" },
|
||||
scrollbarKnobOffset = { type = "number" },
|
||||
hideScrollbars = { type = "boolean" },
|
||||
scrollbarPlacement = { type = "string" },
|
||||
scrollbarBalance = { type = "number" },
|
||||
_scrollX = { type = "number", storageKey = "_scrollX" },
|
||||
_scrollY = { type = "number", storageKey = "_scrollY" },
|
||||
|
||||
-- ------------------------------------------------------------------ select
|
||||
selectParent = { type = "table" },
|
||||
selectOption = { type = "table" },
|
||||
|
||||
-- ------------------------------------------------------------------ transition
|
||||
transition = { type = "table", default = {} },
|
||||
})
|
||||
end
|
||||
|
||||
--- (Re)populate the default schema. Idempotent: safe to call from Element.init
|
||||
--- for build profiles that re-require the module. Returns the live registry.
|
||||
---@return table registry
|
||||
function PropertySchema.populate()
|
||||
defineDefaults()
|
||||
return registry
|
||||
end
|
||||
|
||||
-- Auto-populate on require so the registry is ready without an explicit init call
|
||||
-- (pure module, no external deps — safe at load time).
|
||||
PropertySchema.populate()
|
||||
|
||||
return PropertySchema
|
||||
@@ -0,0 +1,124 @@
|
||||
local RoundedRect = {}
|
||||
|
||||
--- Generate points for a rounded rectangle
|
||||
---@param x number
|
||||
---@param y number
|
||||
---@param width number
|
||||
---@param height number
|
||||
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number
|
||||
---@param segments number? -- Number of segments per corner arc (default: 10)
|
||||
---@return table -- Array of vertices for love.graphics.polygon
|
||||
function RoundedRect.getPoints(x, y, width, height, cornerRadius, segments)
|
||||
segments = segments or 10
|
||||
local points = {}
|
||||
|
||||
-- Helper to add arc points
|
||||
local function addArc(cx, cy, radius, startAngle, endAngle)
|
||||
if radius <= 0 then
|
||||
table.insert(points, cx)
|
||||
table.insert(points, cy)
|
||||
return
|
||||
end
|
||||
|
||||
for i = 0, segments do
|
||||
local angle = startAngle + (endAngle - startAngle) * (i / segments)
|
||||
table.insert(points, cx + math.cos(angle) * radius)
|
||||
table.insert(points, cy + math.sin(angle) * radius)
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle uniform corner radius (number)
|
||||
if type(cornerRadius) == "number" then
|
||||
cornerRadius = {
|
||||
topLeft = cornerRadius,
|
||||
topRight = cornerRadius,
|
||||
bottomLeft = cornerRadius,
|
||||
bottomRight = cornerRadius,
|
||||
}
|
||||
end
|
||||
|
||||
local r1 = math.min(cornerRadius.topLeft, width / 2, height / 2)
|
||||
local r2 = math.min(cornerRadius.topRight, width / 2, height / 2)
|
||||
local r3 = math.min(cornerRadius.bottomRight, width / 2, height / 2)
|
||||
local r4 = math.min(cornerRadius.bottomLeft, width / 2, height / 2)
|
||||
|
||||
-- Top-right corner
|
||||
addArc(x + width - r2, y + r2, r2, -math.pi / 2, 0)
|
||||
|
||||
-- Bottom-right corner
|
||||
addArc(x + width - r3, y + height - r3, r3, 0, math.pi / 2)
|
||||
|
||||
-- Bottom-left corner
|
||||
addArc(x + r4, y + height - r4, r4, math.pi / 2, math.pi)
|
||||
|
||||
-- Top-left corner
|
||||
addArc(x + r1, y + r1, r1, math.pi, math.pi * 1.5)
|
||||
|
||||
return points
|
||||
end
|
||||
|
||||
--- Draw a filled rounded rectangle
|
||||
---@param mode string -- "fill" or "line"
|
||||
---@param x number
|
||||
---@param y number
|
||||
---@param width number
|
||||
---@param height number
|
||||
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil
|
||||
function RoundedRect.draw(mode, x, y, width, height, cornerRadius)
|
||||
-- OPTIMIZATION: Handle nil cornerRadius (no rounding)
|
||||
if not cornerRadius then
|
||||
love.graphics.rectangle(mode, x, y, width, height)
|
||||
return
|
||||
end
|
||||
|
||||
-- Handle uniform corner radius (number)
|
||||
if type(cornerRadius) == "number" then
|
||||
if cornerRadius <= 0 then
|
||||
love.graphics.rectangle(mode, x, y, width, height)
|
||||
return
|
||||
end
|
||||
-- Convert to table format for processing
|
||||
cornerRadius = {
|
||||
topLeft = cornerRadius,
|
||||
topRight = cornerRadius,
|
||||
bottomLeft = cornerRadius,
|
||||
bottomRight = cornerRadius,
|
||||
}
|
||||
end
|
||||
|
||||
-- Check if any corners are rounded
|
||||
local hasRoundedCorners = cornerRadius.topLeft > 0
|
||||
or cornerRadius.topRight > 0
|
||||
or cornerRadius.bottomLeft > 0
|
||||
or cornerRadius.bottomRight > 0
|
||||
|
||||
if not hasRoundedCorners then
|
||||
-- No rounded corners, use regular rectangle
|
||||
love.graphics.rectangle(mode, x, y, width, height)
|
||||
return
|
||||
end
|
||||
|
||||
local points = RoundedRect.getPoints(x, y, width, height, cornerRadius)
|
||||
|
||||
if mode == "fill" then
|
||||
love.graphics.polygon("fill", points)
|
||||
else
|
||||
-- For line mode, draw the outline
|
||||
love.graphics.polygon("line", points)
|
||||
end
|
||||
end
|
||||
|
||||
--- Create a stencil function for rounded rectangle clipping
|
||||
---@param x number
|
||||
---@param y number
|
||||
---@param width number
|
||||
---@param height number
|
||||
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil
|
||||
---@return function
|
||||
function RoundedRect.stencilFunction(x, y, width, height, cornerRadius)
|
||||
return function()
|
||||
RoundedRect.draw("fill", x, y, width, height, cornerRadius)
|
||||
end
|
||||
end
|
||||
|
||||
return RoundedRect
|
||||
@@ -0,0 +1,719 @@
|
||||
---@class Select
|
||||
local Select = {}
|
||||
|
||||
---Initialize Select module with required dependencies
|
||||
---@param deps table
|
||||
function Select.init(deps)
|
||||
Select._ErrorHandler = deps.ErrorHandler
|
||||
Select._Context = deps.Context
|
||||
Select._StateManager = deps.StateManager
|
||||
Select._utils = deps.utils
|
||||
Select._Element = deps.Element
|
||||
end
|
||||
|
||||
---Initialize selectParent state on an element
|
||||
---@param element Element
|
||||
---@param selectParentConfig table
|
||||
function Select.initSelectParent(element, selectParentConfig)
|
||||
element._selectState = {
|
||||
value = selectParentConfig.value,
|
||||
open = selectParentConfig.open or false,
|
||||
placeholder = selectParentConfig.placeholder,
|
||||
selectFrame = nil,
|
||||
selectAnchor = nil,
|
||||
onChange = selectParentConfig.onChange,
|
||||
options = {},
|
||||
optionLookup = {},
|
||||
expectedFrameParent = nil,
|
||||
frameAdopted = false,
|
||||
}
|
||||
|
||||
-- Restore select state from StateManager. Mode-aware via
|
||||
-- Context.isImmediateMode (behavior-mode-unification task 11).
|
||||
if Select._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
|
||||
local state = Select._StateManager.getState(element._stateId)
|
||||
if state and state._selectOpen ~= nil then
|
||||
element._selectState.open = state._selectOpen
|
||||
end
|
||||
if state and state._selectValue ~= nil then
|
||||
element._selectState.value = state._selectValue
|
||||
if element.selectParent then
|
||||
element.selectParent.value = state._selectValue
|
||||
end
|
||||
end
|
||||
if state and state._selectSelectedLabel ~= nil then
|
||||
element._selectState.selectedLabel = state._selectSelectedLabel
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---Initialize selectOption on an element
|
||||
---@param element Element
|
||||
---@param selectOptionConfig table
|
||||
function Select.initSelectOption(element, selectOptionConfig)
|
||||
element.selectOption = {
|
||||
value = selectOptionConfig.value,
|
||||
label = selectOptionConfig.label or element.text,
|
||||
disabled = selectOptionConfig.disabled or false,
|
||||
}
|
||||
end
|
||||
|
||||
---@param selectParent Element
|
||||
function Select.rebuildOptionLookup(selectParent)
|
||||
if not selectParent or not selectParent._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
selectParent._selectState.optionLookup = {}
|
||||
for _, optionElement in ipairs(selectParent._selectState.options) do
|
||||
if optionElement and optionElement.selectOption then
|
||||
selectParent._selectState.optionLookup[optionElement.selectOption.value] = optionElement
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param selectParent Element
|
||||
function Select.syncOptionStates(selectParent)
|
||||
if not selectParent or not selectParent._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
local selectedOption = nil
|
||||
local selectedLabel = selectParent._selectState.selectedLabel
|
||||
|
||||
for _, optionElement in ipairs(selectParent._selectState.options) do
|
||||
local isSelected = optionElement.selectOption
|
||||
and optionElement.selectOption.value == selectParent._selectState.value
|
||||
optionElement._selectSelected = isSelected
|
||||
optionElement.ariaChecked = isSelected
|
||||
|
||||
if isSelected then
|
||||
selectedOption = optionElement
|
||||
selectedLabel = optionElement.selectOption.label or optionElement.text
|
||||
end
|
||||
end
|
||||
|
||||
selectParent._selectState.selectedOption = selectedOption
|
||||
selectParent._selectState.selectedLabel = selectedLabel
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.resetOptions(element)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
element._selectState.options = {}
|
||||
element._selectState.optionLookup = {}
|
||||
element._selectState.selectedOption = nil
|
||||
end
|
||||
|
||||
---@param frame any
|
||||
---@return boolean
|
||||
function Select.isValidSelectFrame(frame)
|
||||
local Element = Select._Element
|
||||
return type(frame) == "table" and getmetatable(frame) == Element
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@param code string
|
||||
---@param details table?
|
||||
function Select.warnSelectFrame(element, code, details)
|
||||
Select._ErrorHandler:warn("Element", code, details or { element = element.id })
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@param frame Element
|
||||
function Select.trackManagedFrame(element, frame)
|
||||
element._selectState.selectFrame = frame
|
||||
local expectedParent = element._selectState.selectAnchor or element
|
||||
element._selectState.expectedFrameParent = expectedParent
|
||||
element._selectState.frameAdopted = frame.parent == expectedParent
|
||||
if frame._managedSelectBaseOpacity == nil then
|
||||
frame._managedSelectBaseOpacity = frame.opacity
|
||||
end
|
||||
if frame._managedSelectBaseVisibility == nil then
|
||||
frame._managedSelectBaseVisibility = frame.visibility or "visible"
|
||||
end
|
||||
if frame._managedSelectBaseDisabled == nil then
|
||||
frame._managedSelectBaseDisabled = frame.disabled or false
|
||||
end
|
||||
frame._managedSelectOwner = element
|
||||
frame._managedSelectFrame = true
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@return Element
|
||||
function Select.getOrCreateManagedAnchor(element)
|
||||
if element._selectState.selectAnchor then
|
||||
return element._selectState.selectAnchor
|
||||
end
|
||||
|
||||
local Element = Select._Element
|
||||
local anchor = Element.new({
|
||||
id = string.format("%s__select_anchor", element.id or "select"),
|
||||
parent = element,
|
||||
positioning = Select._utils.enums.Positioning.ABSOLUTE,
|
||||
left = 0,
|
||||
top = element:getBorderBoxHeight(),
|
||||
width = element:getBorderBoxWidth(),
|
||||
opacity = 1,
|
||||
visibility = "hidden",
|
||||
disabled = true,
|
||||
})
|
||||
|
||||
anchor._managedSelectAnchor = true
|
||||
anchor._managedSelectOwner = element
|
||||
element._selectState.selectAnchor = anchor
|
||||
return anchor
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@param frame Element
|
||||
function Select.applyManagedFrameLayout(element, frame)
|
||||
local anchor = Select.getOrCreateManagedAnchor(element)
|
||||
local triggerBorderBoxWidth = element:getBorderBoxWidth()
|
||||
anchor.left = 0
|
||||
anchor.top = element:getBorderBoxHeight()
|
||||
anchor.width = triggerBorderBoxWidth
|
||||
anchor.units.left = { value = 0, unit = "px" }
|
||||
anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" }
|
||||
anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" }
|
||||
frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth
|
||||
|
||||
frame.positioning = frame.positioning or Select._utils.enums.Positioning.RELATIVE
|
||||
frame._explicitlyAbsolute = false
|
||||
frame.left = nil
|
||||
frame.top = nil
|
||||
frame.right = nil
|
||||
frame.bottom = nil
|
||||
|
||||
if frame.parent ~= anchor then
|
||||
frame:setParent(anchor)
|
||||
end
|
||||
|
||||
if frame.autosizing and frame.autosizing.width then
|
||||
local contentWidth = frame:calculateAutoWidth()
|
||||
frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right
|
||||
frame.width = contentWidth
|
||||
end
|
||||
|
||||
if frame.parent == anchor then
|
||||
anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth())
|
||||
anchor.units.width = { value = anchor.width, unit = "px" }
|
||||
end
|
||||
|
||||
element._selectState.expectedFrameParent = anchor
|
||||
element._selectState.frameAdopted = frame.parent == anchor
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@param frame Element
|
||||
function Select.adoptSelectFrame(element, frame)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
if not Select.isValidSelectFrame(frame) then
|
||||
Select.warnSelectFrame(element, "ELEM_007", {
|
||||
element = element.id,
|
||||
property = "selectParent.selectFrame",
|
||||
got = type(frame),
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
if frame == element then
|
||||
Select.warnSelectFrame(element, "ELEM_007", {
|
||||
element = element.id,
|
||||
property = "selectParent.selectFrame",
|
||||
reason = "select cannot use itself as its managed frame",
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
local anchor = Select.getOrCreateManagedAnchor(element)
|
||||
|
||||
if frame.parent and frame.parent ~= element and frame.parent ~= anchor then
|
||||
Select.warnSelectFrame(element, "ELEM_008", {
|
||||
element = element.id,
|
||||
frame = frame.id,
|
||||
parent = frame.parent.id,
|
||||
})
|
||||
end
|
||||
|
||||
Select.trackManagedFrame(element, frame)
|
||||
Select.applyManagedFrameLayout(element, frame)
|
||||
Select.syncManagedFrameVisibility(element)
|
||||
|
||||
-- Layout is deferred to endFrame in immediate mode. shouldLayout()
|
||||
-- encapsulates the mode check (behavior-mode-unification task 11).
|
||||
if Select._StateManager.shouldLayout() then
|
||||
anchor:layoutChildren()
|
||||
element:layoutChildren()
|
||||
end
|
||||
|
||||
local pendingOptions = {}
|
||||
for _, child in ipairs(element.children) do
|
||||
if child ~= frame and child.selectOption then
|
||||
table.insert(pendingOptions, child)
|
||||
end
|
||||
end
|
||||
|
||||
for _, option in ipairs(pendingOptions) do
|
||||
Select.attachOptionToManagedFrame(option)
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.ensureFrameState(element)
|
||||
if not element._selectState or not element._selectState.selectFrame then
|
||||
return
|
||||
end
|
||||
|
||||
local frame = element._selectState.selectFrame
|
||||
local anchor = element._selectState.selectAnchor
|
||||
if anchor then
|
||||
local triggerBorderBoxWidth = element:getBorderBoxWidth()
|
||||
anchor.left = 0
|
||||
anchor.top = element:getBorderBoxHeight()
|
||||
anchor.width = triggerBorderBoxWidth
|
||||
anchor.units.left = { value = 0, unit = "px" }
|
||||
anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" }
|
||||
anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" }
|
||||
frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth
|
||||
if frame.autosizing and frame.autosizing.width then
|
||||
local contentWidth = frame:calculateAutoWidth()
|
||||
frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right
|
||||
frame.width = contentWidth
|
||||
end
|
||||
if frame.parent == anchor then
|
||||
anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth())
|
||||
anchor.units.width = { value = anchor.width, unit = "px" }
|
||||
end
|
||||
if frame.parent == anchor then
|
||||
anchor:layoutChildren()
|
||||
end
|
||||
elseif frame.parent == element then
|
||||
Select.applyManagedFrameLayout(element, frame)
|
||||
end
|
||||
|
||||
local expectedParent = anchor or element._selectState.expectedFrameParent
|
||||
if frame.parent ~= expectedParent then
|
||||
Select.warnSelectFrame(element, "ELEM_009", {
|
||||
element = element.id,
|
||||
frame = frame.id,
|
||||
expectedParent = expectedParent and expectedParent.id or nil,
|
||||
actualParent = frame.parent and frame.parent.id or nil,
|
||||
})
|
||||
element._selectState.expectedFrameParent = frame.parent
|
||||
element._selectState.frameAdopted = frame.parent == expectedParent
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.syncManagedFrameVisibility(element)
|
||||
if not element._selectState or not element._selectState.selectFrame then
|
||||
return
|
||||
end
|
||||
|
||||
local frame = element._selectState.selectFrame
|
||||
local anchor = element._selectState.selectAnchor
|
||||
local isOpen = element._selectState.open == true
|
||||
frame.visibility = isOpen and (frame._managedSelectBaseVisibility or "visible") or "hidden"
|
||||
frame.opacity = frame._managedSelectBaseOpacity or 1
|
||||
if isOpen then
|
||||
frame.disabled = frame._managedSelectBaseDisabled == true
|
||||
else
|
||||
frame.disabled = true
|
||||
end
|
||||
if anchor then
|
||||
anchor.visibility = isOpen and "visible" or "hidden"
|
||||
anchor.opacity = 1
|
||||
anchor.disabled = not isOpen
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@return Element?
|
||||
function Select.findOwningSelectParent(element)
|
||||
if element._selectParentHint and element._selectParentHint._selectState then
|
||||
return element._selectParentHint
|
||||
end
|
||||
|
||||
local current = element.parent
|
||||
while current do
|
||||
if current._selectState then
|
||||
return current
|
||||
end
|
||||
current = current.parent
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.registerWithSelectParent(element)
|
||||
if not element.selectOption then
|
||||
return
|
||||
end
|
||||
|
||||
local selectParent = Select.findOwningSelectParent(element)
|
||||
if not selectParent then
|
||||
return
|
||||
end
|
||||
|
||||
element._selectParentElement = selectParent
|
||||
|
||||
for _, optionElement in ipairs(selectParent._selectState.options) do
|
||||
if optionElement == element then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(selectParent._selectState.options, element)
|
||||
Select.rebuildOptionLookup(selectParent)
|
||||
Select.syncOptionStates(selectParent)
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.attachOptionToManagedFrame(element)
|
||||
if not element.selectOption then
|
||||
return
|
||||
end
|
||||
|
||||
local selectParent = Select.findOwningSelectParent(element)
|
||||
if not selectParent or not selectParent._selectState or not selectParent._selectState.selectFrame then
|
||||
return
|
||||
end
|
||||
|
||||
local selectFrame = selectParent._selectState.selectFrame
|
||||
if element.parent ~= selectFrame then
|
||||
element._selectParentHint = selectParent
|
||||
|
||||
if
|
||||
element._originalPositioning == Select._utils.enums.Positioning.ABSOLUTE
|
||||
and element._managedSelectOptionUsesFrameLayout == nil
|
||||
then
|
||||
element._managedSelectOptionUsesFrameLayout = true
|
||||
element.positioning = Select._utils.enums.Positioning.RELATIVE
|
||||
element._originalPositioning = nil
|
||||
element._explicitlyAbsolute = false
|
||||
element.left = nil
|
||||
element.top = nil
|
||||
element.right = nil
|
||||
element.bottom = nil
|
||||
end
|
||||
|
||||
element:setParent(selectFrame)
|
||||
-- Ensure frame geometry eagerly only in retained mode; deferred to the
|
||||
-- per-frame update in immediate mode (behavior-mode-unification task 11).
|
||||
if Select._StateManager.shouldLayout() then
|
||||
Select.ensureFrameState(selectParent)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.unregisterFromSelectParent(element)
|
||||
if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then
|
||||
element._selectParentElement = nil
|
||||
return
|
||||
end
|
||||
|
||||
local selectParent = element._selectParentElement
|
||||
for index, optionElement in ipairs(selectParent._selectState.options) do
|
||||
if optionElement == element then
|
||||
table.remove(selectParent._selectState.options, index)
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
Select.rebuildOptionLookup(selectParent)
|
||||
Select.syncOptionStates(selectParent)
|
||||
element._selectParentElement = nil
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.saveStateToStateManager(element)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
if element._stateId and Select._Context.isImmediateMode() and element._stateId ~= "" then
|
||||
Select._StateManager.updateState(element._stateId, {
|
||||
_selectOpen = element._selectState.open,
|
||||
_selectValue = element._selectState.value,
|
||||
_selectSelectedLabel = element._selectState.selectedLabel,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.openSelect(element)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
Select.ensureFrameState(element)
|
||||
element._selectState.open = true
|
||||
element.ariaExpanded = true
|
||||
if element.selectParent then
|
||||
element.selectParent.open = true
|
||||
end
|
||||
Select.syncManagedFrameVisibility(element)
|
||||
Select.saveStateToStateManager(element)
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.closeSelect(element)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
Select.ensureFrameState(element)
|
||||
element._selectState.open = false
|
||||
element.ariaExpanded = false
|
||||
if element.selectParent then
|
||||
element.selectParent.open = false
|
||||
end
|
||||
Select.syncManagedFrameVisibility(element)
|
||||
Select.saveStateToStateManager(element)
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.toggleSelect(element)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
if element.disabled then
|
||||
return
|
||||
end
|
||||
|
||||
if element._selectState.open then
|
||||
Select.closeSelect(element)
|
||||
else
|
||||
Select.openSelect(element)
|
||||
end
|
||||
|
||||
if element.onEvent then
|
||||
element.onEvent(element, { type = "selecttoggle", open = element._selectState.open })
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@return boolean
|
||||
function Select.isSelectOpen(element)
|
||||
return element._selectState ~= nil and element._selectState.open == true
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@return any
|
||||
function Select.getSelectValue(element)
|
||||
if not element._selectState then
|
||||
return nil
|
||||
end
|
||||
return element._selectState.value
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@return string?
|
||||
function Select.getSelectLabel(element)
|
||||
if not element._selectState then
|
||||
return nil
|
||||
end
|
||||
|
||||
local selectedOption = element._selectState.selectedOption
|
||||
or element._selectState.optionLookup[element._selectState.value]
|
||||
if selectedOption and selectedOption.selectOption then
|
||||
return selectedOption.selectOption.label or selectedOption.text
|
||||
end
|
||||
|
||||
return element._selectState.selectedLabel or element._selectState.placeholder
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@return boolean
|
||||
function Select.isSelectedOption(element)
|
||||
if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then
|
||||
return false
|
||||
end
|
||||
return element._selectParentElement._selectState.value == element.selectOption.value
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
---@param value any
|
||||
---@param optionElement Element?
|
||||
function Select.setSelectValue(element, value, optionElement)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
|
||||
if element.disabled then
|
||||
return
|
||||
end
|
||||
|
||||
local didChange = element._selectState.value ~= value
|
||||
element._selectState.value = value
|
||||
if element.selectParent then
|
||||
element.selectParent.value = value
|
||||
end
|
||||
|
||||
if optionElement and optionElement.selectOption then
|
||||
element._selectState.selectedLabel = optionElement.selectOption.label or optionElement.text
|
||||
end
|
||||
|
||||
Select.syncOptionStates(element)
|
||||
Select.closeSelect(element)
|
||||
Select.saveStateToStateManager(element)
|
||||
|
||||
if element.onEvent then
|
||||
element.onEvent(element, { type = "selectchange", value = value, option = optionElement })
|
||||
end
|
||||
|
||||
if didChange and element._selectState.onChange then
|
||||
element._selectState.onChange(element, value, optionElement and optionElement.selectOption or nil)
|
||||
end
|
||||
end
|
||||
|
||||
---@param element Element
|
||||
function Select.handleRelease(element)
|
||||
if element.disabled then
|
||||
return
|
||||
end
|
||||
|
||||
if element.selectOption then
|
||||
local selectParent = element._selectParentElement or Select.findOwningSelectParent(element)
|
||||
if not selectParent then
|
||||
return
|
||||
end
|
||||
|
||||
if element.selectOption.disabled then
|
||||
Select.closeSelect(selectParent)
|
||||
return
|
||||
end
|
||||
|
||||
Select.setSelectValue(selectParent, element.selectOption.value, element)
|
||||
return
|
||||
end
|
||||
|
||||
if element._selectState then
|
||||
Select.toggleSelect(element)
|
||||
end
|
||||
end
|
||||
|
||||
---Save select state for state persistence (called from Element:saveState)
|
||||
---@param element Element
|
||||
---@return table?
|
||||
function Select.saveState(element)
|
||||
if not element._selectState then
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
value = element._selectState.value,
|
||||
open = element._selectState.open,
|
||||
selectedLabel = element._selectState.selectedLabel,
|
||||
}
|
||||
end
|
||||
|
||||
---Restore select state (called from Element:restoreState)
|
||||
---@param element Element
|
||||
---@param state table
|
||||
function Select.restoreState(element, state)
|
||||
if not element._selectState or not state then
|
||||
return
|
||||
end
|
||||
element._selectState.value = state.value
|
||||
element._selectState.open = state.open or false
|
||||
element._selectState.selectedLabel = state.selectedLabel
|
||||
if element.selectParent then
|
||||
element.selectParent.value = state.value
|
||||
element.selectParent.open = state.open or false
|
||||
end
|
||||
element.ariaExpanded = element._selectState.open
|
||||
Select.syncOptionStates(element)
|
||||
end
|
||||
|
||||
---Clean up select-related resources (called from Element:destroy)
|
||||
---@param element Element
|
||||
function Select.cleanupDestroy(element)
|
||||
if element._selectState then
|
||||
local frame = element._selectState.selectFrame
|
||||
local anchor = element._selectState.selectAnchor
|
||||
if frame then
|
||||
frame._managedSelectOwner = nil
|
||||
frame._managedSelectFrame = nil
|
||||
frame._managedSelectBaseOpacity = nil
|
||||
frame._managedSelectBaseVisibility = nil
|
||||
frame._managedSelectBaseDisabled = nil
|
||||
end
|
||||
if anchor then
|
||||
anchor._managedSelectOwner = nil
|
||||
anchor._managedSelectAnchor = nil
|
||||
end
|
||||
element._selectState = nil
|
||||
end
|
||||
if element._managedSelectFrame and element._managedSelectOwner then
|
||||
if element._managedSelectOwner._selectState then
|
||||
element._managedSelectOwner._selectState.selectFrame = nil
|
||||
element._managedSelectOwner._selectState.expectedFrameParent = nil
|
||||
element._managedSelectOwner._selectState.frameAdopted = false
|
||||
end
|
||||
element._managedSelectOwner = nil
|
||||
element._managedSelectFrame = nil
|
||||
element._managedSelectBaseOpacity = nil
|
||||
element._managedSelectBaseVisibility = nil
|
||||
element._managedSelectBaseDisabled = nil
|
||||
end
|
||||
if element._managedSelectAnchor and element._managedSelectOwner then
|
||||
if element._managedSelectOwner._selectState then
|
||||
element._managedSelectOwner._selectState.selectAnchor = nil
|
||||
end
|
||||
element._managedSelectOwner = nil
|
||||
element._managedSelectAnchor = nil
|
||||
end
|
||||
if element.selectParent then
|
||||
element.selectParent.onChange = nil
|
||||
end
|
||||
end
|
||||
|
||||
--- Called when a select parent removes a child: clears frame/anchor refs if the removed child was the
|
||||
--- select-managed frame or anchor. Keeps select state-mutation logic owned by the Select module.
|
||||
---@param element Element The select parent whose child was removed.
|
||||
---@param child Element The removed child.
|
||||
function Select.handleChildRemoved(element, child)
|
||||
if not element._selectState then
|
||||
return
|
||||
end
|
||||
if element._selectState.selectFrame == child then
|
||||
element._selectState.selectFrame = nil
|
||||
element._selectState.expectedFrameParent = nil
|
||||
element._selectState.frameAdopted = false
|
||||
end
|
||||
if element._selectState.selectAnchor == child then
|
||||
element._selectState.selectAnchor = nil
|
||||
end
|
||||
end
|
||||
|
||||
--- Layout-path hook: adjust an auto-width child's border-box width for a managed-select frame.
|
||||
--- Invoked from LayoutEngine (via the Element delegate) during vertical-flex auto-width calculation.
|
||||
---@param element Element The managed-select frame (the dropdown container).
|
||||
---@param child Element The flex child being measured.
|
||||
---@param childBorderBoxWidth number Current computed border-box width of `child`.
|
||||
---@return number Possibly-adjusted border-box width.
|
||||
function Select.adjustAutoWidthChild(element, child, childBorderBoxWidth)
|
||||
if
|
||||
element._managedSelectFrame
|
||||
and element.autosizing
|
||||
and element.autosizing.width
|
||||
and child.units
|
||||
and child.units.width
|
||||
and child.units.width.unit == "%"
|
||||
then
|
||||
local intrinsicBorderBoxWidth = child:calculateAutoWidth() + child.padding.left + child.padding.right
|
||||
return math.max(childBorderBoxWidth, intrinsicBorderBoxWidth)
|
||||
end
|
||||
return childBorderBoxWidth
|
||||
end
|
||||
|
||||
return Select
|
||||
@@ -0,0 +1,790 @@
|
||||
---@class StateManager
|
||||
local StateManager = {}
|
||||
|
||||
-- ErrorHandler will be injected via init
|
||||
local ErrorHandler
|
||||
|
||||
-- State storage: ID -> state table
|
||||
local stateStore = {}
|
||||
|
||||
-- Frame tracking metadata: ID -> {lastFrame, createdFrame, accessCount}
|
||||
local stateMetadata = {}
|
||||
|
||||
-- Frame counter
|
||||
local frameNumber = 0
|
||||
|
||||
-- Counter to track multiple elements created at the same source location (e.g., in loops)
|
||||
local callSiteCounters = {}
|
||||
|
||||
-- Stateful element mapping: stateId -> element instance
|
||||
-- Used in retained mode for cache-through: StateManager resolves id -> element -> field
|
||||
local statefulElements = {}
|
||||
|
||||
-- Dirty state tracking for flushFrame: set of {id, key} pairs modified this frame
|
||||
local dirtyState = {}
|
||||
|
||||
-- Immediate mode flag
|
||||
local _immediateMode = false
|
||||
|
||||
-- Configuration
|
||||
local config = {
|
||||
stateRetentionFrames = 2, -- Keep unused state for 2 frames
|
||||
maxStateEntries = 1000, -- Maximum state entries before forced GC
|
||||
}
|
||||
|
||||
-- Default state values (sparse storage - don't store these)
|
||||
local stateDefaults = {
|
||||
-- Interaction states
|
||||
hover = false,
|
||||
pressed = false,
|
||||
focused = false,
|
||||
disabled = false,
|
||||
active = false,
|
||||
|
||||
-- Scrollbar states
|
||||
scrollbarHoveredVertical = false,
|
||||
scrollbarHoveredHorizontal = false,
|
||||
scrollbarDragging = false,
|
||||
hoveredScrollbar = nil,
|
||||
scrollbarDragOffset = 0,
|
||||
dragStartMouseX = 0,
|
||||
dragStartMouseY = 0,
|
||||
dragStartScrollX = 0,
|
||||
dragStartScrollY = 0,
|
||||
|
||||
-- Scroll position
|
||||
scrollX = 0,
|
||||
scrollY = 0,
|
||||
_scrollX = 0,
|
||||
_scrollY = 0,
|
||||
|
||||
-- Click tracking
|
||||
_clickCount = 0,
|
||||
_lastClickTime = nil,
|
||||
_lastClickButton = nil,
|
||||
|
||||
-- Internal states
|
||||
_hovered = nil,
|
||||
_focused = nil,
|
||||
_cursorPosition = nil,
|
||||
_selectionStart = nil,
|
||||
_selectionEnd = nil,
|
||||
_textBuffer = "",
|
||||
_cursorBlinkTimer = 0,
|
||||
_cursorVisible = true,
|
||||
_cursorBlinkPaused = false,
|
||||
_cursorBlinkPauseTimer = 0,
|
||||
}
|
||||
|
||||
--- Check if a value equals the default for a key
|
||||
---@param key string State key
|
||||
---@param value any Value to check
|
||||
---@return boolean isDefault True if value equals default
|
||||
local function isDefaultValue(key, value)
|
||||
local defaultVal = stateDefaults[key]
|
||||
|
||||
-- If no default defined, check for common defaults
|
||||
if defaultVal == nil then
|
||||
-- Empty tables are default
|
||||
if type(value) == "table" and next(value) == nil then
|
||||
return true
|
||||
end
|
||||
-- nil values are default
|
||||
if value == nil then
|
||||
return true
|
||||
end
|
||||
-- Otherwise, not a default value
|
||||
return false
|
||||
end
|
||||
|
||||
-- Compare values
|
||||
if type(value) == "table" then
|
||||
-- Empty tables are considered default
|
||||
if next(value) == nil then
|
||||
return true
|
||||
end
|
||||
-- For other tables, compare contents (shallow)
|
||||
if type(defaultVal) ~= "table" then
|
||||
return false
|
||||
end
|
||||
for k, v in pairs(value) do
|
||||
if defaultVal[k] ~= v then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
else
|
||||
return value == defaultVal
|
||||
end
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- ID Generation
|
||||
-- ====================
|
||||
|
||||
--- Generate a hash from a table of properties
|
||||
---@param props table
|
||||
---@param visited table|nil Tracking table to prevent circular references
|
||||
---@param depth number|nil Current recursion depth
|
||||
---@return string
|
||||
local function hashProps(props, visited, depth)
|
||||
if not props then
|
||||
return ""
|
||||
end
|
||||
|
||||
-- Initialize visited table on first call
|
||||
visited = visited or {}
|
||||
depth = depth or 0
|
||||
|
||||
-- Limit recursion depth to prevent deep nesting issues
|
||||
if depth > 3 then
|
||||
return "[deep]"
|
||||
end
|
||||
|
||||
-- Check if we've already visited this table (circular reference)
|
||||
if visited[props] then
|
||||
return "[circular]"
|
||||
end
|
||||
|
||||
-- Mark this table as visited
|
||||
visited[props] = true
|
||||
|
||||
local parts = {}
|
||||
local keys = {}
|
||||
|
||||
-- Properties to skip (they cause issues or aren't relevant for ID generation)
|
||||
local skipKeys = {
|
||||
onEvent = true,
|
||||
parent = true,
|
||||
children = true,
|
||||
onFocus = true,
|
||||
onBlur = true,
|
||||
onTextInput = true,
|
||||
onTextChange = true,
|
||||
onEnter = true,
|
||||
userdata = true,
|
||||
-- Dynamic input/state properties that should not affect ID stability
|
||||
text = true, -- Text content changes as user types
|
||||
placeholder = true, -- Placeholder text is presentational
|
||||
editable = true, -- Editable state can be toggled dynamically
|
||||
selectOnFocus = true, -- Input behavior flag
|
||||
autoGrow = true, -- Auto-grow behavior flag
|
||||
passwordMode = true, -- Password mode can be toggled
|
||||
}
|
||||
|
||||
-- Collect and sort keys for consistent ordering
|
||||
for k in pairs(props) do
|
||||
if not skipKeys[k] then
|
||||
table.insert(keys, k)
|
||||
end
|
||||
end
|
||||
table.sort(keys)
|
||||
|
||||
-- Build hash string from sorted key-value pairs
|
||||
for _, k in ipairs(keys) do
|
||||
local v = props[k]
|
||||
local vtype = type(v)
|
||||
|
||||
if vtype == "string" or vtype == "number" or vtype == "boolean" then
|
||||
table.insert(parts, k .. "=" .. tostring(v))
|
||||
elseif vtype == "table" then
|
||||
table.insert(parts, k .. "={" .. hashProps(v, visited, depth + 1) .. "}")
|
||||
end
|
||||
end
|
||||
|
||||
return table.concat(parts, ";")
|
||||
end
|
||||
|
||||
--- Generate a unique ID from call site and properties
|
||||
---@param props table|nil Optional properties to include in ID generation
|
||||
---@param parent table|nil Optional parent element for tree-based ID generation
|
||||
---@return string
|
||||
function StateManager.generateID(props, parent)
|
||||
-- Get call stack information
|
||||
local info = debug.getinfo(3, "Sl") -- Level 3: caller of Element.new -> caller of generateID
|
||||
|
||||
if not info then
|
||||
-- Fallback to random ID if debug info unavailable
|
||||
return "auto_" .. tostring(math.random(1000000, 9999999))
|
||||
end
|
||||
|
||||
local source = info.source or "unknown"
|
||||
local line = info.currentline or 0
|
||||
|
||||
-- Create base location key from source file and line number
|
||||
local filename = source:match("([^/\\]+)$") or source -- Get filename
|
||||
filename = filename:gsub("%.lua$", "") -- Remove .lua extension
|
||||
local locationKey = filename .. "_L" .. line
|
||||
|
||||
-- If we have a parent, use tree-based ID generation for stability
|
||||
if parent and parent.id and parent.id ~= "" then
|
||||
-- For child elements, use call-site (file + line) like top-level elements
|
||||
-- This ensures the same call site always generates the same ID, even when
|
||||
-- retained children persist in parent.children array
|
||||
local baseID = parent.id .. "_" .. locationKey
|
||||
|
||||
-- Count how many children have been created at THIS call site
|
||||
local callSiteKey = parent.id .. "_" .. locationKey
|
||||
callSiteCounters[callSiteKey] = (callSiteCounters[callSiteKey] or 0) + 1
|
||||
local instanceNum = callSiteCounters[callSiteKey]
|
||||
|
||||
if instanceNum > 1 then
|
||||
baseID = baseID .. "_" .. instanceNum
|
||||
end
|
||||
|
||||
-- Add property hash if provided (for additional differentiation)
|
||||
if props then
|
||||
local propHash = hashProps(props)
|
||||
if propHash ~= "" then
|
||||
-- Use first 8 chars of a simple hash
|
||||
local hash = 0
|
||||
for i = 1, #propHash do
|
||||
hash = (hash * 31 + string.byte(propHash, i)) % 1000000
|
||||
end
|
||||
baseID = baseID .. "_" .. hash
|
||||
end
|
||||
end
|
||||
|
||||
return baseID
|
||||
end
|
||||
|
||||
-- No parent (top-level element): use call-site counter approach
|
||||
-- Track how many elements have been created at this location
|
||||
callSiteCounters[locationKey] = (callSiteCounters[locationKey] or 0) + 1
|
||||
local instanceNum = callSiteCounters[locationKey]
|
||||
|
||||
local baseID = locationKey
|
||||
|
||||
-- Add instance number if multiple elements created at same location (e.g., in loops)
|
||||
if instanceNum > 1 then
|
||||
baseID = baseID .. "_" .. instanceNum
|
||||
end
|
||||
|
||||
-- Add property hash if provided (for additional differentiation)
|
||||
if props then
|
||||
local propHash = hashProps(props)
|
||||
if propHash ~= "" then
|
||||
-- Use first 8 chars of a simple hash
|
||||
local hash = 0
|
||||
for i = 1, #propHash do
|
||||
hash = (hash * 31 + string.byte(propHash, i)) % 1000000
|
||||
end
|
||||
baseID = baseID .. "_" .. hash
|
||||
end
|
||||
end
|
||||
|
||||
return baseID
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- State Management
|
||||
-- ====================
|
||||
|
||||
--- Initialize StateManager with dependencies
|
||||
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
|
||||
function StateManager.init(deps)
|
||||
if type(deps) == "table" then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
end
|
||||
|
||||
--- Get state for an element ID, creating if it doesn't exist
|
||||
---@param id string Element ID
|
||||
---@param defaultState table|nil Default state if creating new
|
||||
---@return table state State table for the element
|
||||
function StateManager.getState(id, defaultState)
|
||||
if not id then
|
||||
ErrorHandler:error("StateManager", "SYS_001", {
|
||||
parameter = "id",
|
||||
value = "nil",
|
||||
})
|
||||
end
|
||||
|
||||
-- Create state if it doesn't exist
|
||||
if not stateStore[id] then
|
||||
-- Start with empty state (sparse storage)
|
||||
stateStore[id] = defaultState or {}
|
||||
|
||||
-- Create metadata
|
||||
stateMetadata[id] = {
|
||||
lastFrame = frameNumber,
|
||||
createdFrame = frameNumber,
|
||||
accessCount = 0,
|
||||
}
|
||||
else
|
||||
-- Update metadata
|
||||
local meta = stateMetadata[id]
|
||||
meta.lastFrame = frameNumber
|
||||
meta.accessCount = meta.accessCount + 1
|
||||
end
|
||||
|
||||
return stateStore[id]
|
||||
end
|
||||
|
||||
--- Set state for an element ID (replaces entire state)
|
||||
---@param id string Element ID
|
||||
---@param state table State to store
|
||||
function StateManager.setState(id, state)
|
||||
if not id then
|
||||
ErrorHandler:error("StateManager", "SYS_001", {
|
||||
parameter = "id",
|
||||
value = "nil",
|
||||
})
|
||||
end
|
||||
|
||||
-- Create sparse state (remove default values)
|
||||
local sparseState = {}
|
||||
for key, value in pairs(state) do
|
||||
if not isDefaultValue(key, value) then
|
||||
sparseState[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
stateStore[id] = sparseState
|
||||
|
||||
-- Update or create metadata
|
||||
if not stateMetadata[id] then
|
||||
stateMetadata[id] = {
|
||||
lastFrame = frameNumber,
|
||||
createdFrame = frameNumber,
|
||||
accessCount = 1,
|
||||
}
|
||||
else
|
||||
stateMetadata[id].lastFrame = frameNumber
|
||||
end
|
||||
end
|
||||
|
||||
--- Update state for an element ID (merges with existing state)
|
||||
---@param id string Element ID
|
||||
---@param newState table New state values to merge
|
||||
function StateManager.updateState(id, newState)
|
||||
local state = StateManager.getState(id)
|
||||
|
||||
-- Merge new state into existing state (with diffing optimization)
|
||||
local changed = false
|
||||
for key, value in pairs(newState) do
|
||||
if state[key] ~= value then
|
||||
state[key] = value
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
|
||||
-- Only update metadata if something actually changed
|
||||
if changed then
|
||||
stateMetadata[id].lastFrame = frameNumber
|
||||
end
|
||||
end
|
||||
|
||||
--- Update state only if values have changed (optimized for immediate mode)
|
||||
---@param id string Element ID
|
||||
---@param newState table New state values to merge
|
||||
---@return boolean changed True if any values changed
|
||||
function StateManager.updateStateIfChanged(id, newState)
|
||||
local state = StateManager.getState(id)
|
||||
local changed = false
|
||||
|
||||
for key, value in pairs(newState) do
|
||||
-- Skip if value hasn't changed (optimization)
|
||||
if state[key] ~= value then
|
||||
state[key] = value
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
|
||||
if changed then
|
||||
stateMetadata[id].lastFrame = frameNumber
|
||||
end
|
||||
|
||||
return changed
|
||||
end
|
||||
|
||||
--- Clear state for a specific element ID
|
||||
---@param id string Element ID
|
||||
function StateManager.clearState(id)
|
||||
stateStore[id] = nil
|
||||
stateMetadata[id] = nil
|
||||
end
|
||||
|
||||
--- Mark state as used this frame (updates last accessed frame)
|
||||
---@param id string Element ID
|
||||
function StateManager.markStateUsed(id)
|
||||
if stateMetadata[id] then
|
||||
stateMetadata[id].lastFrame = frameNumber
|
||||
end
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Frame Management
|
||||
-- ====================
|
||||
|
||||
--- Increment frame counter (called at frame start)
|
||||
function StateManager.incrementFrame()
|
||||
frameNumber = frameNumber + 1
|
||||
-- Reset call site counters for new frame
|
||||
callSiteCounters = {}
|
||||
end
|
||||
|
||||
--- Get current frame number
|
||||
---@return number
|
||||
function StateManager.getFrameNumber()
|
||||
return frameNumber
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Granular State Access (Unified API for both modes)
|
||||
-- ====================
|
||||
|
||||
--- Get a single state value by key for a given element ID.
|
||||
--- Works identically in both modes — the caller does not need to know the mode.
|
||||
---
|
||||
--- Immediate mode: reads from persistent state store.
|
||||
--- Retained mode: resolves through registered element field (cache-through).
|
||||
---
|
||||
---@param id string Element state ID
|
||||
---@param key string State key
|
||||
---@return any value The stored value, or nil if not found
|
||||
function StateManager.getStateValue(id, key)
|
||||
if not id or not key then
|
||||
ErrorHandler:error("StateManager", "SYS_001", {
|
||||
parameter = "id and key",
|
||||
value = "missing",
|
||||
})
|
||||
end
|
||||
|
||||
-- Update metadata for access tracking
|
||||
if stateMetadata[id] then
|
||||
stateMetadata[id].lastFrame = frameNumber
|
||||
stateMetadata[id].accessCount = stateMetadata[id].accessCount + 1
|
||||
end
|
||||
|
||||
if _immediateMode then
|
||||
-- Immediate mode: read from persistent state store
|
||||
local state = stateStore[id]
|
||||
if state then
|
||||
return state[key]
|
||||
end
|
||||
return nil
|
||||
else
|
||||
-- Retained mode: resolve through element field
|
||||
local element = statefulElements[id]
|
||||
if element then
|
||||
return element[key]
|
||||
end
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
--- Set a single state value by key for a given element ID.
|
||||
--- Works identically in both modes — the caller does not need to know the mode.
|
||||
---
|
||||
--- Immediate mode: marks dirty for flushFrame() persistence.
|
||||
--- Retained mode: writes directly to element field (cache-through).
|
||||
---
|
||||
---@param id string Element state ID
|
||||
---@param key string State key
|
||||
---@param value any Value to store
|
||||
function StateManager.setStateValue(id, key, value)
|
||||
if not id or not key then
|
||||
ErrorHandler:error("StateManager", "SYS_001", {
|
||||
parameter = "id and key",
|
||||
value = "missing",
|
||||
})
|
||||
end
|
||||
|
||||
-- Update metadata
|
||||
if not stateMetadata[id] then
|
||||
stateMetadata[id] = {
|
||||
lastFrame = frameNumber,
|
||||
createdFrame = frameNumber,
|
||||
accessCount = 1,
|
||||
}
|
||||
else
|
||||
stateMetadata[id].lastFrame = frameNumber
|
||||
end
|
||||
|
||||
if _immediateMode then
|
||||
-- Immediate mode: mark dirty for flushFrame persistence
|
||||
local state = StateManager.getState(id)
|
||||
state[key] = value
|
||||
dirtyState[id] = dirtyState[id] or {}
|
||||
dirtyState[id][key] = true
|
||||
else
|
||||
-- Retained mode: write directly to element field
|
||||
local element = statefulElements[id]
|
||||
if element then
|
||||
element[key] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Stateful Element Registration (Retained Mode Cache-Through)
|
||||
-- ====================
|
||||
|
||||
--- Register an element instance for retained-mode cache-through.
|
||||
--- After registration, getStateValue/setStateValue will resolve through the element's fields.
|
||||
---
|
||||
--- Called by Element in _construct phase.
|
||||
---
|
||||
---@param id string State ID (typically element.id)
|
||||
---@param element table Element instance to link
|
||||
function StateManager.registerStateful(id, element)
|
||||
if not id or not element then
|
||||
return
|
||||
end
|
||||
statefulElements[id] = element
|
||||
end
|
||||
|
||||
--- Unregister an element instance.
|
||||
--- After unregistration, retained-mode access will fall back to nil.
|
||||
---
|
||||
--- Called by Element in _cleanup phase.
|
||||
---
|
||||
---@param id string State ID to unregister
|
||||
function StateManager.unregisterStateful(id)
|
||||
if id then
|
||||
statefulElements[id] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Frame Flush (Immediate Mode Dirty State Persistence)
|
||||
-- ====================
|
||||
|
||||
--- Flush dirty state to persistent store at end of frame.
|
||||
--- Called automatically at frame end in immediate mode.
|
||||
--- Behaviors call setStateValue during update without knowing the mode.
|
||||
---
|
||||
--- In retained mode, this is a no-op (state is written directly to elements).
|
||||
function StateManager.flushFrame()
|
||||
if not _immediateMode then
|
||||
return
|
||||
end
|
||||
|
||||
-- All dirty writes were already applied to stateStore during setStateValue
|
||||
-- This method exists for future extensions (e.g., batching, analytics)
|
||||
-- Reset dirty tracking for next frame
|
||||
dirtyState = {}
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Mode Configuration
|
||||
-- ====================
|
||||
|
||||
--- Configure immediate mode state.
|
||||
--- Called by Context when immediate mode is enabled/disabled.
|
||||
---
|
||||
---@param enabled boolean Whether immediate mode is active
|
||||
function StateManager.setImmediateMode(enabled)
|
||||
_immediateMode = enabled
|
||||
end
|
||||
|
||||
--- Check if immediate mode is active.
|
||||
---@return boolean
|
||||
function StateManager.isImmediateMode()
|
||||
return _immediateMode
|
||||
end
|
||||
|
||||
--- Whether at-construction layout / eager initialization should run now.
|
||||
--- Returns true in retained mode (layout eagerly), false in immediate mode
|
||||
--- (layout is deferred to `FlexLove.endFrame` / FlexLove so it runs once all
|
||||
--- elements for the frame have been created). This replaces the scattered
|
||||
--- `if not _immediateMode then layoutChildren()` mode checks with a single
|
||||
--- mode-aware query (behavior-mode-unification task 11).
|
||||
---@return boolean
|
||||
function StateManager.shouldLayout()
|
||||
return not _immediateMode
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Cleanup & Maintenance
|
||||
-- ====================
|
||||
|
||||
--- Clean up stale states (not accessed recently)
|
||||
---@return number count Number of states cleaned up
|
||||
function StateManager.cleanup()
|
||||
local cleanedCount = 0
|
||||
local retentionFrames = config.stateRetentionFrames
|
||||
|
||||
for id, meta in pairs(stateMetadata) do
|
||||
local framesSinceAccess = frameNumber - meta.lastFrame
|
||||
|
||||
if framesSinceAccess > retentionFrames then
|
||||
stateStore[id] = nil
|
||||
stateMetadata[id] = nil
|
||||
cleanedCount = cleanedCount + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- Clean up empty states (sparse storage optimization)
|
||||
for id, state in pairs(stateStore) do
|
||||
if next(state) == nil then
|
||||
stateStore[id] = nil
|
||||
stateMetadata[id] = nil
|
||||
cleanedCount = cleanedCount + 1
|
||||
end
|
||||
end
|
||||
|
||||
return cleanedCount
|
||||
end
|
||||
|
||||
--- Force cleanup if state count exceeds maximum
|
||||
---@return number count Number of states cleaned up
|
||||
function StateManager.forceCleanupIfNeeded()
|
||||
local stateCount = StateManager.getStateCount()
|
||||
|
||||
if stateCount > config.maxStateEntries then
|
||||
-- Clean up states not accessed in last 10 frames (aggressive)
|
||||
local cleanedCount = 0
|
||||
|
||||
for id, meta in pairs(stateMetadata) do
|
||||
local framesSinceAccess = frameNumber - meta.lastFrame
|
||||
|
||||
if framesSinceAccess > 10 then
|
||||
stateStore[id] = nil
|
||||
stateMetadata[id] = nil
|
||||
cleanedCount = cleanedCount + 1
|
||||
end
|
||||
end
|
||||
|
||||
return cleanedCount
|
||||
end
|
||||
|
||||
return 0
|
||||
end
|
||||
|
||||
--- Get total number of stored states
|
||||
---@return number
|
||||
function StateManager.getStateCount()
|
||||
local count = 0
|
||||
for _ in pairs(stateStore) do
|
||||
count = count + 1
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
--- Clear all states
|
||||
function StateManager.clearAllStates()
|
||||
stateStore = {}
|
||||
stateMetadata = {}
|
||||
end
|
||||
|
||||
--- Configure state management
|
||||
---@param newConfig {stateRetentionFrames?: number, maxStateEntries?: number}
|
||||
function StateManager.configure(newConfig)
|
||||
if newConfig.stateRetentionFrames then
|
||||
config.stateRetentionFrames = newConfig.stateRetentionFrames
|
||||
end
|
||||
if newConfig.maxStateEntries then
|
||||
config.maxStateEntries = newConfig.maxStateEntries
|
||||
end
|
||||
end
|
||||
|
||||
--- Get state statistics for debugging
|
||||
---@return table stats State usage statistics
|
||||
function StateManager.getStats()
|
||||
local stateCount = StateManager.getStateCount()
|
||||
local oldest = nil
|
||||
local newest = nil
|
||||
|
||||
for _, meta in pairs(stateMetadata) do
|
||||
if not oldest or meta.createdFrame < oldest then
|
||||
oldest = meta.createdFrame
|
||||
end
|
||||
if not newest or meta.createdFrame > newest then
|
||||
newest = meta.createdFrame
|
||||
end
|
||||
end
|
||||
|
||||
-- Count callSiteCounters
|
||||
local callSiteCount = 0
|
||||
for _ in pairs(callSiteCounters) do
|
||||
callSiteCount = callSiteCount + 1
|
||||
end
|
||||
|
||||
-- Warn if callSiteCounters is unexpectedly large
|
||||
if callSiteCount > 1000 then
|
||||
if ErrorHandler then
|
||||
ErrorHandler.warn("StateManager", "STATE_001", {
|
||||
count = callSiteCount,
|
||||
expected = "near 0",
|
||||
frameNumber = frameNumber,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
stateCount = stateCount,
|
||||
frameNumber = frameNumber,
|
||||
oldestState = oldest,
|
||||
newestState = newest,
|
||||
callSiteCounterCount = callSiteCount,
|
||||
}
|
||||
end
|
||||
|
||||
--- Get internal state (for debugging/profiling only)
|
||||
---@return table internal {stateStore, stateMetadata, callSiteCounters}
|
||||
function StateManager._getInternalState()
|
||||
return {
|
||||
stateStore = stateStore,
|
||||
stateMetadata = stateMetadata,
|
||||
callSiteCounters = callSiteCounters,
|
||||
}
|
||||
end
|
||||
|
||||
--- Reset the entire state system (for testing)
|
||||
function StateManager.reset()
|
||||
stateStore = {}
|
||||
stateMetadata = {}
|
||||
frameNumber = 0
|
||||
callSiteCounters = {}
|
||||
statefulElements = {}
|
||||
dirtyState = {}
|
||||
_immediateMode = false
|
||||
end
|
||||
|
||||
-- ====================
|
||||
-- Convenience Functions (for backward compatibility)
|
||||
-- ====================
|
||||
|
||||
--- Check if an element is currently hovered
|
||||
---@param id string Element ID
|
||||
---@return boolean
|
||||
function StateManager.isHovered(id)
|
||||
local state = StateManager.getState(id)
|
||||
return state.hover or false
|
||||
end
|
||||
|
||||
--- Check if an element is currently pressed
|
||||
---@param id string Element ID
|
||||
---@return boolean
|
||||
function StateManager.isPressed(id)
|
||||
local state = StateManager.getState(id)
|
||||
return state.pressed or false
|
||||
end
|
||||
|
||||
--- Check if an element is currently focused
|
||||
---@param id string Element ID
|
||||
---@return boolean
|
||||
function StateManager.isFocused(id)
|
||||
local state = StateManager.getState(id)
|
||||
return state.focused or false
|
||||
end
|
||||
|
||||
--- Check if an element is disabled
|
||||
---@param id string Element ID
|
||||
---@return boolean
|
||||
function StateManager.isDisabled(id)
|
||||
local state = StateManager.getState(id)
|
||||
return state.disabled or false
|
||||
end
|
||||
|
||||
--- Check if an element is active (e.g., input focused)
|
||||
---@param id string Element ID
|
||||
---@return boolean
|
||||
function StateManager.isActive(id)
|
||||
local state = StateManager.getState(id)
|
||||
return state.active or false
|
||||
end
|
||||
|
||||
return StateManager
|
||||
@@ -0,0 +1,183 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
-- Text sanitization, escaping, and input validation utilities.
|
||||
|
||||
-- ErrorHandler is injected via init() for truncation warnings.
|
||||
local ErrorHandler = nil
|
||||
|
||||
--- Initialize dependencies
|
||||
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
|
||||
local function init(deps)
|
||||
if type(deps) == "table" then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
end
|
||||
|
||||
--- Sanitize text to prevent security vulnerabilities
|
||||
--- @param text string? Text to sanitize
|
||||
--- @param options table? Sanitization options
|
||||
--- @return string Sanitized text
|
||||
local function sanitizeText(text, options)
|
||||
local utf8 = require("utf8")
|
||||
-- Handle nil or non-string inputs
|
||||
if text == nil then
|
||||
return ""
|
||||
end
|
||||
if type(text) ~= "string" then
|
||||
text = tostring(text)
|
||||
end
|
||||
|
||||
-- Default options
|
||||
options = options or {}
|
||||
local maxLength = options.maxLength or 10000
|
||||
local allowNewlines = options.allowNewlines ~= false -- default true
|
||||
local allowTabs = options.allowTabs ~= false -- default true
|
||||
local stripControls = options.stripControls ~= false -- default true
|
||||
local trimWhitespace = options.trimWhitespace ~= false -- default true
|
||||
|
||||
-- Remove null bytes (critical security risk)
|
||||
text = text:gsub("%z", "")
|
||||
|
||||
-- Strip control characters except allowed ones
|
||||
if stripControls then
|
||||
local pattern = "[\1-\31\127]" -- All control characters
|
||||
if allowNewlines and allowTabs then
|
||||
pattern = "[\1-\8\11\12\14-\31\127]" -- Exclude \t (9), \n (10), \r (13)
|
||||
elseif allowNewlines then
|
||||
pattern = "[\1-\9\11\12\14-\31\127]" -- Exclude \n (10), \r (13)
|
||||
elseif allowTabs then
|
||||
pattern = "[\1-\8\10\12-\31\127]" -- Exclude \t (9)
|
||||
end
|
||||
text = text:gsub(pattern, "")
|
||||
end
|
||||
|
||||
-- Trim leading/trailing whitespace
|
||||
if trimWhitespace then
|
||||
text = text:match("^%s*(.-)%s*$") or ""
|
||||
end
|
||||
|
||||
-- Limit string length (use UTF-8 character count, not byte count)
|
||||
local charCount = utf8.len(text)
|
||||
if charCount and charCount > maxLength then
|
||||
if ErrorHandler then
|
||||
ErrorHandler:warn("utils", "UTIL_001", {
|
||||
original = charCount,
|
||||
truncated = maxLength,
|
||||
})
|
||||
end
|
||||
-- Truncate to maxLength UTF-8 characters
|
||||
local bytePos = utf8.offset(text, maxLength + 1)
|
||||
if bytePos then
|
||||
text = text:sub(1, bytePos - 1)
|
||||
end
|
||||
if ErrorHandler then
|
||||
ErrorHandler:warn("utils", string.format("Text truncated from %d to %d characters", charCount, maxLength))
|
||||
end
|
||||
end
|
||||
|
||||
return text
|
||||
end
|
||||
|
||||
--- Validate text input against rules
|
||||
--- @param text string Text to validate
|
||||
--- @param rules table Validation rules
|
||||
--- @return boolean, string? Returns true if valid, or false with error message
|
||||
local function validateTextInput(text, rules)
|
||||
rules = rules or {}
|
||||
|
||||
-- Check minimum length
|
||||
if rules.minLength and #text < rules.minLength then
|
||||
return false, string.format("Text must be at least %d characters", rules.minLength)
|
||||
end
|
||||
|
||||
-- Check maximum length
|
||||
if rules.maxLength and #text > rules.maxLength then
|
||||
return false, string.format("Text must be at most %d characters", rules.maxLength)
|
||||
end
|
||||
|
||||
-- Check pattern match
|
||||
if rules.pattern and not text:match(rules.pattern) then
|
||||
return false, rules.patternError or "Text does not match required pattern"
|
||||
end
|
||||
|
||||
-- Check character whitelist
|
||||
if rules.allowedChars then
|
||||
local pattern = "[^" .. rules.allowedChars .. "]"
|
||||
if text:match(pattern) then
|
||||
return false, "Text contains invalid characters"
|
||||
end
|
||||
end
|
||||
|
||||
-- Check character blacklist
|
||||
if rules.forbiddenChars then
|
||||
local pattern = "[" .. rules.forbiddenChars .. "]"
|
||||
if text:match(pattern) then
|
||||
return false, "Text contains forbidden characters"
|
||||
end
|
||||
end
|
||||
|
||||
return true, nil
|
||||
end
|
||||
|
||||
--- Validate text against range/length rules (alias of validateTextInput)
|
||||
--- @param text string Text to validate
|
||||
--- @param rules table Validation rules (minLength, maxLength, pattern, etc.)
|
||||
--- @return boolean, string? Returns true if valid, or false with error message
|
||||
local function validateTextRange(text, rules)
|
||||
return validateTextInput(text, rules)
|
||||
end
|
||||
|
||||
--- Escape HTML special characters
|
||||
--- @param text string Text to escape
|
||||
--- @return string Escaped text
|
||||
local function escapeHtml(text)
|
||||
if text == nil then
|
||||
return ""
|
||||
end
|
||||
text = tostring(text)
|
||||
text = text:gsub("&", "&")
|
||||
text = text:gsub("<", "<")
|
||||
text = text:gsub(">", ">")
|
||||
text = text:gsub('"', """)
|
||||
text = text:gsub("'", "'")
|
||||
return text
|
||||
end
|
||||
|
||||
--- Escape Lua pattern special characters
|
||||
--- @param text string Text to escape
|
||||
--- @return string Escaped text
|
||||
local function escapeLuaPattern(text)
|
||||
if text == nil then
|
||||
return ""
|
||||
end
|
||||
text = tostring(text)
|
||||
-- Escape all Lua pattern special characters
|
||||
text = text:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")
|
||||
return text
|
||||
end
|
||||
|
||||
--- Strip all non-printable characters from text
|
||||
--- @param text string Text to clean
|
||||
--- @return string Cleaned text
|
||||
local function stripNonPrintable(text)
|
||||
if text == nil then
|
||||
return ""
|
||||
end
|
||||
text = tostring(text)
|
||||
-- Keep printable ASCII (32-126), newline (10), tab (9), and carriage return (13)
|
||||
text = text:gsub("[^\9\10\13\32-\126]", "")
|
||||
return text
|
||||
end
|
||||
|
||||
return {
|
||||
init = init,
|
||||
sanitizeText = sanitizeText,
|
||||
validateTextInput = validateTextInput,
|
||||
validateTextRange = validateTextRange,
|
||||
escapeHtml = escapeHtml,
|
||||
escapeLuaPattern = escapeLuaPattern,
|
||||
stripNonPrintable = stripNonPrintable,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
---@class UTF8
|
||||
---Compatibility layer for UTF-8 support across Lua versions
|
||||
---Handles utf8 (Lua 5.3+), lua-utf8 (LuaRocks), and basic fallbacks
|
||||
|
||||
local UTF8 = {}
|
||||
|
||||
-- Try to load UTF-8 library in order of preference:
|
||||
-- 1. Built-in utf8 (Lua 5.3+, LÖVE2D)
|
||||
-- 2. lua-utf8 from LuaRocks (Lua 5.1, 5.2)
|
||||
-- 3. Error if neither available
|
||||
local function loadUTF8()
|
||||
-- Try built-in utf8 first (Lua 5.3+ and LÖVE2D)
|
||||
if utf8 and type(utf8) == "table" and utf8.len then
|
||||
return utf8
|
||||
end
|
||||
|
||||
-- Try lua-utf8 from LuaRocks
|
||||
local ok, luautf8 = pcall(require, "lua-utf8")
|
||||
if ok then
|
||||
return luautf8
|
||||
end
|
||||
|
||||
-- Try standard utf8 module name as fallback
|
||||
ok, luautf8 = pcall(require, "utf8")
|
||||
if ok then
|
||||
return luautf8
|
||||
end
|
||||
|
||||
-- No UTF-8 library available
|
||||
error("No UTF-8 library available. Please install 'luautf8' via LuaRocks: luarocks install luautf8")
|
||||
end
|
||||
|
||||
-- Load the UTF-8 implementation
|
||||
local utf8lib = loadUTF8()
|
||||
|
||||
-- Export all utf8 functions
|
||||
UTF8.char = utf8lib.char
|
||||
UTF8.charpattern = utf8lib.charpattern
|
||||
UTF8.codes = utf8lib.codes
|
||||
UTF8.codepoint = utf8lib.codepoint
|
||||
UTF8.len = utf8lib.len
|
||||
UTF8.offset = utf8lib.offset
|
||||
|
||||
return UTF8
|
||||
@@ -0,0 +1,335 @@
|
||||
--- Utility module for parsing and resolving CSS-like units (px, %, vw, vh)
|
||||
--- Provides unit parsing, validation, and conversion to pixel values
|
||||
---@class Units
|
||||
---@field _Context table? Context module dependency
|
||||
---@field _ErrorHandler table? ErrorHandler module dependency
|
||||
---@field _Calc table? Calc module dependency
|
||||
local Units = {}
|
||||
|
||||
--- Initialize Units module with dependencies
|
||||
---@param deps table Dependencies: { Context = table?, ErrorHandler = table?, Calc = table? }
|
||||
function Units.init(deps)
|
||||
Units._Context = deps.Context
|
||||
Units._ErrorHandler = deps.ErrorHandler
|
||||
Units._Calc = deps.Calc
|
||||
end
|
||||
|
||||
--- Parse a unit value into numeric value and unit type
|
||||
--- Supports: px (pixels), % (percentage), vw/vh (viewport), and calc() expressions
|
||||
---@param value string|number|table The value to parse (e.g., "50px", "10%", "2vw", 100, or calc object)
|
||||
---@return number|table numericValue The numeric portion of the value or calc object
|
||||
---@return string unitType The unit type ("px", "%", "vw", "vh", "calc")
|
||||
function Units.parse(value)
|
||||
-- Check if value is a calc expression
|
||||
if Units._Calc and Units._Calc.isCalc(value) then
|
||||
return value, "calc"
|
||||
end
|
||||
|
||||
if type(value) == "number" then
|
||||
return value, "px"
|
||||
end
|
||||
|
||||
if type(value) ~= "string" and type(value) ~= "table" then
|
||||
Units._ErrorHandler:warn("Units", "VAL_001", {
|
||||
property = "unit value",
|
||||
expected = "string, number, or calc object",
|
||||
got = type(value),
|
||||
})
|
||||
return 0, "px"
|
||||
end
|
||||
|
||||
-- Check for unit-only input (e.g., "px", "%", "vw" without a number)
|
||||
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
|
||||
if validUnits[value] then
|
||||
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||
input = value,
|
||||
expected = "number + unit (e.g., '50" .. value .. "')",
|
||||
})
|
||||
return 0, "px"
|
||||
end
|
||||
|
||||
-- Check for invalid format (space between number and unit)
|
||||
if value:match("%d%s+%a") then
|
||||
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||
input = value,
|
||||
issue = "contains space between number and unit",
|
||||
})
|
||||
return 0, "px"
|
||||
end
|
||||
|
||||
-- Match number followed by optional unit
|
||||
local numStr, unit = value:match("^([%-]?[%d%.]+)(.*)$")
|
||||
if not numStr then
|
||||
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||
input = value,
|
||||
})
|
||||
return 0, "px"
|
||||
end
|
||||
|
||||
local num = tonumber(numStr)
|
||||
if not num then
|
||||
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||
input = value,
|
||||
issue = "numeric value cannot be parsed",
|
||||
})
|
||||
return 0, "px"
|
||||
end
|
||||
|
||||
-- Default to pixels if no unit specified
|
||||
if unit == "" then
|
||||
unit = "px"
|
||||
end
|
||||
|
||||
-- validUnits is already defined at the top of the function
|
||||
if not validUnits[unit] then
|
||||
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||
input = value,
|
||||
unit = unit,
|
||||
validUnits = "px, %, vw, vh",
|
||||
})
|
||||
return num, "px"
|
||||
end
|
||||
|
||||
return num, unit
|
||||
end
|
||||
|
||||
--- Convert relative units to absolute pixel values
|
||||
--- Resolves %, vw, vh units based on viewport and parent dimensions, and evaluates calc() expressions
|
||||
---@param value number|table Numeric value to convert or calc object
|
||||
---@param unit string Unit type ("px", "%", "vw", "vh", "calc")
|
||||
---@param viewportWidth number Current viewport width in pixels
|
||||
---@param viewportHeight number Current viewport height in pixels
|
||||
---@param parentSize number? Required for percentage units (parent dimension in pixels)
|
||||
---@return number resolvedValue Resolved pixel value
|
||||
function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize)
|
||||
if unit == "calc" then
|
||||
-- Resolve calc expression
|
||||
if Units._Calc then
|
||||
return Units._Calc.resolve(value, viewportWidth, viewportHeight, parentSize)
|
||||
else
|
||||
Units._ErrorHandler:warn("Units", "VAL_006", {
|
||||
unit = "calc",
|
||||
issue = "Calc module not available",
|
||||
})
|
||||
return 0
|
||||
end
|
||||
elseif unit == "px" then
|
||||
return value
|
||||
elseif unit == "%" then
|
||||
if not parentSize then
|
||||
Units._ErrorHandler:warn("Units", "LAY_003", {
|
||||
unit = "%",
|
||||
issue = "parent dimension not available",
|
||||
})
|
||||
return 0
|
||||
end
|
||||
return (value / 100) * parentSize
|
||||
elseif unit == "vw" then
|
||||
return (value / 100) * viewportWidth
|
||||
elseif unit == "vh" then
|
||||
return (value / 100) * viewportHeight
|
||||
else
|
||||
Units._ErrorHandler:warn("Units", "VAL_005", {
|
||||
unit = unit,
|
||||
validUnits = "px, %, vw, vh, calc",
|
||||
})
|
||||
return 0
|
||||
end
|
||||
end
|
||||
|
||||
--- Get current viewport dimensions
|
||||
--- Uses cached viewport during resize operations, otherwise queries LÖVE graphics
|
||||
---@return number width Viewport width in pixels
|
||||
---@return number height Viewport height in pixels
|
||||
function Units.getViewport()
|
||||
-- Return cached viewport if available (only during resize operations)
|
||||
if Units._Context._cachedViewport and Units._Context._cachedViewport.width > 0 then
|
||||
return Units._Context._cachedViewport.width, Units._Context._cachedViewport.height
|
||||
end
|
||||
|
||||
if love.graphics and love.graphics.getDimensions then
|
||||
return love.graphics.getDimensions()
|
||||
else
|
||||
local w, h = love.window.getMode()
|
||||
return w, h
|
||||
end
|
||||
end
|
||||
|
||||
--- Apply base scale factor to a value based on axis
|
||||
--- Used for responsive scaling of UI elements
|
||||
---@param value number The value to scale
|
||||
---@param axis "x"|"y" The axis to scale on
|
||||
---@param scaleFactors {x:number, y:number} Scale factors for each axis
|
||||
---@return number scaledValue The scaled value
|
||||
function Units.applyBaseScale(value, axis, scaleFactors)
|
||||
if axis == "x" then
|
||||
return value * scaleFactors.x
|
||||
else
|
||||
return value * scaleFactors.y
|
||||
end
|
||||
end
|
||||
|
||||
--- Resolve spacing properties (margin, padding) to pixel values
|
||||
--- Supports individual sides (top, right, bottom, left) and shortcuts (vertical, horizontal)
|
||||
---@param spacingProps table? Spacing properties with top/right/bottom/left/vertical/horizontal
|
||||
---@param parentWidth number Parent element width in pixels
|
||||
---@param parentHeight number Parent element height in pixels
|
||||
---@return table resolvedSpacing Table with top, right, bottom, left in pixels
|
||||
function Units.resolveSpacing(spacingProps, parentWidth, parentHeight)
|
||||
if not spacingProps then
|
||||
return { top = 0, right = 0, bottom = 0, left = 0 }
|
||||
end
|
||||
|
||||
local viewportWidth, viewportHeight = Units.getViewport()
|
||||
local result = {}
|
||||
|
||||
local vertical = spacingProps.vertical
|
||||
local horizontal = spacingProps.horizontal
|
||||
|
||||
if vertical then
|
||||
if type(vertical) == "string" or (Units._Calc and Units._Calc.isCalc(vertical)) then
|
||||
local value, unit = Units.parse(vertical)
|
||||
vertical = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight)
|
||||
end
|
||||
end
|
||||
|
||||
if horizontal then
|
||||
if type(horizontal) == "string" or (Units._Calc and Units._Calc.isCalc(horizontal)) then
|
||||
local value, unit = Units.parse(horizontal)
|
||||
horizontal = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth)
|
||||
end
|
||||
end
|
||||
|
||||
for _, side in ipairs({ "top", "right", "bottom", "left" }) do
|
||||
local value = spacingProps[side]
|
||||
if value then
|
||||
if type(value) == "string" or (Units._Calc and Units._Calc.isCalc(value)) then
|
||||
local numValue, unit = Units.parse(value)
|
||||
local parentSize = (side == "top" or side == "bottom") and parentHeight or parentWidth
|
||||
result[side] = Units.resolve(numValue, unit, viewportWidth, viewportHeight, parentSize)
|
||||
else
|
||||
result[side] = value
|
||||
end
|
||||
else
|
||||
if side == "top" or side == "bottom" then
|
||||
result[side] = vertical or 0
|
||||
else
|
||||
result[side] = horizontal or 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return result
|
||||
end
|
||||
|
||||
--- Validate a unit string format
|
||||
--- Checks if the string can be successfully parsed as a valid unit or calc expression
|
||||
---@param unitStr string|table The unit string to validate (e.g., "50px", "10%") or calc object
|
||||
---@return boolean isValid True if the unit string is valid, false otherwise
|
||||
function Units.isValid(unitStr)
|
||||
-- Check if it's a calc expression
|
||||
if Units._Calc and Units._Calc.isCalc(unitStr) then
|
||||
return true
|
||||
end
|
||||
|
||||
if type(unitStr) ~= "string" then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Check for invalid format (space between number and unit)
|
||||
if unitStr:match("%d%s+%a") then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Match number followed by optional unit
|
||||
local numStr, unit = unitStr:match("^([%-]?[%d%.]+)(.*)$")
|
||||
if not numStr then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Check if numeric part is valid
|
||||
local num = tonumber(numStr)
|
||||
if not num then
|
||||
return false
|
||||
end
|
||||
|
||||
-- Default to pixels if no unit specified
|
||||
if unit == "" then
|
||||
unit = "px"
|
||||
end
|
||||
|
||||
-- Check if unit is valid
|
||||
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
|
||||
return validUnits[unit] == true
|
||||
end
|
||||
|
||||
--- Parse CSS flex shorthand into flexGrow, flexShrink, flexBasis
|
||||
--- Supports: number, "auto", "none", "grow shrink basis"
|
||||
---@param flexValue number|string The flex shorthand value
|
||||
---@return number flexGrow
|
||||
---@return number flexShrink
|
||||
---@return string|number flexBasis
|
||||
function Units.parseFlexShorthand(flexValue)
|
||||
-- Single number: flex-grow
|
||||
if type(flexValue) == "number" then
|
||||
return flexValue, 1, 0
|
||||
end
|
||||
|
||||
-- String values
|
||||
if type(flexValue) == "string" then
|
||||
-- "auto" = 1 1 auto
|
||||
if flexValue == "auto" then
|
||||
return 1, 1, "auto"
|
||||
end
|
||||
|
||||
-- "none" = 0 0 auto
|
||||
if flexValue == "none" then
|
||||
return 0, 0, "auto"
|
||||
end
|
||||
|
||||
-- Parse "grow shrink basis" format
|
||||
local parts = {}
|
||||
for part in flexValue:gmatch("%S+") do
|
||||
table.insert(parts, part)
|
||||
end
|
||||
|
||||
local grow = 0
|
||||
local shrink = 1
|
||||
local basis = "auto"
|
||||
|
||||
if #parts == 1 then
|
||||
-- Single value: could be grow (number) or basis (with unit)
|
||||
local num = tonumber(parts[1])
|
||||
if num then
|
||||
grow = num
|
||||
basis = 0
|
||||
else
|
||||
basis = parts[1]
|
||||
end
|
||||
elseif #parts == 2 then
|
||||
-- Two values: grow shrink (both numbers) or grow basis
|
||||
local num1 = tonumber(parts[1])
|
||||
local num2 = tonumber(parts[2])
|
||||
if num1 and num2 then
|
||||
grow = num1
|
||||
shrink = num2
|
||||
basis = 0
|
||||
elseif num1 then
|
||||
grow = num1
|
||||
basis = parts[2]
|
||||
end
|
||||
elseif #parts >= 3 then
|
||||
-- Three values: grow shrink basis
|
||||
grow = tonumber(parts[1]) or 0
|
||||
shrink = tonumber(parts[2]) or 1
|
||||
basis = parts[3]
|
||||
end
|
||||
|
||||
return grow, shrink, basis
|
||||
end
|
||||
|
||||
-- Default fallback
|
||||
return 0, 1, "auto"
|
||||
end
|
||||
|
||||
return Units
|
||||
@@ -0,0 +1,35 @@
|
||||
---@class ZIndex
|
||||
local ZIndex = {}
|
||||
|
||||
-- The effective z-index formula used for sorting is:
|
||||
-- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ
|
||||
-- where rootZ is the z-index of the top-level ancestor, depth is the
|
||||
-- nesting level, and ownZ is the element's own z property.
|
||||
--
|
||||
-- Constraints enforced by these weights:
|
||||
-- |ownZ| <= MAX_Z (must fit within DEPTH_WEIGHT digits)
|
||||
-- DEPTH_WEIGHT has enough room for depths well beyond any practical tree
|
||||
-- ROOT_WEIGHT has enough room for the rootZ without exceeding double-precision
|
||||
---
|
||||
---@type integer
|
||||
ZIndex.MIN_Z = -999
|
||||
---@type integer
|
||||
ZIndex.MAX_Z = 999
|
||||
---@type integer
|
||||
ZIndex.ROOT_WEIGHT = 10000000000
|
||||
---@type integer
|
||||
ZIndex.DEPTH_WEIGHT = 1000
|
||||
|
||||
--- Clamp a z-index value to the valid range
|
||||
---@param value number
|
||||
---@return integer
|
||||
function ZIndex.clamp(value)
|
||||
if value < ZIndex.MIN_Z then
|
||||
return ZIndex.MIN_Z
|
||||
elseif value > ZIndex.MAX_Z then
|
||||
return ZIndex.MAX_Z
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
return ZIndex
|
||||
@@ -0,0 +1,245 @@
|
||||
-- modules/behaviors/Animated.lua
|
||||
--
|
||||
-- Concrete behavior: animation update, interpolation application, chaining
|
||||
-- resolution, and transition wiring.
|
||||
--
|
||||
-- Task 06 of the behavior-mode-unification refactor. Moves the entire
|
||||
-- animation-update block out of Element:update (lines ~2761-2800) into
|
||||
-- `Animated.onUpdate(element, dt)`, and the `_ColorModule`/`_TransformModule`
|
||||
-- init-time wiring into `Animated.onAttach(element)`.
|
||||
--
|
||||
-- This behavior is UNIQUE among the behavior set because it can attach
|
||||
-- AFTER element creation. Animation is opt-in: a plain Element created without
|
||||
-- `transitions` and without an `animation` field never attaches Animated.
|
||||
-- The moment something creates an animation on the element — either directly
|
||||
-- (`element.animation = Animation.new(...)`, `element:fadeIn(...)`) or via a
|
||||
-- transition firing in `setProperty` — `Animated.ensureAttached(element)`
|
||||
-- attaches this behavior on demand so subsequent `Element:update` frames
|
||||
-- dispatch to `Animated.onUpdate`.
|
||||
--
|
||||
-- Attachment rule (shouldAttach): true when `props.transitions` is set OR an
|
||||
-- `element.animation` already exists at runtime. The runtime arm covers the
|
||||
-- late-attach case (animateTo / fadeIn / direct animation assignment).
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * Per-element runtime state lives ON THE ELEMENT (`element.animation`).
|
||||
-- * The behavior instance itself is stateless and shared across elements.
|
||||
-- * Element-class-level dependencies (Element._Animation, Element._Color,
|
||||
-- Element._Transform) are resolved from the owning element's metatable,
|
||||
-- exactly like Clickable does — keeping the behavior stateless without
|
||||
-- expanding the 6-hook signature.
|
||||
--
|
||||
-- saveState/restoreState are no-ops: animations are ephemeral (an in-flight
|
||||
-- animation is not part of immediate-mode persisted state — the next frame
|
||||
-- re-evaluates transitions / re-applies animations fresh). Persisted scalar
|
||||
-- props (`opacity`, `x`, ...) survive via Element.saveState's `_props` block,
|
||||
-- not via the animation.
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Resolve the Element class from an element instance.
|
||||
-- Element instances are created via `setmetatable({}, Element)` in _construct,
|
||||
-- so their metatable IS the Element class — giving us Element._Animation,
|
||||
-- Element._Color, Element._Transform, etc. without threading deps through the
|
||||
-- behavior hook signature.
|
||||
local function ElementClass(element)
|
||||
return getmetatable(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- ensureAnimationModuleWiring — set Element._Animation._ColorModule /
|
||||
-- _TransformModule. Idempotent; called from both onAttach and onUpdate so it
|
||||
-- works even when an animation was assigned by a caller that bypassed
|
||||
-- onAttach (direct `element.animation = Animation.new(...)`).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function ensureAnimationModuleWiring(element)
|
||||
local Element = ElementClass(element)
|
||||
local Animation = Element._Animation
|
||||
if not Animation then
|
||||
return
|
||||
end
|
||||
-- Ensure animation has Color module reference for color interpolation
|
||||
if not Animation._ColorModule and Element._Color then
|
||||
Animation._ColorModule = Element._Color
|
||||
end
|
||||
-- Ensure animation has Transform module reference for transform interpolation
|
||||
if not Animation._TransformModule and Element._Transform then
|
||||
Animation._TransformModule = Element._Transform
|
||||
end
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- shouldAttach (class-level predicate, no element required)
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- True when the element declares transitions up front OR already has an
|
||||
-- animation attached. The `animation` arm is consulted by ensureAttached at
|
||||
-- runtime (after creation); the `transitions` arm lets Animated auto-attach
|
||||
-- during Element.new for elements that pre-declare transitions.
|
||||
local function shouldAttach(props)
|
||||
if not props then
|
||||
return false
|
||||
end
|
||||
if props.transitions ~= nil then
|
||||
return true
|
||||
end
|
||||
-- Late-attach case: an animation was assigned after creation. When ensure
|
||||
-- Attached passes the element instance as `props`, this arm catches it.
|
||||
if type(props) == "table" and props.animation ~= nil then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- ensureAttached — dynamic late-attach entry point
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- Idempotently attach the Animated behavior to an element that just gained an
|
||||
-- animation (via animateTo / fadeIn / direct assignment / a firing transition
|
||||
-- in setProperty). Called from Element.setProperty when a transition fires and
|
||||
-- from the transition helper methods on Element. Safe to call when already
|
||||
-- attached (no-op / returns false).
|
||||
--
|
||||
-- `animatedBehavior` is the shared behavior instance resolved lazily by
|
||||
-- Element (see Element._resolveAnimatedBehavior). The behavior is looked up
|
||||
-- from the registry once and cached on the class.
|
||||
--
|
||||
-- Returns true if the behavior was attached this call, false otherwise.
|
||||
local function ensureAttached(element, animatedBehavior)
|
||||
if not element or not animatedBehavior then
|
||||
return false
|
||||
end
|
||||
-- Already attached? Avoid duplicate entries within one element lifetime
|
||||
-- (a behavior may legitimately be re-added across immediate-mode frames
|
||||
-- since Element is recreated each frame, but within one lifetime at most
|
||||
-- once).
|
||||
local behaviors = element.behaviors
|
||||
if behaviors then
|
||||
for i = 1, #behaviors do
|
||||
if behaviors[i] == animatedBehavior then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
table.insert(element.behaviors, animatedBehavior)
|
||||
animatedBehavior.onAttach(element)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onAttach — initialize Animation module references (formerly the
|
||||
-- Element._Animation._ColorModule / _TransformModule wiring in Element:update
|
||||
-- lines ~2772-2778).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onAttach(element)
|
||||
ensureAnimationModuleWiring(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onUpdate — the animation update + interpolation + chain-resolution block
|
||||
-- (formerly Element:update lines ~2761-2800).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onUpdate(element, dt)
|
||||
local animation = element.animation
|
||||
if not animation then
|
||||
return
|
||||
end
|
||||
|
||||
-- (Re)ensure module wiring is present in case the Animation instance was
|
||||
-- created by a caller that bypassed onAttach (e.g. direct
|
||||
-- `element.animation = Animation.new(...)`). Cheap idempotent writes.
|
||||
ensureAnimationModuleWiring(element)
|
||||
|
||||
local finished = animation:update(dt, element)
|
||||
if finished then
|
||||
-- Animation:update() already called onComplete callback.
|
||||
-- Check for chained animation.
|
||||
if animation._next then
|
||||
element.animation = animation._next
|
||||
elseif animation._nextFactory and type(animation._nextFactory) == "function" then
|
||||
local success, nextAnim = pcall(animation._nextFactory, element)
|
||||
if success and nextAnim then
|
||||
element.animation = nextAnim
|
||||
else
|
||||
element.animation = nil
|
||||
end
|
||||
else
|
||||
element.animation = nil
|
||||
end
|
||||
else
|
||||
-- Apply animation interpolation during update.
|
||||
animation:applyInterpolation(element)
|
||||
end
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- saveState / restoreState — no-ops (animations are ephemeral).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- Animations are not persisted across immediate-mode frames — they are
|
||||
-- re-derived each frame from transitions / direct calls. The element's scalar
|
||||
-- props (opacity, x, ...) are persisted by Element.saveState's _props block,
|
||||
-- so a completed animation's final visual state still survives recreation.
|
||||
-- While an animation is mid-flight in immediate mode, the element is recreated
|
||||
-- and the animation is NOT carried over (intentional — animating in immediate
|
||||
-- mode requires setting up the animation each frame).
|
||||
local function saveState()
|
||||
return nil
|
||||
end
|
||||
|
||||
local function restoreState()
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Build the (stateless, shared) behavior instance.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- onDetach/onDraw omitted: they default to no-ops (the behavior allocates no
|
||||
-- behavior-local state and animations have no draw pass). Animation state lives
|
||||
-- on the element (`element.animation`); nothing to tear down on detach.
|
||||
--
|
||||
-- We build the immutable behavior via Behavior.new (for validation + freeze +
|
||||
-- isBehavior parity with Clickable), then expose the late-attach helper on a
|
||||
-- thin module table since the frozen instance cannot accept new keys. The
|
||||
-- module table passes the behavior to the registry while making
|
||||
-- `Animated.ensureAttached` callable from Element.setProperty / the transition
|
||||
-- helpers — exactly as the task spec requires.
|
||||
local behavior = Behavior.new({
|
||||
onAttach = onAttach,
|
||||
onUpdate = onUpdate,
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
shouldAttach = shouldAttach,
|
||||
})
|
||||
|
||||
-- Thin module table: exposes the behavior instance (for the registry) plus the
|
||||
-- late-attach helper (for Element.setProperty). All hooks delegate to the
|
||||
-- frozen behavior instance so dispatch sites get the validated, frozen
|
||||
-- implementation. shouldAttach is also exposed at module level (mirrors
|
||||
-- Clickable.shouldAttach) for tests/callers without an element.
|
||||
local Animated = {
|
||||
behavior = behavior,
|
||||
ensureAttached = ensureAttached,
|
||||
shouldAttach = shouldAttach,
|
||||
onAttach = onAttach,
|
||||
onUpdate = onUpdate,
|
||||
}
|
||||
|
||||
-- Metatable so the module table itself satisfies the duck-typed registry
|
||||
-- contract (iterating `Element._behaviorRegistry` calls `behavior.shouldAttach`
|
||||
-- and `behavior.onAttach` / `behavior.onUpdate` directly). Falls through to the
|
||||
-- frozen behavior instance for every hook.
|
||||
setmetatable(Animated, {
|
||||
__index = behavior,
|
||||
__tostring = function()
|
||||
return "Animated"
|
||||
end,
|
||||
})
|
||||
|
||||
return Animated
|
||||
@@ -0,0 +1,344 @@
|
||||
-- modules/behaviors/Clickable.lua
|
||||
--
|
||||
-- Concrete behavior: mouse/touch event handling, pressed-state tracking,
|
||||
-- hit-testing, and theme-state sync.
|
||||
--
|
||||
-- This is the largest behavior in the behavior-mode-unification refactor
|
||||
-- (~200 LOC moved out of Element:update / _initSubSystems / saveState).
|
||||
-- Task 02 extracts the entire `if self.onEvent or self.themeComponent or
|
||||
-- self.editable or self._selectState or self.selectOption then ... end` block
|
||||
-- from Element:update (hit-testing, mouse/touch event processing, immediate-
|
||||
-- mode state save, theme-state update) plus EventHandler creation (formerly the
|
||||
-- first half of Element:_initSubSystems) plus pressed-state drawing (formerly a
|
||||
-- render layer in Renderer) plus EventHandler save/restore.
|
||||
--
|
||||
-- Attachment rule (shouldAttach): the same predicate that previously guarded
|
||||
-- mouse-event processing in Element:update. An element owns the EventHandler /
|
||||
-- gets press feedback exactly when it is interactive: when it declares an
|
||||
-- `onEvent` callback, a `themeComponent`, is `editable`, or participates in a
|
||||
-- Select group (selectParent / selectOption). A plain passive element never
|
||||
-- attaches Clickable and therefore never allocates an EventHandler.
|
||||
--
|
||||
-- Element retains only the `self._eventHandler` field; Clickable owns it on
|
||||
-- attach. All other Element paths that touched the EventHandler (handleTouchEvent,
|
||||
-- handleGesture, getTouches) already nil-guard `self._eventHandler`, so they keep
|
||||
-- working unchanged for non-clickable elements.
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * Per-element runtime state lives ON THE ELEMENT (self._eventHandler etc.).
|
||||
-- * The behavior instance itself is stateless and shared across elements.
|
||||
-- * Element-class-level dependencies (EventHandler factory, StateManager,
|
||||
-- Context) are resolved from the owning element's metatable (the Element
|
||||
-- class set by Element:_construct). This keeps the behavior stateless while
|
||||
-- avoiding a dependency-injection parameter that would violate the locked
|
||||
-- 6-hook signature `(element, ...)`.
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Resolve the Element class from an element instance.
|
||||
-- Element instances are created via `setmetatable({}, Element)` in _construct,
|
||||
-- so their metatable IS the Element class — giving us Element._EventHandler,
|
||||
-- Element._eventHandlerDeps, Element._StateManager, Element._Context, etc.
|
||||
-- without threading deps through the behavior hook signature.
|
||||
local function ElementClass(element)
|
||||
return getmetatable(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- shouldAttach (class-level predicate, no element required)
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- Mirrors the cases that previously caused Element to allocate + use an
|
||||
-- EventHandler. MUST cover every element that touches the EventHandler at
|
||||
-- runtime: click (onEvent), theme press-feedback (themeComponent), text mouse
|
||||
-- interaction (editable), Select groups (selectParent / selectOption), touch
|
||||
-- callbacks (onTouchEvent), and gesture callbacks (onGesture). selectParent /
|
||||
-- selectOption are the props that produce _selectState during _initSubSystems;
|
||||
-- checking the props (rather than the runtime _selectState) lets shouldAttach
|
||||
-- run before the Select subsystem is initialized.
|
||||
local function shouldAttach(props)
|
||||
props = props or {}
|
||||
return props.onEvent ~= nil
|
||||
or props.themeComponent ~= nil
|
||||
or props.editable == true
|
||||
or props.onTouchEvent ~= nil
|
||||
or props.onGesture ~= nil
|
||||
or props.selectOption ~= nil
|
||||
or props.selectParent ~= nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onAttach — create the EventHandler (formerly Element:_initSubSystems
|
||||
-- lines ~640-690) and restore immediate-mode EventHandler state.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onAttach(element)
|
||||
local Element = ElementClass(element)
|
||||
|
||||
local eventHandlerConfig = {
|
||||
-- element.onEvent is source of truth; not cached on handler
|
||||
onEventDeferred = element.onEventDeferred,
|
||||
-- element.onTouchEvent is source of truth; not cached on handler
|
||||
onTouchEventDeferred = element.onTouchEventDeferred,
|
||||
-- element.onGesture is source of truth; not cached on handler
|
||||
onGestureDeferred = element.onGestureDeferred,
|
||||
touchEnabled = element.touchEnabled,
|
||||
multiTouchEnabled = element.multiTouchEnabled,
|
||||
}
|
||||
|
||||
-- In immediate mode, restore EventHandler state from StateManager so pressed
|
||||
-- / hovered / click-count survive the per-frame element recreation cycle.
|
||||
-- Mode-aware via Context.isImmediateMode (behavior-mode-unification task 11):
|
||||
-- in retained mode the eventHandler persists, so nothing to restore.
|
||||
if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
|
||||
local state = Element._StateManager.getState(element._stateId)
|
||||
if state then
|
||||
-- Restore EventHandler state from StateManager (sparse storage — provide defaults)
|
||||
eventHandlerConfig._pressed = state._pressed or {}
|
||||
eventHandlerConfig._lastClickTime = state._lastClickTime
|
||||
eventHandlerConfig._lastClickButton = state._lastClickButton
|
||||
eventHandlerConfig._clickCount = state._clickCount or 0
|
||||
eventHandlerConfig._dragStartX = state._dragStartX or {}
|
||||
eventHandlerConfig._dragStartY = state._dragStartY or {}
|
||||
eventHandlerConfig._lastMouseX = state._lastMouseX or {}
|
||||
eventHandlerConfig._lastMouseY = state._lastMouseY or {}
|
||||
eventHandlerConfig._hovered = state._hovered
|
||||
end
|
||||
end
|
||||
|
||||
element._eventHandler = Element._EventHandler.new(eventHandlerConfig, Element._eventHandlerDeps)
|
||||
end
|
||||
|
||||
local function onDetach(element)
|
||||
-- Clear focus callbacks read by KeyboardNavigation / TextEditor:focus so the
|
||||
-- element's closure references can be collected in immediate mode (formerly
|
||||
-- part of Element:_cleanup). The EventHandler instance itself is INTENTIONALLY
|
||||
-- kept: Element:_cleanup preserves element structure for inspection (the
|
||||
-- stale-element refs are released when the element is GC'd). onEvent,
|
||||
-- onTouchEvent, onGesture are also left intact — the Renderer/EventHandler
|
||||
-- read those directly from the element (not the cache), so clearing them
|
||||
-- would break retained mode.
|
||||
element.onFocus = nil
|
||||
element.onBlur = nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onUpdate — the mouse hit-testing + event-processing + theme-state +
|
||||
-- immediate-mode save block (formerly Element:update lines ~2813-2960).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onUpdate(element, dt)
|
||||
local Element = ElementClass(element)
|
||||
local eventHandler = element._eventHandler
|
||||
if not eventHandler then
|
||||
return
|
||||
end
|
||||
|
||||
local mx, my = love.mouse.getPosition()
|
||||
|
||||
-- Clickable area is the border box (x, y already includes padding)
|
||||
-- BORDER-BOX MODEL: Use stored border-box dimensions for hit detection
|
||||
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)
|
||||
|
||||
-- Account for scroll offsets from parent containers
|
||||
-- Walk up the parent chain and accumulate scroll offsets. This stays in
|
||||
-- Clickable because it's an interaction concern (hit-testing), not layout.
|
||||
local scrollOffsetX = 0
|
||||
local scrollOffsetY = 0
|
||||
local current = element.parent
|
||||
while current do
|
||||
local overflowX = current.overflowX or current.overflow
|
||||
local overflowY = current.overflowY or current.overflow
|
||||
local hasScrollableOverflow = (
|
||||
overflowX == "scroll"
|
||||
or overflowX == "auto"
|
||||
or overflowY == "scroll"
|
||||
or overflowY == "auto"
|
||||
or overflowX == "hidden"
|
||||
or overflowY == "hidden"
|
||||
)
|
||||
if hasScrollableOverflow then
|
||||
scrollOffsetX = scrollOffsetX + (current._scrollX or 0)
|
||||
scrollOffsetY = scrollOffsetY + (current._scrollY or 0)
|
||||
end
|
||||
current = current.parent
|
||||
end
|
||||
|
||||
-- Adjust mouse position by accumulated scroll offset for hit testing
|
||||
local adjustedMx = mx + scrollOffsetX
|
||||
local adjustedMy = my + scrollOffsetY
|
||||
local isHovering = adjustedMx >= bx and adjustedMx <= bx + bw and adjustedMy >= by and adjustedMy <= by + bh
|
||||
|
||||
-- Check if this is the topmost interactive element at the mouse position
|
||||
-- (z-index ordering). This prevents blocked/occluded elements from
|
||||
-- receiving interactions or visual feedback. A single mode-agnostic lookup
|
||||
-- via `Context.findInteractiveAtPosition` (unified-event-routing task 05)
|
||||
-- replaces the previous immediate/retained-mode split that used
|
||||
-- `getTopElementAt` in immediate mode and `_activeEventElement` in retained
|
||||
-- mode. `findInteractiveAtPosition` routes every hit test through
|
||||
-- `pointHitsElement` (the single canonical `display == false` guard) and
|
||||
-- resolves occlusion by z-index in both modes, so the active element is the
|
||||
-- same one that would receive a hit under the cursor.
|
||||
local topElement = Element._Context.findInteractiveAtPosition(mx, my)
|
||||
local isActiveElement = (topElement == element or topElement == nil)
|
||||
|
||||
-- Reset scrollbar press flag at start of each frame
|
||||
eventHandler:resetScrollbarPressFlag()
|
||||
|
||||
-- Process mouse events through EventHandler FIRST
|
||||
-- This ensures pressed states are updated before theme state is calculated
|
||||
eventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement)
|
||||
|
||||
-- In immediate mode, save EventHandler state to StateManager after
|
||||
-- processing events so it survives the per-frame recreation.
|
||||
if element._stateId and Element._Context.isImmediateMode() and element._stateId ~= "" then
|
||||
local eventHandlerState = eventHandler:getState()
|
||||
Element._StateManager.updateState(element._stateId, {
|
||||
_pressed = eventHandlerState._pressed,
|
||||
_lastClickTime = eventHandlerState._lastClickTime,
|
||||
_lastClickButton = eventHandlerState._lastClickButton,
|
||||
_clickCount = eventHandlerState._clickCount,
|
||||
_dragStartX = eventHandlerState._dragStartX,
|
||||
_dragStartY = eventHandlerState._dragStartY,
|
||||
_lastMouseX = eventHandlerState._lastMouseX,
|
||||
_lastMouseY = eventHandlerState._lastMouseY,
|
||||
_hovered = eventHandlerState._hovered,
|
||||
})
|
||||
end
|
||||
|
||||
-- Update theme state based on interaction. themeComponent state update
|
||||
-- lives in Clickable because it is driven by hover/press state; the actual
|
||||
-- theme RENDERING is the Themed behavior (task 07).
|
||||
if element.themeComponent then
|
||||
-- Check if any button is pressed via EventHandler
|
||||
local anyPressed = eventHandler:isAnyButtonPressed()
|
||||
|
||||
-- Update theme state via ThemeManager
|
||||
local isFocused = Element._Context.getFocused() == element
|
||||
local newThemeState =
|
||||
element._themeManager:updateState(isHovering and isActiveElement, anyPressed, isFocused, element.disabled)
|
||||
|
||||
if element._stateId and Element._Context.isImmediateMode() then
|
||||
local hover = newThemeState == "hover"
|
||||
local pressed = newThemeState == "pressed"
|
||||
local focused = isFocused
|
||||
|
||||
Element._StateManager.updateState(element._stateId, {
|
||||
hover = hover,
|
||||
pressed = pressed,
|
||||
focused = focused,
|
||||
disabled = element.disabled,
|
||||
active = element.active,
|
||||
})
|
||||
end
|
||||
|
||||
if element._renderer then
|
||||
element._renderer:setThemeState(newThemeState)
|
||||
end
|
||||
end
|
||||
|
||||
-- Process touch events through EventHandler
|
||||
eventHandler:processTouchEvents(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onDraw — pressed-state visual feedback (formerly Renderer Layer 5).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- Draws the grey pressed overlay when any mouse button is currently pressed on
|
||||
-- the element. Delegates the actual pixels to Renderer:drawPressedState (which
|
||||
-- owns the RoundedRect + opacity math) but drives the DECISION + transform
|
||||
-- context here, so the renderer no longer needs the `if element.onEvent ...`
|
||||
-- behavioral branch. Honors disableHighlight (themes handle their own visual
|
||||
-- feedback) exactly as the old render layer did.
|
||||
local function onDraw(element)
|
||||
if element.disableHighlight then
|
||||
return
|
||||
end
|
||||
local eventHandler = element._eventHandler
|
||||
if not eventHandler then
|
||||
return
|
||||
end
|
||||
|
||||
local anyPressed = false
|
||||
local pressedState = eventHandler:getState()._pressed or {}
|
||||
for _, pressed in pairs(pressedState) do
|
||||
if pressed then
|
||||
anyPressed = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not anyPressed then
|
||||
return
|
||||
end
|
||||
|
||||
local renderer = element._renderer
|
||||
if not renderer then
|
||||
return
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
-- Apply the element transform around the overlay, mirroring how the
|
||||
-- Renderer wrapped its whole command buffer (pressed state was a render
|
||||
-- layer subject to the same transform).
|
||||
local Element = ElementClass(element)
|
||||
local Transform = Element._Transform
|
||||
local hasTransform = element.transform ~= nil and Transform ~= nil and not Transform.isIdentity(element.transform)
|
||||
if hasTransform then
|
||||
Transform.apply(element.transform, element.x, element.y, element.width, element.height)
|
||||
end
|
||||
|
||||
renderer:drawPressedState(element.x, element.y, bw, bh, element.opacity, element.cornerRadius)
|
||||
|
||||
if hasTransform then
|
||||
Transform.unapply()
|
||||
end
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- saveState / restoreState — EventHandler state (formerly the eventHandler
|
||||
-- branches of Element:saveState / Element:restoreState).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function saveState(element)
|
||||
if element._eventHandler then
|
||||
return { eventHandler = element._eventHandler:getState() }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function restoreState(element, state)
|
||||
if not state then
|
||||
return nil
|
||||
end
|
||||
if element._eventHandler and state.eventHandler then
|
||||
element._eventHandler:setState(state.eventHandler)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Build the (stateless, shared, immutable) behavior instance.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local Clickable = Behavior.new({
|
||||
onAttach = onAttach,
|
||||
onDetach = onDetach,
|
||||
onUpdate = onUpdate,
|
||||
onDraw = onDraw,
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
shouldAttach = shouldAttach,
|
||||
})
|
||||
|
||||
-- Expose the predicate at module level so callers/tests can reference it
|
||||
-- directly without an element instance (mirrors Behavior.shouldAttach).
|
||||
Clickable.shouldAttach = shouldAttach
|
||||
|
||||
return Clickable
|
||||
@@ -0,0 +1,282 @@
|
||||
-- modules/behaviors/Imageable.lua
|
||||
--
|
||||
-- Concrete behavior: image loading + image rendering config.
|
||||
--
|
||||
-- Imageable owns the image side of the Renderer: it runs the deferred image-
|
||||
-- load pipeline (cache check → defer → load → fire onImageLoad/onImageError
|
||||
-- callbacks), populates the resolved `_loadedImage` cache on both the element
|
||||
-- and the shared renderer, and persists that cache across immediate-mode
|
||||
-- recreation. It is the behavior-mode-unification replacement for the image-
|
||||
-- loading half of Element:_initImageAndRenderer and the deferred
|
||||
-- Element:_loadImage method (behavior-mode-unification task 07).
|
||||
--
|
||||
-- Image value props (imagePath/image/objectFit/objectPosition/imageOpacity/
|
||||
-- imageRepeat/imageTint) are bound on the ELEMENT by Element:_applyProps and read
|
||||
-- from the element at draw time (Renderer._executeDrawCommand image branch) —
|
||||
-- Imageable does NOT mirror them onto the renderer, so bare writes and
|
||||
-- setProperty(...) are immediately consistent. Only the resolved _loadedImage
|
||||
-- cache (the love.Image produced by the load pipeline) is renderer-mirrored,
|
||||
-- because Renderer:draw reads `self._loadedImage`.
|
||||
--
|
||||
-- Runtime reload: setProperty("imagePath", ...) / setProperty("image", ...) and
|
||||
-- the bare-write-equivalent setImage* flows route through element._reloadImage
|
||||
-- (installed below) which re-runs the load pipeline. See
|
||||
-- TestRetainedPropertyConsistency (image props) and TestImageableIntegration.
|
||||
--
|
||||
-- Attachment rule (shouldAttach): an element owns image concern exactly when it
|
||||
-- declares an `imagePath` (load-from-path) or a direct `image` (already-loaded
|
||||
-- love.Image). Mirrors the old `if self.imagePath / if self.image` init branches.
|
||||
--
|
||||
-- Pairing with Themed: Themed.onAttach creates the Renderer with theme/blur
|
||||
-- config; Imageable.onAttach enriches the SAME renderer instance with image
|
||||
-- config + kicks off loading. They share `element._renderer`. In the registry
|
||||
-- Imageable runs after Themed, so the renderer already exists; the create-or-
|
||||
-- reuse guard below covers the defensive case where Imageable attaches first.
|
||||
--
|
||||
-- onDraw: the image LAYER is rendered by the integrated `Renderer:draw` call
|
||||
-- (owned by the Themed behavior) which executes the renderer's `image` draw
|
||||
-- command using the config Imageable.onAttach wired. Imageable.onDraw is
|
||||
-- therefore a no-op for the draw call itself — there is no separate
|
||||
-- `_renderer:_drawImage` entry point; pixel emission lives in the integrated
|
||||
-- Renderer:draw command buffer. Splitting it out would require Renderer surgery
|
||||
-- with no behavioral gain (Renderer:draw already conditionally skips the image
|
||||
-- layer when no image is loaded).
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * Per-element runtime state lives ON THE ELEMENT (`element._loadedImage`,
|
||||
-- `element._renderer._loadedImage`). The behavior instance is stateless.
|
||||
-- * saveState/restoreState persist `_loadedImage` across immediate-mode frames
|
||||
-- so the image renders even if the ImageCache is cleared between frames and
|
||||
-- so the renderer's loaded-image cache survives element recreation.
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Lua 5.4 removed the global `unpack`; mirror Element's alias.
|
||||
local unpack = table.unpack or unpack
|
||||
|
||||
-- Resolve the Element class from an element instance (mirrors Clickable/Themed).
|
||||
local function ElementClass(element)
|
||||
return getmetatable(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- shouldAttach (class-level predicate, no element required)
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function shouldAttach(props)
|
||||
props = props or {}
|
||||
return props.imagePath ~= nil or props.image ~= nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Image callback helper (moved from Element._fireImageCallback).
|
||||
-- Fires a user-supplied image callback (onImageLoad/onImageError) under pcall,
|
||||
-- honoring the onXDeferred flag when `honorDeferred` is true, and emits a single
|
||||
-- EVT_002 warn on failure. The direct-`image` sync init path passes
|
||||
-- honorDeferred=false to preserve immediate firing (image is already loaded).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function fireImageCallback(element, callbackField, honorDeferred, ...)
|
||||
local cb = element[callbackField]
|
||||
if type(cb) ~= "function" then
|
||||
return
|
||||
end
|
||||
local Element = ElementClass(element)
|
||||
local argc = select("#", ...)
|
||||
local args = { ... }
|
||||
local function invoke()
|
||||
local ok, err = pcall(cb, element, unpack(args, 1, argc))
|
||||
if not ok then
|
||||
Element._ErrorHandler:warn("Element", "EVT_002", {
|
||||
callback = callbackField,
|
||||
error = tostring(err),
|
||||
})
|
||||
end
|
||||
end
|
||||
if honorDeferred and element[callbackField .. "Deferred"] then
|
||||
Element._Context.deferCallback(invoke)
|
||||
else
|
||||
invoke()
|
||||
end
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Deferred image loader (replaces Element:_loadImage).
|
||||
--
|
||||
-- Invoked by Element's deferred-method dispatcher via the instance closure that
|
||||
-- onAttach installs on `element._loadImage`. Loads the image from cache or disk
|
||||
-- (I/O), updates BOTH the element and renderer `_loadedImage` caches so the
|
||||
-- image draws after an async load, and fires the load/error callback (deferred,
|
||||
-- honoring onImageLoadDeferred / onImageErrorDeferred).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function loadImage(element)
|
||||
if not element.imagePath or element.image then
|
||||
return
|
||||
end
|
||||
local Element = ElementClass(element)
|
||||
local loadedImage, err = Element._ImageCache.load(element.imagePath)
|
||||
if loadedImage then
|
||||
element._loadedImage = loadedImage
|
||||
if element._renderer then
|
||||
element._renderer._loadedImage = loadedImage
|
||||
end
|
||||
fireImageCallback(element, "onImageLoad", true, loadedImage)
|
||||
else
|
||||
fireImageCallback(element, "onImageError", true, err or "Unknown error")
|
||||
end
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- reloadImage — recompute the loaded-image cache from the current image/imagePath.
|
||||
--
|
||||
-- This is the single entry point for (re)loading after either initial attach or
|
||||
-- a runtime property change (see Element._specialSetHandlers.imagePath/image,
|
||||
-- which call element:_reloadImage()). Precedence matches onAttach: a direct
|
||||
-- `image` wins over `imagePath`; `nil` for both clears the cache.
|
||||
--
|
||||
-- * direct image → set _loadedImage immediately, fire onImageLoad SYNC (the
|
||||
-- image is already loaded; honorDeferred=false preserves the
|
||||
-- original synchronous init contract).
|
||||
-- * imagePath → cache CHECK only (no I/O) so a cached image can draw this
|
||||
-- frame, then defer the loader (_loadImage) for the actual
|
||||
-- I/O + deferred callbacks. load bails if `image` is later set.
|
||||
-- * neither → clear _loadedImage on both element + renderer.
|
||||
--
|
||||
-- Image value props (objectFit/imageOpacity/imageRepeat/imageTint/objectPosition)
|
||||
-- and imagePath/image themselves live on the ELEMENT as source of truth; the
|
||||
-- renderer reads them at draw time, so reloadImage does NOT mirror them onto the
|
||||
-- renderer — only the resolved _loadedImage cache is pushed.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function reloadImage(element)
|
||||
local Element = ElementClass(element)
|
||||
local renderer = element._renderer
|
||||
if element.image then
|
||||
element._loadedImage = element.image
|
||||
if renderer then
|
||||
renderer._loadedImage = element.image
|
||||
end
|
||||
fireImageCallback(element, "onImageLoad", false, element.image)
|
||||
elseif element.imagePath then
|
||||
-- Cache check (no I/O). Populate both caches immediately if cached so the
|
||||
-- image can draw this frame without waiting for the deferred load.
|
||||
local cached = Element._ImageCache.get(element.imagePath)
|
||||
element._loadedImage = cached
|
||||
if renderer then
|
||||
renderer._loadedImage = cached
|
||||
end
|
||||
-- Kick off the deferred I/O load + callbacks (idempotent: loadImage bails
|
||||
-- if image is set or imagePath is nil by the time it runs).
|
||||
if element._loadImage then
|
||||
element:_deferMethod("_loadImage")
|
||||
end
|
||||
else
|
||||
element._loadedImage = nil
|
||||
if renderer then
|
||||
renderer._loadedImage = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onAttach — enrich the shared renderer with image config + kick off loading
|
||||
-- (formerly the image block of Element:_initImageAndRenderer).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onAttach(element)
|
||||
local Element = ElementClass(element)
|
||||
|
||||
-- Ensure the renderer exists (Thamed normally creates it; this create-or-reuse
|
||||
-- guard is defensive for the Imageable-attaches-first ordering).
|
||||
if not element._renderer then
|
||||
element._renderer = Element._Renderer.new({
|
||||
theme = element.theme,
|
||||
scaleCorners = element.scaleCorners,
|
||||
scalingAlgorithm = element.scalingAlgorithm,
|
||||
contentBlur = element.contentBlur,
|
||||
backdropBlur = element.backdropBlur,
|
||||
}, Element._rendererDeps)
|
||||
end
|
||||
|
||||
-- Install the (re)load hooks as instance methods so Element's
|
||||
-- deferred-method dispatcher / setProperty special handlers can trigger a
|
||||
-- reload without Element needing a behavior reference. This keeps Element
|
||||
-- decoupled from the Imageable behavior (mirrors the stateless-behavior +
|
||||
-- element-owned-state contract). Image value props and imagePath/image live
|
||||
-- on the element as source of truth (read at draw time); only the resolved
|
||||
-- _loadedImage cache is mirrored onto the renderer by reloadImage.
|
||||
element._loadImage = function(el)
|
||||
loadImage(el)
|
||||
end
|
||||
element._reloadImage = function(el)
|
||||
reloadImage(el)
|
||||
end
|
||||
|
||||
-- Initial load: compute _loadedImage + defer the I/O load.
|
||||
reloadImage(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onDraw — no-op (see file header: the image layer is rendered by the integrated
|
||||
-- Renderer:draw call owned by the Themed behavior, using the config wired here).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- saveState / restoreState — `_loadedImage` cache (for immediate-mode).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function saveState(element)
|
||||
if element._loadedImage ~= nil then
|
||||
return { _loadedImage = element._loadedImage }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function restoreState(element, state)
|
||||
if not state or state._loadedImage == nil then
|
||||
return nil
|
||||
end
|
||||
local loadedImage = state._loadedImage
|
||||
element._loadedImage = loadedImage
|
||||
if element._renderer then
|
||||
element._renderer._loadedImage = loadedImage
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onDetach — release image-load callback closures so the element can be GC'd
|
||||
-- cleanly in immediate mode (formerly part of Element:_cleanup). The cached
|
||||
-- `_loadedImage` is reproduced on the next attach via the Imageable saveState
|
||||
-- -> restoreState cycle, so dropping the live references is always safe.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onDetach(element)
|
||||
element.onImageLoad = nil
|
||||
element.onImageError = nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Build the (stateless, shared, immutable) behavior instance.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local Imageable = Behavior.new({
|
||||
onAttach = onAttach,
|
||||
onDetach = onDetach,
|
||||
onUpdate = function() end,
|
||||
onDraw = function() end,
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
shouldAttach = shouldAttach,
|
||||
})
|
||||
|
||||
-- Expose the predicate at module level so callers/tests can reference it
|
||||
-- directly without an element instance (mirrors Behavior.shouldAttach /
|
||||
-- Clickable.shouldAttach). `loadImage` is NOT exposed on the (frozen) behavior
|
||||
-- instance; it is captured as a module-local upvalue by the onAttach closure that
|
||||
-- installs `element._loadImage`.
|
||||
Imageable.shouldAttach = shouldAttach
|
||||
|
||||
return Imageable
|
||||
@@ -0,0 +1,132 @@
|
||||
-- modules/behaviors/Persistable.lua
|
||||
--
|
||||
-- Concrete behavior: generic public-property persistence across the immediate-
|
||||
-- mode recreation cycle (behavior-mode-unification task 12).
|
||||
--
|
||||
-- Owns the ONE piece of Element save/restore state that is NOT subsystem state:
|
||||
-- the snapshot of an element's own public scalar fields (`text`, `display`,
|
||||
-- `opacity`, `x`, `width`, ...). Event-driven mutations to these fields (a
|
||||
-- release callback changing `text`, a toggle hiding a panel via `display =
|
||||
-- false`) must survive the per-frame Element recreation that defines immediate
|
||||
-- mode. Persistable captures them in `saveState` and reapplies them in
|
||||
-- `restoreState`, so the caller never branches on mode.
|
||||
--
|
||||
-- This behavior is the final home for the former `Element:saveState` `_props`
|
||||
-- block and the former `Element:restoreState` `_props` block (~20 LOC moved out
|
||||
-- of Element.lua). With it in place, `Element:saveState` / `Element:restoreState`
|
||||
-- collapse to a pure behavior-dispatch loop and Element owns zero property-
|
||||
-- extraction logic — every persisted slice is owned by exactly one behavior.
|
||||
--
|
||||
-- Attachment rule (shouldAttach): every element. Persistable attaches
|
||||
-- unconditionally (mirrors the pre-refactor invariant that every element's
|
||||
-- public scalar props were scanned). The actual snapshot is mode-gated inside
|
||||
-- `saveState` (immediate-mode-only, matching the legacy contract); in retained
|
||||
-- mode `saveState` returns nil and `restoreState` is a no-op unless a snapshot
|
||||
-- is explicitly passed.
|
||||
--
|
||||
-- Registry ordering: Persistable is intentionally placed LAST in the behavior
|
||||
-- registry. `restoreState` applies `_props` AFTER every other behavior has
|
||||
-- hydrated its subsystem state, so a persisted public-prop mutation (e.g.
|
||||
-- `text = "mutated"`) overrides the freshly-restored TextEditor/Select state —
|
||||
-- preserving the legacy restore ordering (behaviors first, `_props` tail).
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * The persisted props live ON the element (they ARE the element's public
|
||||
-- fields). The behavior instance is stateless + immutable and shared.
|
||||
-- * The snapshot is returned under the `_props` key (prefixed with `_` so
|
||||
-- the public-prop scan itself skips it — avoiding self-recursion).
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Resolve the Element class from an element instance (mirrors Clickable /
|
||||
-- Themed). Element instances are created via `setmetatable({}, Element)`, so
|
||||
-- their metatable IS the Element class — giving access to Element._StateManager
|
||||
-- without threading deps through the hook signature.
|
||||
local function ElementClass(element)
|
||||
return getmetatable(element)
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- shouldAttach (class-level predicate, no element required)
|
||||
-- ============================================================================
|
||||
|
||||
-- Every element's public scalar props are persistable, so this behavior
|
||||
-- attaches unconditionally. The mode gate lives inside saveState (it needs the
|
||||
-- runtime mode, which is only available with an element via StateManager).
|
||||
local function shouldAttach()
|
||||
return true
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- saveState — snapshot public scalar fields (immediate-mode-only).
|
||||
-- ============================================================================
|
||||
|
||||
-- Mirrors the former `Element:saveState` `_props` block exactly:
|
||||
-- * Only string keys NOT prefixed with `_` (so internal fields like
|
||||
-- `_renderer`, `_themeState`, `_initProps` are excluded).
|
||||
-- * Only scalar values (numbers, strings, booleans); tables and functions
|
||||
-- are excluded (children, padding, onEvent, ...).
|
||||
-- Returns `{ _props = {...} }` when there is at least one persistable prop and
|
||||
-- the element is in immediate mode; nil otherwise (retained mode no-op —
|
||||
-- state lives on the element directly there, so nothing to snapshot).
|
||||
local function saveState(element)
|
||||
local Element = ElementClass(element)
|
||||
if not Element._StateManager.isImmediateMode() then
|
||||
return nil
|
||||
end
|
||||
local props = {}
|
||||
for k, v in pairs(element) do
|
||||
if type(k) == "string" and k:sub(1, 1) ~= "_" and type(v) ~= "table" and type(v) ~= "function" then
|
||||
props[k] = v
|
||||
end
|
||||
end
|
||||
if next(props) then
|
||||
return { _props = props }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- restoreState — reapply the persisted public-prop snapshot onto a fresh
|
||||
-- element (mode-agnostic; only fires when a `_props` slice is present).
|
||||
-- ============================================================================
|
||||
|
||||
-- Applies persisted mutations on top of whatever the constructor + other
|
||||
-- behaviors already set, so event-driven changes from the previous frame
|
||||
-- override the declarative props of the recreated element. Runs last in the
|
||||
-- behavior dispatch (Persistable is the registry tail) to preserve the legacy
|
||||
-- restore ordering (subsystem restore first, `_props` override last).
|
||||
local function restoreState(element, state)
|
||||
if not state or not state._props then
|
||||
return
|
||||
end
|
||||
for k, v in pairs(state._props) do
|
||||
element[k] = v
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- onAttach / onUpdate / onDraw / onDetach — no-ops.
|
||||
-- ============================================================================
|
||||
|
||||
-- Persistable owns no subsystem and allocates no per-element state (the
|
||||
-- "state" it persists IS the element's own fields). The lifecycle is purely
|
||||
-- save/restore.
|
||||
|
||||
-- ============================================================================
|
||||
-- Build the (stateless, shared, immutable) behavior instance.
|
||||
-- ============================================================================
|
||||
|
||||
local Persistable = Behavior.new({
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
shouldAttach = shouldAttach,
|
||||
})
|
||||
|
||||
-- Expose the predicate at module level so callers/tests can reference it
|
||||
-- directly without an element instance (mirrors Behavior.shouldAttach /
|
||||
-- Clickable.shouldAttach).
|
||||
Persistable.shouldAttach = shouldAttach
|
||||
|
||||
return Persistable
|
||||
@@ -0,0 +1,267 @@
|
||||
-- modules/behaviors/Scrollable.lua
|
||||
--
|
||||
-- Concrete behavior: ScrollManager lifecycle (creation + immediate-mode
|
||||
-- scrollbar interaction-state restore).
|
||||
--
|
||||
-- Scrollable owns the per-element ScrollManager instance — the subsystem that
|
||||
-- manages overflow detection, scrollbar geometry, scroll position, and scrollbar
|
||||
-- drag/hover interaction. It is the behavior-mode-unification replacement for
|
||||
-- the former `Element:_initScrollManager` phase (~84 LOC) of Element.new
|
||||
-- (behavior-mode-unification task 03 / landed as part of the task 08 capstone).
|
||||
--
|
||||
-- Attachment rule (shouldAttach): an element owns a ScrollManager exactly when
|
||||
-- it declares an `overflow`, `overflowX`, or `overflowY` prop — mirroring the
|
||||
-- legacy `if props.overflow or props.overflowX or props.overflowY then` guard
|
||||
-- in `Element:_initScrollManager`. The ScrollManager is created and its
|
||||
-- normalized fields are exposed back onto the element (so the Renderer /
|
||||
-- ScrollManager delegates read `element.overflow` / `element.scrollbarWidth`
|
||||
-- etc.) exactly as the legacy inline phase did.
|
||||
--
|
||||
-- Why onAttach reads `element._initProps` (not element fields): the scrollbar
|
||||
-- configuration props (scrollbarWidth / scrollbarColor / scrollSpeed /
|
||||
-- scrollbarPlacement / scrollbarBalance / invertScroll / smoothScrollEnabled /
|
||||
-- scrollBarStyle / scrollbarKnobOffset / hideScrollbars / scrollbarRadius /
|
||||
-- scrollbarPadding / scrollbarTrackColor / _scrollX / _scrollY) are listed in
|
||||
-- SPECIAL_PROPS and therefore NOT bound onto the element by the schema-driven
|
||||
-- `_applyProps` loop — they are consumed only by the ScrollManager constructor.
|
||||
-- The locked behavior hook signature is `(element, ...)` with no props arg, so
|
||||
-- the original construction props are stashed on the element as `_initProps` by
|
||||
-- `Element:_construct` and read back here. (`overflow` / `overflowX` /
|
||||
-- `overflowY` ARE bound onto the element by `_applyProps` so that
|
||||
-- `Element:addChild`'s scroll-container auto-size guard sees them during
|
||||
-- declarative-children processing in `_finalizeConstruction`, which runs BEFORE
|
||||
-- this onAttach; onAttach then overwrites them with the ScrollManager's
|
||||
-- normalized values, matching the legacy field-exposure order.)
|
||||
--
|
||||
-- onUpdate / onDraw / saveState / restoreState are deferred to the
|
||||
-- behavior-driven update/draw tasks (09 / 12): the ScrollManager update,
|
||||
-- interaction, scrollbar drawing, and state save/restore currently stay inline
|
||||
-- in `Element:update` / `Element:draw` / `Element:saveState` /
|
||||
-- `Element:restoreState` (delegated through the ScrollManager API bound in
|
||||
-- `Element.init`). Those inline call sites are NOT behavioral `if` branches —
|
||||
-- they are unconditional 1-line delegates — so leaving them in Element does not
|
||||
-- regress the behavior-dispatch goals of tasks 09/12; task 09 will fold them
|
||||
-- into Scrollable hooks.
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * Per-element runtime state lives ON THE ELEMENT (`element._scrollManager`,
|
||||
-- `element.overflow`, `element._scrollX`, `element._scrollbarDragging`, ...).
|
||||
-- * The behavior instance is stateless + immutable and shared across elements.
|
||||
-- * Element-class-level dependencies (`Element._ScrollManager`,
|
||||
-- `Element._scrollManagerDeps`, `Element._Context`, `Element._StateManager`)
|
||||
-- are resolved from the owning element's metatable (the Element class set by
|
||||
-- `Element:_construct`).
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Resolve the Element class from an element instance.
|
||||
-- `setmetatable({}, Element)` in `_construct` makes the instance metatable BE
|
||||
-- the Element class, so this yields Element._ScrollManager,
|
||||
-- Element._scrollManagerDeps, Element._Context, Element._StateManager without
|
||||
-- threading deps through the hook signature.
|
||||
local function ElementClass(element)
|
||||
return getmetatable(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- shouldAttach (class-level predicate, no element required)
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- Mirrors the legacy `if props.overflow or props.overflowX or props.overflowY`
|
||||
-- guard. Uses `~= nil` (rather than truthiness) so that an explicit
|
||||
-- `overflow = false` / `overflow = ""` does not spuriously attach — though in
|
||||
-- practice overflow values are always strings or unset, matching the predicate
|
||||
-- semantics of the other behaviors (Clickable / TextEditable / Selectable).
|
||||
local function shouldAttach(props)
|
||||
props = props or {}
|
||||
return props.overflow ~= nil or props.overflowX ~= nil or props.overflowY ~= nil
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onAttach — create the ScrollManager + expose its fields + restore immediate-
|
||||
-- mode scrollbar interaction state (formerly Element:_initScrollManager).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onAttach(element)
|
||||
local Element = ElementClass(element)
|
||||
-- Construction props are stashed on the element by _construct (the scrollbar
|
||||
-- config props are SPECIAL_PROPS and not bound as element fields).
|
||||
local props = element._initProps or {}
|
||||
|
||||
element._scrollManager = Element._ScrollManager.new({
|
||||
overflow = props.overflow,
|
||||
overflowX = props.overflowX,
|
||||
overflowY = props.overflowY,
|
||||
scrollbarWidth = props.scrollbarWidth,
|
||||
scrollbarColor = props.scrollbarColor,
|
||||
scrollbarTrackColor = props.scrollbarTrackColor,
|
||||
scrollbarRadius = props.scrollbarRadius,
|
||||
scrollbarPadding = props.scrollbarPadding,
|
||||
scrollSpeed = props.scrollSpeed,
|
||||
invertScroll = props.invertScroll,
|
||||
smoothScrollEnabled = props.smoothScrollEnabled,
|
||||
scrollBarStyle = props.scrollBarStyle,
|
||||
scrollbarKnobOffset = props.scrollbarKnobOffset,
|
||||
hideScrollbars = props.hideScrollbars,
|
||||
scrollbarPlacement = props.scrollbarPlacement,
|
||||
scrollbarBalance = props.scrollbarBalance,
|
||||
_scrollX = props._scrollX,
|
||||
_scrollY = props._scrollY,
|
||||
}, Element._scrollManagerDeps)
|
||||
|
||||
-- Expose ScrollManager properties for backward compatibility (Renderer access).
|
||||
local sm = element._scrollManager
|
||||
element.overflow = sm.overflow
|
||||
element.overflowX = sm.overflowX
|
||||
element.overflowY = sm.overflowY
|
||||
element.scrollbarWidth = sm.scrollbarWidth
|
||||
element.scrollbarColor = sm.scrollbarColor
|
||||
element.scrollbarTrackColor = sm.scrollbarTrackColor
|
||||
element.scrollbarRadius = sm.scrollbarRadius
|
||||
element.scrollbarPadding = sm.scrollbarPadding
|
||||
element.scrollSpeed = sm.scrollSpeed
|
||||
element.invertScroll = sm.invertScroll
|
||||
element.scrollBarStyle = sm.scrollBarStyle
|
||||
element.scrollbarKnobOffset = sm.scrollbarKnobOffset
|
||||
element.hideScrollbars = sm.hideScrollbars
|
||||
element.scrollbarPlacement = sm.scrollbarPlacement
|
||||
element.scrollbarBalance = sm.scrollbarBalance
|
||||
|
||||
-- Initialize state properties (will be synced from ScrollManager).
|
||||
element._overflowX = false
|
||||
element._overflowY = false
|
||||
element._contentWidth = 0
|
||||
element._contentHeight = 0
|
||||
element._scrollX = 0
|
||||
element._scrollY = 0
|
||||
element._maxScrollX = 0
|
||||
element._maxScrollY = 0
|
||||
element._scrollbarHoveredVertical = false
|
||||
element._scrollbarHoveredHorizontal = false
|
||||
element._scrollbarDragging = false
|
||||
element._hoveredScrollbar = nil
|
||||
element._scrollbarDragOffset = 0
|
||||
|
||||
-- Restore scrollbar state from StateManager in immediate mode (must happen
|
||||
-- before layout). Mirrors the legacy _initScrollManager restore block.
|
||||
-- Mode-aware via Context.isImmediateMode (behavior-mode-unification task 11).
|
||||
if Element._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
|
||||
local state = Element._StateManager.getState(element._stateId)
|
||||
if state and state.scrollManager then
|
||||
element._scrollbarHoveredVertical = state.scrollManager._scrollbarHoveredVertical or false
|
||||
element._scrollbarHoveredHorizontal = state.scrollManager._scrollbarHoveredHorizontal or false
|
||||
element._scrollbarDragging = state.scrollManager._scrollbarDragging or false
|
||||
element._hoveredScrollbar = state.scrollManager._hoveredScrollbar
|
||||
element._scrollbarDragOffset = state.scrollManager._scrollbarDragOffset or 0
|
||||
|
||||
-- Apply to ScrollManager immediately.
|
||||
sm._scrollbarHoveredVertical = element._scrollbarHoveredVertical
|
||||
sm._scrollbarHoveredHorizontal = element._scrollbarHoveredHorizontal
|
||||
sm._scrollbarDragging = element._scrollbarDragging
|
||||
sm._hoveredScrollbar = element._hoveredScrollbar
|
||||
sm._scrollbarDragOffset = element._scrollbarDragOffset
|
||||
|
||||
-- Restore drag start positions for relative movement tracking.
|
||||
sm._dragStartMouseX = state.scrollManager._dragStartMouseX or 0
|
||||
sm._dragStartMouseY = state.scrollManager._dragStartMouseY or 0
|
||||
sm._dragStartScrollX = state.scrollManager._dragStartScrollX or 0
|
||||
sm._dragStartScrollY = state.scrollManager._dragStartScrollY or 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- onUpdate — scroll-position momentum + scrollbar hover/drag/press interaction
|
||||
-- (formerly the inline ScrollManager blocks in Element:update).
|
||||
-- Runs BEFORE Clickable.onUpdate in the registry so the scrollbar press flag
|
||||
-- is set before Clickable's EventHandler processes mouse events.
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
local function onUpdate(element, dt)
|
||||
local Element = ElementClass(element)
|
||||
local sm = element._scrollManager
|
||||
if not sm then
|
||||
return
|
||||
end
|
||||
-- Restore scrollbar interaction state from StateManager in immediate mode
|
||||
-- (no-op outside immediate mode / when no state is stored).
|
||||
Element._ScrollManager.restoreImmediateState(element)
|
||||
|
||||
-- Smooth-scroll / momentum interpolation.
|
||||
sm:update(dt)
|
||||
element:_syncScrollManagerState()
|
||||
|
||||
-- Scrollbar hover / drag / press interaction. Captures the mouse here so the
|
||||
-- interaction state is consistent across the rest of the frame's behaviors.
|
||||
local mx, my = love.mouse.getPosition()
|
||||
Element._ScrollManager.updateInteraction(element, mx, my)
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- onDraw — scrollbar rendering (post-children overlay). Marked
|
||||
-- `drawLayer = "overlay"` so Element:draw dispatches it AFTER children, so
|
||||
-- scrollbars paint on top of clipped child content and without parent clipping.
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
local function onDraw(element, _ctx)
|
||||
local overflowX = element.overflowX or element.overflow
|
||||
local overflowY = element.overflowY or element.overflow
|
||||
if overflowX ~= "scroll" and overflowX ~= "auto" and overflowY ~= "scroll" and overflowY ~= "auto" then
|
||||
return
|
||||
end
|
||||
local scrollbarDims = element:_calculateScrollbarDimensions()
|
||||
if not (scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible) then
|
||||
return
|
||||
end
|
||||
-- Lift the parent scissor while drawing scrollbars so they render fully
|
||||
-- visible, then RESTORE it: clearing it outright let every later sibling
|
||||
-- draw unclipped (scrolled page content over the pinned header).
|
||||
local sx, sy, sw, sh = love.graphics.getScissor()
|
||||
love.graphics.setScissor()
|
||||
element._renderer:drawScrollbars(element, element.x, element.y, element.width, element.height, scrollbarDims)
|
||||
if sx then love.graphics.setScissor(sx, sy, sw, sh) end
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- saveState / restoreState — ScrollManager state snapshot for immediate-mode
|
||||
-- recreation (formerly the inline blocks in Element:saveState/
|
||||
-- Element:restoreState). Returns a table merged under the `scrollManager` key
|
||||
-- by Element:saveState's behavior loop, mirroring the legacy contract.
|
||||
-- --------------------------------------------------------------------------
|
||||
|
||||
local function saveState(element)
|
||||
local sm = element._scrollManager
|
||||
if not sm then
|
||||
return nil
|
||||
end
|
||||
return { scrollManager = sm:getState() }
|
||||
end
|
||||
|
||||
local function restoreState(element, state)
|
||||
if not state then
|
||||
return
|
||||
end
|
||||
local sm = element._scrollManager
|
||||
local smState = state.scrollManager
|
||||
if sm and smState then
|
||||
sm:setState(smState)
|
||||
end
|
||||
end
|
||||
|
||||
local Scrollable = Behavior.new({
|
||||
onAttach = onAttach,
|
||||
onDetach = function() end,
|
||||
onUpdate = onUpdate,
|
||||
onDraw = onDraw,
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
drawLayer = "overlay",
|
||||
})
|
||||
|
||||
-- Expose the predicate at module level so callers/tests can reference it
|
||||
-- directly without an element instance (mirrors Clickable.shouldAttach /
|
||||
-- Selectable.shouldAttach).
|
||||
Scrollable.shouldAttach = shouldAttach
|
||||
|
||||
return Scrollable
|
||||
@@ -0,0 +1,206 @@
|
||||
-- modules/behaviors/Selectable.lua
|
||||
--
|
||||
-- Concrete behavior: Select state-machine lifecycle for dropdown-style
|
||||
-- select groups. Owns the per-element Select subsystem initialization, the
|
||||
-- managed-frame layout sync each frame, and select save/restore across the
|
||||
-- immediate-mode recreation cycle.
|
||||
--
|
||||
-- This behavior consolidates the legacy `if self._selectState` / `if
|
||||
-- self.selectOption` branches that previously lived inside Element.lua:
|
||||
--
|
||||
-- * Select subsystem init (formerly Element:_initSubSystems lines ~810-825 —
|
||||
-- `Select.initSelectParent` / `Select.initSelectOption`).
|
||||
-- * Managed-frame adoption (formerly Element:_initPositioning lines ~1700-
|
||||
-- 1702 — `Select.adoptSelectFrame`).
|
||||
-- * Per-frame frame-state sync (formerly Element:update line ~2747 —
|
||||
-- `Select.ensureFrameState`).
|
||||
-- * Save/restore of select open/value/label (formerly the `select` branch of
|
||||
-- Element:saveState / Element:restoreState).
|
||||
--
|
||||
-- Element retains `self._selectState` and `self.selectOption` for backward-
|
||||
-- compat field access; runtime state lives ON THE ELEMENT. The behavior itself
|
||||
-- is stateless + immutable (a single shared instance attaches to every
|
||||
-- selectable element).
|
||||
--
|
||||
-- The 20 Element select-API delegate methods (openSelect, closeSelect,
|
||||
-- toggleSelect, isSelectOpen, getSelectValue, setSelectValue, ...) stay as
|
||||
-- 1-line forwarders into the Select module — the behavior owns the
|
||||
-- *lifecycle* (attach / update / save / restore / detach), not the API
|
||||
-- surface (per task 05 spec notes).
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * Per-element runtime state lives ON THE ELEMENT (self._selectState,
|
||||
-- self.selectOption, self._selectParentElement, ...).
|
||||
-- * The behavior instance is stateless + immutable and shared across elements.
|
||||
-- * Element-class-level dependencies are resolved via `getmetatable(element)`
|
||||
-- (which IS the Element class set by Element._construct), so the hook
|
||||
-- signature stays exactly `(element, ...)` with no DI parameters.
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Resolve the Element class from an element instance.
|
||||
-- `setmetatable({}, Element)` in `_construct` makes the instance metatable BE
|
||||
-- the Element class, so this yields Element._Select, Element._Context,
|
||||
-- Element._StateManager, etc. without threading deps through the hook signature.
|
||||
local function ElementClass(element)
|
||||
return getmetatable(element)
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- shouldAttach (class-level predicate, no element required)
|
||||
-- ============================================================================
|
||||
|
||||
-- Mirrors the cases that previously caused Element to initialize a Select
|
||||
-- subsystem. An element owns select state exactly when it declares a
|
||||
-- `selectParent` config (the dropdown trigger) or a `selectOption` config (an
|
||||
-- option inside a dropdown). Checking the props (rather than the runtime
|
||||
-- `_selectState`) lets shouldAttach run before onAttach initializes the
|
||||
-- subsystem, matching the auto-attach contract established by Clickable /
|
||||
-- TextEditable.
|
||||
local function shouldAttach(props)
|
||||
props = props or {}
|
||||
return type(props.selectParent) == "table" or type(props.selectOption) == "table"
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- onAttach — initialize the Select subsystem (formerly Element:_initSubSystems
|
||||
-- lines ~810-825) and adopt the managed frame (formerly Element:_initPositioning
|
||||
-- lines ~1700-1702).
|
||||
-- ============================================================================
|
||||
|
||||
local function onAttach(element)
|
||||
local Element = ElementClass(element)
|
||||
|
||||
-- Initialize the appropriate select role. Mirrors the legacy _initSubSystems
|
||||
-- block exactly: selectParent → initSelectParent (sets _selectState +
|
||||
-- immediate-mode restore from StateManager); selectOption → initSelectOption
|
||||
-- (sets the option value/label/disabled).
|
||||
if type(element.selectParent) == "table" then
|
||||
Element._Select.initSelectParent(element, element.selectParent)
|
||||
end
|
||||
|
||||
if type(element.selectOption) == "table" then
|
||||
Element._Select.initSelectOption(element, element.selectOption)
|
||||
end
|
||||
|
||||
-- Adopt the managed dropdown frame. This was formerly the tail of
|
||||
-- _initPositioning (after the select parent's own addChild). It creates the
|
||||
-- select anchor, reparents the frame under it, and syncs visibility. Moving
|
||||
-- it here is safe because onAttach runs after _initPositioning: the parent's
|
||||
-- own positioning is finalized, so the anchor's geometry can be computed.
|
||||
if element._selectState and type(element.selectParent) == "table" and element.selectParent.selectFrame ~= nil then
|
||||
Element._Select.adoptSelectFrame(element, element.selectParent.selectFrame)
|
||||
end
|
||||
|
||||
-- Backfill option registration for children added BEFORE this behavior
|
||||
-- attached. The auto-attach pass runs at the very end of Element.new
|
||||
-- (after _finalizeConstruction, which processes declarative `children`).
|
||||
-- Declarative select-option children are addChild'd to this element during
|
||||
-- _finalizeConstruction — at that point _selectState did not yet exist (this
|
||||
-- onAttach had not run), so their registerWithSelectParent call walked the
|
||||
-- parent chain, found no _selectState, and returned early. Re-scan now that
|
||||
-- _selectState is initialized so these options are registered + reparented
|
||||
-- into the managed frame exactly like runtime-added options.
|
||||
-- (registerWithSelectParent is idempotent — it skips options already
|
||||
-- registered — so this is a no-op for children added after _selectState was
|
||||
-- set, e.g. the common `FlexLove.new({ parent = sp, selectOption = {...} })`
|
||||
-- pattern.)
|
||||
if element._selectState then
|
||||
for _, child in ipairs(element.children) do
|
||||
if child.selectOption then
|
||||
Element._Select.registerWithSelectParent(child)
|
||||
Element._Select.attachOptionToManagedFrame(child)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function onDetach(element)
|
||||
-- Clear select-managed fields so the element can be GC'd cleanly in immediate
|
||||
-- mode (formerly part of Element:_cleanup). This mirrors the select-clearing
|
||||
-- block that lived in Element:_cleanup; Element:destroy separately routes
|
||||
-- through Select.cleanupDestroy for full teardown (idempotent with this).
|
||||
if element.selectParent then
|
||||
element.selectParent.onChange = nil
|
||||
end
|
||||
element._selectState = nil
|
||||
element._managedSelectOwner = nil
|
||||
element._managedSelectFrame = nil
|
||||
element._managedSelectAnchor = nil
|
||||
element._managedSelectBaseOpacity = nil
|
||||
element._managedSelectBaseVisibility = nil
|
||||
element._managedSelectBaseDisabled = nil
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- onUpdate — per-frame managed-frame layout sync (formerly Element:update
|
||||
-- line ~2747 — `Select.ensureFrameState`).
|
||||
-- ============================================================================
|
||||
|
||||
local function onUpdate(element, dt)
|
||||
local Element = ElementClass(element)
|
||||
Element._Select.ensureFrameState(element)
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- onDraw — no-op.
|
||||
-- ============================================================================
|
||||
|
||||
-- Select rendering is driven by the managed frame / anchor elements themselves
|
||||
-- (visibility synced by Select.syncManagedFrameVisibility), not by the select
|
||||
-- parent's draw path. The parent's own pixels are the theme/renderer's job.
|
||||
local function onDraw() end
|
||||
|
||||
-- ============================================================================
|
||||
-- saveState / restoreState — select open/value/label (formerly the `select`
|
||||
-- branch of Element:saveState / Element:restoreState).
|
||||
-- ============================================================================
|
||||
|
||||
-- Returns a snapshot under the `select` key to match the legacy immediate-mode
|
||||
-- restoreState contract (Element:restoreState looked up state.select). The
|
||||
-- behavior-dispatch loop merges behavior snapshots into the top-level state
|
||||
-- table, so returning { select = ... } slots in identically to the old inline
|
||||
-- `state.select = selectState` assignment.
|
||||
local function saveState(element)
|
||||
local Element = ElementClass(element)
|
||||
local selectState = Element._Select.saveState(element)
|
||||
if selectState then
|
||||
return { select = selectState }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Consumes the previously-saved snapshot keyed under `select`. The behavior-
|
||||
-- dispatch loop passes the FULL top-level state table; this hook reads only
|
||||
-- its own `state.select` slice, mirroring the legacy `if state.select then`
|
||||
-- guard in Element:restoreState.
|
||||
local function restoreState(element, state)
|
||||
if not state then
|
||||
return
|
||||
end
|
||||
local Element = ElementClass(element)
|
||||
if state.select then
|
||||
Element._Select.restoreState(element, state.select)
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Build the (stateless, shared, immutable) behavior instance.
|
||||
-- ============================================================================
|
||||
|
||||
local Selectable = Behavior.new({
|
||||
onAttach = onAttach,
|
||||
onDetach = onDetach,
|
||||
onUpdate = onUpdate,
|
||||
onDraw = onDraw,
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
shouldAttach = shouldAttach,
|
||||
})
|
||||
|
||||
-- Expose the predicate at module level so callers/tests can reference it
|
||||
-- directly without an element instance (mirrors Behavior.shouldAttach).
|
||||
Selectable.shouldAttach = shouldAttach
|
||||
|
||||
return Selectable
|
||||