Compare commits
232 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3a215e081 | |||
| 7fa758602a | |||
| bb979911c8 | |||
| f225f8a6e7 | |||
| ee891fb8fa | |||
| 9d73ff4110 | |||
| 0b7ecf1599 | |||
| aa45f05c63 | |||
| 9234f12ed0 | |||
| 26e0bd227e | |||
| 64ce66a619 | |||
| 9302bbcbf3 | |||
| 1855f838ef | |||
| 11af06a752 | |||
| 05668591ef | |||
| e74beec32d | |||
| 3efb2c9a6c | |||
| d3af63e013 | |||
| ca6bee64b9 | |||
| b52d183dba | |||
| 23e1877a62 | |||
| d8736fe33e | |||
| a74365a4fd | |||
| 1c2163bb5f | |||
| 12c2677dc2 | |||
| ae1b59ce7f | |||
| 02c4de897d | |||
| 3c6242717e | |||
| 3c71afb9fa | |||
| e79107c644 | |||
| 0afce96c8d | |||
| d881997acd | |||
| 8906133b93 | |||
| b9e3c4a689 | |||
| 81f18e244d | |||
| ff91481018 | |||
| fdb3184c71 | |||
| aa3b2a18ec | |||
| d498016a47 | |||
| 492e0344b5 | |||
| a710c64909 | |||
| 943ba5dcbf | |||
| 371edd1a02 | |||
| 9fc6ba5b01 | |||
| 983bea61aa | |||
| ab74f7adf7 | |||
| bdd0bf45bd | |||
| 4b950fc23d | |||
| af183d95ad | |||
| 1d4d5d2337 | |||
| 653b72d86a | |||
| 12b6ef6350 | |||
| c979685a1f | |||
| 5b1d0261ff | |||
| 5bffc89a2f | |||
| 2ce612d5b1 | |||
| 6999e5aecf | |||
| 44a7910c69 | |||
| 85c6fde443 | |||
| 67b6dcc293 | |||
| 24d9f279ec | |||
| 83f93c27a1 | |||
| d756b7cd43 | |||
| 1e6f504a84 | |||
| abb4f3f9f0 | |||
| 5b0209a7ce | |||
| c1e0685e97 | |||
| 3c57b28c6d | |||
| cab62ff7b3 | |||
| 5b6dfed359 | |||
| 91a85a065b | |||
| 32e2c51365 | |||
| 1992fdf398 | |||
| 59ac27971a | |||
| cd0ace2a39 | |||
| 274c728e77 | |||
| 8547127f54 | |||
| 45d799aa98 | |||
| 660c90626d | |||
| d9dc42895f | |||
| 02fd21bcdb | |||
| af00d6ad14 | |||
| 0d4a518a08 | |||
| 05b43ca258 | |||
| 9d6ea845d7 | |||
| bbca4e8e39 | |||
| 49954ec4ad | |||
| 6e94625f2a | |||
| 0399ad040f | |||
| 726ed1102e | |||
| 5bc7d203a5 | |||
| ce204aaf16 | |||
| ed8a89c5ce | |||
| 66e7d9432e | |||
| d59a9522ee | |||
| f1661b62b3 | |||
| a98562f46b | |||
| 365bd24740 | |||
| d50ea4d067 | |||
| 52c96437fa | |||
| 2a7c8ec81b | |||
| bd6c606303 | |||
| 112120e8fe | |||
| 00f6e3a7b4 | |||
| 9cc72f951b | |||
| 0f484682d8 | |||
| 60cf07fb0a | |||
| 493caee715 | |||
| aa6217e581 | |||
| a640f5504f | |||
| e01672b97d | |||
| 9e3d487a08 | |||
| 506cd2c5c5 | |||
| 6cb0ac5666 | |||
| b7d0ff2e16 | |||
| 984cefdc7b | |||
| 6c892cb7c1 | |||
| 52d1ddc5af | |||
| 75cd28435c | |||
| 9aa8b4e371 | |||
| e94d8e32ef | |||
| 3fae3088ca | |||
| cbb08f76ae | |||
| dd503706b0 | |||
| eeb181bcc0 | |||
| 287534cf26 | |||
| d08fce5fd5 | |||
| 0ae29744c8 | |||
| 84e1a5177b | |||
| c7e72149f1 | |||
| ecfca19f11 | |||
| 90a53e509a | |||
| 15fc97a996 | |||
| 35ed326dda | |||
| f4a1dd9e16 | |||
| 6f75a64c46 | |||
| dc04a4e848 | |||
| cd37aa175c | |||
| cb6cfb5556 | |||
| 439e97aee6 | |||
| 5528525888 | |||
| a49ba66c36 | |||
| 9de4db8531 | |||
| dbc48f377a | |||
| 1d77d42fe2 | |||
| 683fee8028 | |||
| a83d18fc51 | |||
| b9e8b00af0 | |||
| e2820e02c5 | |||
| f92364a002 | |||
| 9696c8d3e2 | |||
| 18b2bcd0a7 | |||
| f56970350a | |||
| 863f371e68 | |||
| 1bfc89fdd4 | |||
| 24c5114745 | |||
| 7def560726 | |||
| 6cbd0de77d | |||
| 9a8101df6a | |||
| 78744a6853 | |||
| efce1bb116 | |||
| 104c95a942 | |||
| f6392e8932 | |||
| 88e34ef65c | |||
| 8f0117a145 | |||
| 1f878c3098 | |||
| ae5276b2d1 | |||
| 4dd4c5c463 | |||
| cafa6f3d61 | |||
| f0ed2efe07 | |||
| bbf48c7e9e | |||
| 99d54b82b6 | |||
| 49776ca1eb | |||
| 272305f3a4 | |||
| 89f023c7dd | |||
| afc5a52978 | |||
| 12a04f4188 | |||
| 0fe7a96781 | |||
| a06a49bfed | |||
| 86b90fd4b8 | |||
| d18824cfae | |||
| 12b4c93279 | |||
| af47e19e1a | |||
| 4e666303d0 | |||
| 0fa8206321 | |||
| 78ac154998 | |||
| bbcaac7b71 | |||
| 719ba49a85 | |||
| 54ae20c69b | |||
| f864837cbe | |||
| 21191b8e80 | |||
| 6426e913b6 | |||
| 3d3e42c6a3 | |||
| 6fe106f55c | |||
| ba8ac3d143 | |||
| 1963a5453e | |||
| 59a383736c | |||
| 1820f411ae | |||
| 8327942a9c | |||
| aec7f6001a | |||
| d377518632 | |||
| ad9b03fe56 | |||
| 3b490bc42f | |||
| dacd9abe73 | |||
| f74e21782b | |||
| 8fbe819493 | |||
| 626080d228 | |||
| f56e82de81 | |||
| 7d3950658a | |||
| 3c1246ee06 | |||
| d69965e3b1 | |||
| 30edac03a6 | |||
| dd4aface93 | |||
| b1350fdecd | |||
| f2d9b74490 | |||
| 84d09d0894 | |||
| e61f231316 | |||
| 33998aab5d | |||
| 9ce7379946 | |||
| b692474e24 | |||
| 6cfc85ca83 | |||
| 7b060c19f4 | |||
| 604b9338f9 | |||
| 3a6557ffe2 | |||
| fe139ca7f4 | |||
| 5c617d3e32 | |||
| 8d11c04d43 | |||
| 92f73d8bed | |||
| 8da51aa7ba | |||
| 78f87111de | |||
| 6ea59f1a79 | |||
| a2bf08c6ff |
@@ -181,6 +181,196 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
xbox-uwp-changes:
|
||||
name: detect Xbox UWP 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 '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|LauncherView|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_baseroms|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
xbox-uwp-selftest:
|
||||
name: Xbox UWP offline selftest
|
||||
needs: xbox-uwp-changes
|
||||
if: needs.xbox-uwp-changes.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Xbox UWP offline selftest
|
||||
run: bash scripts/xbox-uwp/selftest_build_xbox_uwp.sh
|
||||
- name: Build shared payload
|
||||
run: |
|
||||
scripts/pack_love.sh \
|
||||
--output .bazinga/work/ci-game.love \
|
||||
--listing .bazinga/work/ci-love-listing.txt \
|
||||
--version 0.0.0
|
||||
- name: Upload shared payload
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-payload
|
||||
path: .bazinga/work/ci-game.love
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
xbox-uwp-build:
|
||||
name: Xbox UWP build
|
||||
needs: [xbox-uwp-changes, xbox-uwp-selftest]
|
||||
if: |
|
||||
always()
|
||||
&& needs.xbox-uwp-changes.outputs.changed == 'true'
|
||||
&& needs.xbox-uwp-selftest.result == 'success'
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-payload
|
||||
path: .bazinga/work
|
||||
- name: Build Xbox UWP package
|
||||
shell: bash
|
||||
run: |
|
||||
bash scripts/build_xbox_uwp.sh \
|
||||
--release \
|
||||
--version 0.0.0 \
|
||||
--game-love .bazinga/work/ci-game.love
|
||||
- name: Upload Xbox UWP package
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp
|
||||
path: |
|
||||
dist/xbox-uwp/gen1recomp-0.0.0-xbox-uwp.zip
|
||||
dist/xbox-uwp/gen1recomp-0.0.0-xbox-uwp.zip.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
linux-arm64-changes:
|
||||
name: detect Linux arm64 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_linux_arm64\.sh$|scripts/linux-arm64/|scripts/pack_love\.sh$|docs/linux-arm64-build\.md$|\.github/workflows/(ci|release)\.yml$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
linux-arm64-selftest:
|
||||
name: Linux arm64 offline selftest
|
||||
needs: linux-arm64-changes
|
||||
if: needs.linux-arm64-changes.outputs.changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# Deliberately on x86_64: everything this gate checks (pins, the
|
||||
# host-arch guard, the dependency exclude list, the AppRun fusion
|
||||
# contract) is answerable without a container or an aarch64 machine,
|
||||
# so the slow native job below only ever starts on a sane tree.
|
||||
- name: Linux arm64 offline selftest
|
||||
run: bash scripts/linux-arm64/selftest_build_linux_arm64.sh
|
||||
|
||||
linux-arm64-build:
|
||||
name: Linux arm64 AppImage build
|
||||
needs: [linux-arm64-changes, linux-arm64-selftest]
|
||||
if: |
|
||||
always()
|
||||
&& needs.linux-arm64-changes.outputs.changed == 'true'
|
||||
&& needs.linux-arm64-selftest.result == 'success'
|
||||
# No fork restriction, unlike switch-build: this needs no secrets and no
|
||||
# self-hosted hardware, just GitHub's free arm64 runner for public repos,
|
||||
# so contributors get the same coverage on their own PRs.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build the aarch64 AppImage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_linux_arm64.sh --version 0.0.0
|
||||
- name: Verify the AppImage is self-contained and bullseye-compatible
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage"
|
||||
|
||||
# --appimage-extract needs no FUSE, so this works on a runner
|
||||
# without /dev/fuse and still exercises the real payload.
|
||||
"$image" --appimage-extract >/dev/null
|
||||
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|
||||
[ -e "squashfs-root/$required" ] \
|
||||
|| { echo "::error::AppImage is missing $required"; exit 1; }
|
||||
done
|
||||
|
||||
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
|
||||
# applied; an unresolved soname here is a user-visible launch crash.
|
||||
#
|
||||
# This runs on a HEADLESS runner on purpose, and that is the point.
|
||||
# The first version of this build bundled Debian's SDL2, which
|
||||
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
|
||||
# started on a full desktop -- a bare runner is what exposed it.
|
||||
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
|
||||
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||
| grep 'not found' || true)"
|
||||
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
|
||||
|
||||
# Nothing may hard-link a driver, session or audio-stack library:
|
||||
# those must be reached through dlopen so the AppImage runs on a box
|
||||
# with only ALSA, only Wayland, or only KMSDRM.
|
||||
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
|
||||
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
|
||||
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
|
||||
[ -z "$linked" ] \
|
||||
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
|
||||
|
||||
# The whole point of compiling on bullseye. If a future change moves
|
||||
# the builder to a newer base, the glibc floor silently rises and
|
||||
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
|
||||
# it here instead of in a release.
|
||||
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
|
||||
echo "highest required glibc symbol version: $floor"
|
||||
[ -n "$floor" ] \
|
||||
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
|
||||
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
|
||||
[ "$highest" = "GLIBC_2.31" ] \
|
||||
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
|
||||
- name: Upload the AppImage
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-linux-arm64
|
||||
path: |
|
||||
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
|
||||
dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
headless:
|
||||
name: headless suites (no ROM)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -193,6 +383,9 @@ jobs:
|
||||
- name: install luajit
|
||||
run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
|
||||
- name: install Pillow
|
||||
run: python3 -m pip install --upgrade pillow
|
||||
|
||||
- name: interpreter version
|
||||
run: luajit -v
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: Release
|
||||
|
||||
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
|
||||
# IPA, a Nintendo Switch SD-ready zip (experimental), and the Anbernic RG34XXSP
|
||||
# (Stock OS 64-bit MOD / PortMaster) port on the self-hosted Mac runner, and
|
||||
# publishes them as a GitHub Release.
|
||||
# IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, and the Anbernic
|
||||
# RG34XXSP (Stock OS 64-bit MOD / PortMaster) port, then publishes them as a
|
||||
# GitHub Release.
|
||||
#
|
||||
# Versioning:
|
||||
# - First ever release is 0.1.0.
|
||||
@@ -43,22 +43,17 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
version:
|
||||
name: determine release version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.ver.outputs.version }}
|
||||
tag: ${{ steps.ver.outputs.tag }}
|
||||
steps:
|
||||
# The self-hosted runner lives under the machine owner's home
|
||||
# directory; mask it first so absolute paths in every later step's
|
||||
# output show up as *** in the public workflow logs.
|
||||
- name: Mask runner paths
|
||||
run: echo "::add-mask::$HOME"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Determine version
|
||||
id: ver
|
||||
env:
|
||||
@@ -66,7 +61,6 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
semver_re='^[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
|
||||
# 1) Explicit override from a manual run.
|
||||
@@ -96,7 +90,6 @@ jobs:
|
||||
| grep -E "$semver_re" \
|
||||
| sort -t. -k1,1n -k2,2n -k3,3n \
|
||||
| tail -1 || true)"
|
||||
|
||||
if [ -z "$latest" ]; then
|
||||
version="0.1.0"
|
||||
echo "No existing release tag; starting at $version"
|
||||
@@ -124,10 +117,157 @@ jobs:
|
||||
echo "::error::Release $tag already exists. Pick a different version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
love-payload:
|
||||
name: build release game.love
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Build shared payload
|
||||
run: |
|
||||
scripts/pack_love.sh \
|
||||
--output dist/payload/game.love \
|
||||
--listing dist/payload/love-listing.txt \
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
- name: Upload shared payload
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: dist/payload/game.love
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
linux-arm64:
|
||||
name: build Linux arm64 AppImage
|
||||
needs: [version, love-payload]
|
||||
# GitHub's free arm64 runner for public repos. It has to be arm64: the
|
||||
# AppImage compiles LÖVE natively inside a Debian bullseye arm64
|
||||
# container, and the qemu-emulated alternative takes hours.
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: .bazinga/work
|
||||
- name: Build Linux arm64 AppImage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_linux_arm64.sh \
|
||||
--version "${{ needs.version.outputs.version }}" \
|
||||
--game-love .bazinga/work/game.love
|
||||
- name: Upload Linux arm64 release
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-linux-arm64-release
|
||||
path: |
|
||||
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage
|
||||
dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
xbox-uwp:
|
||||
name: build Xbox UWP release
|
||||
needs: [version, love-payload]
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: .bazinga/work
|
||||
- name: Prepare signing certificate
|
||||
shell: pwsh
|
||||
env:
|
||||
CERTIFICATE_BASE64: ${{ secrets.XBOX_UWP_SIGNING_CERTIFICATE }}
|
||||
CERTIFICATE_PASSWORD: ${{ secrets.XBOX_UWP_SIGNING_PASSWORD }}
|
||||
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
|
||||
run: |
|
||||
if ($env:CANONICAL_REPOSITORY -eq 'true' -and
|
||||
[string]::IsNullOrWhiteSpace($env:CERTIFICATE_BASE64)) {
|
||||
throw 'XBOX_UWP_SIGNING_CERTIFICATE is not configured.'
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($env:CERTIFICATE_BASE64)) {
|
||||
"UWP_PUBLISHER=CN=Gen1Recomp" | Out-File $env:GITHUB_ENV -Append
|
||||
exit 0
|
||||
}
|
||||
$pfx = Join-Path $env:RUNNER_TEMP 'gen1recomp-uwp.pfx'
|
||||
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:CERTIFICATE_BASE64))
|
||||
$flags = [Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet
|
||||
$cert = [Security.Cryptography.X509Certificates.X509Certificate2]::new(
|
||||
$pfx, $env:CERTIFICATE_PASSWORD, $flags)
|
||||
$cer = Join-Path $env:RUNNER_TEMP 'gen1recomp-uwp.cer'
|
||||
[IO.File]::WriteAllBytes(
|
||||
$cer,
|
||||
$cert.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert))
|
||||
Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople | Out-Null
|
||||
"UWP_PFX=$pfx" | Out-File $env:GITHUB_ENV -Append
|
||||
"UWP_CERT_THUMBPRINT=$($cert.Thumbprint)" | Out-File $env:GITHUB_ENV -Append
|
||||
"UWP_PUBLISHER=$($cert.Subject)" | Out-File $env:GITHUB_ENV -Append
|
||||
- name: Build Xbox UWP package
|
||||
shell: bash
|
||||
run: |
|
||||
bash scripts/build_xbox_uwp.sh \
|
||||
--release \
|
||||
--version "${{ needs.version.outputs.version }}" \
|
||||
--publisher "$UWP_PUBLISHER" \
|
||||
--game-love .bazinga/work/game.love
|
||||
- name: Sign and stage Xbox UWP release
|
||||
shell: pwsh
|
||||
env:
|
||||
CERTIFICATE_PASSWORD: ${{ secrets.XBOX_UWP_SIGNING_PASSWORD }}
|
||||
run: |
|
||||
if (-not $env:UWP_PFX) {
|
||||
exit 0
|
||||
}
|
||||
scripts/xbox-uwp/stage_release.ps1 `
|
||||
-Version '${{ needs.version.outputs.version }}' `
|
||||
-Configuration Release `
|
||||
-BuildInfo .bazinga/work/xbox-uwp-build-info.json `
|
||||
-CertificatePath $env:UWP_PFX `
|
||||
-CertificatePassword $env:CERTIFICATE_PASSWORD
|
||||
- name: Upload Xbox UWP release
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-release
|
||||
path: |
|
||||
dist/xbox-uwp/gen1recomp-${{ needs.version.outputs.version }}-xbox-uwp.zip
|
||||
dist/xbox-uwp/gen1recomp-${{ needs.version.outputs.version }}-xbox-uwp.zip.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
- name: Remove signing certificate
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ($env:UWP_CERT_THUMBPRINT) {
|
||||
Remove-Item "Cert:\LocalMachine\TrustedPeople\$env:UWP_CERT_THUMBPRINT" -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($env:UWP_PFX) {
|
||||
Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
release:
|
||||
needs: [version, xbox-uwp, linux-arm64]
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
steps:
|
||||
# The self-hosted runner lives under the machine owner's home
|
||||
# directory; mask it first so absolute paths in every later step's
|
||||
# output show up as *** in the public workflow logs.
|
||||
- name: Mask runner paths
|
||||
run: echo "::add-mask::$HOME"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Import signing certificate into a temporary keychain
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
@@ -172,12 +312,12 @@ jobs:
|
||||
# notarize separately below so it uses secret credentials, not a
|
||||
# login-keychain profile. "all" also builds the Linux AppImage,
|
||||
# which needs no signing/notarization.
|
||||
scripts/build.sh all --version "${{ steps.ver.outputs.version }}" --no-notarize
|
||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
|
||||
|
||||
- name: Build Android
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||
scripts/build_android.sh --version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Install xcbeautify
|
||||
run: |
|
||||
@@ -191,10 +331,10 @@ jobs:
|
||||
set -euo pipefail
|
||||
if [ "$CANONICAL_REPOSITORY" = true ]; then
|
||||
scripts/build_ios.sh --fetch --device --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
else
|
||||
scripts/build_ios.sh --fetch --release \
|
||||
--version "${{ steps.ver.outputs.version }}"
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
fi
|
||||
|
||||
- name: Build Switch
|
||||
@@ -206,14 +346,14 @@ jobs:
|
||||
# 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 }}"
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
|
||||
# runtime from PortMaster-GUI, so it needs no signing/notarization.
|
||||
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
|
||||
./build-rg34xxsp.sh --version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Notarize & staple macOS app
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
@@ -250,18 +390,41 @@ jobs:
|
||||
ditto -c -k --sequesterRsrc --keepParent "$app" "$zip"
|
||||
echo "Notarized + stapled ✓"
|
||||
|
||||
- name: Download Xbox UWP release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-xbox-uwp-release
|
||||
path: dist/xbox-uwp
|
||||
|
||||
- name: Download Linux arm64 release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-linux-arm64-release
|
||||
path: dist/linux-arm64
|
||||
|
||||
- name: Stage release assets
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
id: assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
v="${{ needs.version.outputs.version }}"
|
||||
outdir="dist/release"
|
||||
rm -rf "$outdir"
|
||||
mkdir -p "$outdir"
|
||||
cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip"
|
||||
cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip"
|
||||
cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip"
|
||||
|
||||
# arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on
|
||||
# its own runner because LÖVE publishes no aarch64 binary and the
|
||||
# AppImage has to be compiled natively; ships as a runnable
|
||||
# AppImage rather than a zip so `chmod +x && ./it` just works.
|
||||
arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
[ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; }
|
||||
cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
apk="$(find dist/android/debug -name '*.apk' | head -1)"
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
@@ -276,6 +439,10 @@ jobs:
|
||||
# Local fused .nro stays under dist/switch/ for PR CI / debug; release
|
||||
# publishes the SD-ready zip only.
|
||||
|
||||
uwp="dist/xbox-uwp/gen1recomp-${v}-xbox-uwp.zip"
|
||||
[ -f "$uwp" ] || { echo "::error::$uwp not found (expected from the Xbox UWP job)"; exit 1; }
|
||||
cp "$uwp" "$outdir/gen1recomp-${v}-xbox-uwp.zip"
|
||||
|
||||
# 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"
|
||||
@@ -302,8 +469,8 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
tag="${{ steps.ver.outputs.tag }}"
|
||||
v="${{ needs.version.outputs.version }}"
|
||||
tag="${{ needs.version.outputs.tag }}"
|
||||
|
||||
# Issues this release closes. Three sources, deduped by number:
|
||||
# 1. GitHub's own "closing issues" links on every PR whose
|
||||
@@ -391,9 +558,11 @@ jobs:
|
||||
"dist/release/gen1recomp-${v}-macos.zip"
|
||||
"dist/release/gen1recomp-${v}-windows.zip"
|
||||
"dist/release/gen1recomp-${v}-linux.zip"
|
||||
"dist/release/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
"dist/release/gen1recomp-${v}-android.apk"
|
||||
"dist/release/gen1recomp-${v}-ios.ipa"
|
||||
"dist/release/gen1recomp-${v}-switch.zip"
|
||||
"dist/release/gen1recomp-${v}-xbox-uwp.zip"
|
||||
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip"
|
||||
"dist/release/gen1recomp-${v}.love"
|
||||
"dist/release/sha256sums.txt"
|
||||
@@ -411,7 +580,7 @@ jobs:
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v="${{ steps.ver.outputs.version }}"
|
||||
v="${{ needs.version.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; }
|
||||
|
||||
@@ -31,12 +31,29 @@ mobile/ios/love-src/
|
||||
mobile/ios/cache/
|
||||
mobile/ios/build/
|
||||
|
||||
# love-nx vendor binaries (fetch per docs/switch-development.md; also covered by .*)
|
||||
# love-nx vendor binaries (fetch per docs/switch-build.md; also covered by .*)
|
||||
.bazinga/love-nx/
|
||||
|
||||
# Final packaged build artifacts (mac/win/web/android/ios/switch) — see scripts/build.sh
|
||||
/dist/
|
||||
|
||||
# Switch OTA launcher build outputs
|
||||
ports/switch/ota-launcher/build/
|
||||
ports/switch/ota-launcher/build-host/
|
||||
ports/switch/ota-launcher/*.nro
|
||||
ports/switch/ota-launcher/*.nacp
|
||||
ports/switch/ota-launcher/*.elf
|
||||
ports/switch/ota-launcher/*.map
|
||||
ports/switch/ota-launcher/romfs/logo.rgba
|
||||
ports/switch/ota-launcher/romfs/logo.png
|
||||
ports/switch/ota-launcher/romfs/cacert.pem
|
||||
ports/switch/ota-launcher/romfs/ota-bootstrap.nro
|
||||
ports/switch/ota-bootstrap/build/
|
||||
ports/switch/ota-bootstrap/*.nro
|
||||
ports/switch/ota-bootstrap/*.nacp
|
||||
ports/switch/ota-bootstrap/*.elf
|
||||
ports/switch/ota-bootstrap/*.map
|
||||
|
||||
# Legacy manual convenience-copy location (superseded by /dist/android/)
|
||||
mobile/dist/
|
||||
|
||||
@@ -46,3 +63,8 @@ mobile/dist/
|
||||
|
||||
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
|
||||
mobile/ios/bundle_id.local
|
||||
|
||||
# Xbox UWP build output
|
||||
/ports/uwp/build/
|
||||
/ports/uwp/third_party/*/source/
|
||||
/ports/uwp/third_party/angle/depot_tools/
|
||||
|
||||
@@ -205,6 +205,39 @@ even on a different computer, as long as the same folder comes along.
|
||||
already written to either location is touched automatically, so copy files
|
||||
over yourself if you want to carry existing progress across the switch.
|
||||
|
||||
## Launch Options
|
||||
|
||||
By default the app opens the launcher so you can pick a game. Launch options
|
||||
skip it and start one game directly, which is what you want for a one-click
|
||||
entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
||||
|
||||
| Option | Effect |
|
||||
| --- | --- |
|
||||
| `--game=red` | boot Red, skipping the launcher (`blue` and `yellow` too, or just `r` / `b` / `y`) |
|
||||
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
||||
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
||||
|
||||
|
||||
## Linux on arm64 (Raspberry Pi)
|
||||
|
||||
Alongside the x86_64 `gen1recomp-*-linux.zip`, every release ships
|
||||
`gen1recomp-*-linux-arm64.AppImage` for 64-bit ARM desktop Linux — Raspberry
|
||||
Pi 4/5, Armbian and other SBC distros, and arm64 VMs on Apple Silicon:
|
||||
|
||||
```sh
|
||||
chmod +x gen1recomp-*-linux-arm64.AppImage
|
||||
./gen1recomp-*-linux-arm64.AppImage
|
||||
```
|
||||
|
||||
LÖVE publishes no aarch64 binary of any kind, so this artifact compiles the
|
||||
engine — and SDL2, OpenAL and the codecs — from source inside a Debian
|
||||
bullseye arm64 container. It needs only glibc 2.29+, libstdc++, freetype and
|
||||
zlib on the host; OpenGL, X11, Wayland, KMSDRM, ALSA and PulseAudio are all
|
||||
dlopened, so the same image runs on a full desktop, a Wayland-only session or
|
||||
a KMSDRM handheld with no X server. Build instructions and the reasoning are
|
||||
in [docs/linux-arm64-build.md](docs/linux-arm64-build.md).
|
||||
|
||||
|
||||
## iOS
|
||||
|
||||
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
|
||||
@@ -222,6 +255,40 @@ build and install from source on a Mac instead, see
|
||||
<a href="https://github.com/bryanthaboi/gen1recomp/releases/latest"><img src="./.github/resources/github-badge.png" alt="Download from GitHub" height="60"></a>
|
||||
</div>
|
||||
|
||||
## Xbox Dev Mode
|
||||
|
||||
Every release ships `gen1recomp-*-xbox-uwp.zip` for Xbox One and Xbox Series
|
||||
consoles in Developer Mode. It cannot be installed in retail mode.
|
||||
|
||||
Extract the archive, then use Xbox Device Portal to install the `.msix` and
|
||||
the x64 package under `Dependencies`.
|
||||
|
||||
### External setup
|
||||
|
||||
1. Put your legally obtained Red, Blue, or Yellow ROMs on an external drive.
|
||||
Mod ZIPs can go on the same drive.
|
||||
2. Connect the drive to the Xbox and open Gen1Recomp.
|
||||
3. Select **Import ROM** or **Import Mod**, then choose the file with the Xbox
|
||||
file picker.
|
||||
4. Repeat the ROM import for each version you want to use.
|
||||
|
||||
### Internal setup
|
||||
|
||||
1. Create a folder named `baseroms` on your PC and place your legally obtained
|
||||
Red, Blue, or Yellow ROMs inside it.
|
||||
2. ZIP the folder, keeping `baseroms` at the top level of the archive.
|
||||
3. Launch Gen1Recomp once, then close it.
|
||||
4. Open Xbox Device Portal and upload the ZIP to
|
||||
`Gen1Recomp/LocalState/pokemon-love2d/`.
|
||||
5. Choose **Yes** when Device Portal asks whether to extract the archive.
|
||||
6. Open Gen1Recomp. The launcher checks baseroms once at startup. When it finds a compatible ROM, that game’s tab shows ROM FOUND and an Import detected ROM button.
|
||||
|
||||
ROMs, generated game data, saves, and mods remain in LocalState and are not
|
||||
included in the app.
|
||||
|
||||
Source builds and package details are covered in
|
||||
[the Xbox UWP build notes](ports/uwp/BUILD.md).
|
||||
|
||||
## Handhelds
|
||||
|
||||
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
|
||||
@@ -231,24 +298,18 @@ Install steps, controls, and troubleshooting live in
|
||||
|
||||
## 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).
|
||||
Releases ship an SD-ready `gen1recomp-*-switch.zip`. Runtime target is pinned
|
||||
[love-nx](https://github.com/retronx-team/love-nx) `11.5-nx1`. Requires a
|
||||
console that can run Switch homebrew.
|
||||
|
||||
- Players: [docs/switch-install.md](docs/switch-install.md) — download the
|
||||
- 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.
|
||||
- Builders: [docs/switch-build.md](docs/switch-build.md). `--fetch` /
|
||||
`--loose` / `--fused`, toolchain, Docker fallback, and CI vs release
|
||||
(path-gated ubuntu selftest, fused PR artifact on the main repo, release
|
||||
hard-fail).
|
||||
- File transfer (MTP / SD / FTP): [docs/switch-transfer.md](docs/switch-transfer.md).
|
||||
|
||||
## Modding
|
||||
|
||||
@@ -297,7 +358,4 @@ 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.
|
||||
@@ -16,9 +16,13 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
M.PEWTER_CITY = {
|
||||
|
||||
@@ -23,9 +23,13 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
M.VIRIDIAN_CITY = {
|
||||
|
||||
@@ -18,6 +18,26 @@ local M = {
|
||||
VIRIDIAN_GYM = { city = "VIRIDIAN CITY", leader = "GIOVANNI", badge = "EARTHBADGE" },
|
||||
}
|
||||
|
||||
-- The originals' middle branch shared by every leader's text_asm: beaten
|
||||
-- but EVENT_GOT_TM* unset means the bag was full when the victory script
|
||||
-- ran GiveItem, so talking to the leader re-runs the ReceiveTM script.
|
||||
-- Returns true when the retry took over the talk. A save from before
|
||||
-- #797 already holds the TM without the flag; treat the owned TM as
|
||||
-- received so those saves fall through to the advice text instead of
|
||||
-- collecting a second copy.
|
||||
local function retryTmGive(game, ow, victoryKey, done)
|
||||
local reward = require("data.scripts.victories")[victoryKey]
|
||||
if not (reward and reward.gotFlag) then return false end
|
||||
if game.save.flags[reward.gotFlag] then return false end
|
||||
local owned = game.save.inventory and game.save.inventory[reward.item] or 0
|
||||
if owned > 0 then
|
||||
game.save.flags[reward.gotFlag] = true
|
||||
return false
|
||||
end
|
||||
ow:offerGymTm(reward, done)
|
||||
return true
|
||||
end
|
||||
|
||||
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
||||
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
||||
-- _PewterGymBrockPreBattleText and engages the leader battle
|
||||
@@ -25,12 +45,14 @@ local M = {
|
||||
-- badge/TM34 rewards and EVENT_BEAT_BROCK come from
|
||||
-- data/scripts/victories.lua OPP_BROCK#1). After the badge his
|
||||
-- .afterBeat branch prints _PewterGymBrockPostBattleAdviceText ("Go to
|
||||
-- the GYM in CERULEAN..."). The original's middle branch (beat but
|
||||
-- TM34 not yet handed over, CheckEventReuseA EVENT_GOT_TM34) is
|
||||
-- unreachable in the port: the TM is granted with the victory.
|
||||
-- the GYM in CERULEAN..."). The middle branch (beat but TM34 not yet
|
||||
-- handed over, CheckEventReuseA EVENT_GOT_TM34 -> call
|
||||
-- PewterGymScriptReceiveTM34) retries the TM give when the bag was full
|
||||
-- at the victory (#797).
|
||||
M.PEWTER_GYM.talk = {
|
||||
TEXT_PEWTERGYM_BROCK = function(game, ow, npc, done)
|
||||
if game.save.flags.EVENT_BEAT_BROCK then
|
||||
if retryTmGive(game, ow, "OPP_BROCK#1", done) then return end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._PewterGymBrockPostBattleAdviceText
|
||||
@@ -48,16 +70,17 @@ M.PEWTER_GYM.talk = {
|
||||
-- (engageTrainer shows that same pre-battle text via resolveText; the
|
||||
-- badge/TM rewards and the beat flag come from data/scripts/victories.lua)
|
||||
-- -- and once beaten print the post-battle advice text. As with Brock,
|
||||
-- the originals' middle branch (beaten but the TM not yet handed over,
|
||||
-- CheckEventReuseA EVENT_GOT_TM*) is unreachable in the port: the TM is
|
||||
-- granted with the victory.
|
||||
-- the middle branch (beaten but the TM not yet handed over,
|
||||
-- CheckEventReuseA EVENT_GOT_TM*) retries the TM give when the bag was
|
||||
-- full at the victory.
|
||||
-- afterAdvice, when given, takes over `done`: it is handed (game, ow, npc,
|
||||
-- done) and must call done() itself once whatever it's doing (e.g. a fade
|
||||
-- around a HideObject) finishes, rather than having it invoked
|
||||
-- automatically. Only Giovanni's farewell uses this.
|
||||
local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice)
|
||||
local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryKey)
|
||||
return function(game, ow, npc, done)
|
||||
if game.save.flags[beatFlag] then
|
||||
if victoryKey and retryTmGive(game, ow, victoryKey, done) then return end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local finish = done
|
||||
if afterAdvice then
|
||||
@@ -79,42 +102,42 @@ end
|
||||
M.CERULEAN_GYM.talk = {
|
||||
TEXT_CERULEANGYM_MISTY = leaderTalk("EVENT_BEAT_MISTY",
|
||||
"_CeruleanGymMistyTM11ExplanationText",
|
||||
"TM11 teaches\nBUBBLEBEAM!"),
|
||||
"TM11 teaches\nBUBBLEBEAM!", nil, "OPP_MISTY#1"),
|
||||
}
|
||||
|
||||
-- scripts/VermilionGym.asm VermilionGymLTSurgeText .got_tm24_already
|
||||
M.VERMILION_GYM.talk = {
|
||||
TEXT_VERMILIONGYM_LT_SURGE = leaderTalk("EVENT_BEAT_LT_SURGE",
|
||||
"_VermilionGymLTSurgePostBattleAdviceText",
|
||||
"A little word of\nadvice, kid!"),
|
||||
"A little word of\nadvice, kid!", nil, "OPP_LT_SURGE#1"),
|
||||
}
|
||||
|
||||
-- scripts/CeladonGym.asm CeladonGymErikaText .afterBeat
|
||||
M.CELADON_GYM.talk = {
|
||||
TEXT_CELADONGYM_ERIKA = leaderTalk("EVENT_BEAT_ERIKA",
|
||||
"_CeladonGymErikaPostBattleAdviceText",
|
||||
"You are cataloging\nPOKéMON? I must\nsay I'm impressed."),
|
||||
"You are cataloging\nPOKéMON? I must\nsay I'm impressed.", nil, "OPP_ERIKA#1"),
|
||||
}
|
||||
|
||||
-- scripts/FuchsiaGym.asm FuchsiaGymKogaText .afterBeat
|
||||
M.FUCHSIA_GYM.talk = {
|
||||
TEXT_FUCHSIAGYM_KOGA = leaderTalk("EVENT_BEAT_KOGA",
|
||||
"_FuchsiaGymKogaPostBattleAdviceText",
|
||||
"When afflicted by\nTOXIC, POKéMON\nsuffer more and\nmore as battle\nprogresses!"),
|
||||
"When afflicted by\nTOXIC, POKéMON\nsuffer more and\nmore as battle\nprogresses!", nil, "OPP_KOGA#1"),
|
||||
}
|
||||
|
||||
-- scripts/SaffronGym.asm SaffronGymSabrinaText .afterBeat
|
||||
M.SAFFRON_GYM.talk = {
|
||||
TEXT_SAFFRONGYM_SABRINA = leaderTalk("EVENT_BEAT_SABRINA",
|
||||
"_SaffronGymSabrinaPostBattleAdviceText",
|
||||
"Everyone has\npsychic power!\nPeople just don't\nrealize it!"),
|
||||
"Everyone has\npsychic power!\nPeople just don't\nrealize it!", nil, "OPP_SABRINA#1"),
|
||||
}
|
||||
|
||||
-- scripts/CinnabarGym.asm CinnabarGymBlaineText .afterBeat
|
||||
M.CINNABAR_GYM.talk = {
|
||||
TEXT_CINNABARGYM_BLAINE = leaderTalk("EVENT_BEAT_BLAINE",
|
||||
"_CinnabarGymBlainePostBattleAdviceText",
|
||||
"FIRE BLAST is the\nultimate fire\ntechnique!"),
|
||||
"FIRE BLAST is the\nultimate fire\ntechnique!", nil, "OPP_BLAINE#1"),
|
||||
}
|
||||
|
||||
-- scripts/ViridianGym.asm ViridianGymGiovanniText .afterBeat: after the
|
||||
@@ -142,7 +165,7 @@ M.VIRIDIAN_GYM.talk = {
|
||||
"VIRIDIAN_GYM", "VIRIDIANGYM_GIOVANNI")
|
||||
end
|
||||
end, done))
|
||||
end),
|
||||
end, "OPP_GIOVANNI#3"),
|
||||
}
|
||||
|
||||
return M
|
||||
|
||||
@@ -800,9 +800,13 @@ M.SILPH_CO_11F = {
|
||||
-- line) would touch, and the whole Silph ending -- the flag, the Master
|
||||
-- Ball, the Saffron streets clearing -- silently never happened.
|
||||
--
|
||||
-- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and,
|
||||
-- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss
|
||||
-- sets nothing, so the trigger re-arms exactly as vanilla does.
|
||||
-- SilphCo11FDefaultScript orders it DisplayTextID TEXT_SILPHCO11F_GIOVANNI
|
||||
-- FIRST, then MoveSprite .GiovanniMovement: he speaks from behind the desk
|
||||
-- and only then walks the three tiles down. Moving him before the box made
|
||||
-- him cross the room in silence and deliver the speech point-blank (#869),
|
||||
-- so the box comes first here and engageTrainer skips its own battle text.
|
||||
-- victories.lua OPP_GIOVANNI#2 sets the event on a win; a loss sets
|
||||
-- nothing, so the trigger re-arms exactly as vanilla does.
|
||||
onStep = function(game, ow, x, y)
|
||||
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end
|
||||
if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end
|
||||
@@ -811,21 +815,28 @@ M.SILPH_CO_11F = {
|
||||
if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
|
||||
end
|
||||
if not gio or ow:trainerDefeated(gio) then return false end
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- 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
|
||||
ow:queueScript(silphAftermathRows())
|
||||
end
|
||||
end)
|
||||
end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._SilphCo11FGiovanniText
|
||||
or "Ah {PLAYER}!\nSo we meet again!",
|
||||
function()
|
||||
ow:scriptMove(gio, "down", 3, function()
|
||||
gio:facePlayer(ow.player)
|
||||
ow:engageTrainer(gio, function()
|
||||
-- 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
|
||||
ow:queueScript(silphAftermathRows())
|
||||
end
|
||||
end, nil, true)
|
||||
end)
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
onEnter = function(game, ow)
|
||||
@@ -1031,38 +1042,43 @@ local championsRoomRivalScript = {
|
||||
{ "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 } }, -- 11
|
||||
{ "show_text", "_ChampionsRoomOakText" }, -- 12
|
||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 13
|
||||
{ "move_npc", 2, "up", 5 }, -- 14 OakEntranceAfterVictoryMovement
|
||||
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in.
|
||||
-- audio/alternate_tempo.asm Music_Cities1AlternateTempo is not a plain
|
||||
-- PlayMusic: it fades the current song out (wAudioFadeOutControl = 10),
|
||||
-- waits 100 frames for the fade, then restarts Cities1 with channel 1
|
||||
-- pointed at Music_Cities1_Ch1_AlternateTempo -- `tempo 232` where the
|
||||
-- normal Music_Cities1_Ch1 opens `tempo 144`, i.e. the slower, heavier
|
||||
-- reading of the town theme this scene is known for (#847).
|
||||
{ "fade_music", 10 }, -- 11
|
||||
{ "wait", 100 }, -- 12
|
||||
{ "play_music", "Music_Cities1", { keep = true, tempo = 232 } }, -- 13
|
||||
{ "show_text", "_ChampionsRoomOakText" }, -- 14
|
||||
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 15
|
||||
{ "move_npc", 2, "up", 5 }, -- 16 OakEntranceAfterVictoryMovement
|
||||
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
|
||||
{ "face_object", 1, "left" }, -- 15
|
||||
{ "face_object", 2, "down" }, -- 16
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 17
|
||||
{ "face_object", 1, "left" }, -- 17
|
||||
{ "face_object", 2, "down" }, -- 18
|
||||
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19
|
||||
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
|
||||
{ "face_object", 2, "right" }, -- 18
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 19
|
||||
{ "face_object", 2, "right" }, -- 20
|
||||
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 21
|
||||
-- OakComeWithMeScript: Oak faces down again, then exits up
|
||||
{ "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
|
||||
{ "face_object", 2, "down" }, -- 22
|
||||
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23
|
||||
{ "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement
|
||||
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25
|
||||
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement.
|
||||
-- The player walks out after Oak instead of the screen just fading on the
|
||||
-- spot (#704). Route one tile right before walking north so the player
|
||||
-- reaches the north-wall HALL_OF_FAME warp without sharing the rival's
|
||||
-- (4,2) cell. The original simulated movement bypasses entity collision,
|
||||
-- but this scene should not visibly walk through the defeated rival.
|
||||
{ "move_player", "right", 1 }, -- 26
|
||||
{ "move_player", "up", 3 }, -- 27
|
||||
-- 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 }, -- 25
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 26
|
||||
{ "set_field", "pendingHallOfFame", true }, -- 28
|
||||
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 29
|
||||
}
|
||||
|
||||
M.CHAMPIONS_ROOM = {
|
||||
|
||||
@@ -151,9 +151,27 @@ M.POKEMON_TOWER_6F = {
|
||||
-- trick, and the speedrun route this bot follows depends on it.
|
||||
if result == "win" or battle.pokeDollEscape then
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!"))
|
||||
-- PokemonTower6FMarowakDepartedText (scripts/PokemonTower6F.asm)
|
||||
-- is two texts, not one: the CUBONE's-mother line first, then
|
||||
-- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants
|
||||
-- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the
|
||||
-- calmed line; the port dropped the first text and the cry
|
||||
-- (#867). play_cry arms the next show_text, so the cry rides
|
||||
-- the calmed box's open with the button prompt kept, and the
|
||||
-- wait row stands in for the asm's 30-frame gap.
|
||||
local rows = {
|
||||
{ "show_text", t._PokemonTower6FGhostWasCubonesMotherText
|
||||
or "The GHOST was the\nrestless soul of\vCUBONE's mother!" },
|
||||
{ "play_cry", "MAROWAK", true },
|
||||
{ "wait", 30 },
|
||||
{ "show_text", t._PokemonTower6FSoulWasCalmedText
|
||||
or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" },
|
||||
}
|
||||
if ow.runner then
|
||||
ow.runner:run(rows)
|
||||
elseif ow.queueScript then
|
||||
ow:queueScript(rows)
|
||||
end
|
||||
elseif result ~= "lose" then
|
||||
-- .did_not_defeat: one simulated step right, off the trigger,
|
||||
-- so fleeing does not leave you standing on a cell that
|
||||
@@ -517,6 +535,14 @@ M.GAME_CORNER = {
|
||||
done()
|
||||
return
|
||||
end
|
||||
-- GameCornerRocketText hands the battle its own loss line through
|
||||
-- SaveEndBattleTextPointers (.BattleEndText ->
|
||||
-- _GameCornerRocketBattleEndText, "Dang!"), and PrintEndBattleText
|
||||
-- prints it ON the battle screen between TrainerDefeatedText and
|
||||
-- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory).
|
||||
-- He is a text_asm trainer with no def_trainers header, so there is no
|
||||
-- header.won for engageTrainer to find and the line has to be handed
|
||||
-- over here or it never shows at all (#862).
|
||||
ow:engageTrainer(npc, function()
|
||||
if not ow:trainerDefeated(npc) then
|
||||
done()
|
||||
@@ -527,19 +553,44 @@ M.GAME_CORNER = {
|
||||
game.data.text._GameCornerRocketAfterBattleText
|
||||
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
|
||||
function()
|
||||
-- #198: GameCornerRocketExitScript (scripts/GameCorner.asm)
|
||||
-- ApplyMovementData walks the grunt one tile UP into the poster
|
||||
-- (the hideout's secret entrance at 9,4) before HideObject, so
|
||||
-- he leaves the floor rather than popping out of existence on
|
||||
-- (9,5). scriptMove locks player input (#scriptMoves>0) and
|
||||
-- ignores collision, so we despawn + unfreeze (done) only once
|
||||
-- the step lands.
|
||||
ow:scriptMove(npc, "up", 1, function()
|
||||
hideRocket()
|
||||
done()
|
||||
end)
|
||||
-- #198/#862: GameCornerRocketBattleScript (scripts/GameCorner.asm)
|
||||
-- picks the exit walk from where the player is standing, because
|
||||
-- the grunt on (9,5) has to get past him: wYCoord == 6 (talked to
|
||||
-- from the south) or wXCoord == 8 (from the west) leaves the row
|
||||
-- clear and takes GameCornerMovement_Rocket_WalkDirect, five steps
|
||||
-- RIGHT; otherwise the player is east of him on (10,5) and
|
||||
-- GameCornerMovement_Rocket_WalkAroundPlayer steps DOWN, right, UP
|
||||
-- and right again to go AROUND him. pokeyellow's copy of the
|
||||
-- around-path takes one extra RIGHT on the lower row before coming
|
||||
-- back up (it also has to clear Pikachu); both versions end on
|
||||
-- (15,5). He never steps UP: (9,4) is the poster wall, which is
|
||||
-- where the old single UP step sent him.
|
||||
local px = ow.player and ow.player.cellX
|
||||
local py = ow.player and ow.player.cellY
|
||||
local path
|
||||
if py == 6 or px == 8 then
|
||||
path = { { "right", 5 } }
|
||||
elseif require("src.core.GameVersion").isYellow() then
|
||||
path = { { "down", 1 }, { "right", 3 }, { "up", 1 }, { "right", 3 } }
|
||||
else
|
||||
path = { { "down", 1 }, { "right", 2 }, { "up", 1 }, { "right", 4 } }
|
||||
end
|
||||
-- GameCornerRocketExitScript only HideObjects him once
|
||||
-- BIT_SCRIPTED_NPC_MOVEMENT clears, i.e. after the last step.
|
||||
-- scriptMove locks player input (#scriptMoves>0) and ignores
|
||||
-- collision, so the despawn + unfreeze (done) ride the final step.
|
||||
local function step(i)
|
||||
if i > #path then
|
||||
hideRocket()
|
||||
done()
|
||||
return
|
||||
end
|
||||
ow:scriptMove(npc, path[i][1], path[i][2],
|
||||
function() step(i + 1) end)
|
||||
end
|
||||
step(1)
|
||||
end))
|
||||
end)
|
||||
end, game.data.text._GameCornerRocketBattleEndText or "Dang!")
|
||||
end,
|
||||
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
|
||||
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
|
||||
|
||||
@@ -13,9 +13,18 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- The question stays on screen under the YES/NO menu. The dojo prize
|
||||
-- balls are the clearest case: FightingDojoHitmonleePokeBallText
|
||||
-- (scripts/FightingDojo.asm) is `call PrintText` on a text_end string --
|
||||
-- no prompt, so no WaitForTextScrollButtonPress -- immediately followed
|
||||
-- by `call YesNoChoice`, and InitYesNoTextBoxParameters
|
||||
-- (engine/menus/text_box.asm) puts the menu above the dialogue box
|
||||
-- rather than replacing it. Ride TextBox's opts.choice, the same as
|
||||
-- Commands.ask, instead of popping the box with an A press and leaving a
|
||||
-- bare ChoiceBox over the overworld (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
-- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER})
|
||||
@@ -155,19 +164,26 @@ local function dojoBall(species, ownBall, otherBall, askKey)
|
||||
push(game, "You'll have to\nbeat the master\nfirst!", done)
|
||||
return
|
||||
end
|
||||
ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes)
|
||||
if not yes then done() return end
|
||||
flags["EVENT_GOT_" .. species] = true
|
||||
flags.EVENT_DEFEATED_FIGHTING_DOJO = true
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.give_pokemon(ctx, species, 30)
|
||||
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
|
||||
-- just the picked object's index) and routes to the greedy line above
|
||||
-- when talked to (#197).
|
||||
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
|
||||
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
|
||||
end)
|
||||
-- Examining a ball shows that species' POKéDEX entry first
|
||||
-- (DisplayPokedex in FightingDojo.asm, which also marks it seen),
|
||||
-- then the yes/no take-it prompt (#853).
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.mark_seen(ctx, species)
|
||||
local DexEntryMenu = require("src.ui.DexEntryMenu")
|
||||
game.stack:push(DexEntryMenu.new(game, species, function()
|
||||
ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes)
|
||||
if not yes then done() return end
|
||||
flags["EVENT_GOT_" .. species] = true
|
||||
flags.EVENT_DEFEATED_FIGHTING_DOJO = true
|
||||
Commands.give_pokemon(ctx, species, 30)
|
||||
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
|
||||
-- just the picked object's index) and routes to the greedy line above
|
||||
-- when talked to (#197).
|
||||
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
|
||||
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
|
||||
end)
|
||||
end))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ local function gift(opts)
|
||||
end
|
||||
end)
|
||||
end
|
||||
if opts.pre then say(opts.pre, "", give) else give() end
|
||||
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -118,11 +118,21 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
|
||||
},
|
||||
}
|
||||
|
||||
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher; no pre text)
|
||||
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
|
||||
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
|
||||
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
|
||||
-- leading underscore, and on Red it sits outside the extractor's symbol
|
||||
-- set, so the literal from text/ViridianCity.asm rides along as the
|
||||
-- fallback; Yellow resolves the ROM string instead.
|
||||
M.VIRIDIAN_CITY = {
|
||||
talk = {
|
||||
TEXT_VIRIDIANCITY_FISHER = gift({
|
||||
flag = "EVENT_GOT_TM42", item = "TM_DREAM_EATER",
|
||||
pre = "ViridianCityFisherYouCanHaveThisText",
|
||||
preFallback = "Yawn!\nI must have dozed\voff in the sun."
|
||||
.. "\fI had this dream\nabout a DROWZEE\veating my dream."
|
||||
.. "\vWhat's this?\vWhere did this TM\vcome from?"
|
||||
.. "\fThis is spooky!\nHere, you can\vhave this TM.",
|
||||
received = "_ViridianCityFisherReceivedTM42Text",
|
||||
explain = "_ViridianCityFisherTM42ExplanationText",
|
||||
noRoom = "_ViridianCityFisherTM42NoRoomText",
|
||||
|
||||
@@ -12,9 +12,13 @@ local function push(game, s, done)
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters,
|
||||
-- engine/menus/text_box.asm); no A press clears the question first. Ride
|
||||
-- TextBox's opts.choice, the same as Commands.ask (#854).
|
||||
local function ask(game, s, cb)
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
@@ -18,6 +18,16 @@
|
||||
-- script). Leaders are not def_trainers entries, so engageTrainer has
|
||||
-- no header.won -- checkVictoryRewards shows this chain instead of a
|
||||
-- synthetic "received badge/TM" stub.
|
||||
--
|
||||
-- Gym entries split the TM hand-over out of `dialogue`, mirroring the
|
||||
-- originals' GiveItem check (`call GiveItem` / `jr nc, .BagFull`):
|
||||
-- `tmPre` is the ReceiveTM script's lead-in (badge info / "Wait! Take
|
||||
-- this!"), shown at the victory and again when a beaten leader retries
|
||||
-- the hand-over; `tmDialogue` shows only when the TM actually goes in
|
||||
-- the bag; `noRoom` is the "make room" line shown instead when the bag
|
||||
-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a
|
||||
-- successful give, which is what makes the leader's talk script retry
|
||||
-- later (gyms.lua).
|
||||
|
||||
local function range(prefix, first, last)
|
||||
local t = {}
|
||||
@@ -33,6 +43,8 @@ return {
|
||||
-- escort NPC and the first Route 22 rival stay gone after the badge.
|
||||
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
|
||||
item = "TM_BIDE",
|
||||
gotFlag = "EVENT_GOT_TM34",
|
||||
noRoom = "_PewterGymTM34NoRoomText",
|
||||
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
|
||||
hide = {
|
||||
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||
@@ -41,69 +53,99 @@ return {
|
||||
dialogue = {
|
||||
"_PewterGymBrockReceivedBoulderBadgeText",
|
||||
"_PewterGymBrockBoulderBadgeInfoText",
|
||||
"_PewterGymBrockWaitTakeThisText",
|
||||
},
|
||||
tmPre = { "_PewterGymBrockWaitTakeThisText" },
|
||||
tmDialogue = {
|
||||
"_PewterGymReceivedTM34Text",
|
||||
"_TM34ExplanationText",
|
||||
} },
|
||||
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
|
||||
item = "TM_BUBBLEBEAM",
|
||||
gotFlag = "EVENT_GOT_TM11",
|
||||
noRoom = "_CeruleanGymMistyTM11NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
||||
dialogue = {
|
||||
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
||||
"_CeruleanGymMistyCascadeBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_CeruleanGymMistyCascadeBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_CeruleanGymMistyReceivedTM11Text",
|
||||
} },
|
||||
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
|
||||
item = "TM_THUNDERBOLT",
|
||||
gotFlag = "EVENT_GOT_TM24",
|
||||
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
||||
dialogue = {
|
||||
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
||||
"_VermilionGymLTSurgeThunderBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_VermilionGymLTSurgeThunderBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_VermilionGymLTSurgeReceivedTM24Text",
|
||||
"_TM24ExplanationText",
|
||||
} },
|
||||
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
|
||||
item = "TM_MEGA_DRAIN",
|
||||
gotFlag = "EVENT_GOT_TM21",
|
||||
noRoom = "_CeladonGymTM21NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
||||
dialogue = {
|
||||
"_CeladonGymErikaReceivedRainbowBadgeText",
|
||||
"_CeladonGymRainbowBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_CeladonGymRainbowBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_CeladonGymReceivedTM21Text",
|
||||
"_TM21ExplanationText",
|
||||
} },
|
||||
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
|
||||
item = "TM_TOXIC",
|
||||
gotFlag = "EVENT_GOT_TM06",
|
||||
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
||||
dialogue = {
|
||||
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
||||
"_FuchsiaGymKogaSoulBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_FuchsiaGymKogaSoulBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_FuchsiaGymKogaReceivedTM06Text",
|
||||
"_FuchsiaGymKogaTM06ExplanationText",
|
||||
} },
|
||||
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
|
||||
item = "TM_PSYWAVE",
|
||||
gotFlag = "EVENT_GOT_TM46",
|
||||
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
||||
dialogue = {
|
||||
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
||||
"_SaffronGymSabrinaMarshBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_SaffronGymSabrinaMarshBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_SaffronGymSabrinaReceivedTM46Text",
|
||||
"_TM46ExplanationText",
|
||||
} },
|
||||
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
|
||||
item = "TM_FIRE_BLAST",
|
||||
gotFlag = "EVENT_GOT_TM38",
|
||||
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
||||
dialogue = {
|
||||
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
||||
"_CinnabarGymBlaineVolcanoBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_CinnabarGymBlaineVolcanoBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_CinnabarGymBlaineReceivedTM38Text",
|
||||
"_CinnabarGymBlaineTM38ExplanationText",
|
||||
} },
|
||||
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
|
||||
item = "TM_FISSURE",
|
||||
gotFlag = "EVENT_GOT_TM27",
|
||||
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
||||
dialogue = {
|
||||
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
||||
"_ViridianGymGiovanniEarthBadgeInfoText",
|
||||
},
|
||||
tmPre = { "_ViridianGymGiovanniEarthBadgeInfoText" },
|
||||
tmDialogue = {
|
||||
"_ViridianGymGiovanniReceivedTM27Text",
|
||||
"_ViridianGymGiovanniTM27ExplanationText",
|
||||
} },
|
||||
|
||||
@@ -62,10 +62,15 @@ M.MT_MOON_B2F = {
|
||||
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
|
||||
{ "face_object", 6, "left" },
|
||||
{ "show_text", "_MtMoonJessieJamesText2" },
|
||||
-- MtMoonB2FScript12 arms _MtMoonJessieJamesText3 with
|
||||
-- SaveEndBattleTextPointers before it sets wCurOpponent, so
|
||||
-- TrainerBattleVictory prints it on the battle screen as "ROCKET: A
|
||||
-- brat beat us?" between TrainerDefeatedText and MoneyForWinningText.
|
||||
-- Its one-word first line only reads right behind that tag (#866).
|
||||
{ "save_end_battle_text", "_MtMoonJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_MtMoonJessieJamesText3" },
|
||||
{ "show_text", "_MtMoonJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
@@ -85,7 +90,8 @@ M.MT_MOON_B2F = {
|
||||
-- motto plays from off-screen FIRST, then the duo pops in at (25,10) /
|
||||
-- (24,10) and whichever of them shares the player's column ($18=24 or
|
||||
-- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three
|
||||
-- tiles down to loom over the player while the other steps one. A loss
|
||||
-- tiles down to loom over the player while the other walks four and ends
|
||||
-- up beside him. A loss
|
||||
-- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the
|
||||
-- trigger re-arms clean.
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -106,7 +112,7 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end
|
||||
-- ON_LEFT: player under James's column (25); movement data pairs
|
||||
-- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the
|
||||
-- column-mate walks 3, the other 1.
|
||||
-- column-mate walks 3, the other 4.
|
||||
local onLeft = (x == 25)
|
||||
ow.runner:run({
|
||||
{ "stop_music" },
|
||||
@@ -116,16 +122,30 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
{ "emote", "player", "shock", 30 },
|
||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" },
|
||||
{ "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" },
|
||||
-- James (object 2) then Jessie (object 3), Script4..Script9 order
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
|
||||
-- James (object 2) then Jessie (object 3), Script4..Script9 order.
|
||||
-- RocketHideoutB4FJessieJamesMovementData_45605 is a lone $4 that FALLS
|
||||
-- THROUGH into _45606 ($4 $4 $4 $ff), so MoveSprite_ (home/pathfinding.asm)
|
||||
-- reads _45605 as FOUR steps and _45606 as three; $4 is DOWN in Yellow's
|
||||
-- Func_5288 lookup (engine/overworld/movement.asm), which walks with no
|
||||
-- collision test. From (25,10)/(24,10) against a player on y=14 the
|
||||
-- column-mate stops three down, right above him, and the other walks the
|
||||
-- full four to stand alongside -- which is what the facings below assume.
|
||||
-- Reading _45605 as a single step stranded whoever was off-column three
|
||||
-- tiles away, so James never reached the player (#865).
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" }
|
||||
or { "down", "down", "down", "down" } },
|
||||
{ "face_object", 2, onLeft and "down" or "left" },
|
||||
{ "walk_npc", 3, onLeft and { "down" } or { "down", "down", "down" } },
|
||||
{ "walk_npc", 3, onLeft and { "down", "down", "down", "down" }
|
||||
or { "down", "down", "down" } },
|
||||
{ "face_object", 3, onLeft and "right" or "down" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText2" },
|
||||
-- RocketHideoutB4FScript10 saves _RocketHideoutJessieJamesText3 as the
|
||||
-- end-battle text, so it prints as "ROCKET: Such a dreadful twerp!" on
|
||||
-- the battle screen ahead of MoneyForWinningText (#866).
|
||||
{ "save_end_battle_text", "_RocketHideoutJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 43 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "lost" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText3" },
|
||||
{ "show_text", "_RocketHideoutJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
@@ -175,16 +195,27 @@ M.POKEMON_TOWER_7F = {
|
||||
{ "show_text", "_PokemonTowerJessieJamesText1" },
|
||||
{ "face_player_dir", "up" },
|
||||
{ "emote", "player", "shock", 30 },
|
||||
-- Jessie (object 1) then James (object 2), Script1..Script6 order
|
||||
{ "walk_npc", 1, onLeft and { "down" } or { "down", "down", "down" } },
|
||||
-- Jessie (object 1) then James (object 2), Script1..Script6 order.
|
||||
-- Same fall-through blob as the hideout: PokemonTower7FMovementData_60d7a
|
||||
-- is a lone $4 running into _60d7b ($4 $4 $4 $FF), so _60d7a is FOUR
|
||||
-- steps and _60d7b is three. From (10,8)/(11,8) against a player on
|
||||
-- y=12 the column-mate halts one tile above him and the other closes the
|
||||
-- full four to his side; the single-step reading is why James only
|
||||
-- "moved a bit" here (#865).
|
||||
{ "walk_npc", 1, onLeft and { "down", "down", "down", "down" }
|
||||
or { "down", "down", "down" } },
|
||||
{ "face_object", 1, onLeft and "right" or "down" },
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } },
|
||||
{ "walk_npc", 2, onLeft and { "down", "down", "down" }
|
||||
or { "down", "down", "down", "down" } },
|
||||
{ "face_object", 2, onLeft and "down" or "left" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText2" },
|
||||
-- PokemonTower7FScript7 saves _PokemonTowerJessieJamesText3 as the
|
||||
-- end-battle text: "ROCKET: You will regret this!" on the battle screen,
|
||||
-- before the prize money (#866).
|
||||
{ "save_end_battle_text", "_PokemonTowerJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 44 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText3" },
|
||||
{ "show_text", "_PokemonTowerJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
@@ -254,10 +285,12 @@ M.SILPH_CO_11F = {
|
||||
{ "walk_npc", 6, jessieDirs },
|
||||
{ "face_object", 6, jessieFace },
|
||||
{ "show_text", "_SilphCoJessieJamesText2" },
|
||||
-- SilphCo11FScript11 saves _SilphCoJessieJamesText3 (SilphCo11FText_624c2)
|
||||
-- as the end-battle text: "ROCKET: Like always..." before the money (#866).
|
||||
{ "save_end_battle_text", "_SilphCoJessieJamesText3" },
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 45 },
|
||||
{ "check_battle_result", "win" },
|
||||
{ "jump_if_false", "end" },
|
||||
{ "show_text", "_SilphCoJessieJamesText3" },
|
||||
{ "show_text", "_SilphCoJessieJamesText4" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetJessieJames" },
|
||||
|
||||
@@ -38,7 +38,7 @@ the same core data and graphics into the source tree for verification.
|
||||
| | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir |
|
||||
| render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling |
|
||||
| | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring |
|
||||
| | `src/render/SpriteRenderer.lua` | 6-frame walker sheets, flipped right facing |
|
||||
| | `src/render/SpriteRenderer.lua` | variable-size anchored sprite sheets, 6-frame walkers and flipped right facing |
|
||||
| | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) |
|
||||
| | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page |
|
||||
| | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades |
|
||||
|
||||
@@ -234,8 +234,12 @@ What was ported from pokered's engine code and where it came from.
|
||||
pre-battle text and engages the leader battle (badge/TM via
|
||||
data/scripts/victories.lua); post-badge talk prints the leader's
|
||||
post-battle advice text (Misty's is her TM11 explanation). The
|
||||
originals' middle branch (beaten but TM not handed over) is
|
||||
unreachable since the TM is granted with the victory. Giovanni's
|
||||
originals' middle branch (beaten but TM not handed over,
|
||||
CheckEventReuseA EVENT_GOT_TM*) is ported too: the victory's GiveItem
|
||||
goes through the bag's capacity check, a full bag shows the leader's
|
||||
"make room" text instead of the received lines and leaves
|
||||
EVENT_GOT_TM* unset, and talking to the leader re-runs the ReceiveTM
|
||||
script until the TM goes in (#797). Giovanni's
|
||||
farewell (`ViridianGymGiovanniText` .afterBeat) hides him inside a
|
||||
fade-to-black/fade-in Transition matching ViridianGym.asm's
|
||||
GBFadeOutToBlack → HideObject → GBFadeInFromBlack, persisted
|
||||
|
||||
@@ -153,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
|
||||
|
||||
@@ -184,11 +195,13 @@ through `src/import/SaveFileIO.lua`, which sits on top of
|
||||
(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/<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)`).
|
||||
writes `exports/<version>/gen1recomp-<version>-<slotId>.sav` under the same
|
||||
root `persistFs` writes slots to: the portable game folder when `portable.txt`
|
||||
marks the install, otherwise the save directory (`exports/` and
|
||||
`exports/<version>/` are created as needed; #752). On desktop it returns the
|
||||
absolute path (`SaveData.portableBaseDir()` when portable, else
|
||||
`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`
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# Linux arm64 (aarch64) AppImage
|
||||
|
||||
Releases ship `gen1recomp-<version>-linux-arm64.AppImage` alongside the
|
||||
existing x86_64 `gen1recomp-<version>-linux.zip`. It targets 64-bit ARM
|
||||
desktop Linux: Raspberry Pi 4/5 running Raspberry Pi OS, Armbian and other
|
||||
SBC distros, arm64 VMs on Apple Silicon, Ampere/Graviton desktops, and the
|
||||
aarch64 handhelds that run a full distro.
|
||||
|
||||
> The Anbernic RG34XXSP has its own PortMaster-style pack
|
||||
> (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see
|
||||
> [anbernic-rg34xxsp.md](anbernic-rg34xxsp.md)). That one bundles PortMaster's
|
||||
> LÖVE runtime and expects the device's own SDL; this AppImage is the generic
|
||||
> desktop-Linux artifact and shares nothing with it but the `game.love`.
|
||||
|
||||
## For players
|
||||
|
||||
```sh
|
||||
chmod +x gen1recomp-*-linux-arm64.AppImage
|
||||
./gen1recomp-*-linux-arm64.AppImage
|
||||
```
|
||||
|
||||
Then use **Import ROM** in the launcher to point it at your own legal Red /
|
||||
Blue / Yellow cartridge dump, exactly as on every other platform.
|
||||
|
||||
If your system has no FUSE (`dlopen(): error loading libfuse.so.2`), either
|
||||
install it (`sudo apt install libfuse2`) or run without it:
|
||||
|
||||
```sh
|
||||
./gen1recomp-*-linux-arm64.AppImage --appimage-extract-and-run
|
||||
```
|
||||
|
||||
### What the host has to provide
|
||||
|
||||
Very little, and this is enforced by an assertion in the build rather than by
|
||||
good intentions. The only libraries the AppImage requires at startup are:
|
||||
|
||||
```
|
||||
glibc 2.29+ libstdc++ libfreetype6 zlib
|
||||
```
|
||||
|
||||
Everything else — OpenGL/Mesa, X11, Wayland, KMSDRM, ALSA, PulseAudio — is
|
||||
**dlopened**, so it is used when present and skipped when absent. That means
|
||||
one image runs on a full desktop, on a Wayland-only session, on a
|
||||
KMSDRM-only handheld with no X server, and on a box with ALSA but no
|
||||
PulseAudio, without a different build for each.
|
||||
|
||||
That property does not come for free from Debian's packages, and getting it
|
||||
is most of what the build below is doing; see
|
||||
[Why five libraries are built from source](#why-five-libraries-are-built-from-source).
|
||||
|
||||
## For builders
|
||||
|
||||
```sh
|
||||
scripts/build_linux_arm64.sh --version 0.1.0
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage
|
||||
dist/linux-arm64/gen1recomp-<version>-linux-arm64.AppImage.sha256
|
||||
```
|
||||
|
||||
Useful flags: `--game-love PATH` reuses an already-packed payload (CI does
|
||||
this so every platform ships identical bytes), `--rebuild-image` forces the
|
||||
builder container to rebuild, `--clean-cache` throws away the pinned
|
||||
downloads and the compiled LÖVE prefix.
|
||||
|
||||
### Requirements
|
||||
|
||||
An **aarch64 host** with **docker or podman**. A Raspberry Pi 5 is the
|
||||
reference machine (a cold build takes about 10 minutes on one — six libraries
|
||||
plus the engine; rebuilds reuse the cached prefix and take seconds). Apple Silicon with Docker
|
||||
Desktop and GitHub's `ubuntu-24.04-arm` runner both work too.
|
||||
|
||||
The script refuses to run on x86_64 rather than falling back to qemu-user
|
||||
emulation: that path takes hours and has produced miscompiled LuaJIT.
|
||||
|
||||
### Why this is not just another `scripts/build.sh` target
|
||||
|
||||
`scripts/build.sh linux` downloads LÖVE's official `love-11.5-x86_64.AppImage`,
|
||||
unpacks its squashfs, drops `game.love` in, and glues it back together. That
|
||||
trick is not available here — **LÖVE publishes no aarch64 binary at all.** The
|
||||
11.5 release has win32, win64, macOS, Android, iOS and one x86_64 AppImage,
|
||||
and that is the entire list.
|
||||
|
||||
So this build compiles LÖVE 11.5 from the official `linux-src` tarball and
|
||||
assembles the AppImage from scratch. Every pinned input — the LÖVE source, the
|
||||
five libraries built alongside it, and the AppImage type-2 runtime — is
|
||||
SHA-256 verified on the host before the container ever sees it, and the
|
||||
container itself runs with no network access.
|
||||
|
||||
### Why the build happens in a Debian bullseye container
|
||||
|
||||
glibc is backward compatible but not forward compatible: a binary linked
|
||||
against glibc 2.41 will not start on a system with 2.31, and there is no way
|
||||
to fix that after the fact. Compiling on the oldest base we support is
|
||||
therefore the only thing that makes one artifact work everywhere.
|
||||
|
||||
Bullseye (glibc 2.31) is that base. The resulting binaries actually come out
|
||||
needing only **glibc 2.29** and **GLIBCXX_3.4.21**, so the AppImage covers
|
||||
everything from Ubuntu 20.04 and Raspberry Pi OS bullseye through current
|
||||
trixie.
|
||||
|
||||
This is a statement about the *compile environment*, not about where the
|
||||
artifact runs — building on your own newer distro would silently raise that
|
||||
floor and strand every user on an older one, with no symptom until they
|
||||
download it. CI enforces the floor: `linux-arm64-build` fails if the highest
|
||||
required glibc symbol version climbs above 2.31.
|
||||
|
||||
### Why five libraries are built from source
|
||||
|
||||
SDL2, OpenAL, libtheora, libogg/libvorbis and libmpg123 are compiled rather
|
||||
than installed from bullseye. In every case the reason is *correctness*, not
|
||||
a newer version number — Debian builds these for a system where every
|
||||
dependency is installed and co-versioned, which is the opposite of an
|
||||
AppImage's situation. Each one broke the build in a different way, and all
|
||||
three failure modes are now assertions that fail the build instead of
|
||||
shipping.
|
||||
|
||||
**1. Hard-linked backends (SDL2, OpenAL).** Debian's `libSDL2` lists
|
||||
`libpulse`, `libasound`, `libX11` and `libwayland-client` as `DT_NEEDED` —
|
||||
resolved by the loader at startup, not dlopened. An AppImage bundling it
|
||||
refuses to start unless the host has *all four*. It appeared to work in
|
||||
testing only because a desktop Pi has all four; a headless CI runner is what
|
||||
exposed it. Debian's OpenAL does the same via `libsndio`, which itself
|
||||
hard-links `libasound`. Built from source with `--enable-*-shared` and
|
||||
`ALSOFT_DLOPEN`, both dlopen their backends instead.
|
||||
|
||||
**2. A stray link (libtheora).** Debian's `libtheoradec.so.1` is linked
|
||||
against `libcairo.so.2` — a packaging artifact, since a video decoder has no
|
||||
business drawing vector graphics — and cairo drags in X11, xcb, fontconfig
|
||||
and freetype. `--disable-examples` produces a `libtheoradec` needing only
|
||||
`libogg`.
|
||||
|
||||
**3. SONAME collision with the host (ogg, vorbis, mpg123).** The subtle one.
|
||||
OpenAL dlopens ALSA, ALSA's config loads its PulseAudio hook plugin, and that
|
||||
plugin pulls the *host's* `libsndfile` into our process. `libsndfile` links
|
||||
`libogg`, `libvorbis` and `libmpg123` — the same three we bundle. The loader
|
||||
resolves a SONAME exactly once per process, so the host's `libsndfile` binds
|
||||
to *our* copies:
|
||||
|
||||
```
|
||||
openal -> libasound -> libasound_module_conf_pulse -> libsndfile (host, new)
|
||||
`-> mpg123_info2 -> libmpg123 (ours, bullseye 1.26)
|
||||
```
|
||||
|
||||
`mpg123_info2` arrived in mpg123 1.32, so the plugin failed to relocate, ALSA
|
||||
config collapsed, and the game ran with **no audio device at all**. Not
|
||||
bundling these instead would make `libogg`/`libvorbis`/`libmpg123` mandatory
|
||||
host packages; building them current means our copies *satisfy* the host's
|
||||
`libsndfile` rather than starving it.
|
||||
|
||||
The same collision is why the font stack — freetype, fontconfig, libpng,
|
||||
brotli, zlib — is left to the host entirely. Bundling a bullseye freetype
|
||||
2.10.4 meant a host `libcairo` could not find `FT_Get_Transform` (added in
|
||||
2.11) and the game died at startup. Leaving the whole stack to the host keeps
|
||||
it self-consistent, while `liblove` — compiled against 2.10.4 — only ever
|
||||
asks for symbols every supported host already has.
|
||||
|
||||
The general rule this all reduces to: **never bundle a library the host's own
|
||||
stack may also load, unless yours is at least as new as theirs.**
|
||||
|
||||
### CI
|
||||
|
||||
Three jobs, path-gated on `scripts/build_linux_arm64.sh`,
|
||||
`scripts/linux-arm64/`, `scripts/pack_love.sh` and this document:
|
||||
|
||||
- **`linux-arm64-selftest`** (`ubuntu-latest`, x86_64) — offline gate. Checks
|
||||
the pins are real digests on a dated tag rather than the moving
|
||||
`continuous` one, that the Dockerfile still builds on bullseye, that the
|
||||
exclude list still classifies known sonames correctly, that AppRun still
|
||||
launches `game.love` with `--fused`, and that the host-arch guard actually
|
||||
fires. Needs no container and no arm64 machine.
|
||||
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts
|
||||
the artifact and asserts the layout, that every bundled object resolves
|
||||
under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31.
|
||||
Uploads the AppImage for 7 days.
|
||||
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
|
||||
`game.love` from the `love-payload` job, and the AppImage is staged and
|
||||
published like every other release asset.
|
||||
|
||||
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
|
||||
it runs on fork PRs too.
|
||||
|
||||
### Updating the pins
|
||||
|
||||
Both pins live in `scripts/linux-arm64/common.sh`:
|
||||
|
||||
- `LOVE_VERSION` / `LOVE_SRC_SHA256` — bumping any version invalidates the
|
||||
cached prefix automatically (its name is keyed by every source version at
|
||||
once, so a partial rebuild cannot mix vintages). Check that bullseye still
|
||||
has `-dev` packages new enough for the new release; `build_appimage.sh`
|
||||
asserts every optional module actually linked, because LÖVE's `configure`
|
||||
exits 0 and silently drops a module when one is missing.
|
||||
- `SDL2_*`, `OPENAL_*`, `THEORA_*`, `OGG_*`, `VORBIS_*`, `MPG123_*` — the
|
||||
source-built libraries. Bumping these is usually safe and occasionally
|
||||
necessary: `libmpg123` in particular must stay at least as new as what a
|
||||
target host's `libsndfile` expects, which is asserted for `mpg123_info2`.
|
||||
- `APPIMAGE_RUNTIME_TAG` / `APPIMAGE_RUNTIME_SHA256` — always a dated tag
|
||||
from [AppImage/type2-runtime](https://github.com/AppImage/type2-runtime/releases).
|
||||
The selftest fails the build if this ever points at `continuous`.
|
||||
@@ -35,6 +35,16 @@ An edited vanilla map becomes a `mod.content.maps:patch` carrying only the
|
||||
fields that moved; a new map becomes a `:register`. See
|
||||
`docs/new-features.md` and the extension's own README.
|
||||
|
||||
## Read-only map overviews
|
||||
|
||||
`mod.world:mapOverview()` returns collision `rows` at map-cell resolution,
|
||||
optional visual `tileRows` at 2x resolution, and optional `tileDetailRows` at
|
||||
4x resolution. Visual rows contain Game Boy shades from `"0"` (lightest) to
|
||||
`"3"` (darkest); their matching width and height fields describe the grid.
|
||||
`markers` contains active `{ kind, x, y }` points in map-cell coordinates for
|
||||
`warp`, visible `item`, and untaken `hidden` locations. All fields are
|
||||
read-only snapshots; mods choose which layers to render.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
Most registries hand the engine *content*. `render_pipelines` hands it
|
||||
@@ -100,6 +110,44 @@ Three rules worth knowing:
|
||||
Returning `nil` from `drawWorld` is a normal answer meaning "not this
|
||||
frame"; the engine draws the vanilla world instead.
|
||||
|
||||
## Variable-size overworld sprites
|
||||
|
||||
The `sprites` registry keeps the vanilla 16x16 grounded walker as its default,
|
||||
but a mod can describe any frame rectangle and anchor for player characters,
|
||||
NPCs, followers, mounts, vehicles, bosses, or other field actors:
|
||||
|
||||
```lua
|
||||
mod.content.sprites:register("SPRITE_COMPANION", {
|
||||
image = "mods/example/companion.png", -- one frame per row
|
||||
frames = 6,
|
||||
walker = true,
|
||||
frameWidth = 32,
|
||||
frameHeight = 32,
|
||||
anchorX = 16, -- frame-relative bottom-center anchor
|
||||
anchorY = 32,
|
||||
})
|
||||
```
|
||||
|
||||
`frameWidth` and `frameHeight` are sheet pixels. `anchorX` and `anchorY` are
|
||||
measured from each frame's top-left; when omitted they default to the frame's
|
||||
horizontal center and bottom edge, so a larger sprite grows upward while its
|
||||
feet stay on the same world cell. Omitting all four fields is exactly the
|
||||
vanilla 16x16 placement. The normal player/NPC/follower draw paths consume
|
||||
these values automatically, including horizontal flips and the fishing pose.
|
||||
|
||||
Custom render pipelines can use the same geometry without reproducing the
|
||||
pose rules:
|
||||
|
||||
```lua
|
||||
local geometry = sprite:getPoseGeometry(facing, walkPhase, stepFlip)
|
||||
-- geometry.quad, .x/.y/.width/.height, .anchorX/.anchorY, .mirror
|
||||
local originX, originY = sprite:getScreenOrigin(px, py, camX, camY)
|
||||
```
|
||||
|
||||
`getFrameGeometry(frame)` is the corresponding accessor for a specific
|
||||
zero-based sheet frame. Both accessors return fresh tables and share the
|
||||
renderer’s frame selection and mirror conventions.
|
||||
|
||||
## Battle sprite scaling
|
||||
|
||||
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
|
||||
@@ -140,6 +188,90 @@ default** (1x front, 2x back).
|
||||
ball-to-pic grow multiplies your scale through each stage, so a rescaled
|
||||
mon still grows into place from the ball, grounded the whole way.
|
||||
|
||||
## Durable tool storage and runtime checkpoints
|
||||
|
||||
`mod.save` remains the right place for state that should travel with the next
|
||||
normal Pokémon SAVE. Tools that need independently written, larger data-only
|
||||
records can use `mod.storage`; the engine scopes every logical key by game
|
||||
version, opaque playthrough identity, and mod id, and routes it through the same
|
||||
standard or portable persistence backend as saves:
|
||||
|
||||
```lua
|
||||
local context, code, message = mod.storage:context(game)
|
||||
local ok, code, message = mod.storage:write(game, "history/quick/q0001", {
|
||||
format = 1, createdAt = os.time(), payload = { money = 3000 },
|
||||
})
|
||||
local value, code, message = mod.storage:read(game, "history/quick/q0001")
|
||||
local keys, code, message = mod.storage:list(game, "history/quick")
|
||||
local deleted, code, message = mod.storage:delete(game, "history/quick/q0001")
|
||||
```
|
||||
|
||||
`context` returns `{ engineVersion, gameVersion, playthroughId }`. The engine
|
||||
version is compatibility metadata; physical launcher-slot and path identity stays
|
||||
private.
|
||||
|
||||
Values must be tables containing serializable data only. Keys are conservative
|
||||
slash-separated segments (letters, digits, `_`, `-`); paths and filesystem
|
||||
handles are never exposed. Writes are staged and decode-verified, reads recover
|
||||
from a valid staged/backup generation, and methods return structured errors for
|
||||
normal data or I/O failures. The playthrough identity is allocated lazily on the
|
||||
first storage/checkpoint call, so an unused API changes no save bytes.
|
||||
|
||||
`mod.checkpoints` captures and reconstructs engine-owned semantic runtime state:
|
||||
|
||||
```lua
|
||||
local capability = mod.checkpoints:inspect(game)
|
||||
if capability.canCapture then
|
||||
local checkpoint, code, message = mod.checkpoints:capture(game)
|
||||
-- Store the detached data-only checkpoint through mod.storage.
|
||||
end
|
||||
|
||||
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
|
||||
```
|
||||
|
||||
Checkpoint format 1 supports settled overworld control and proven battle
|
||||
player-decision safe points. Battle checkpoints are limited to ordinary
|
||||
single-player wild/trainer origins with no suspended script; link, Safari,
|
||||
ghost, demo, scripted, animation, message, queue, and forced-action phases fail
|
||||
closed. New checkpoints preserve gameplay RNG, while legacy overworld records
|
||||
without RNG remain loadable. Capture excludes global options and runtime
|
||||
objects. Restore validates format, game/playthrough identity, content,
|
||||
coordinates, battle relationships, continuation, and RNG before mutation;
|
||||
preserves current options; suppresses normal map-entry/save-load/intro side
|
||||
effects; verifies a recapture; and rolls back runtime plus RNG in memory if
|
||||
reconstruction fails. Callers that need crash recovery should durably capture
|
||||
their own recovery checkpoint before restore.
|
||||
|
||||
Checkpoint ownership follows the persistence model rather than mod identity:
|
||||
|
||||
- canonical `game.save` progress, including every mod's `save.modData` /
|
||||
`mod.save` bucket and data-only fields added to saved Pokémon, rewinds;
|
||||
- global and per-mod options remain at their current values;
|
||||
- independently written `mod.storage` records do not rewind; and
|
||||
- mod-owned runtime objects, references, and caches are never serialized.
|
||||
|
||||
Successful restore emits `checkpoint.restored` only after reconstruction and
|
||||
differential recapture have committed. Mods that cache rewound progress or hold
|
||||
references to reconstructed runtime objects can re-read their own public state
|
||||
and rebuild at that point:
|
||||
|
||||
```lua
|
||||
mod.events:on("checkpoint.restored", function(ev)
|
||||
-- ev.kind is "overworld" or "battle"; ev.game is fully reconstructed.
|
||||
cachedQuestStage = mod.save:get("quest_stage", 0)
|
||||
rebuildRuntimeFor(ev.game, ev.kind)
|
||||
end)
|
||||
```
|
||||
|
||||
The event is not emitted for validation failure, failed reconstruction, or a
|
||||
successful rollback. Its payload contains no checkpoint data or other mod's
|
||||
private state. A mod that deliberately stores progress-coupled truth in
|
||||
`mod.storage` must version and reconcile that relationship itself; the engine
|
||||
cannot distinguish it safely from independent history, configuration, or cache
|
||||
data.
|
||||
|
||||
See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes.
|
||||
|
||||
## Developer console
|
||||
|
||||
Boot with developer mode on to unlock the in-game console and hot-reload
|
||||
@@ -174,6 +306,32 @@ It runs immediately before queued button edges are promoted, so input added by
|
||||
the wrapper is visible during that same fixed step. The callback receives
|
||||
`(next, game, dt)` and must call `next(game, dt)`.
|
||||
|
||||
`input.pointer` delivers uncaptured gameplay pointer events -- touches and
|
||||
real mouse input alike. The callback receives `(next, game, ev)` where `ev`
|
||||
is `{ phase, source, id, x, y, dx, dy, pressure, button }`: `phase` is
|
||||
`"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"`
|
||||
or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates
|
||||
are LOVE window units, the same space `render.hud`'s viewport and the touch
|
||||
overlay lay out in. The on-screen touch controls keep first refusal: a
|
||||
pointer that begins on a virtual control belongs to the pad for its whole
|
||||
lifecycle and never reaches the hook, while one that begins outside stays
|
||||
visible even if it later crosses a control. A real mouse reaches the hook
|
||||
without `POKEPORT_TOUCH` (synthesized `istouch` mouse twins are dropped, so
|
||||
a mobile touch fires once), and focus or visibility loss and input recovery
|
||||
deliver a `"cancelled"` for every pointer the hook saw pressed but not yet
|
||||
released. Return `true` without calling `next` to consume the event.
|
||||
|
||||
`mod.input` presses GB buttons source-safely. `mod.input:tap(game, btn)`
|
||||
queues exactly one `wasPressed` edge for the next fixed step and holds
|
||||
nothing; `local token = mod.input:press(game, btn)` holds the button until
|
||||
`mod.input:release(token)`. Buttons are `up`, `down`, `left`, `right`, `a`,
|
||||
`b`, `start` and `select`. Every press is its own input source inside the
|
||||
engine's multi-source bookkeeping, so releasing a token never clears a hold
|
||||
the keyboard, a controller, the touch overlay or another mod still owns;
|
||||
`release` is idempotent and refuses tokens taken by another mod.
|
||||
Outstanding tokens are released automatically on entry-chunk rollback, hot
|
||||
reload and input recovery.
|
||||
|
||||
`ui.title_menu.items` receives `(next, game, items)` and follows the same
|
||||
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
|
||||
for a tool to offer a fresh-session action before gameplay begins.
|
||||
@@ -195,10 +353,76 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
|
||||
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
|
||||
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
|
||||
palette-correct blit of either canvas into an arbitrary screen rect, and the
|
||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`)
|
||||
for driving a second physical display. This is what lets a mod lay the two
|
||||
passes out as two stacked Game Boy screens, or push one onto a second screen,
|
||||
without the engine knowing the layout.
|
||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` /
|
||||
`setEnabled`) for driving a second physical display. `pollTouch()` returns the
|
||||
oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
|
||||
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
|
||||
or push one onto a second screen, without the engine knowing the layout.
|
||||
|
||||
`screen.render_visible` receives `(next, state)` while the main screen is being
|
||||
composed. Return `false` to omit that state from drawing, opacity selection and
|
||||
palette-zone ownership. The state remains on the stack and keeps its normal
|
||||
update and input ownership, so a mod can mirror a native menu on another
|
||||
display without reimplementing it. The default is `true`. Treat the wrapper as
|
||||
a pure predicate: the renderer may ask it more than once per frame.
|
||||
|
||||
Scrollable list states expose `state.kind` for use with this hook. Generic
|
||||
lists fall back to their title; PC lists use stable, localization-independent
|
||||
identifiers: `pc_box_withdraw`, `pc_box_deposit`, `pc_box_release`,
|
||||
`pc_box_change`, `pc_item_withdraw`, `pc_item_deposit`, and `pc_item_toss`.
|
||||
|
||||
`battle.bottom_ui_visible` and `battle.status_hud_visible` independently
|
||||
control the battle text/menu layer and the HP/status panels. Both receive
|
||||
`(next, state)` and default to `true`, so vanilla rendering is unchanged.
|
||||
Pushed text boxes also pass through `battle.bottom_ui_visible`; a wrapper that
|
||||
only owns battle presentation should return `false` only for its active battle
|
||||
or text-box state.
|
||||
|
||||
`core.logic_speed` receives `(next, game)` once per `Game:logicSpeed()` call
|
||||
(once per frame). Vanilla behavior resolves the per-category GAME SPEED
|
||||
option (`GameSpeed.CATEGORIES`: overworld/battle/menu) for whichever
|
||||
category `Game.speedCategoryInStack` says is active right now. A mod may
|
||||
call `next(game)` and return its result to pass that resolution through, or
|
||||
return a different number outright to override it for that frame (a bot mod
|
||||
forcing 1X for one route segment, say, regardless of the category or saved
|
||||
option). The result is clamped to the nearest valid `GameSpeed.LEVELS` entry
|
||||
regardless of what a subscriber returns, so a bad value (0, negative, `nil`)
|
||||
cannot destabilize the fixed-step accumulator. This hook runs *after* link
|
||||
play's 1X lock and the `--speed`/equivalent run-argument override, both of
|
||||
which stay unconditional and are never visible to a subscriber.
|
||||
|
||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||
that reach outside their permission set.
|
||||
|
||||
## Process-lifecycle hooks
|
||||
|
||||
These exist so a platform-specific launcher integration (a native shell
|
||||
that embeds this engine and wraps its window in platform UI) can live
|
||||
entirely in a mod instead of hand-patching `main.lua`, which every other
|
||||
engine change also touches.
|
||||
|
||||
`core.update` receives `(next, game, dt)` once per frame from
|
||||
`love.update`. Vanilla behavior is `game:update(dt)`, unconditionally. A
|
||||
mod may skip calling `next(game, dt)` to pause the simulation for that
|
||||
frame (e.g. while a native settings sheet is on top), and may run
|
||||
additional per-frame polling before or after that call regardless of
|
||||
whether it calls `next` -- useful for one-shot flags that must be observed
|
||||
every frame even while paused.
|
||||
|
||||
`core.quit_to_launcher` receives `(next)` once from `love.quit()`. `next()`
|
||||
returns the engine's own decision for whether closing the window should
|
||||
return to the Lua launcher instead of exiting; a mod may return `false`
|
||||
outright, without ever calling `next`, to veto that and let the process
|
||||
really quit -- for a platform host that owns its own "return to launcher"
|
||||
UI and would otherwise get looped straight back into the game it just
|
||||
quit.
|
||||
|
||||
A manifest may also declare `force_enable_env`, an environment variable
|
||||
name that re-enables the mod regardless of a saved disable in
|
||||
`options.mods` when that variable is set to `"1"`. This is for a mod that
|
||||
cannot function disabled on the one build where its env var is set (a
|
||||
platform-bridge mod bundled only with that build's launcher, for example).
|
||||
|
||||
Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call`
|
||||
already falls straight through to the vanilla function when no mod has
|
||||
wrapped the name, at negligible cost.
|
||||
|
||||
@@ -1,521 +1,26 @@
|
||||
# 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.
|
||||
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.
|
||||
|
||||
## Survey zoom
|
||||
|
||||
The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4`
|
||||
zooms the overworld between 1 pixel per world pixel (full survey) and 2×
|
||||
the window fit scale (close-up), in crisp integer steps. This has no Game
|
||||
Boy equivalent:
|
||||
|
||||
- Connected maps render their full bodies, and their NPCs appear as
|
||||
visual-only "ghosts", they wander but have no sight lines, triggers,
|
||||
dialogue, or collision until the map is actually entered.
|
||||
- Menus, text boxes, and battles draw at normal scale on top of the
|
||||
zoomed world. Zoom input is ignored while a script, menu, or battle is
|
||||
active; the zoom offset is persisted as `save.options.zoom` (default
|
||||
`0` = FIT) and survives New Game via `options.lua`.
|
||||
- Hotkey `4` ticks through every integer zoom level (survey → FIT →
|
||||
close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`.
|
||||
- Beyond the border ring the void fill repeats indefinitely (see VOID
|
||||
FILL below); interiors keep their own border block. Each visible map
|
||||
area is colorized with its own SGB palette (the original recolored the
|
||||
whole screen per map).
|
||||
- Neighbor maps load two connection hops out so corner-adjacent maps
|
||||
don't pop in and out, and ghost NPCs share instances with the real ones
|
||||
so their wander positions persist across seamless connection crossings
|
||||
(a warp or fresh map entry still respawns everything at its script
|
||||
position, like the original's per-entry sprite init).
|
||||
|
||||
## VOID FILL
|
||||
|
||||
The Options **VOID FILL** row picks what paints the infinite beyond-edge
|
||||
space on OVERWORLD-tileset maps during survey zoom:
|
||||
|
||||
- **TREES** (default): solid tree wall block `$0F`.
|
||||
- **WATER**: animated water tile `$14` (same hshift cycle as on-map water).
|
||||
- **BLACK**: solid black.
|
||||
|
||||
Other tilesets are unchanged (house/cave borders stay as authored).
|
||||
Persisted as `save.options.voidFill`.
|
||||
|
||||
## Tilt mode
|
||||
|
||||
The `3` key (and the Options menu TILT row) cycles a visual-only perspective
|
||||
tilt of the overworld through **OFF → 15° → 35° → 50° → OFF** for an HD-2D /
|
||||
diorama look. Like survey zoom this is purely presentational and has no
|
||||
Game Boy equivalent:
|
||||
|
||||
- The entire map tilts as one rigid ground plane, paths, grass, water,
|
||||
floors, and every background-tile structure (buildings, trees, fences,
|
||||
signs; in Gen 1 these are baked into the tile layer, not sprites), so
|
||||
rows above the player recede and rows below come toward the viewer. Only
|
||||
things that actually *stand* on the ground draw as upright billboards,
|
||||
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
|
||||
and the standing FX attached to them (emote bubbles, the fishing rod,
|
||||
the FLY bird). The Poké Center heal-machine overlay stays on the ground
|
||||
plane with the machine tiles (it is OAM glued to a BG graphic, not a
|
||||
standing sprite). An earlier revision tried
|
||||
billboarding buildings/trees/signs too (cutting them out of the ground
|
||||
per hand-curated per-tileset tables); that chased an endless tail of
|
||||
special cases, dense tree canopy, fences fused into grass, building
|
||||
facades with their own baked-in fake perspective, because Gen 1's art
|
||||
was never drawn with a clean seam between ground and standing scenery. It
|
||||
wasn't merged; tilting everything but the characters as one plane is the
|
||||
simpler, shipped tradeoff (buildings recede/foreshorten with the ground
|
||||
like a photo of a diorama, rather than standing fully upright next to
|
||||
a full-height character).
|
||||
- Cycling tweens the angle between levels over ~0.25s rather than snapping;
|
||||
with tilt fully off the world pass drops back onto the flat blit path, so
|
||||
flat rendering stays pixel-identical to tilt-off and off costs nothing.
|
||||
- Tilt input is gated exactly like survey zoom, honored only while
|
||||
free-roaming, ignored while a script, menu, or battle is active, and it
|
||||
composes with survey zoom (the zoom scale feeds the projection). The tilt
|
||||
level is persisted in `save.options.tilt` (default OFF).
|
||||
- It applies everywhere the overworld draws, interiors and caves included.
|
||||
Menus, text boxes, and battles render flat on top, unaffected, and the
|
||||
infinite beyond-the-border-ring fill stays flat by design.
|
||||
- Collision, movement, sight lines, triggers, encounters, and scripts are
|
||||
untouched; nothing about the tilt reaches gameplay.
|
||||
|
||||
## Colors mode
|
||||
|
||||
The `2` key (and the Options menu COLORS row) cycles the display mode
|
||||
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**
|
||||
(on Blue the first slot labels **OG BLUE**; on Yellow, **OG YELLOW**).
|
||||
The first three are the real colorizations; the rest are DMG-shade novelties:
|
||||
|
||||
- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart --
|
||||
one global BG palette + one OBJ palette, every map, no per-map variation
|
||||
(Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally).
|
||||
The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP
|
||||
bake + post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`, or Blue's blue/pink
|
||||
pair).
|
||||
- **OG YELLOW** (Yellow playthrough, same `ogred` save id): Pokemon Yellow's
|
||||
authentic GBC look from `CGBBasePalettes` (`data/palettes_yellow.lua`,
|
||||
sourced from pret/pokeyellow). Per-map / per-species colors, not a single
|
||||
boot-ROM ramp -- Yellow was CGB-enhanced.
|
||||
- **SGB** (default): the per-map Super Game Boy region palettes
|
||||
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
|
||||
real SGB. (This is the mode formerly mislabeled "GBC".)
|
||||
- **ADVANCED**: pokered-gbc SuperPalettes -- real per-tile GBC coloring plus
|
||||
per-species mon colors (`data/palettes_gbc.lua`). (Formerly labeled
|
||||
"RED++"; it is the richest colorization rather than anything Red-specific.)
|
||||
- **OG**: force the four DMG grays (colorization off).
|
||||
- **OG INV**: inverted DMG grays.
|
||||
- **SGB INV**: each SGB zone palette with shade order reversed.
|
||||
- **CLASSIC**: original Game Boy pea-soup greens
|
||||
(`#9BBC0F` / `#8BAC0F` / `#306230` / `#0F380F`).
|
||||
|
||||
The shade-remap transform is applied centrally in `PaletteFX.sendColors`, so
|
||||
it covers overworld, menus, battles, and tilt upright billboards. OG RED's
|
||||
global BG palette is supplied by `OverworldState:overworldBgColors` (per-map
|
||||
override in the overworld pass). Persisted as `save.options.colors`; the
|
||||
`gbc` / `gbc_inv` / `redpp` save ids are kept for back-compat under the new
|
||||
labels.
|
||||
|
||||
## GBC FX
|
||||
|
||||
The `5` key (and the Options menu GBC FX row) cycles a "played on real
|
||||
unlit-GBC hardware" post-process through **OFF → 1 → 2 → 3 → 4**. The
|
||||
levels are a cumulative ladder:
|
||||
|
||||
- **1**: reflective-screen backing transparency.
|
||||
- **2**: + LCD pixel grid.
|
||||
- **3**: + pixel drop shadows.
|
||||
- **4**: + sunlight glare and rainbow shimmer with a drifting light.
|
||||
|
||||
It runs as a final present pass after world + UI composite in
|
||||
`Renderer:endFrame`, inspired by the Pixel Transparency RetroArch shader
|
||||
([github.com/mattakins/Pixel_Transparency](https://github.com/mattakins/Pixel_Transparency)).
|
||||
Default OFF; persisted as `save.options.gbcfx`.
|
||||
|
||||
Mobile GPUs often compile the pass but present a black frame, so Android and
|
||||
iOS hide the row entirely, pin the level to OFF, and rewrite a level already
|
||||
persisted in `options.lua` (issue #136). `POKEPORT_GBCFX` overrides that
|
||||
decision either way, same tri-state as `POKEPORT_TOUCH`: `=0` refuses the
|
||||
effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
|
||||
its launcher because the device reports `"Linux"` while its GPU is in the
|
||||
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
|
||||
|
||||
## Performance tier (low-end devices)
|
||||
|
||||
The Options **PERFORMANCE** row scales the port's optional presentation
|
||||
extras down for weaker hardware. The extras it governs are the three
|
||||
heaviest things the port adds on top of the original -- the whole-screen 3D
|
||||
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
|
||||
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
|
||||
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
|
||||
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
|
||||
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
|
||||
only in how much eye-candy the renderer is allowed to do.
|
||||
|
||||
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
|
||||
| ------------ | ---- | ------ | ----------- | ----------------- |
|
||||
| **HIGH** | on | on | on | none |
|
||||
| **BALANCED** | off | off | on | none |
|
||||
| **LOW** | off | off | off | 60 |
|
||||
| **AUTO** | picks a default from the device (below) |||
|
||||
|
||||
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
|
||||
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
|
||||
desktops to **BALANCED**, and everything else -- a normal desktop, and
|
||||
every existing `options.lua` that predates this option -- to **HIGH**,
|
||||
so the common case is unchanged. See `src/core/Performance.detect`.
|
||||
- AUTO only chooses the *default*; all four tiers are selectable, so a
|
||||
wrong guess is one row away from being overridden.
|
||||
- The clamps are applied **live** against your stored options and never
|
||||
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
|
||||
FX / ZOOM without forgetting them -- raising the tier restores exactly
|
||||
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
|
||||
saved choice on a clamped tier: it's your preference, waiting for a tier
|
||||
that can afford it.)
|
||||
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
|
||||
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
|
||||
|
||||
## Peer-to-peer link play (lua-enet)
|
||||
|
||||
Trades and link battles connect two copies of the game directly over
|
||||
lua-enet (ENet ships inside LÖVE, nothing to install, no server to run)
|
||||
on a reliable-ordered channel, replacing the original standalone Python
|
||||
room-code relay (`tools/relay_server.py`, deleted). HOST A GAME shows the
|
||||
host's LAN address (UDP 7777; `POKEPORT_LINK_PORT` overrides); JOIN A
|
||||
GAME enters it. Closing performs a graceful ENet disconnect so the final
|
||||
confirm/bye always lands; a vanished peer exits with "The link was
|
||||
broken." Internet play needs a forwarded UDP port or a VPN (deliberate
|
||||
tradeoff vs. the relay). Headless tests drive the protocol over an
|
||||
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
||||
also exercises real UDP pairing.
|
||||
|
||||
Red, Blue, and Yellow copies link with each other, as the real cable
|
||||
does. The compatibility fingerprint hashes only data a link mode can
|
||||
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
|
||||
only R/B/Y link-surface difference) no longer read as different games
|
||||
(issue #511). Moving the fingerprint is a link parity change: builds
|
||||
from before this fix will refuse to pair with builds after it.
|
||||
|
||||
## Fair play in link and online matches
|
||||
|
||||
A link session is decided by the battle and nothing else, so for its
|
||||
duration:
|
||||
|
||||
- **Game speed is pinned to normal.** The GAME SPEED option and
|
||||
`POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it
|
||||
closes, and apply again after. Fast-forward otherwise runs one peer's
|
||||
queue faster than the peer it is locked to and drains a tournament shot
|
||||
clock faster than the opponent racing it.
|
||||
- **Online play runs vanilla, 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
|
||||
divergence there ends the match as a draw. `volatile` carries per-turn
|
||||
flags both sides recompute anyway - a divergence there is logged and
|
||||
reported to mods, and play continues.
|
||||
|
||||
The relay logs which component diverged on which turn, so a desync report
|
||||
names something specific.
|
||||
|
||||
## Custom boot text
|
||||
|
||||
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
|
||||
"bois club" / "bryanthaboi", a deliberate branding customization. The
|
||||
rest of the boot beats (copyright splash, "presents" shooting-star, the
|
||||
Nidorino-vs-Gengar attract scene) mirror the original.
|
||||
|
||||
|
||||
## Custom Options
|
||||
|
||||
Options persist in a standalone `options.lua` (separate from the game
|
||||
progress `save.lua`), so audio/display/battle preferences survive New Game
|
||||
and aren't wiped when a save slot is cleared. Changing a row in the Options
|
||||
menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also
|
||||
flushes the live options. Old saves that still embed an `options` table are
|
||||
migrated once into `options.lua` on load.
|
||||
|
||||
- Music / SFX volume
|
||||
- PIKACHU VOL (0-7, Yellow only): trims Pikachu's PCM voice clips under the
|
||||
SFX level, so the follower's constant chatter, the title-screen cry and
|
||||
every in-battle "Pika!" can be pulled down (or muted at 0) without
|
||||
quieting the rest of the sound effects. The row is hidden on Red/Blue,
|
||||
which have no voice clips.
|
||||
- Music Filter
|
||||
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
|
||||
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
|
||||
- COLORS (OG RED / SGB / ADVANCED / OG / OG INV / SGB INV / CLASSIC), also
|
||||
hotkey `2` (OG RED = GBC boot-ROM look; ADVANCED uses pokered-gbc
|
||||
SuperPalettes + per-species mon colors)
|
||||
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
|
||||
`-`/`=` step one level and save
|
||||
- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space
|
||||
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
||||
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
|
||||
a hard render frame-rate cap (`save.options.fpsCap`).
|
||||
|
||||
## Battle transition cascade + white battle letterbox
|
||||
|
||||
Into-battle wipes still run the original eight styles inside the classic
|
||||
160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8
|
||||
blocks cascade outward from that square into the surrounding world so the
|
||||
void outside the OG wipe fills in lockstep. Once the battle state is up,
|
||||
letterbox voids around the battle canvas fill **white** instead of black
|
||||
so the whole window reads as one continuous battle screen.
|
||||
|
||||
## Widescreen battle layout
|
||||
|
||||
Options **BATTLE LAYOUT** picks the battle screen's composition: **OG**
|
||||
(the default: the original 160×144 arrangement, unchanged) or **WIDE**,
|
||||
which gives battles a 304×144 native-pixel surface and a Gen 3-style
|
||||
arrangement on it:
|
||||
|
||||
- the foe's status box upper left, the foe's picture upper right;
|
||||
- the player's picture lower left, the player's status box lower right,
|
||||
with a longer HP bar and the numeric HP under it;
|
||||
- a full-width message window;
|
||||
- a split "What will X do?" prompt / 2×2 command window;
|
||||
- a 2×2 move menu, navigated with all four directions, with a PP and type
|
||||
panel attached to its right.
|
||||
|
||||
Only the composition changes. Pictures, palettes, HP-bar colors, font
|
||||
pages, window borders, sounds, animations, timing and every battle rule
|
||||
stay the engine's, so a COLORS mode or an asset mod still owns the look.
|
||||
Each side's picture keeps its original pixels and placement math and is
|
||||
composited into its own region of the wider battlefield -- nothing is
|
||||
scaled or squeezed -- and animations, which are authored in the original
|
||||
160-pixel space, shift as one rigid group onto whichever side they play
|
||||
on. The whole screen is drawn at the window's integer fit scale for the
|
||||
wider surface, so a 304-pixel screen is drawn a step smaller than a
|
||||
160-pixel one in the same window.
|
||||
|
||||
The wide surface is live only while the battle itself is the screen on
|
||||
top: a party menu, the bag or a nickname prompt is a 160×144 screen and
|
||||
brings the classic surface back with it.
|
||||
|
||||
## On-screen touch controls (mobile)
|
||||
|
||||
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
|
||||
buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom
|
||||
center) over the frame, using Xelu's CC0 controller prompts
|
||||
(`assets/touch/`). Real buttons, not gestures: press lands the frame the
|
||||
finger does, sliding on the d-pad changes direction without lifting, and
|
||||
multi-touch chords (e.g. hold a direction + tap B) work. The overlay only
|
||||
appears while no controller is being used: the first gamepad button or
|
||||
stick push hides it, the next screen touch brings it back, and unplugging
|
||||
the last controller restores it immediately. Layout re-derives from the
|
||||
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
|
||||
the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||
|
||||
The launcher's **Touch Controls** button opens a drag editor: move each
|
||||
button freely, resize the whole pad with **-/+** (60% to 160%), **Disable**
|
||||
to hide the overlay permanently (for controllers / emulation handhelds --
|
||||
distinct from the temporary gamepad auto-hide), **Reset** for defaults,
|
||||
**Done** to save into `options.lua` as normalized window fractions so a
|
||||
different screen keeps the relative placement.
|
||||
|
||||
Portrait and landscape are edited and saved separately (#633): the editor
|
||||
follows whichever orientation is on screen, and **Reset** only clears that
|
||||
one, so a layout that works held upright does not have to double as the
|
||||
one used sideways. An `options.lua` from before this split keeps its single
|
||||
layout in both orientations until one of them is edited. In-game, Options →
|
||||
**TOUCH PAD** toggles the same on/off flag without leaving a play session.
|
||||
|
||||
## Screen orientation lock (Android)
|
||||
|
||||
Options → **ORIENTATION** (also in the launcher's gear menu) locks the
|
||||
screen to **PORTRAIT**, **LANDSCAPE** (either landscape, following the
|
||||
device), or **REVERSE LANDSCAPE**, or leaves it on **AUTO** (#592). AUTO
|
||||
allows every orientation but defers to the system: with auto-rotate turned
|
||||
off in Android's quick settings, the game stays put instead of following
|
||||
the sensor (#716). Changes apply immediately -- the screen rotates as the
|
||||
row is stepped -- and persist in `options.lua`. Android only: iOS follows
|
||||
the app's fixed orientation list, and desktop windows rotate nothing.
|
||||
|
||||
## Translation support
|
||||
|
||||
Every string the player can read is now reachable from a mod, so a
|
||||
translation is an ordinary content mod rather than a fork.
|
||||
|
||||
Two things had to change. Text layout stopped counting bytes: the dialogue
|
||||
box measures a line in glyphs (charmap sequences), so a 3-byte character
|
||||
costs one column, a cut never lands inside a character, and a page with a
|
||||
non-default `advance` re-measures instead of overflowing. That also fixed
|
||||
25 vanilla English lines that were wrapping early because `é` in POKéMON
|
||||
and POKéDEX costs two bytes ("I study POKéMON as" is 19 bytes and 18
|
||||
glyphs, and the box was breaking it).
|
||||
|
||||
Second, the text the engine writes itself - battle messages, item results,
|
||||
menu labels, the link-play screens - moved behind `src/core/Strings.lua`
|
||||
and the new `strings` registry. Extracted script text was already
|
||||
overridable through `text`; this covers the other half. Entries are keyed
|
||||
by the English source, so a translation that has not reached a string yet
|
||||
keeps rendering in English and a half-finished translation stays playable.
|
||||
|
||||
Authors generate the whole thing:
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py translation francais --language "Francais"
|
||||
```
|
||||
|
||||
That scaffolds a mod with every translatable string as an empty catalog,
|
||||
plus a glyph-page and charmap stub, a naming-grid stub, and a
|
||||
`francais-worksheet/` directory holding the English to translate from
|
||||
(deliberately outside the mod: extracted text is ROM content and must not
|
||||
be packed). `--refresh` re-harvests after an engine update, keeping
|
||||
existing translations and parking orphaned keys rather than dropping them.
|
||||
|
||||
See the wiki's Translations guide.
|
||||
|
||||
## Save editor (bundled, reachable from the launcher)
|
||||
|
||||
The save editor ships inside every build instead of being a developer-only
|
||||
script, and the launcher's SAVE SLOT card grows an **Edit** label next to
|
||||
Delete on every slot that actually holds a save. Edit suspends the
|
||||
launcher, opens that slot's file in the editor, and **Close** hands the
|
||||
process back to the launcher with the slot list re-read (a rename, a badge
|
||||
or a dex change shows up on the row immediately). Unsaved edits arm a
|
||||
confirm first, so leaving cannot lose work. `love . --editor` still opens
|
||||
it standalone, where Close quits instead; `--save <path>` points it at any
|
||||
file, and a save can be dragged onto the window.
|
||||
|
||||
The editor now wears the launcher's visual language - the same navy radial
|
||||
field, 16px translucent cards, tri-colour version rail and green/yellow/red
|
||||
semantics - so the two windows read as one app. Six tabs:
|
||||
|
||||
- **Party**: the roster with sprites, HP bars and level chips on the left,
|
||||
and the mon inspector permanently docked on the right instead of floating
|
||||
over the list. Species, level, DVs and moves all round-trip through the
|
||||
Gen 1 formulas, so the inspector can never show illegal stats.
|
||||
- **Boxes**: the 12 PC boxes as a 5x4 grid with a fill meter per box and a
|
||||
party dock, so deposit and withdraw live in one place. Empty slots are
|
||||
clickable and create a mon there.
|
||||
- **Items**: money, a searchable item picker (replacing the arrows that
|
||||
cycled one id at a time through ~250 items), the configurable bag (20 slots
|
||||
by default), PC storage
|
||||
with no slot cap, and the eight badges as toggle chips. The picker, the bag
|
||||
and PC storage all scroll under the mouse wheel, so the whole catalog is
|
||||
reachable one-handed without typing a query.
|
||||
- **Events**: flags, defeated trainers, taken items and per-map object
|
||||
toggles, with a real filter field and a two-column paged grid.
|
||||
- **Map**: any map rendered with the game's own renderer, warps followable,
|
||||
and the player / lastHeal / lastOutdoor spawn points settable by clicking
|
||||
a cell. Setting lastOutdoor on a map the game would not accept as an
|
||||
outdoor source is refused with the reason.
|
||||
- **Dex**: seen / owned completion meters and a four-column grid; owning
|
||||
implies seen and un-seeing clears owned, exactly as the game requires.
|
||||
|
||||
Two rules run through all of it. Every mutation goes through one funnel
|
||||
that sets the dirty flag and writes the status line together, so nothing
|
||||
changes silently and no branch can quietly no-op - "Party is full", "Bag is
|
||||
full", "click a cell first" all say so. And every destructive verb (Remove,
|
||||
Release, Clear all, Wipe dex) arms on the first click and commits on the
|
||||
second, relabelling itself to `Confirm?` in between.
|
||||
|
||||
A validation pill in the tab rail mirrors what the running game would
|
||||
quarantine on load; clicking it jumps to the tab holding the first problem.
|
||||
|
||||
## Tiled map editing (mod authoring)
|
||||
|
||||
`tools/tiled_export.py` turns the imported ROM cache into a Tiled workspace,
|
||||
so maps can be edited in a real map editor and exported back out as a mod.
|
||||
It has its own document: docs/tiled-map-editing.md.
|
||||
|
||||
## Pokédex diploma (both versions)
|
||||
|
||||
The Celadon Mansion 3F game designer shows the dex-completion diploma
|
||||
once 150 species are owned. On Yellow, the graphic artist next to him
|
||||
then offers to print it, saving the certificate as a PNG under `prints/`
|
||||
in the save directory, and Bill's PC gains Yellow's PRINT BOX item which
|
||||
exports the current box list the same way.
|
||||
|
||||
## Pokédex printing (Yellow)
|
||||
|
||||
Yellow's Game Boy Printer PRNT option in the Pokédex side menu is stood in
|
||||
for by an image export: choosing PRNT renders the mon's entry page (sprite,
|
||||
kind, number, height/weight, dex text) to a PNG at 4x scale under
|
||||
`prints/` in the save directory, then reports the filename in a dialog.
|
||||
No printer hardware or link cable emulation involved; the file is the
|
||||
printout.
|
||||
|
||||
## Find Mods (community mod indexes)
|
||||
|
||||
A FIND MODS tab sits beside MODS in the launcher and browses a published
|
||||
mod index: a metadata-only feed listing mods that live in their authors'
|
||||
own repositories. No index ships with the launcher and none is ever added
|
||||
automatically, so the tab opens on an "Add an index" prompt until you name
|
||||
one; paste an index URL or its `owner/repo` and it is remembered in
|
||||
`options.lua`. More than one index can be added, and the listings merge.
|
||||
|
||||
## Soft reset (all versions)
|
||||
|
||||
Holding A, B, START and SELECT together restarts the game the way flicking
|
||||
a Game Boy's power switch did, dropping straight back to the title screen.
|
||||
It works from anywhere, including mid-battle, which the QUIT entry on the
|
||||
start menu cannot do: the original combo is how stationary and gift
|
||||
Pokemon get their stats rerolled without sitting through a full relaunch.
|
||||
Unsaved progress is discarded, exactly as on hardware.
|
||||
|
||||
As on the original, the four buttons have to stay held for 16 straight
|
||||
polls (better than a quarter of a second) and any direction in the mix
|
||||
cancels it, so it is hard to hit by accident -- including on the on-screen
|
||||
touch controls, where it would take four fingers held on four separate
|
||||
controls.
|
||||
|
||||
## Controls rebinding (CONTROLS screen)
|
||||
|
||||
OPTIONS -> CONTROLS lists every Game Boy button with its current keyboard
|
||||
key and controller button side by side (Z/A). Press A on a row, then press
|
||||
and release the key or pad button you want; the rebind commits on the
|
||||
release. If that input already belongs to another row, the two rows swap,
|
||||
so no button is ever stranded without an input and no input ever serves
|
||||
two buttons. Holding a second key or pad button while the first is still
|
||||
down backs out of the capture without touching a keyboard; Escape still
|
||||
cancels too. SELECT clears one row back to its default, and START resets
|
||||
every binding after a confirmation.
|
||||
|
||||
Controllers a system has no mapping for (common on Linux handhelds and
|
||||
off-brand pads) report bare button numbers rather than names. Those are
|
||||
rebindable on the same screen and show up as JOY1, JOY2 and so on in the
|
||||
controller column. Recognized controllers are read only through their
|
||||
named buttons, so a rebind on those is never shadowed by the factory
|
||||
layout underneath it.
|
||||
|
||||
## Mod profiles (#593)
|
||||
|
||||
The mod manager's PROFILES tab holds named setups. A profile remembers which
|
||||
mods are on, every mod's own options, and which save slot each game version
|
||||
plays, so swapping profiles swaps the whole playthrough and not just the mod
|
||||
list. The setup that existed before profiles shipped becomes PROFILE 1 the
|
||||
first time the manager opens.
|
||||
|
||||
EXPORT.. writes the selected profile to `profiles/<NAME>.g1rmodlist` in the
|
||||
save directory; drop a `.g1rmodlist` someone shared into that folder and
|
||||
IMPORT.. adds it. Imported profiles never overwrite an existing one (a name
|
||||
clash gets a number). Mods the shared profile names but that are not installed
|
||||
are reported when the profile is applied; installing them is still a manual
|
||||
trip through the mods list or Find Mods.
|
||||
|
||||
## Windows: no console windows on launcher actions
|
||||
|
||||
Checking for updates, browsing a mod index, adding a mod repo, installing a
|
||||
mod and picking a ROM all run a host tool (curl, PowerShell) in a child
|
||||
process. On Windows those children used to each open their own console
|
||||
window, so a session could end up buried under half a dozen of them. The
|
||||
game now claims one console for itself at boot and hides it; the children
|
||||
inherit that invisible console and nothing pops up. Nothing else changes:
|
||||
file pickers are ordinary desktop dialogs and still appear normally, and a
|
||||
run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers)
|
||||
keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt
|
||||
out.
|
||||
# New Features
|
||||
|
||||
Features intentionally added beyond the original Pokémon Red, Blue, and Yellow games:
|
||||
|
||||
* **Survey zoom** with connected-map rendering and configurable void fill
|
||||
* **Perspective tilt mode** for an HD-2D-style overworld
|
||||
* **Multiple color modes**, including original, SGB, advanced GBC, monochrome, and classic green
|
||||
* **Optional GBC screen effects**, including pixel grids, shadows, glare, and transparency
|
||||
* **Performance presets** and configurable FPS limits
|
||||
* **Peer-to-peer link play** for trades and battles between Red, Blue, and Yellow
|
||||
* **Persistent custom options** stored separately from game saves
|
||||
* **Optional widescreen battle layout**
|
||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
||||
* **Translation and custom font support**
|
||||
* **Built-in save editor** for parties, boxes, items, events, maps, and Pokédex data
|
||||
* **Tiled map editing tools** for mod authors
|
||||
* **Pokédex diploma and printer image exports**
|
||||
* **Community mod browser**
|
||||
* **Soft reset button combination**
|
||||
* **Keyboard and controller rebinding**
|
||||
* **Mod profiles** with separate mod settings and save slots
|
||||
* **Improved launcher and save editor UI**, including background downloads and update checks
|
||||
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
|
||||
* **Custom boot branding**
|
||||
|
||||
Actual approximations, and missing original behavior are documented separately in `docs/known-differences.md`.
|
||||
|
||||
@@ -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,54 @@
|
||||
# RFC 0002 — Let mods hide an active screen state from the main render
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `StateStack.lua`, `Game.lua`. Tests:
|
||||
`screen_render_visible.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
A mod can render a native menu on a companion display through
|
||||
`render.compose`, but it cannot remove that menu from the main display without
|
||||
also popping it. Popping transfers update and input ownership and forces the
|
||||
mod to reimplement native menu behavior.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
No prior D-number. Extends the render-hook plan in `docs/modding.md` and the
|
||||
state-stack rendering contract in `docs/architecture.md`.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only.
|
||||
|
||||
### `screen.render_visible`
|
||||
|
||||
New hook called with `(state) -> boolean` through the public wrapper signature
|
||||
`(next, state)`. Its vanilla result is `true`.
|
||||
|
||||
Returning `false` excludes the state from the main draw, from opaque-base
|
||||
selection and from palette-zone ownership. It does not remove the state or
|
||||
change update, input, push or pop behavior. The call sites are
|
||||
`StateStack:visibleBase`, `StateStack:draw` and the equivalent draw and palette
|
||||
walks in `Game:draw`.
|
||||
|
||||
The hook is guarded by `Runtime.wantsHook`, so the no-subscriber path allocates
|
||||
nothing. It is a pure render predicate and may be evaluated more than once per
|
||||
frame.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** With no subscriber every state remains visible, and the existing
|
||||
state-stack, event and hook behavior is unchanged.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** the topmost opaque state still owns drawing and palette zones,
|
||||
and `Runtime.wantsHook("screen.render_visible")` stays false.
|
||||
- **Mod-API:** a fixture mod registers through `mod.hooks:wrap`, hides one
|
||||
opaque state and proves the state beneath draws and owns the palette while
|
||||
the hidden state remains topmost and continues updating.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. This is one additive hook with a `true` vanilla default.
|
||||
@@ -0,0 +1,114 @@
|
||||
# RFC 0003 — Add a reusable multiplayer session layer
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Session.lua`, `Net.lua`, `LinkState.lua`,
|
||||
`Tournament.lua`. Tests: `link_session.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
Link play and tournaments currently own transport lifecycle details and
|
||||
temporarily remove and reinsert packets in `Net.inbox` when a handshake or
|
||||
battle starts. That makes packet ownership fragile and gives a future
|
||||
shared-world mode no stable host/guest-aware boundary to reuse.
|
||||
|
||||
The engine needs one small layer that preserves today's wire protocol while
|
||||
owning received-packet order and terminal cleanup. Pokémon, battle, tournament,
|
||||
save, and overworld rules remain outside that layer.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the existing split between `Net` (backend setup, framing, and relay
|
||||
controls), `Handshake`/`Protocol` (mode payloads), and the states that
|
||||
interpret those payloads. It does not replace any of those components.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible and internal-only.
|
||||
|
||||
### `Session.new(transport, options)`
|
||||
|
||||
Wraps one successfully configured Net-compatible transport. `options.role`
|
||||
is exactly `"host"` or `"guest"`; `options.kind` is a non-empty
|
||||
local label such as `"link"` or `"tournament"`. Role and kind are
|
||||
immutable session metadata selected locally and are never inferred from peer
|
||||
packets.
|
||||
|
||||
The facade forwards the narrow fields current consumers need:
|
||||
`paired`, `code`, `address`, `target`,
|
||||
`error`, and `closed`.
|
||||
It forwards valid outbound tables unchanged through `send(message)`.
|
||||
|
||||
### Receive and lifecycle methods
|
||||
|
||||
- `update()` pumps the transport, validates decoded inbound values, and
|
||||
appends accepted messages to a private FIFO.
|
||||
- `pollOne()` removes the oldest queued message.
|
||||
- `poll()` removes every queued message in order.
|
||||
- `take(type)` removes the first queued message with that type without
|
||||
disturbing any other message.
|
||||
- `hasPending()` reports whether the FIFO is non-empty.
|
||||
- `getRole()`, `getKind()`, `getStatus()`, and
|
||||
`getFailure()` expose local metadata and lifecycle.
|
||||
- `close()` closes the underlying transport once and is safe to repeat.
|
||||
|
||||
Statuses are `connecting`, `paired`, `draining`, `closed`,
|
||||
and `failed`. A transport close or failure becomes `draining` while
|
||||
accepted packets remain queued. The terminal `closed`/`error`
|
||||
compatibility projection appears only after that FIFO drains, so a last packet
|
||||
travelling with a disconnect remains observable.
|
||||
|
||||
An inbound value is structurally valid only when it is a table with a string
|
||||
`type`. Invalid decoded values end the session with a protocol failure.
|
||||
Unknown but structurally valid types remain queued for the owning mode; the
|
||||
session does not contain a packet allowlist.
|
||||
|
||||
## Authority direction
|
||||
|
||||
A later `WorldSession` may compose this facade. In that mode the host
|
||||
will own the world snapshot, map state, NPC state, event results, and shared
|
||||
progression. A guest will bring a trainer identity plus their Pokémon party,
|
||||
inventory, and other explicitly selected profile snapshot.
|
||||
|
||||
Guest profile data and commands will be untrusted input. The host must validate
|
||||
them and must authorize every world mutation before rebroadcasting the result.
|
||||
The concrete snapshot schema, command vocabulary, conflict rules, and
|
||||
persistence policy require a separate RFC and are not introduced here.
|
||||
|
||||
## Compatibility and security
|
||||
|
||||
No packet envelope, message name, payload shape, framing rule, relay protocol,
|
||||
save schema, or engine protocol version changes. Existing valid outbound
|
||||
messages encode exactly as before, and existing link and tournament screens
|
||||
keep their current player-facing behavior.
|
||||
|
||||
The layer does not authenticate players or encrypt traffic. Existing LAN and
|
||||
relay access assumptions remain unchanged; knowledge of a join address or code
|
||||
still grants the same access it grants today. Authentication, reconnect
|
||||
identity, rate limits, and abuse controls remain future protocol decisions.
|
||||
|
||||
## Migration note for players, mods, and peers
|
||||
|
||||
**Nothing.** `LinkState` and `Tournament` adopt the facade
|
||||
internally. Existing peers receive the same messages, mods gain no new API, and
|
||||
players do not migrate saves or settings.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **ROM-free facade:** constructor validation, immutable role/kind, unchanged
|
||||
send shape, FIFO ordering, typed retrieval, unknown typed packets, draining,
|
||||
terminal failure latching, protected transport calls, and decoded-value
|
||||
rejection.
|
||||
- **Existing modes:** source guards prohibit direct inbox mutation; headless
|
||||
module loads and the complete engine tier cover both migrated states.
|
||||
- **ROM-backed link play:** run the existing link driver when generated ROM
|
||||
data is available; the normal quick suite remains the required baseline.
|
||||
|
||||
## Deprecation etiquette and non-goals
|
||||
|
||||
Nothing deprecated. This RFC adds an internal facade and removes no transport
|
||||
method.
|
||||
|
||||
It does not add shared-world packets, co-op screens, a remote actor, save
|
||||
transfer, server persistence, matchmaking, reconnect, or a protocol-version
|
||||
bump. Those changes require the world-specific layer and its own review.
|
||||
@@ -0,0 +1,124 @@
|
||||
# RFC 0003 — Playthrough-scoped mod storage
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `SaveData.lua`, `SaveSerializer.lua`, `Storage.lua`,
|
||||
`Loader.lua`. Tests: `playthrough_identity.lua`, `storage.lua`, the existing
|
||||
save-slot and mod-save suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
`mod.save` intentionally lives inside the normal progress record. That is the
|
||||
right home for quest state, but not for independent tool data such as replay
|
||||
captures, checkpoint histories, or recovery records: writing it would require a
|
||||
normal Pokémon SAVE, and storing copies of progress beneath `save.modData` would
|
||||
recursively embed the save that contains them.
|
||||
|
||||
Mods also cannot safely infer which launcher slot or portable filesystem backs
|
||||
the active playthrough. Direct filesystem access would expose private paths and
|
||||
make isolation dependent on engine implementation details.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the per-mod persistence contract documented in `docs/modding.md` and the
|
||||
wiki's Save Model. `mod.save` and `mod.options` keep their existing behavior.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only. `Loader:_api` binds a new `mod.storage`
|
||||
facade to the calling mod id. Mods receive logical keys and decoded values, never
|
||||
filesystem handles or physical paths.
|
||||
|
||||
### Lazy opaque playthrough identity
|
||||
|
||||
`SaveData.ensurePlaythroughId(save[, fs]) -> id | nil` allocates an opaque
|
||||
32-hex-character identity without consuming gameplay RNG. It is called only when
|
||||
`mod.storage` or `mod.checkpoints` first needs a scope; New Game, ordinary SAVE,
|
||||
and ordinary load remain byte-compatible when no caller uses either API.
|
||||
|
||||
The id is stored in `save.meta.playthroughId` after allocation. Until the next
|
||||
ordinary SAVE writes it into progress, a mapping in `options.lua` keeps legacy
|
||||
saves stable by game version and active launcher slot (or the legacy flat-save
|
||||
scope). A newly created playthrough never adopts the previous playthrough's
|
||||
mapping for that slot.
|
||||
|
||||
`SaveData.persistenceFs([fs])` is engine-only routing used by the storage
|
||||
implementation. It follows the same standard/portable backend as progress and
|
||||
honors injected test filesystems; it is not exposed on the mod object.
|
||||
|
||||
### `mod.storage:context(game)`
|
||||
|
||||
Returns:
|
||||
|
||||
```lua
|
||||
{ engineVersion = "0.9.0", gameVersion = "red", playthroughId = "..." }
|
||||
```
|
||||
|
||||
or `nil, code, message`. `engineVersion` is warning-grade compatibility metadata;
|
||||
the context intentionally omits launcher slot ids and paths.
|
||||
|
||||
### `mod.storage:write(game, key, value)`
|
||||
|
||||
Accepts a data-only table and returns `true`, or
|
||||
`false, code, message`. Keys are nonempty slash-separated segments containing
|
||||
letters, digits, underscore, or dash. Empty segments, leading/trailing slash,
|
||||
`.`/`..`, and other characters are rejected.
|
||||
|
||||
The engine encodes deterministically, stages and decodes a `.tmp` witness,
|
||||
preserves the previous valid generation, writes and decodes the main record,
|
||||
then rolls the verified bytes to `.bak`. A failed stage or replacement leaves a
|
||||
verified prior generation readable.
|
||||
|
||||
### `mod.storage:read(game, key)`
|
||||
|
||||
Returns a freshly decoded table, or `nil, code, message`. It tries main, staged,
|
||||
then backup data. A valid staged/backup value is returned and promoted
|
||||
best-effort; corrupt bytes are never executed.
|
||||
|
||||
### `mod.storage:list(game[, prefix])`
|
||||
|
||||
Returns sorted logical keys beneath a valid prefix, an exact key when the prefix
|
||||
names one, or `nil, code, message`. Physical witness filenames are hidden.
|
||||
|
||||
### `mod.storage:delete(game, key)`
|
||||
|
||||
Deletes only that key's main, backup, and staged witnesses. Returns `true`, or
|
||||
`false, code, message`.
|
||||
|
||||
### Scope and errors
|
||||
|
||||
Physical records are scoped as:
|
||||
|
||||
`persistence root / mod_storage / game version / playthrough id / mod id`
|
||||
|
||||
Stable error codes are `not_in_playthrough`, `storage_unavailable`,
|
||||
`invalid_key`, `encode_failed`, `write_failed`, `verify_failed`, and
|
||||
`not_found`. Ordinary data and I/O failures are return values, not callback-
|
||||
terminating errors.
|
||||
|
||||
The restricted serializer's recursive writer runs outside LuaJIT traces. A
|
||||
1,000-process GC stress regression found compiled recursion could intermittently
|
||||
drop a newly inserted nested identity entry and produce undecodable bytes; save
|
||||
encoding is infrequent and I/O-bound, so interpreter execution is the safe
|
||||
boundary.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** No API is removed, no manifest field changes, and no storage path or
|
||||
playthrough id is created unless a mod invokes `mod.storage` or
|
||||
`mod.checkpoints`. Existing save bytes remain unchanged on the no-caller path.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** New Game plus ordinary save/load creates no identity or storage
|
||||
file; the existing save-slot and mod-save suites remain green.
|
||||
- **Engine identity:** lazy allocation, save/load preservation, stable legacy
|
||||
mapping, fresh-playthrough replacement, and version/slot isolation.
|
||||
- **Public Mod API:** two real API-2 entry chunks prove data-only roundtrip,
|
||||
deterministic listing, key rejection, mod/game/playthrough isolation,
|
||||
corrupt-main recovery, failure retention, exact delete, and no-mod no-write.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. The additions are one bound public facade and engine-private
|
||||
persistence/identity helpers.
|
||||
@@ -0,0 +1,138 @@
|
||||
# RFC 0004 — Stable runtime checkpoints for mods
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Checkpoint.lua`, `Game.lua`, `OverworldController.lua`,
|
||||
`Loader.lua`. Tests: `checkpoints.lua`, existing world and engine suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
Mods can observe world events and request semantic actions, but no supported API
|
||||
can capture canonical progress at a proven-safe runtime boundary or reconstruct
|
||||
the overworld without replaying map-entry scripts. Reaching into the state stack,
|
||||
controller, ScriptRunner, or save restore internals would bind distributable mods
|
||||
to private objects and can duplicate story side effects.
|
||||
|
||||
The engine is the only component that can authoritatively decide whether the
|
||||
runtime is settled and rebuild its controller objects. A generic checkpoint seam
|
||||
lets tools store data-only records while keeping those responsibilities private.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the public world/tool surfaces in `docs/modding.md`. It does not change
|
||||
`mod.world`, normal CONTINUE, vanilla SAVE, or save lifecycle hooks/events.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only. `Loader:_api` binds `mod.checkpoints`; mods
|
||||
never receive `Game`, StateStack, controller, coroutine, renderer, or filesystem
|
||||
internals inside a checkpoint.
|
||||
|
||||
### `mod.checkpoints:inspect(game)`
|
||||
|
||||
Returns a capability record. Stable overworld control returns:
|
||||
|
||||
```lua
|
||||
{ canCapture = true, canRestore = true, kind = "overworld" }
|
||||
```
|
||||
|
||||
A refusal returns the same booleans as `false` plus `kind`, `reason`, and a
|
||||
player-readable `message`. Format-1 supports only an overworld whose controller
|
||||
is topmost, player movement has settled on a tile, and no transition, foreground
|
||||
or parallel ScriptRunner, queued script, scripted move, engagement, emote,
|
||||
teleport, field animation, or similar partial controller mutation is active.
|
||||
|
||||
Refusal reasons are `not_in_playthrough`, `not_overworld`, `screen_busy`,
|
||||
`transition_busy`, `script_busy`, `animation_busy`, and `movement_busy`.
|
||||
Identity allocation is lazy and happens only after an active topmost overworld
|
||||
has been established.
|
||||
|
||||
### `mod.checkpoints:capture(game)`
|
||||
|
||||
Returns a detached data-only format-1 checkpoint, or
|
||||
`nil, code, message`:
|
||||
|
||||
```lua
|
||||
{
|
||||
format = 1,
|
||||
kind = "overworld",
|
||||
identity = {
|
||||
engineVersion = "...", gameVersion = "red", playthroughId = "...",
|
||||
},
|
||||
save = { -- canonical dynamic progress, excluding global options },
|
||||
runtime = { overworld = {
|
||||
map = "PALLET_TOWN", x = 5, y = 6,
|
||||
facing = "down", surfing = false,
|
||||
} },
|
||||
}
|
||||
```
|
||||
|
||||
`engineVersion` is metadata for caller compatibility warnings; the engine does
|
||||
not reject patch/minor mismatches on restore. Capture deep-copies through the restricted serializer before and after
|
||||
`OverworldController:captureSave` synchronizes live map, tile, facing, and surf
|
||||
state. It excludes `save.options`, functions, userdata, threads, metatables as
|
||||
behavior, controller instances, and static content registries. Failure code
|
||||
`capture_failed` covers non-data progress and synchronization errors.
|
||||
|
||||
### `mod.checkpoints:restore(game, checkpoint)`
|
||||
|
||||
Returns `true`, or `false, code, message`. Before mutation it requires the current
|
||||
runtime to be capturable and validates a detached copy of the complete record:
|
||||
format, kind, internal identity consistency, current game/playthrough identity,
|
||||
map availability, integral in-bounds tile, facing, surfing, and synchronized save
|
||||
position.
|
||||
|
||||
Validation codes are `invalid_checkpoint`, `invalid_content`, `unsupported_format`,
|
||||
`unsupported_runtime_kind`, `wrong_game`, `wrong_playthrough`, `invalid_map`, and
|
||||
`invalid_position`, in addition to the capability refusal reasons.
|
||||
|
||||
The canonical save validator runs against the detached record. Unlike ordinary
|
||||
CONTINUE, a checkpoint never accepts a quarantine, remap, reclaim, clamp, or
|
||||
repair: any such content change returns `invalid_content` before live mutation.
|
||||
|
||||
The engine captures an in-memory rollback checkpoint, preserves current global
|
||||
options, then reconstructs semantic overworld state through
|
||||
`Game:restoreCheckpointSave`. Checkpoint entry suppresses normal map exit/entry
|
||||
events, `onEnter` scripts, forced-movement/current checks, and last-map rewrites;
|
||||
it does not emit normal `save.loading`/`save.loaded` lifecycle events. After
|
||||
reconstruction, the engine recaptures and byte-compares normalized data. A failed
|
||||
apply rolls back and returns `restore_failed`; failure of that rollback returns
|
||||
`rollback_failed`. Only after a successful comparison does the engine emit
|
||||
`checkpoint.restored` with `{ game = game, kind = "overworld" }`. Validation
|
||||
failure, failed apply, and successful rollback emit nothing.
|
||||
|
||||
Durable recovery remains a caller responsibility: in-memory rollback handles a
|
||||
runtime exception, not process termination.
|
||||
|
||||
## Runtime boundary and future kinds
|
||||
|
||||
This RFC's original Level A contract intentionally rejects battles, menus,
|
||||
transitions, animations, and suspended/queued scripts. RFC 0005 subsequently
|
||||
adds a separately inventoried `battle` kind with deterministic RNG and
|
||||
differential reconstruction tests; it does not broaden script or arbitrary-frame
|
||||
support implied here.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing required.** No existing hook, save, controller, or world action changes
|
||||
when `mod.checkpoints` is unused. The reconstruction path is called only by a
|
||||
successful public restore after validation. Mods whose runtime caches derive from
|
||||
rewound `game.save` or `mod.save` state may optionally subscribe to
|
||||
`checkpoint.restored` and rebuild from their own public state.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** the complete ROM-free engine suite and existing world behavior stay
|
||||
green; ordinary New Game/save/load allocates no checkpoint identity.
|
||||
- **Public Mod API:** a real API-2 entry chunk proves stable inspection and every
|
||||
unsafe refusal, detached data-only capture, exact map/tile/facing/surf sync,
|
||||
`A -> mutate B -> restore A -> recapture A2` equality across representative
|
||||
progress, settings preservation, compatibility rejection without mutation,
|
||||
map-side-effect suppression, injected reconstruction rollback, mod-owned
|
||||
metadata and `mod.save` rewind, independent `mod.storage`/options preservation,
|
||||
and success-only runtime-cache reconciliation through `checkpoint.restored`.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. This adds one public facade and a checkpoint-only semantic
|
||||
reconstruction route.
|
||||
@@ -0,0 +1,140 @@
|
||||
# RFC 0005 — Persistent battle safe-point checkpoints
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Extends RFC 0004. Engine: `BattleCheckpoint.lua`, `Checkpoint.lua`,
|
||||
`Game.lua`, `BattleState.lua`, and `OverworldController.lua`. Tests:
|
||||
`battle_checkpoint_*.lua`, `checkpoints.lua`, and the existing no-mod suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
RFC 0004 lets a tool capture and reconstruct settled overworld progress without
|
||||
private engine access. A battle is a different runtime: its queue can hold Lua
|
||||
functions and UI factories, its controller contains renderer objects and live
|
||||
references, completion is currently an `onFinish` closure, and scripted battles
|
||||
resume a suspended `ScriptRunner` coroutine. Copying the controller would create
|
||||
a record that is neither data-only nor process-independent.
|
||||
|
||||
The engine can instead expose a narrow semantic safe point. This gives all mods
|
||||
the strongest persistent battle checkpoint the current architecture can prove,
|
||||
without claiming mid-animation or suspended-script support.
|
||||
|
||||
## API delta
|
||||
|
||||
No new facade is added. The existing additive `mod.checkpoints` API gains a
|
||||
second format-1 runtime kind.
|
||||
|
||||
### Capability
|
||||
|
||||
`mod.checkpoints:inspect(game)` returns this only when an ordinary single-player
|
||||
wild or trainer battle is settled at the player command menu:
|
||||
|
||||
```lua
|
||||
{ canCapture = true, canRestore = true, kind = "battle" }
|
||||
```
|
||||
|
||||
The action/message queue, waits, UI, animations, HP/status presentation, and
|
||||
faint processing must be settled. The player must actually control the menu.
|
||||
The underlying overworld must have no running/queued script or scripted move,
|
||||
and the battle must carry an engine-owned semantic continuation descriptor.
|
||||
|
||||
Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`,
|
||||
`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari,
|
||||
ghost, old-man/demo, fishing, static-object, script-suspended, and mod-created
|
||||
closure continuations remain rejected.
|
||||
|
||||
### Capture
|
||||
|
||||
A battle checkpoint remains detached and data-only:
|
||||
|
||||
```lua
|
||||
{
|
||||
format = 1,
|
||||
kind = "battle",
|
||||
identity = { engineVersion = "...", gameVersion = "red",
|
||||
playthroughId = "..." },
|
||||
save = { -- canonical dynamic progress, excluding global options },
|
||||
runtime = {
|
||||
overworld = { map = "ROUTE_1", x = 7, y = 8,
|
||||
facing = "left", surfing = false },
|
||||
battle = { -- normalized semantic model and continuation },
|
||||
},
|
||||
rng = { love = "..." },
|
||||
}
|
||||
```
|
||||
|
||||
The model carries player/enemy roster indices, dynamic enemy Pokémon, turn and
|
||||
escape state, HP/PP/status/stages/volatiles, participants, level-up tracking,
|
||||
trainer AI state, battle ruleset identity, side/field extension data, and
|
||||
normalized pointer relationships such as multi-turn move slots and Mimic
|
||||
restoration entries. Definitions, sprites, canvases, queues, callbacks, and
|
||||
controller objects are reconstructed or excluded.
|
||||
|
||||
Callback-bearing battle extension tokens fail with `battle_extension_unsafe`;
|
||||
invalid live reference relationships fail with `battle_state_invalid`. Nothing
|
||||
is silently stripped.
|
||||
|
||||
New overworld checkpoints also carry the LÖVE gameplay RNG state. Legacy
|
||||
format-1 overworld checkpoints without `rng` remain loadable and leave the
|
||||
current stream untouched.
|
||||
|
||||
### Restore
|
||||
|
||||
Battle restore validates the detached save, map, content references, ruleset,
|
||||
roster indices, move references, continuation identity, and RNG before live
|
||||
mutation. The engine then:
|
||||
|
||||
1. reconstructs the saved overworld return point without entry side effects;
|
||||
2. creates a fresh `BattleState` from current content registries;
|
||||
3. applies the normalized battle model and rebuilds object-reference relations;
|
||||
4. binds an engine-owned wild/trainer completion continuation;
|
||||
5. installs the battle directly at the settled menu without replaying its intro;
|
||||
6. restores the RNG after reconstruction has finished; and
|
||||
7. recaptures and compares the complete checkpoint; and
|
||||
8. emits `checkpoint.restored` with `{ game = game, kind = "battle" }` after the
|
||||
comparison succeeds.
|
||||
|
||||
The pre-operation checkpoint is the transaction rollback. A failed post-install
|
||||
RNG restore is covered: both battle runtime and RNG are reconstructed back to
|
||||
their original values. Validation failure, failed reconstruction, and successful
|
||||
rollback emit no checkpoint lifecycle event.
|
||||
|
||||
## Continuation decision
|
||||
|
||||
Ordinary random wild battles resume through `OverworldState:afterBattle`.
|
||||
Ordinary trainer battles use a descriptor containing map id, stable NPC id,
|
||||
trainer class/party, and optional header event; a win reapplies the same defeated
|
||||
flag, event, reward, and `afterBattle` path. Reconstructed overworld input and
|
||||
NPC freeze state are normalized instead of reviving the old closure.
|
||||
|
||||
`Commands.start_battle` is deliberately unsupported: its completion closure
|
||||
mutates script context and resumes a coroutine whose program counter and Lua
|
||||
stack cannot be serialized. Existing script rejection remains the correct safe
|
||||
contract until a separate semantic ScriptRunner checkpoint RFC exists.
|
||||
|
||||
## Migration note
|
||||
|
||||
**Existing mods require no changes.** The facade and format number are unchanged;
|
||||
the new kind, RNG field, and success-only lifecycle event are additive.
|
||||
Overworld-only callers may continue to filter `capability.kind`. Mods with derived
|
||||
runtime caches may rebuild them from restored public state when the event fires.
|
||||
No-mod behavior is unchanged when checkpoints are unused.
|
||||
|
||||
## Verification
|
||||
|
||||
- settled/unsafe boundary and every variant refusal;
|
||||
- data-only wild and trainer capture, including callback-bearing extension
|
||||
rejection;
|
||||
- process-independent controller and continuation reconstruction;
|
||||
- exact differential recapture for wild and trainer states;
|
||||
- HP, PP, status/stages/volatiles, AI layer, participants, enemy roster,
|
||||
multi-turn move references, and Mimic restore pointers;
|
||||
- exact damage, critical, accuracy, random AI, escape, next encounter, and next
|
||||
raw RNG result after reload;
|
||||
- corrupt content/continuation rejection before mutation;
|
||||
- injected post-install failure with full runtime and RNG rollback;
|
||||
- mod-added Pokémon metadata and `mod.save` rewind while independent
|
||||
`mod.storage` and options remain current;
|
||||
- exactly one post-verification `checkpoint.restored` event and none on failure;
|
||||
- legacy overworld checkpoint compatibility;
|
||||
- complete ROM-free engine and public mod-API suites.
|
||||
@@ -0,0 +1,107 @@
|
||||
# RFC 0006 — Generic process-lifecycle hooks for platform launcher integrations
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `PlatformHooks.lua` (new), `main.lua`, `Manifest.lua`,
|
||||
`Loader.lua`. Tests: `tests/modkit/cases/platform_lifecycle_hooks.lua`,
|
||||
`tests/mod_loader_tests.lua`, `tests/mod_manifest_tests.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
A platform-specific launcher wrapper -- a native shell that embeds this
|
||||
engine and owns its own UI around the game window (a mobile app shell,
|
||||
say, presenting its own settings/import/save screens and only handing
|
||||
control to the LÖVE window once play starts) needs three things no
|
||||
current hook covers:
|
||||
|
||||
1. Pause the simulation while its own UI is on top of the game window.
|
||||
2. Live-reload options it wrote from outside any Lua UI.
|
||||
3. Veto `main.lua`'s "closing the window returns to the Lua launcher"
|
||||
behavior when the platform shell owns that job itself -- without this,
|
||||
a shell that re-fronts its own launcher UI on quit gets looped straight
|
||||
back into `HostShell.restart()`'s in-process reboot instead.
|
||||
|
||||
Implementing this by hand-patching `main.lua`'s `love.update`/`love.quit`
|
||||
directly ties every such integration to editing the one file every other
|
||||
engine change also touches, guaranteeing merge conflicts for any second
|
||||
platform integration (or any unrelated engine PR landing around the same
|
||||
time). No existing hook covers "should the per-frame simulation step run"
|
||||
or "should closing the window return to the Lua launcher."
|
||||
|
||||
## The decision it extends
|
||||
|
||||
No prior D-number. Extends the hook-contract section of `docs/modding.md`
|
||||
alongside `input.step`, `render.hud`, `screen.render_visible`, etc.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible, additive-only.
|
||||
|
||||
### `core.update`
|
||||
|
||||
New hook, `(game, dt) -> nil` through the public wrapper signature
|
||||
`(next, game, dt)`, called once per frame from `love.update` via
|
||||
`src/core/PlatformHooks.lua`'s `PlatformHooks.update(game, dt)`. Vanilla
|
||||
behavior (used when no mod claims the hook) is `game:update(dt)`,
|
||||
unconditionally -- identical to `love.update`'s behavior before this hook
|
||||
existed. A subscriber may skip calling `next(game, dt)` to pause the
|
||||
simulation for that frame, or do additional per-frame work before/after
|
||||
calling it regardless of whether it calls `next`.
|
||||
|
||||
### `core.quit_to_launcher`
|
||||
|
||||
New hook, `() -> boolean` through the public wrapper signature `(next)`,
|
||||
called once from `love.quit()` via
|
||||
`PlatformHooks.quitToLauncher(vanilla)`. `vanilla` is the pre-existing
|
||||
non-platform-specific decision (`Game and not Importer and not
|
||||
quitToLauncher and not scripted and not launchedIntoGame`). A subscriber
|
||||
may return `false` outright to veto returning to the Lua launcher (without
|
||||
ever calling `next`, so the vanilla condition is never evaluated), or call
|
||||
`next()` and return its result to pass the vanilla decision through
|
||||
unchanged.
|
||||
|
||||
Neither hook is guarded by `Runtime.wantsHook` -- both fire unconditionally
|
||||
every call, matching the existing `input.step` precedent
|
||||
(`src/core/Game.lua`), since `Hooks:call` already fast-paths to a bare
|
||||
`vanilla(...)` call when no mod has wrapped the name.
|
||||
|
||||
### `Manifest.force_enable_env`
|
||||
|
||||
New optional manifest field, a bare env-var name. `Loader:load` re-enables
|
||||
a mod carrying this field whenever that variable is set to `"1"`,
|
||||
regardless of a saved disable in `options.mods`. This exists for exactly
|
||||
the mod class this RFC is for: a platform-bridge mod that ships only with
|
||||
one build and cannot function disabled there, but must still behave like
|
||||
every other mod (a manifest opt-in, not an engine special case) on every
|
||||
build that doesn't set its variable.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing.** With no subscriber, `love.update` still calls `Game:update(dt)`
|
||||
unconditionally every frame and `love.quit()`'s restart-to-launcher
|
||||
decision is exactly the pre-existing condition -- bit-identical to today's
|
||||
behavior on every platform where no mod wraps either hook. A manifest with
|
||||
no `force_enable_env` field behaves exactly as before.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** `core.update`'s vanilla runs exactly once per call with the
|
||||
hook chain empty; `core.quit_to_launcher`'s vanilla return value passes
|
||||
through unchanged. Both hooks are picked up automatically by the
|
||||
catalog-driven no-mod gate (`tests/engine/gate_hooks.lua`, which scans
|
||||
for `Runtime.call("...")` call sites), so neither needs a dedicated
|
||||
no-mod test file.
|
||||
- **Mod-API:** `tests/modkit/cases/platform_lifecycle_hooks.lua` proves,
|
||||
through a fixture mod loaded via the public loader (not the engine's
|
||||
internals), that a subscriber can skip the vanilla update call (pause),
|
||||
run extra per-frame polling regardless of pause state, and veto the
|
||||
quit-to-launcher decision without the vanilla condition ever running.
|
||||
- `tests/mod_loader_tests.lua` and `tests/mod_manifest_tests.lua` cover
|
||||
`force_enable_env`: a matching env var re-enables a mod saved as
|
||||
disabled, and an unset one leaves the saved disable alone.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated. These are two additive hooks and one additive manifest
|
||||
field; `main.lua`'s only footprint is one `require` and two call sites
|
||||
into `src/core/PlatformHooks.lua`.
|
||||
@@ -0,0 +1,212 @@
|
||||
# RFC 0007 — Per-category GAME SPEED and the `core.logic_speed` hook
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `GameSpeed.lua`, `Game.lua`, `BattleState.lua`,
|
||||
`OptionsMenu.lua`, `SaveData.lua`, `LauncherSettings.lua`. Tests:
|
||||
`tests/engine/game_speed_categories_test.lua`,
|
||||
`tests/engine/gate_hooks.lua` (structural, automatic), `tests/run_tests.lua`
|
||||
(OptionsMenu row walk), `tests/mod_ui_tests.lua` (row id/order).
|
||||
|
||||
## Motivation
|
||||
|
||||
`GameSpeed` (`src/core/GameSpeed.lua`) is a single fast-forward multiplier
|
||||
applied uniformly to the whole logic clock in `Game:logicSpeed()` /
|
||||
`Game:update()` -- overworld walking, menu navigation and battle turns all
|
||||
scale together. A player who wants 4X battles (grinding, a long gym fight)
|
||||
but 1X overworld (so a scripted cutscene or NPC dialogue doesn't blur past)
|
||||
has no way to get both; the one GAME SPEED row is a single ladder that
|
||||
applies everywhere at once.
|
||||
|
||||
This needs to be an engine change, not a mod: there is no per-frame seam a
|
||||
mod can use to swap the multiplier mid-step, and no public event granular
|
||||
enough to say "which category is active" (`screen.pushed`/`screen.popped`
|
||||
and `battle.started`/`battle.ended` are the closest and are not enough --
|
||||
see Decisions below). The engine's own speed resolution has to become
|
||||
category-aware.
|
||||
|
||||
A category-aware speed resolution is also the general seam a
|
||||
platform-launcher integration or automation tool needs to read or override
|
||||
the effective multiplier for a given frame without caring which category
|
||||
produced it -- this RFC's `core.logic_speed` hook is written for that case
|
||||
alongside the player-facing Options rows.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
No prior D-number. Extends `GameSpeed.lua`'s multiplier ladder (unchanged)
|
||||
with per-category resolution.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible except for one save-data field rename, which ships with
|
||||
an automatic migration (see below) -- nothing in the public mod API (hooks,
|
||||
events, registries, `mod.*`) is renamed or removed.
|
||||
|
||||
### `save.options`: `speed` -> `speedOverworld` / `speedBattle` / `speedMenu`
|
||||
|
||||
`GameSpeed.CATEGORIES = { "overworld", "battle", "menu" }` is the new list
|
||||
of categories, and `GameSpeed.optionKey(category)` maps a category to its
|
||||
`save.options` field name (`"overworld"` -> `"speedOverworld"`, etc.).
|
||||
`GameSpeed.LEVELS`, `.DEFAULT`, `.levelLabel`, `.clamp` and `.cycle` are
|
||||
unchanged -- the ladder and its behavior are exactly what they were, just
|
||||
applied three times instead of once.
|
||||
|
||||
`SaveData.defaultOptions()` drops `speed = 1` and adds `speedOverworld = 1`,
|
||||
`speedBattle = 1`, `speedMenu = 1`. `SaveData.mergeOptions()` migrates: a
|
||||
loaded options table that still has `speed` and none of the three new
|
||||
fields seeds all three from it, so an existing player's fast-forward
|
||||
preference carries over instead of two of the three categories silently
|
||||
resetting to 1X. `speed` is dropped on the way out (not carried forward),
|
||||
so a re-save never re-triggers the migration.
|
||||
|
||||
### `Game.speedCategoryInStack(stack)`
|
||||
|
||||
New static helper, `(stack) -> "battle" | "overworld" | "menu"`. Walks the
|
||||
whole state stack top-down -- the same idiom `Game.wideBattleInStack` and
|
||||
`Game.fillScaleInStack` already use -- looking for `state.isBattle` (new
|
||||
marker, `BattleState.isBattle = true`, covering every battle: wild,
|
||||
trainer, link, safari, the old-man demo) or `state.isOverworld` (existing
|
||||
marker, `OverworldController`'s `OverworldState.isOverworld = true`). The
|
||||
first match wins; a state with neither marker (a menu, a text box, a
|
||||
naming screen, a cutscene) is transparent to the walk and falls through to
|
||||
whatever is under it. Nothing in the stack matching either falls back to
|
||||
`"menu"`.
|
||||
|
||||
### `Game:logicSpeed()` / `Game:_resolveLogicSpeed()`
|
||||
|
||||
`Game:_resolveLogicSpeed()` is new: it resolves `Game.speedCategoryInStack`
|
||||
against the live stack, maps the category to its `save.options` key via
|
||||
`GameSpeed.optionKey`, and returns `GameSpeed.clamp` of that option (or
|
||||
`GameSpeed.DEFAULT`). This is the exact category-resolution logic the new
|
||||
hook wraps.
|
||||
|
||||
`Game:logicSpeed()` keeps its existing early returns -- link play forces
|
||||
`1`, a run-argument speed override wins over the saved option -- unchanged,
|
||||
and in the same order, before ever calling the hook. Only once neither
|
||||
applies does it call the `core.logic_speed` hook.
|
||||
|
||||
### `Game:_cycleSpeed(dir)`
|
||||
|
||||
The keyboard hotkey and the gamepad shoulders/triggers that used to cycle
|
||||
the single `speed` option now cycle whichever category
|
||||
`Game.speedCategoryInStack` says is active: pressing the hotkey during a
|
||||
battle speeds up just the battle, on the overworld just the walk, in a menu
|
||||
just the menu. This is the natural per-category answer for a control that
|
||||
used to have one option to reach and now has three -- see Decisions below
|
||||
for why this reading was chosen over, say, always cycling `overworld`.
|
||||
|
||||
### `core.logic_speed`
|
||||
|
||||
New hook, `(game) -> number` through the public wrapper signature
|
||||
`(next, game)`, called once per `Game:logicSpeed()` (i.e. once per frame).
|
||||
Vanilla behavior (used when no mod claims the hook) is
|
||||
`Game:_resolveLogicSpeed()` -- exactly the category resolution above,
|
||||
nothing else. A subscriber may call `next(game)` and return its result to
|
||||
pass the vanilla multiplier through, or return a different number outright
|
||||
to override it for that frame (e.g. a bot mod forcing `1` during one route
|
||||
segment regardless of what category or option is active).
|
||||
|
||||
This intentionally sits *after* the link and speed-override checks in
|
||||
`Game:logicSpeed()`, not around them: link play staying locked to 1X "no
|
||||
matter what either player set this to" is exactly the invariant that would
|
||||
break if a mod's hook could override it, and the run-argument override
|
||||
exists so a bot/screenshot run's speed does not depend on a mod any more
|
||||
than on the player's saved option. Both stay unconditional early returns a
|
||||
mod never sees.
|
||||
|
||||
Not guarded by `Runtime.wantsHook`: `Hooks:call` already fast-paths to a
|
||||
bare `vanilla(...)` call when no mod has wrapped the name, and this hook
|
||||
fires every frame regardless.
|
||||
|
||||
## Decisions on the issue's open questions
|
||||
|
||||
**1. Overlays on top of another category's state (a party menu, a choice
|
||||
box, a naming screen opened mid-battle or mid-overworld).** Resolved by
|
||||
making the category a property of stack *position*, not of the overlay's
|
||||
own type: an overlay with no `isBattle`/`isOverworld` marker is transparent
|
||||
to `Game.speedCategoryInStack`'s walk and inherits whatever is under it. A
|
||||
party swap opened mid-battle reads as `"battle"`; a bag opened while
|
||||
walking reads as `"overworld"`. This was chosen over giving every UI state
|
||||
its own fixed category (which would make a fast-forwarded battle visibly
|
||||
stutter back to 1X every time its party menu opens) because it matches
|
||||
what the player is actually doing moment to moment, and it reuses a
|
||||
pattern the codebase already leans on for exactly this "menus opened over
|
||||
X should behave like X" class of problem (`Game.fillScaleInStack`,
|
||||
`Game.wideBattleInStack`).
|
||||
|
||||
**2. Cutscenes/scripts.** No fourth category. A scripted sequence runs
|
||||
through the owning state's own machinery -- the overworld's script runner
|
||||
or a battle's message queue -- rather than pushing a state of its own, so
|
||||
it is already covered by decision 1: it inherits whatever category the
|
||||
state driving it resolves to. A cutscene state that genuinely has nothing
|
||||
under it (a pre-game intro) falls to `"menu"`, the default for anything
|
||||
that is not battle or overworld gameplay -- consistent with those being
|
||||
pre-game presentation, not something a player is likely to want scaled
|
||||
differently from menu navigation.
|
||||
|
||||
**3. Category granularity (splitting "menu" further).** Deferred. Start
|
||||
with the three named here; `GameSpeed.CATEGORIES` and `GameSpeed.optionKey`
|
||||
are written so adding a fourth later (a Pokédex/Bag category, say) is one
|
||||
entry plus one new `save.options` field, not a resolution-logic rewrite.
|
||||
No current request motivates it.
|
||||
|
||||
**4. The GAME SPEED hotkey/shoulder buttons, once "the" speed is three
|
||||
things.** `Game:_cycleSpeed` now cycles whichever category is currently
|
||||
active (`Game.speedCategoryInStack`), rather than, say, always cycling
|
||||
`overworld` or requiring a modifier key to pick a category. A single
|
||||
physical control that means "speed up whatever I'm looking at right now"
|
||||
is the reading that needs no new UI and matches what a player pressing it
|
||||
mid-battle almost certainly wants.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
**Nothing**, for the mod API surface: `content.X:register/override/get`,
|
||||
`events:on`, `hooks:wrap`, `mod.log`, `mod:read`, manifest v1 fields are
|
||||
untouched, and `GameSpeed.LEVELS`/`.DEFAULT`/`.levelLabel`/`.clamp`/`.cycle`
|
||||
keep their exact signatures and behavior.
|
||||
|
||||
**One save-data field**, for anything that read `save.options.speed`
|
||||
directly (not a formal registry/hook surface, but worth naming): it is
|
||||
superseded by `speedOverworld`/`speedBattle`/`speedMenu`, migrated
|
||||
automatically on load (see above) so a save from before this RFC keeps its
|
||||
player's chosen speed. A mod reading `save.options.speed` after this change
|
||||
sees `nil` (the key is dropped on migration, not kept as a stale alias) and
|
||||
should read the per-category fields, or hook `core.logic_speed` to observe
|
||||
the resolved multiplier directly regardless of which category produced it.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **No-mod:** `core.logic_speed` needs no dedicated no-mod test file --
|
||||
`tests/engine/gate_hooks.lua` walks the live hook catalog (which scans
|
||||
`src` for `Runtime.call("...")` call sites), so the new
|
||||
`Runtime.call("core.logic_speed", ...)` site is picked up and gated
|
||||
automatically: vanilla runs exactly once with an empty hook chain, an
|
||||
unsubscribed-but-live bus passes values and multiple returns through
|
||||
unchanged, and `Runtime.wantsHook` reads `false`.
|
||||
- **Mod-API:** `tests/engine/game_speed_categories_test.lua` exercises the
|
||||
hook through the public API (`Hooks.new()` + `bus:wrap("core.logic_speed",
|
||||
...)` + `Runtime.call`, the same idiom other hooks' tests use) -- a
|
||||
subscriber can read the vanilla category resolution via `next(game)` and
|
||||
can override it outright -- plus direct coverage of
|
||||
`Game.speedCategoryInStack` (battle-on-top, overworld-on-top, an overlay
|
||||
inheriting each, an empty/unmatched stack falling to `"menu"`) and
|
||||
`Game:logicSpeed()`'s precedence (link forces 1X over all three
|
||||
categories and over a hook override; the run-argument override wins over
|
||||
the category resolution).
|
||||
- `tests/run_tests.lua`'s OptionsMenu walk exercises the three new rows
|
||||
(OVERWORLD SPEED / BATTLE SPEED / MENU SPEED) cycling and wrapping
|
||||
independently, in place of the old single GAME SPEED row.
|
||||
- `tests/mod_ui_tests.lua`'s row-id/order check and hardcoded row-index
|
||||
activations (MODS, CONTROLS) are updated for the two extra rows.
|
||||
- A link-play driver should set all three per-category speeds high before
|
||||
asserting `game:logicSpeed()` reads `1` during a real link session,
|
||||
proving the lock wins over every category at once, not just whichever
|
||||
one happens to be active.
|
||||
|
||||
## Deprecation etiquette
|
||||
|
||||
Nothing deprecated in the mod-facing hook/event/registry catalog -- this
|
||||
adds one hook, additive. The `save.options.speed` field is superseded with
|
||||
an automatic migration rather than a deprecation notice, since it was never
|
||||
a registered mod-API surface (no schema entry, no registry) -- the same
|
||||
treatment any other `save.options` field would get if it needed reshaping.
|
||||
@@ -1,38 +1,66 @@
|
||||
# Build the Nintendo Switch NRO — contributor guide
|
||||
# Build Gen1Recomp for Nintendo Switch
|
||||
|
||||
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).
|
||||
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.
|
||||
> Releases ship `gen1recomp-*-switch.zip` (SD tree under `switch/gen1recomp/`).
|
||||
> Runtime target is pinned [love-nx](https://github.com/retronx-team/love-nx)
|
||||
> `11.5-nx1`. Player install and limitations: [switch-install.md](switch-install.md).
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites by OS
|
||||
|
||||
All packaging entrypoints are **bash**. On Windows, use Git Bash, MSYS2, or
|
||||
WSL — not cmd.exe or PowerShell (AD-008).
|
||||
WSL, not cmd.exe or PowerShell (AD-008).
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
1. Install [devkitPro pacman](https://devkitpro.org/wiki/devkitPro_pacman).
|
||||
2. Install Switch tools:
|
||||
2. Install Switch tools (**required for `--fused`**):
|
||||
|
||||
```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).
|
||||
3. OTA launcher toolchain, **native or Docker** (either is fine):
|
||||
|
||||
**Optional:** Install [Docker](https://docs.docker.com/get-docker/) so fused
|
||||
builds can fall back to the pinned image when native tools are missing.
|
||||
```sh
|
||||
bash scripts/switch/install_devkitpro_deps.sh # native
|
||||
# or install Docker (same pin as fused builds)
|
||||
```
|
||||
|
||||
4. Ensure `DEVKITPRO` is exported (typical macOS: `/opt/devkitpro`) and
|
||||
`nacptool` / `elf2nro` are on `PATH` (or under `$DEVKITPRO/tools/bin`).
|
||||
|
||||
Fused game builds can also use Docker when native `nacptool`/`elf2nro` are absent.
|
||||
|
||||
### Native OTA launcher (included in `--fused`)
|
||||
|
||||
In-console OTA uses a **separate DEVKITPRO NRO** (not LÖVE). The LÖVE
|
||||
self-updater (`Check.lua`) is disabled on NX. Source:
|
||||
`ports/switch/ota-launcher/`. Host protocol tests (no toolchain):
|
||||
|
||||
```sh
|
||||
make -C ports/switch/ota-launcher host-test
|
||||
# or
|
||||
scripts/switch/build_ota_launcher.sh # host-test first; NRO needs DEVKITPRO/Docker
|
||||
```
|
||||
|
||||
`--fused` always builds the fused game, native OTA launcher, and dual-NRO SD
|
||||
zip. The same `*-switch.zip` is the OTA download asset. **DEVKITPRO is
|
||||
required.** OTA launcher: native packages **or** Docker. Both are supported.
|
||||
|
||||
Release-like build from repo root:
|
||||
|
||||
```sh
|
||||
scripts/build_switch.sh --fetch --fused --version X.Y.Z
|
||||
```
|
||||
|
||||
See `ports/switch/ota-launcher/README.md` and
|
||||
`scripts/switch/ota_launcher.manifest`.
|
||||
|
||||
### Windows (Git Bash / MSYS2 / WSL)
|
||||
|
||||
@@ -42,16 +70,15 @@ builds can fall back to the pinned image when native tools are missing.
|
||||
- **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).
|
||||
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 |
|
||||
| bash, git, zip tooling the repo already expects | (none) |
|
||||
| `dkp-pacman` + `switch-dev` + OTA packages **or** Docker | `dkp-pacman -S …` |
|
||||
| A legal `.gb` ROM (to play) | Any ROM or game data |
|
||||
|
||||
---
|
||||
@@ -64,12 +91,12 @@ builds can fall back to the pinned image when native tools are missing.
|
||||
| ---- | ------------ |
|
||||
| `--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**. |
|
||||
| `--fused` | Builds fused game NRO, OTA launcher NRO, and dual-NRO SD zip. **Requires DEVKITPRO** + `switch-dev`. OTA launcher: native packages 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.
|
||||
- `--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
|
||||
@@ -109,13 +136,15 @@ 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
|
||||
# Fused game + OTA launcher + dual-NRO SD 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).
|
||||
The fused path also writes `gen1recomp-<ver>-switch.nro` (game),
|
||||
`gen1recomp-<ver>-launcher.nro`, `gen1recomp-<ver>-game.nro`,
|
||||
`gen1recomp-<ver>-switch.nro.sha256`, and `gen1recomp-<ver>-switch.zip`
|
||||
(+ `.sha256` sidecar for the zip).
|
||||
|
||||
Offline packaging smoke (no network, no nacptool required):
|
||||
|
||||
@@ -143,7 +172,7 @@ the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.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):
|
||||
1. **Offline selftest** on `ubuntu-latest` (forks **and** the main repo):
|
||||
`scripts/switch/selftest_build_switch.sh`,
|
||||
`scripts/switch/verify_payload.sh --self-test`,
|
||||
`luajit tests/switch_ci_workflows_test.lua`,
|
||||
@@ -151,14 +180,10 @@ or the Switch-related workflow YAML), CI runs:
|
||||
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
|
||||
2. **Fused NRO build** only on the **main** 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.
|
||||
head is that repo (same-repo push/PR). Fork CI never runs fused. Fork PRs into the main repo 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
|
||||
@@ -169,7 +194,7 @@ 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`):
|
||||
other platforms. This is a **hard gate** (no `continue-on-error`):
|
||||
|
||||
```sh
|
||||
scripts/build_switch.sh --fetch --fused --version "<release version>"
|
||||
@@ -181,9 +206,26 @@ A Switch packaging failure fails the entire release job. The release asset is
|
||||
|
||||
### 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.
|
||||
The self-hosted Mac runner **must** have **DEVKITPRO** installed and exported.
|
||||
`--fused` preflight fails early with setup steps if it is missing.
|
||||
|
||||
**One-time setup on the runner** (if not already present):
|
||||
|
||||
```sh
|
||||
# devkitPro pacman installer from https://devkitpro.org/wiki/devkitPro_pacman
|
||||
sudo dkp-pacman -S switch-dev
|
||||
export DEVKITPRO=/opt/devkitpro
|
||||
export PATH="$DEVKITPRO/tools/bin:$PATH"
|
||||
|
||||
# OTA launcher: pick one
|
||||
bash scripts/switch/install_devkitpro_deps.sh # native
|
||||
# or ensure Docker is installed (same pin as fused builds)
|
||||
```
|
||||
|
||||
CI and release still run `scripts/build_switch.sh --fetch --fused`. Preflight
|
||||
requires DEVKITPRO and either native OTA packages or Docker. Without all of
|
||||
that, the job fails with the setup steps above. Scripts never auto-run
|
||||
`dkp-pacman -S` during CI.
|
||||
|
||||
---
|
||||
|
||||
@@ -194,11 +236,9 @@ 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
|
||||
- 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).
|
||||
[switch-transfer.md](switch-transfer.md).
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
# 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`)
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -2,15 +2,11 @@
|
||||
|
||||
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
|
||||
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.
|
||||
> This project does not help you set that up.
|
||||
|
||||
Prefer building from source? See [switch-build.md](switch-build.md).
|
||||
|
||||
@@ -29,12 +25,18 @@ help from [booshankles](https://github.com/booshankles).
|
||||
Extract the zip at the **root** of the microSD so you get:
|
||||
|
||||
```text
|
||||
sdmc:/switch/gen1recomp/gen1recomp.nro
|
||||
sdmc:/switch/gen1recomp/gen1recomp.nro # native OTA launcher (hbmenu entry)
|
||||
sdmc:/switch/gen1recomp/gen1recomp-game.nro # fused LÖVE game
|
||||
sdmc:/switch/gen1recomp/version.txt
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/mods/
|
||||
sdmc:/switch/gen1recomp/pokemon-love2d/imports/saves/...
|
||||
```
|
||||
|
||||
Older single-NRO zips only had `gen1recomp.nro` (the fused game). Current
|
||||
releases use the dual-NRO layout above. Open `gen1recomp` in hbmenu (the
|
||||
launcher).
|
||||
|
||||
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
|
||||
@@ -42,17 +44,44 @@ 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.
|
||||
#### Native OTA launcher (in-console)
|
||||
|
||||
Switch OTA runs in a separate **native launcher NRO** (libnx + curl), not the
|
||||
LÖVE self-updater (`src/update/Check.lua`). hbmenu opens `gen1recomp.nro`.
|
||||
|
||||
When a newer release exists, the launcher downloads the same install zip
|
||||
(`gen1recomp-*-switch.zip`), checks SHA-256 against `sha256sums.txt`, replaces
|
||||
both `gen1recomp-game.nro` and `gen1recomp.nro` (keeps NACP version in sync
|
||||
for hbmenu and Sphaira), then loads the game with `envSetNextLoad`.
|
||||
|
||||
If you are up to date or offline, it skips straight to the game with no
|
||||
prompt. If an update is available, you get a short prompt styled like the
|
||||
in-game launcher: black background, RGB rail, logo, A/B buttons. Saves under
|
||||
`pokemon-love2d/` are not touched. See `src/update/SwitchOta.lua` for the
|
||||
wire format.
|
||||
|
||||
The LÖVE self-updater stays **disabled** on NX (`networkValidated == false`).
|
||||
|
||||
**Sphaira forwarder (HOME shortcut):** Sphaira copies name/version/icon into
|
||||
the installed forwarder at creation time. After an OTA (or zip) update, the
|
||||
`.nro` on the microSD already has the new version, but the HOME shortcut
|
||||
keeps the old badge until you **reinstall the forwarder once** in Sphaira
|
||||
(Install Forwarder again on `gen1recomp.nro`). Browsing the NRO in Sphaira /
|
||||
hbmenu always shows the live file version.
|
||||
|
||||
#### Manual zip (fallback)
|
||||
|
||||
Use the **same** extract/merge of `gen1recomp-*-switch.zip`. It replaces the
|
||||
NROs (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
|
||||
2. Hold **R** and launch that title. This opens hbmenu with full memory
|
||||
(title override).
|
||||
3. From hbmenu, open `gen1recomp`.
|
||||
|
||||
@@ -66,14 +95,14 @@ This project ships **no** game data. On first launch:
|
||||
(`.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
|
||||
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**:
|
||||
SD / FTP, same transfer methods as ROMs. Paths are **per game**:
|
||||
|
||||
| Game | Import inbox | Export folder |
|
||||
| ---- | ------------ | ------------- |
|
||||
@@ -81,19 +110,19 @@ SD / FTP — same transfer methods as ROMs. Paths are **per game**:
|
||||
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
||||
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
||||
|
||||
(Under the save dir `pokemon-love2d/` — the zip already creates these folders.)
|
||||
(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
|
||||
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** →
|
||||
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.
|
||||
from that game's **`exports/<game>/`** folder via MTP / SD / FTP.
|
||||
|
||||
Do not put `.sav` files into git. Prefer clean copies — some MTP clients
|
||||
Do not put `.sav` files into git. Prefer clean copies. Some MTP clients
|
||||
create `._*.sav` AppleDouble sidecars that are not real saves.
|
||||
|
||||
## Controls
|
||||
@@ -130,12 +159,12 @@ create `._*.sav` AppleDouble sidecars that are not real saves.
|
||||
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)).
|
||||
launcher shows (MTP / SD / FTP. See [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
|
||||
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)
|
||||
@@ -153,12 +182,23 @@ hotkeys (`2`/`3`/`5` are claimed before any mod pipeline hotkey runs).
|
||||
| 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).
|
||||
`LOW` or `BALANCED`.
|
||||
|
||||
## Limitations
|
||||
|
||||
- You need homebrew (custom firmware, hbmenu). This project does not set that
|
||||
up.
|
||||
- Launch with title override (hold **R** on a title). Applet Mode (Album) is
|
||||
not supported. The game needs full memory.
|
||||
- ROMs, mods, and saves are copied manually via MTP, direct SD, or FTP. There
|
||||
is no automated deploy.
|
||||
- Updates use the native OTA launcher only. The LÖVE self-updater and remote
|
||||
**FIND MODS** stay off on Switch.
|
||||
- Tested on Switch OLED. Switch V1 / Erista boot confirmed by the community.
|
||||
Other models may work but are less tested.
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
# 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.
|
||||
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**.
|
||||
This is the 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).
|
||||
[switch-build.md](switch-build.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.
|
||||
> for a fast contrib rebuild loop; deferred (AD-009). Do not treat netloader as
|
||||
> the release or ROM/mod install path.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,7 +22,7 @@ Player install (what to download, title override) stays in
|
||||
| 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 `.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 |
|
||||
@@ -34,7 +33,7 @@ files, or third-party mod zips to git.
|
||||
|
||||
---
|
||||
|
||||
## Canonical methods
|
||||
## Transfer methods
|
||||
|
||||
### 1. MTP (DBI responder + host client)
|
||||
|
||||
@@ -47,8 +46,8 @@ 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.
|
||||
[OpenMTP](https://github.com/ganeshrvel/openmtp) is a documented example for
|
||||
macOS, not a Mac-only requirement.
|
||||
|
||||
1. Quit other MTP clients.
|
||||
2. Open OpenMTP → select the DBI device → **`1: SD Card`**.
|
||||
@@ -60,25 +59,25 @@ hardware evidence — **one contributor example**, not a Mac-only product rule.
|
||||
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
|
||||
`._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).
|
||||
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)
|
||||
"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.
|
||||
card reader) or **FTP** instead. Same destinations in the table above.
|
||||
|
||||
#### Windows
|
||||
|
||||
@@ -93,7 +92,7 @@ card reader) or **FTP** instead — same destinations in the table above.
|
||||
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.
|
||||
card reader) or **FTP** instead. Same destinations in the table above.
|
||||
|
||||
### 2. Direct SD (Hekate UMS or card reader)
|
||||
|
||||
@@ -109,9 +108,9 @@ 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).
|
||||
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
|
||||
@@ -154,7 +153,7 @@ Copy the file back from the SD and compare hashes. Round-trip must match.
|
||||
| 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` |
|
||||
| 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 |
|
||||
|
||||
@@ -164,5 +163,3 @@ Copy the file back from the SD and compare hashes. Round-trip must match.
|
||||
|
||||
- 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)
|
||||
|
||||
@@ -124,3 +124,8 @@ bundled game, in that case.
|
||||
`love.filesystem.isFused()` is false, and a working tree's `engine` is the
|
||||
`"0.0.0-dev"` placeholder that always reports up to date, so a source
|
||||
checkout is always "the game" itself; updating it means pulling the repo.
|
||||
- **Nintendo Switch does not use this LÖVE self-updater.** On NX,
|
||||
`Platform.networkValidated()` is `false`, so `Boot.run` / `Check` never
|
||||
download `.love` payloads. In-console OTA uses the **native OTA launcher**
|
||||
(DEVKITPRO), documented in [switch-install.md](switch-install.md). Wire
|
||||
format: `src/update/SwitchOta.lua`. Manual zip install remains the fallback.
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,686 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,385 +0,0 @@
|
||||
--- 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
|
||||
@@ -1,346 +0,0 @@
|
||||
---@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
|
||||
@@ -1,596 +0,0 @@
|
||||
---@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
|
||||
@@ -1,171 +0,0 @@
|
||||
-- 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 }
|
||||
@@ -1,843 +0,0 @@
|
||||
---@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
|
||||
@@ -1,232 +0,0 @@
|
||||
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
|
||||
@@ -1,269 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,583 +0,0 @@
|
||||
---@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
|
||||
@@ -1,336 +0,0 @@
|
||||
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
|
||||
@@ -1,160 +0,0 @@
|
||||
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
|
||||
@@ -1,380 +0,0 @@
|
||||
---@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
|
||||
@@ -1,174 +0,0 @@
|
||||
-- ====================
|
||||
-- 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
|
||||
@@ -1,88 +0,0 @@
|
||||
---@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
|
||||
@@ -1,748 +0,0 @@
|
||||
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
|
||||
@@ -1,697 +0,0 @@
|
||||
---@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
|
||||
@@ -1,202 +0,0 @@
|
||||
---@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
|
||||
@@ -1,217 +0,0 @@
|
||||
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
|
||||
@@ -1,351 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,560 +0,0 @@
|
||||
---@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
|
||||
@@ -1,505 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,124 +0,0 @@
|
||||
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
|
||||
@@ -1,719 +0,0 @@
|
||||
---@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
|
||||
@@ -1,790 +0,0 @@
|
||||
---@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
|
||||
@@ -1,183 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
---@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
|
||||
@@ -1,335 +0,0 @@
|
||||
--- 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
|
||||
@@ -1,35 +0,0 @@
|
||||
---@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
|
||||
@@ -1,245 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,344 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,282 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,132 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,264 +0,0 @@
|
||||
-- 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
|
||||
-- Clear any parent scissor clipping before drawing scrollbars so they render
|
||||
-- fully visible (scrollbars must not be clipped by ancestor overflow).
|
||||
love.graphics.setScissor()
|
||||
element._renderer:drawScrollbars(element, element.x, element.y, element.width, element.height, scrollbarDims)
|
||||
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
|
||||
@@ -1,206 +0,0 @@
|
||||
-- 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
|
||||
@@ -1,576 +0,0 @@
|
||||
-- modules/behaviors/TextEditable.lua
|
||||
--
|
||||
-- Concrete behavior: TextEditor subsystem ownership — text editing, cursor
|
||||
-- management, text selection, text-related input handling, and text-editor
|
||||
-- state save/restore.
|
||||
--
|
||||
-- This behavior consolidates the legacy `if self._textEditor` nil-guard
|
||||
-- patterns that previously lived inside Element.lua:
|
||||
--
|
||||
-- * TextEditor creation + immediate-mode state restore (formerly
|
||||
-- Element:_initSubSystems lines ~813-830 — the `if self.editable then
|
||||
-- self._textEditor = Element._TextEditor.new {...}` block).
|
||||
-- * Cursor-blink update (formerly Element:update line ~2810 —
|
||||
-- `if self._textEditor then self._textEditor:update(self, dt) end`).
|
||||
-- * The 27 text-editor delegate methods (formerly Element:setText /
|
||||
-- getText / setCursorPosition / setSelection / focus / textinput /
|
||||
-- keypressed / _handleTextClick / _handleTextDrag / ...). Each was a 3-line
|
||||
-- nil-guard stub (check `_textEditor`, forward call, end). They are now
|
||||
-- module-level functions on this behavior; Element retains only 1-line
|
||||
-- forwarders that route through `Element._TextEditable.<fn>(self, ...)`.
|
||||
-- * Text-editor state save/restore (formerly the textEditor branch of
|
||||
-- Element:saveState / Element:restoreState), including the cursor/selection
|
||||
-- field sync and the text-selection drag-tracking fields
|
||||
-- (`_mouseDownPosition` / `_textDragOccurred`).
|
||||
--
|
||||
-- Element retains the `self._textEditor` field for backward-compat field
|
||||
-- access (Renderer:drawText reads it directly for cursor/selection rendering);
|
||||
-- runtime state lives ON THE ELEMENT. The behavior itself is stateless +
|
||||
-- immutable + shared across elements.
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * Per-element runtime state lives ON THE ELEMENT (`self._textEditor`,
|
||||
-- `self._mouseDownPosition`, `self._textDragOccurred`). The behavior
|
||||
-- instance is stateless + immutable and shared across all editable
|
||||
-- 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.
|
||||
--
|
||||
-- onDraw is a no-op: text/cursor/selection rendering stays in the Renderer's
|
||||
-- command buffer (Layer 4 "text"), driven by the Thamed behavior's single
|
||||
-- `Renderer:draw` call. The Renderer's `drawText` already reads
|
||||
-- `element._textEditor` for cursor/selection, so TextEditable OWNS the
|
||||
-- subsystem that drawText consumes, but the draw dispatch stays in the
|
||||
-- renderer to preserve the unified transform/scissor command-buffer ordering
|
||||
-- (mirrors Selectable.onDraw's no-op precedent, where rendering is owned by a
|
||||
-- different layer). Hoisting drawText into this behavior's onDraw would
|
||||
-- double-render text, since the Renderer command buffer already emits a "text"
|
||||
-- layer for every element.
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Resolve the Element class from an element instance (mirrors Clickable /
|
||||
-- Selectable). `setmetatable({}, Element)` in `_construct` makes the instance
|
||||
-- metatable BE the Element class, so this yields Element._TextEditor,
|
||||
-- Element._textEditorDeps, 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 spec predicate: attach when the element is text-editable OR
|
||||
-- carries text content. onAttach only ALLOCATES a TextEditor when
|
||||
-- `element.editable` is true (preserving the pre-refactor creation invariant
|
||||
-- "TextEditor created iff editable"), so non-editable text labels attach the
|
||||
-- behavior but allocate no TextEditor — their onUpdate/onDraw/saveState are
|
||||
-- nil-guarded no-ops, and the Element forwarders route them through the
|
||||
-- non-editable branch of each delegate function (reads/writes `element.text`
|
||||
-- directly). This keeps shouldAttach faithful to the spec while preserving
|
||||
-- exact pre-refactor allocation behavior.
|
||||
local function shouldAttach(props)
|
||||
props = props or {}
|
||||
return props.editable == true or props.text ~= nil
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- onAttach — create the TextEditor (formerly Element:_initSubSystems lines
|
||||
-- ~813-830) and restore immediate-mode TextEditor state.
|
||||
-- ============================================================================
|
||||
|
||||
local function onAttach(element)
|
||||
local Element = ElementClass(element)
|
||||
|
||||
-- Only editable elements own a TextEditor. Preserves the exact pre-refactor
|
||||
-- creation guard (`if self.editable then ... end`) — non-editable text
|
||||
-- elements attach the behavior (so their forwarders route through a single
|
||||
-- code path) but allocate no TextEditor.
|
||||
if not element.editable then
|
||||
return
|
||||
end
|
||||
|
||||
-- Config is sourced from element fields (bound by _applyProps / _initVisualState
|
||||
-- before _attachBehaviors runs at the tail of Element.new) — NOT from raw
|
||||
-- props. The callbacks (onFocus/onBlur/onTextInput/onTextChange/onEnter) are
|
||||
-- schema-bound element fields by this point, and `element.text` is set by
|
||||
-- _initVisualState, so no `props` reference is needed here (the hook
|
||||
-- signature is `(element)`).
|
||||
element._textEditor = Element._TextEditor.new({
|
||||
editable = element.editable,
|
||||
multiline = element.multiline,
|
||||
passwordMode = element.passwordMode,
|
||||
textWrap = element.textWrap,
|
||||
maxLines = element.maxLines,
|
||||
maxLength = element.maxLength,
|
||||
placeholder = element.placeholder,
|
||||
inputType = element.inputType,
|
||||
textOverflow = element.textOverflow,
|
||||
scrollable = element.scrollable,
|
||||
autoGrow = element.autoGrow,
|
||||
selectOnFocus = element.selectOnFocus,
|
||||
cursorColor = element.cursorColor,
|
||||
selectionColor = element.selectionColor,
|
||||
cursorBlinkRate = element.cursorBlinkRate,
|
||||
text = element.text or "",
|
||||
onFocus = element.onFocus,
|
||||
onBlur = element.onBlur,
|
||||
onTextInput = element.onTextInput,
|
||||
onTextChange = element.onTextChange,
|
||||
onEnter = element.onEnter,
|
||||
}, Element._textEditorDeps)
|
||||
|
||||
-- Restore TextEditor state from StateManager in immediate mode. Mirrors the
|
||||
-- legacy _initSubSystems immediate-mode restore. Safe to run here (after
|
||||
-- _construct registered the element with StateManager) — the StateManager
|
||||
-- lookup is sparse and returns nil for a fresh element. 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.textEditor then
|
||||
element._textEditor:setState(state.textEditor, element)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function onDetach(element)
|
||||
-- Clear text-input callback closures read by TextEditor / KeyboardNavigation
|
||||
-- so the element's closure references can be collected in immediate mode
|
||||
-- (formerly part of Element:_cleanup). The TextEditor instance itself is
|
||||
-- INTENTIONALLY kept: Element:_cleanup preserves element structure for
|
||||
-- inspection (released when the element is GC'd).
|
||||
element.onTextInput = nil
|
||||
element.onTextChange = nil
|
||||
element.onEnter = nil
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- onUpdate — cursor-blink animation (formerly Element:update line ~2810).
|
||||
-- ============================================================================
|
||||
|
||||
-- Drives TextEditor:update (cursor blink + blink-pause timer). Guarded on
|
||||
-- `element._textEditor` because non-editable text elements attach this
|
||||
-- behavior (per shouldAttach) but own no TextEditor. Element:update contains
|
||||
-- zero text-editor references — the dispatch loop calls this hook.
|
||||
local function onUpdate(element, dt)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:update(element, dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- onDraw — no-op (see file header: text rendering stays in the Renderer
|
||||
-- command buffer driven by the Thamed behavior's Renderer:draw call).
|
||||
-- ============================================================================
|
||||
|
||||
local function onDraw() end
|
||||
|
||||
-- ============================================================================
|
||||
-- saveState / restoreState — TextEditor state + text-selection drag
|
||||
-- tracking (formerly the textEditor branch of Element:saveState /
|
||||
-- Element:restoreState, including the _mouseDownPosition / _textDragOccurred
|
||||
-- fields).
|
||||
-- ============================================================================
|
||||
|
||||
-- Returns a snapshot under the `textEditor` key to match the legacy immediate-
|
||||
-- mode restoreState contract (Element:restoreState looked up state.textEditor).
|
||||
-- The behavior-dispatch loop in Element:saveState merges behavior snapshots
|
||||
-- into the top-level state table, so returning { textEditor = ... } slots in
|
||||
-- identically to the old inline `state.textEditor = self._textEditor:getState()`
|
||||
-- assignment. The drag-tracking fields are merged at the top level too
|
||||
-- (matching the legacy `state._mouseDownPosition` / `state._textDragOccurred`
|
||||
-- assignments) since they are text-selection state.
|
||||
local function saveState(element)
|
||||
local textEditor = element._textEditor
|
||||
if not textEditor then
|
||||
-- Non-editable text element: still persist drag-tracking fields if set
|
||||
-- (they are only ever set for editable elements, but persist defensively).
|
||||
local hasDragState = element._mouseDownPosition ~= nil or element._textDragOccurred ~= nil
|
||||
if not hasDragState then
|
||||
return nil
|
||||
end
|
||||
local snapshot = {}
|
||||
if element._mouseDownPosition ~= nil then
|
||||
snapshot._mouseDownPosition = element._mouseDownPosition
|
||||
end
|
||||
if element._textDragOccurred ~= nil then
|
||||
snapshot._textDragOccurred = element._textDragOccurred
|
||||
end
|
||||
return snapshot
|
||||
end
|
||||
|
||||
local snapshot = { textEditor = textEditor:getState() }
|
||||
if element._mouseDownPosition ~= nil then
|
||||
snapshot._mouseDownPosition = element._mouseDownPosition
|
||||
end
|
||||
if element._textDragOccurred ~= nil then
|
||||
snapshot._textDragOccurred = element._textDragOccurred
|
||||
end
|
||||
return snapshot
|
||||
end
|
||||
|
||||
-- Consumes the previously-saved snapshot keyed under `textEditor` plus the
|
||||
-- drag-tracking fields. The behavior-dispatch loop passes the FULL top-level
|
||||
-- state table; this hook reads only its own slices, mirroring the legacy
|
||||
-- `if self._textEditor and state.textEditor then ... end` guard.
|
||||
local function restoreState(element, state)
|
||||
if not state then
|
||||
return
|
||||
end
|
||||
local textEditor = element._textEditor
|
||||
if textEditor and state.textEditor then
|
||||
textEditor:setState(state.textEditor, element)
|
||||
-- Sync TextEditor's focus/cursor/selection state to Element for theme
|
||||
-- management (mirrors the legacy restoreState field sync).
|
||||
element._focused = textEditor._focused
|
||||
element._cursorPosition = textEditor._cursorPosition
|
||||
element._selectionStart = textEditor._selectionStart
|
||||
element._selectionEnd = textEditor._selectionEnd
|
||||
element._textBuffer = textEditor._textBuffer
|
||||
end
|
||||
|
||||
-- Restore drag-tracking state for text selection (top-level keys).
|
||||
if state._mouseDownPosition ~= nil then
|
||||
element._mouseDownPosition = state._mouseDownPosition
|
||||
end
|
||||
if state._textDragOccurred ~= nil then
|
||||
element._textDragOccurred = state._textDragOccurred
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Text-editor delegate functions.
|
||||
--
|
||||
-- These are the module-level implementations of the 27 text-editor delegate
|
||||
-- methods that previously lived on Element. Each mirrors the pre-refactor
|
||||
-- Element method body VERBATIM (with `self` → `element`), including the
|
||||
-- `element._textEditor` nil-guard: the guard is required because (a) non-
|
||||
-- editable text elements attach this behavior (per shouldAttach) but own no
|
||||
-- TextEditor, and (b) Element forwards these methods BEFORE onAttach has run
|
||||
-- (e.g. an `onCreate` callback firing during _finalizeConstruction, which
|
||||
-- runs before _attachBehaviors). The nil-guards live in THIS file (not in
|
||||
-- Element.lua), so the Element.lua `if self._textEditor` count drops to 0.
|
||||
--
|
||||
-- Element retains 1-line forwarders: `Element.setText = function(self, text)
|
||||
-- return Element._TextEditable.setText(self, text) end` (etc.), so external
|
||||
-- callers (EventHandler, KeyboardNavigation, game UI) keep working unchanged.
|
||||
--
|
||||
-- The TextEditor API is mixed: most methods take the element as first arg
|
||||
-- (`te:method(element, ...)` — "passesSelf"); a few getters omit it
|
||||
-- (`te:method()`). The delegation contract is pinned by
|
||||
-- subsystem_delegation_test.lua, so this mapping must match TextEditor's
|
||||
-- method signatures exactly.
|
||||
-- ============================================================================
|
||||
|
||||
-- --- Cursor management (passesSelf = element forwarded) ------------------
|
||||
|
||||
local function setCursorPosition(element, position)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:setCursorPosition(element, position)
|
||||
end
|
||||
end
|
||||
|
||||
local function getCursorPosition(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
return textEditor:getCursorPosition()
|
||||
end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function moveCursorBy(element, delta)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:moveCursorBy(element, delta)
|
||||
end
|
||||
end
|
||||
|
||||
local function moveCursorToStart(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:moveCursorToStart(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function moveCursorToEnd(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:moveCursorToEnd(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function moveCursorToLineStart(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:moveCursorToLineStart(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function moveCursorToLineEnd(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:moveCursorToLineEnd(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function moveCursorToPreviousWord(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:moveCursorToPreviousWord(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function moveCursorToNextWord(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:moveCursorToNextWord(element)
|
||||
end
|
||||
end
|
||||
|
||||
-- --- Selection management ------------------------------------------------
|
||||
|
||||
local function setSelection(element, startPos, endPos)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:setSelection(element, startPos, endPos)
|
||||
end
|
||||
end
|
||||
|
||||
local function getSelection(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
return textEditor:getSelection()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function hasSelection(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor ~= nil then
|
||||
return textEditor:hasSelection()
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function clearSelection(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:clearSelection(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function selectAll(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:selectAll(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function getSelectedText(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
return textEditor:getSelectedText()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function deleteSelection(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
return textEditor:deleteSelection(element)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- --- Focus management ----------------------------------------------------
|
||||
|
||||
local function focus(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:focus(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function blur(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:blur(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function isFocused(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
return textEditor:isFocused()
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- --- Text buffer management (with post-delegation sync) ------------------
|
||||
-- These methods sync `element.text` from the TextEditor result + drive
|
||||
-- auto-grow, exactly as the legacy Element methods did.
|
||||
|
||||
local function getText(element)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
return textEditor:getText()
|
||||
end
|
||||
return element.text or ""
|
||||
end
|
||||
|
||||
local function setText(element, text)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:setText(element, text)
|
||||
element.text = textEditor:getText() -- Sync display text
|
||||
textEditor:updateAutoGrowHeight(element)
|
||||
return
|
||||
end
|
||||
element.text = text
|
||||
end
|
||||
|
||||
local function insertText(element, text, position)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:insertText(element, text, position)
|
||||
element.text = textEditor:getText() -- Sync display text
|
||||
textEditor:updateAutoGrowHeight(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function deleteText(element, startPos, endPos)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:deleteText(element, startPos, endPos)
|
||||
element.text = textEditor:getText() -- Sync display text
|
||||
textEditor:updateAutoGrowHeight(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function replaceText(element, startPos, endPos, newText)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:replaceText(element, startPos, endPos, newText)
|
||||
element.text = textEditor:getText() -- Sync display text
|
||||
textEditor:updateAutoGrowHeight(element)
|
||||
end
|
||||
end
|
||||
|
||||
-- --- Mouse text selection ------------------------------------------------
|
||||
|
||||
local function handleTextClick(element, mouseX, mouseY, clickCount)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:handleTextClick(element, mouseX, mouseY, clickCount)
|
||||
-- Store mouse down position on element for drag tracking
|
||||
if clickCount == 1 then
|
||||
element._mouseDownPosition = textEditor:mouseToTextPosition(element, mouseX, mouseY)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function handleTextDrag(element, mouseX, mouseY)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:handleTextDrag(element, mouseX, mouseY)
|
||||
element._textDragOccurred = textEditor._textDragOccurred
|
||||
end
|
||||
end
|
||||
|
||||
-- --- Keyboard input ------------------------------------------------------
|
||||
|
||||
local function textinput(element, text)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:handleTextInput(element, text)
|
||||
element.text = textEditor:getText() -- Sync display text
|
||||
textEditor:updateAutoGrowHeight(element)
|
||||
end
|
||||
end
|
||||
|
||||
local function keypressed(element, key, scancode, isrepeat)
|
||||
local textEditor = element._textEditor
|
||||
if textEditor then
|
||||
textEditor:handleKeyPress(element, key, scancode, isrepeat)
|
||||
element.text = textEditor:getText() -- Sync display text
|
||||
textEditor:updateAutoGrowHeight(element)
|
||||
end
|
||||
end
|
||||
|
||||
-- ============================================================================
|
||||
-- Build the (stateless, shared, immutable) behavior instance + thin module
|
||||
-- table exposing the delegate functions (mirrors the Animated pattern).
|
||||
-- ============================================================================
|
||||
|
||||
local behavior = Behavior.new({
|
||||
onAttach = onAttach,
|
||||
onDetach = onDetach,
|
||||
onUpdate = onUpdate,
|
||||
onDraw = onDraw,
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
shouldAttach = shouldAttach,
|
||||
})
|
||||
|
||||
-- Thin module table: exposes the frozen behavior instance (for the registry)
|
||||
-- plus the text-editor delegate functions (for Element's 1-line forwarders).
|
||||
-- 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 TextEditable = {
|
||||
behavior = behavior,
|
||||
shouldAttach = shouldAttach,
|
||||
onAttach = onAttach,
|
||||
onUpdate = onUpdate,
|
||||
onDraw = onDraw,
|
||||
saveState = saveState,
|
||||
restoreState = restoreState,
|
||||
-- Text-editor delegate functions (Element forwarders route through these):
|
||||
setCursorPosition = setCursorPosition,
|
||||
getCursorPosition = getCursorPosition,
|
||||
moveCursorBy = moveCursorBy,
|
||||
moveCursorToStart = moveCursorToStart,
|
||||
moveCursorToEnd = moveCursorToEnd,
|
||||
moveCursorToLineStart = moveCursorToLineStart,
|
||||
moveCursorToLineEnd = moveCursorToLineEnd,
|
||||
moveCursorToPreviousWord = moveCursorToPreviousWord,
|
||||
moveCursorToNextWord = moveCursorToNextWord,
|
||||
setSelection = setSelection,
|
||||
getSelection = getSelection,
|
||||
hasSelection = hasSelection,
|
||||
clearSelection = clearSelection,
|
||||
selectAll = selectAll,
|
||||
getSelectedText = getSelectedText,
|
||||
deleteSelection = deleteSelection,
|
||||
focus = focus,
|
||||
blur = blur,
|
||||
isFocused = isFocused,
|
||||
getText = getText,
|
||||
setText = setText,
|
||||
insertText = insertText,
|
||||
deleteText = deleteText,
|
||||
replaceText = replaceText,
|
||||
_handleTextClick = handleTextClick,
|
||||
_handleTextDrag = handleTextDrag,
|
||||
textinput = textinput,
|
||||
keypressed = keypressed,
|
||||
}
|
||||
|
||||
-- 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 / isBehavior parity.
|
||||
setmetatable(TextEditable, {
|
||||
__index = behavior,
|
||||
__tostring = function()
|
||||
return "TextEditable"
|
||||
end,
|
||||
})
|
||||
|
||||
return TextEditable
|
||||
@@ -1,178 +0,0 @@
|
||||
-- modules/behaviors/Themed.lua
|
||||
--
|
||||
-- Concrete behavior: Renderer ownership + theme-state rendering.
|
||||
--
|
||||
-- Themed owns the per-element Renderer instance and the single
|
||||
-- `Renderer:draw` call that paints the core visual layers (background, image,
|
||||
-- theme 9-patch, borders, text, customDraw). It is the behavior-mode-unification
|
||||
-- replacement for the former `_initImageAndRenderer` Renderer creation block and
|
||||
-- the former first `self._renderer:draw(self, backdropCanvas)` call in
|
||||
-- Element:draw (behavior-mode-unification task 07).
|
||||
--
|
||||
-- Attachment rule (shouldAttach): every renderable Element. The pre-refactor
|
||||
-- code unconditionally created a Renderer for every Element and unconditionally
|
||||
-- called `Renderer:draw` in Element:draw; Themed mirrors that invariant so the
|
||||
-- Renderer is always available to subsystems that depend on it (TextEditor font
|
||||
-- / wrap delegation, ScrollManager scrollbar drawing) AND so visual rendering of
|
||||
-- background / border / theme / image layers is preserved for every element.
|
||||
-- Restricting attachment to `themeComponent`-only elements would break editable
|
||||
-- text fields and scrollable containers (which need a Renderer for subsystem
|
||||
-- delegation even when they have no theme component). The 9-patch theme-state
|
||||
-- rendering within `Renderer:draw` is a no-op for elements without a
|
||||
-- `themeComponent`, so always-attaching carries no rendering cost.
|
||||
--
|
||||
-- Themed and Imageable are paired (both configure the same `element._renderer`):
|
||||
-- Themed.onAttach creates the Renderer with the theme/blur config; Imageable
|
||||
-- (attached for imagePath/image elements) enriches the SAME renderer instance with
|
||||
-- image config + deferred image loading. They share `element._renderer`.
|
||||
--
|
||||
-- onUpdate is a no-op: theme-state transitions are DRIVEN by the Clickable
|
||||
-- behavior (whose onUpdate recomputes hover/press/focus and calls
|
||||
-- `renderer:setThemeState`). Themed only READS that state for rendering, so it has
|
||||
-- no per-frame update work.
|
||||
--
|
||||
-- saveState owns the blur-region snapshot (`state.blur`): the per-frame blur
|
||||
-- geometry + radius/quality used by the Blur cache for invalidation (formerly
|
||||
-- the inline `if self.backdropBlur or self.contentBlur` block of
|
||||
-- Element:saveState — behavior-mode-unification task 12). restoreState is a
|
||||
-- no-op: blur cache data is used for invalidation, not restoration (the Blur
|
||||
-- cache is keyed by element id and cleared via `Blur.clearElementCache` from
|
||||
-- FlexLove.endFrame, not replayed through restoreState).
|
||||
--
|
||||
-- State ownership (per the locked Behavior contract):
|
||||
-- * Per-element runtime state lives ON THE ELEMENT (`element._renderer`,
|
||||
-- `element._themeState`, `element.backdropBlur`, `element.contentBlur`).
|
||||
-- The behavior instance is stateless and shared.
|
||||
-- * `element._renderer` is recreated on attach; onDetach is a no-op — the
|
||||
-- reference is released when the element is GC'd (Element:_cleanup keeps
|
||||
-- element structure for inspection).
|
||||
|
||||
local _pkg = (...):match("^(.-)behaviors%.") or "modules."
|
||||
local Behavior = require(_pkg .. "Behavior")
|
||||
|
||||
-- Resolve the Element class from an element instance (mirrors Clickable).
|
||||
-- Element instances are created via `setmetatable({}, Element)`, so their
|
||||
-- metatable IS the Element class — giving access to Element._Renderer,
|
||||
-- Element._rendererDeps, etc. without threading deps through the hook signature.
|
||||
local function ElementClass(element)
|
||||
return getmetatable(element)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- shouldAttach (class-level predicate, no element required)
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
-- Returns true for every renderable Element. See file header for the rationale:
|
||||
-- the pre-refactor invariant was "every Element has a Renderer; Element:draw
|
||||
-- always calls Renderer:draw", and Thamed is the behavior-system embodiment of
|
||||
-- that invariant. Returns true for `themeComponent`-bearing props (the spec's
|
||||
-- headline case) and for every other element so subsystems/rendering stay intact.
|
||||
local function shouldAttach(props)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onAttach — create the Renderer with theme/blur config (formerly the
|
||||
-- Renderer.new block of Element:_initImageAndRenderer).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onAttach(element)
|
||||
local Element = ElementClass(element)
|
||||
|
||||
-- Create-or-reuse the Renderer. Thamed is the first render behavior in the
|
||||
-- registry, so it normally creates the instance; Imageable (if attached) will
|
||||
-- reuse this same instance for image config. Guarded so Imageable-onAttach-
|
||||
-- first (defensive) does not clobber an existing renderer.
|
||||
if element._renderer then
|
||||
return
|
||||
end
|
||||
|
||||
-- NOTE: backgroundColor/borderColor/opacity/cornerRadius/themeComponent are
|
||||
-- intentionally NOT passed here. Renderer:draw() reads them from the element
|
||||
-- as the single source of truth (see Renderer.lua draw()). Only renderer-owned
|
||||
-- state (theme, blur) is cached on the renderer; image config is added by the
|
||||
-- Imageable behavior. border is element-sourced too.
|
||||
element._renderer = Element._Renderer.new({
|
||||
theme = element.theme,
|
||||
scaleCorners = element.scaleCorners,
|
||||
scalingAlgorithm = element.scalingAlgorithm,
|
||||
contentBlur = element.contentBlur,
|
||||
backdropBlur = element.backdropBlur,
|
||||
}, Element._rendererDeps)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onDraw — the single Renderer:draw call (formerly the first call in
|
||||
-- Element:draw). Paints all core visual layers for this element.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onDraw(element, ctx)
|
||||
local renderer = element._renderer
|
||||
if not renderer then
|
||||
return
|
||||
end
|
||||
renderer:draw(element, ctx and ctx.backdropCanvas)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- onDetach — no-op. Element:_cleanup preserves element structure for
|
||||
-- inspection (the original invariant), so the Renderer reference is released
|
||||
-- when the element is GC'd rather than torn down here. Present as an explicit
|
||||
-- hook so the behavior conforms to the full lifecycle contract.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function onDetach() end
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- saveState — blur-region snapshot (formerly the `blur` branch of
|
||||
-- Element:saveState). Returns `{ blur = {...} }` when the element configures a
|
||||
-- backdrop or content blur, so the Blur cache can invalidate by element id;
|
||||
-- nil otherwise. Mode-agnostic to match the legacy contract (the snapshot is
|
||||
-- only read back by the cache-invalidation path, which itself is
|
||||
-- immediate-mode-only via FlexLove.endFrame).
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local function saveState(element)
|
||||
if not (element.backdropBlur or element.contentBlur) then
|
||||
return nil
|
||||
end
|
||||
local blur = {
|
||||
_blurX = element.x,
|
||||
_blurY = element.y,
|
||||
_blurWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right),
|
||||
_blurHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom),
|
||||
}
|
||||
if element.backdropBlur then
|
||||
blur._backdropBlurRadius = element.backdropBlur.radius
|
||||
blur._backdropBlurQuality = element.backdropBlur.quality or 5
|
||||
end
|
||||
if element.contentBlur then
|
||||
blur._contentBlurRadius = element.contentBlur.radius
|
||||
blur._contentBlurQuality = element.contentBlur.quality or 5
|
||||
end
|
||||
return { blur = blur }
|
||||
end
|
||||
|
||||
-- restoreState — no-op: blur cache data is used for invalidation, not
|
||||
-- restoration (see file header). Present so the behavior conforms to the
|
||||
-- lifecycle contract without replaying geometry that the cache recomputes.
|
||||
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Build the (stateless, shared, immutable) behavior instance.
|
||||
-- ----------------------------------------------------------------------------
|
||||
|
||||
local Themed = Behavior.new({
|
||||
onAttach = onAttach,
|
||||
onDetach = onDetach,
|
||||
onUpdate = function() end,
|
||||
onDraw = onDraw,
|
||||
saveState = saveState,
|
||||
restoreState = function() end,
|
||||
})
|
||||
|
||||
-- Expose the predicate at module level so callers/tests can reference it
|
||||
-- directly without an element instance (mirrors Behavior.shouldAttach /
|
||||
-- Clickable.shouldAttach).
|
||||
Themed.shouldAttach = shouldAttach
|
||||
|
||||
return Themed
|
||||
@@ -1,662 +0,0 @@
|
||||
---@class SelectOptionProps
|
||||
---@field value any -- Stable option value owned by the parent select
|
||||
---@field label string? -- Optional label override, falls back to the element text
|
||||
---@field disabled boolean? -- Whether the option can be selected
|
||||
local SelectOptionProps = {}
|
||||
|
||||
---@class SelectParentProps
|
||||
---@field value any -- Currently selected option value
|
||||
---@field open boolean? -- Initial open state for the select container
|
||||
---@field placeholder string? -- Fallback text when no option is selected
|
||||
---@field selectFrame Element? -- Optional pre-instantiated dropdown container; intended to be unattached before being adopted by the select
|
||||
---@field onChange fun(element:Element, value:any, option:SelectOptionProps)? -- Called when selection changes
|
||||
local SelectParentProps = {}
|
||||
|
||||
---@class Animation
|
||||
local Animation = {}
|
||||
|
||||
---@class Color
|
||||
local Color = {}
|
||||
|
||||
---@class Theme
|
||||
local Theme = {}
|
||||
|
||||
---@class ThemeManager
|
||||
local ThemeManager = {}
|
||||
|
||||
--=====================================--
|
||||
-- For Animation.lua
|
||||
--=====================================--
|
||||
---@alias EasingFunction fun(t:number): number
|
||||
|
||||
---@class AnimationProps
|
||||
---@field duration number -- Duration in seconds
|
||||
---@field start table -- Starting values (can contain: width, height, opacity, x, y, gap, imageOpacity, backgroundColor, borderColor, textColor, padding, margin, cornerRadius, transform, etc.)
|
||||
---@field final table -- Final values (same properties as start)
|
||||
---@field easing string? -- Easing function name: "linear", "easeInQuad", "easeOutQuad", "easeInOutQuad", "easeInCubic", "easeOutCubic", "easeInOutCubic", "easeInQuart", "easeOutQuart", "easeInExpo", "easeOutExpo" (default: "linear")
|
||||
---@field keyframes AnimationKeyframe[]? -- Array of keyframes for complex animations
|
||||
---@field onStart fun(animation:Animation, element:Element?)? -- Called when animation starts
|
||||
---@field onUpdate fun(animation:Animation, element:Element?, progress:number)? -- Called each frame with progress (0-1)
|
||||
---@field onComplete fun(animation:Animation, element:Element?)? -- Called when animation completes
|
||||
---@field onCancel fun(animation:Animation, element:Element?)? -- Called when animation is cancelled
|
||||
---@field transform TransformProps? -- Additional transform properties (legacy support)
|
||||
---@field transition table? -- Transition properties (legacy support)
|
||||
local AnimationProps = {}
|
||||
|
||||
---@class Transform
|
||||
---@field rotate number? Rotation in radians (default: 0)
|
||||
---@field scaleX number? X-axis scale (default: 1)
|
||||
---@field scaleY number? Y-axis scale (default: 1)
|
||||
---@field translateX number? X translation in pixels (default: 0)
|
||||
---@field translateY number? Y translation in pixels (default: 0)
|
||||
---@field skewX number? X-axis skew in radians (default: 0)
|
||||
---@field skewY number? Y-axis skew in radians (default: 0)
|
||||
---@field originX number? Transform origin X (0-1, default: 0.5)
|
||||
---@field originY number? Transform origin Y (0-1, default: 0.5)
|
||||
local Transform = {}
|
||||
|
||||
---@alias TransformProps Transform
|
||||
|
||||
---@class TransitionProps
|
||||
---@field duration number?
|
||||
---@field easing string?
|
||||
---@field delay number?
|
||||
---@field onComplete fun(element:Element)?
|
||||
|
||||
--=====================================--
|
||||
-- For Element.lua
|
||||
--=====================================--
|
||||
---@class ElementProps
|
||||
---@field id string? -- Unique identifier for the element (auto-generated in immediate mode if not provided)
|
||||
---@field mode "immediate"|"retained"|nil -- Lifecycle mode override: "immediate" (auto-managed state), "retained" (manual state), nil (use global mode from FlexLove.getMode(), default)
|
||||
---@field parent Element? -- Parent element for hierarchical structure
|
||||
---@field x number|string|CalcObject? -- X coordinate: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0)
|
||||
---@field y number|string|CalcObject? -- Y coordinate: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: 0)
|
||||
---@field z number? -- Z-index for layering (default: 0, clamped to -999..999)
|
||||
---@field tabIndex number? -- Tab navigation order: >0 (explicit order, visited first), 0 or nil (natural document order), -1 (excluded from keyboard navigation)
|
||||
---@field width number|string|CalcObject? -- Width of the element: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: calculated automatically)
|
||||
---@field height number|string|CalcObject? -- Height of the element: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: calculated automatically)
|
||||
---@field minWidth number|string|CalcObject? -- Minimum width constraint: number (px), string ("50%", "10vw"), or CalcObject. Clamps both fixed `width` and the flex-distributed main size when horizontal.
|
||||
---@field maxWidth number|string|CalcObject? -- Maximum width constraint: number (px), string ("50%", "10vw"), or CalcObject. Clamps both fixed `width` and the flex-distributed main size when horizontal.
|
||||
---@field minHeight number|string|CalcObject? -- Minimum height constraint: number (px), string ("50%", "10vh"), or CalcObject. Clamps both fixed `height` and the flex-distributed main size when vertical.
|
||||
---@field maxHeight number|string|CalcObject? -- Maximum height constraint: number (px), string ("50%", "10vh"), or CalcObject. Clamps both fixed `height` and the flex-distributed main size when vertical.
|
||||
---@field top number|string|CalcObject? -- Offset from top edge: number (px), string ("50%", "10vh"), or CalcObject (CSS-style positioning)
|
||||
---@field right number|string|CalcObject? -- Offset from right edge: number (px), string ("50%", "10vw"), or CalcObject (CSS-style positioning)
|
||||
---@field bottom number|string|CalcObject? -- Offset from bottom edge: number (px), string ("50%", "10vh"), or CalcObject (CSS-style positioning)
|
||||
---@field left number|string|CalcObject? -- Offset from left edge: number (px), string ("50%", "10vw"), or CalcObject (CSS-style positioning)
|
||||
---@field border Border? -- Border configuration for the element
|
||||
---@field borderColor Color? -- Color of the border (default: black)
|
||||
---@field opacity number? -- Element opacity 0-1 (default: 1)
|
||||
---@field visibility "visible"|"hidden"? -- Element visibility (default: "visible")
|
||||
---@field display boolean? -- Whether element participates in layout, rendering, and hit testing (default: true). Set false for CSS display:none behavior (zero layout space, no rendering, no hit testing). NOTE: In retained mode, toggling at runtime requires setting the parent's `_dirty = true` or calling `layoutChildren()` on the parent to trigger re-layout.
|
||||
---@field backgroundColor Color? -- Background color (default: transparent)
|
||||
---@field cornerRadius number|{topLeft:number?, topRight:number?, bottomLeft:number?, bottomRight:number?}? -- Corner radius: number (all corners) or table for individual corners (default: 0)
|
||||
---@field gap number|string|CalcObject? -- Space between children elements: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0)
|
||||
---@field padding number|string|CalcObject|{top:number|string|CalcObject?, right:number|string|CalcObject?, bottom:number|string|CalcObject?, left:number|string|CalcObject?, horizontal:number|string|CalcObject?, vertical:number|string|CalcObject?}? -- Padding around children: single value, string, CalcObject for all sides, or table for individual sides (default: {top=0, right=0, bottom=0, left=0})
|
||||
---@field margin number|string|CalcObject|{top:number|string|CalcObject?, right:number|string|CalcObject?, bottom:number|string|CalcObject?, left:number|string|CalcObject?, horizontal:number|string|CalcObject?, vertical:number|string|CalcObject?}? -- Margin around element: single value, string, CalcObject for all sides, or table for individual sides (default: {top=0, right=0, bottom=0, left=0})
|
||||
---@field text string? -- Text content to display (default: nil)
|
||||
---@field textAlign TextAlignSpec? -- Alignment of the text content: simple string, compound string ("top-left"), or {horizontal, vertical} table (default: START)
|
||||
---@field textColor Color? -- Color of the text content (default: black or theme text color)
|
||||
---@field textSize number|string? -- Font size: number (px), string with units ("2vh", "10%"), or preset ("xxs"|"xs"|"sm"|"md"|"lg"|"xl"|"xxl"|"3xl"|"4xl") (default: "md" or 12px)
|
||||
---@field minTextSize number? -- Minimum text size in pixels for auto-scaling
|
||||
---@field maxTextSize number? -- Maximum text size in pixels for auto-scaling
|
||||
---@field fontFamily string? -- Font family name from theme or path to font file (default: theme default or system default, inherits from parent)
|
||||
---@field autoScaleText boolean? -- Whether text should auto-scale with window size (default: true)
|
||||
---@field positioning Positioning? -- Layout positioning mode: "absolute"|"relative"|"flex"|"grid" (default: RELATIVE)
|
||||
---@field flexDirection FlexDirection? -- Direction of flex layout: "horizontal"|"vertical"|"row"|"column"|"row-reverse"|"column-reverse"|"horizontal-reverse"|"vertical-reverse" (row→horizontal, column→vertical, row-reverse→horizontal-reverse, column-reverse→vertical-reverse, default: HORIZONTAL)
|
||||
---@field justifyContent JustifyContent? -- Alignment of items along main axis (default: FLEX_START)
|
||||
---@field alignItems AlignItems? -- Alignment of items along cross axis (default: STRETCH)
|
||||
---@field alignContent AlignContent? -- Alignment of lines in multi-line flex containers (default: STRETCH)
|
||||
---@field flexWrap FlexWrap? -- Whether children wrap to multiple lines: "nowrap"|"wrap"|"wrap-reverse" (default: NOWRAP)
|
||||
---@field flex number|string? -- Shorthand for flexGrow, flexShrink, flexBasis: number (flex-grow only), string ("1 0 auto"), or nil (default: nil)
|
||||
---@field flexGrow number? -- How much the element should grow relative to siblings (default: 0)
|
||||
---@field flexShrink number? -- How much the element should shrink relative to siblings (default: 1)
|
||||
---@field flexBasis number|string|CalcObject? -- Initial size before growing/shrinking: number (px), string ("50%", "10vw", "auto"), or CalcObject (default: "auto")
|
||||
---@field justifySelf JustifySelf? -- Alignment of the item itself along main axis (default: AUTO)
|
||||
---@field alignSelf AlignSelf? -- Alignment of the item itself along cross axis (default: AUTO)
|
||||
---@field onEvent fun(element:Element, event:InputEvent)? -- Callback function for interaction events
|
||||
---@field onEventDeferred boolean? -- Whether onEvent callback should be deferred until after canvases are released (default: false)
|
||||
---@field onFocus fun(element:Element)? -- Callback when element receives focus
|
||||
---@field onFocusDeferred boolean? -- Whether onFocus callback should be deferred (default: false)
|
||||
---@field dropFocusOnSelection boolean? -- Override keyboard-navigation focus drop after Enter/Space activation (default: nil, uses KeyboardNavigation.config.dropFocusOnSelection)
|
||||
---@field onBlur fun(element:Element)? -- Callback when element loses focus
|
||||
---@field onBlurDeferred boolean? -- Whether onBlur callback should be deferred (default: false)
|
||||
---@field onTextInput fun(element:Element, text:string)? -- Callback when text is input
|
||||
---@field onTextInputDeferred boolean? -- Whether onTextInput callback should be deferred (default: false)
|
||||
---@field onTextChange fun(element:Element, text:string)? -- Callback when text content changes
|
||||
---@field onTextChangeDeferred boolean? -- Whether onTextChange callback should be deferred (default: false)
|
||||
---@field onEnter fun(element:Element)? -- Callback when Enter key is pressed
|
||||
---@field onEnterDeferred boolean? -- Whether onEnter callback should be deferred (default: false)
|
||||
---@field onCreate fun(element:Element, props:table)? -- Callback when element is created, receives the element and original creation props
|
||||
---@field onCreateDeferred boolean? -- Whether onCreate callback should be deferred (default: false)
|
||||
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Callback for touch-specific events (touchpress, touchmove, touchrelease)
|
||||
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent callback should be deferred (default: false)
|
||||
---@field onGesture fun(element:Element, gesture:table)? -- Callback for recognized gestures (tap, swipe, pinch, etc.)
|
||||
---@field onGestureDeferred boolean? -- Whether onGesture callback should be deferred (default: false)
|
||||
---@field touchEnabled boolean? -- Whether the element responds to touch events (default: true)
|
||||
---@field multiTouchEnabled boolean? -- Whether the element supports multiple simultaneous touches (default: false)
|
||||
---@field transform TransformProps? -- Transform properties for animations and styling
|
||||
---@field transition TransitionProps? -- Transition settings for animations
|
||||
---@field customDraw fun(element:Element)? -- Custom rendering callback called after standard rendering but before visual feedback (default: nil)
|
||||
---@field gridRows number|table? -- Number of equal 1fr rows, or array of track specs (e.g. {"1fr","100px","auto"})
|
||||
---@field gridColumns number|table? -- Number of equal 1fr columns, or array of track specs (e.g. {"1fr","100px","auto"})
|
||||
---@field columnGap number|string|CalcObject? -- Gap between grid columns: number (px), string ("50%", "10vw"), or CalcObject from FlexLove.calc() (default: 0)
|
||||
---@field rowGap number|string|CalcObject? -- Gap between grid rows: number (px), string ("50%", "10vh"), or CalcObject from FlexLove.calc() (default: 0)
|
||||
---@field theme string? -- Theme name to use (e.g., "space", "metal"). Defaults to theme from flexlove.init()
|
||||
---@field themeComponent string? -- Theme component to use (e.g., "panel", "button", "input"). If nil, no theme is applied
|
||||
---@field disabled boolean? -- Whether the element is disabled (default: false)
|
||||
---@field active boolean? -- Whether the element is active/focused (for inputs, default: false)
|
||||
---@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false, or true when using themeComponent)
|
||||
---@field themeStateLock boolean|string? -- Lock theme state: true/"default" = lock to base state, false = normal behavior, string = specific state ("hover", "pressed", "active", "disabled") (default: false)
|
||||
---@field themeComponentDisabledStates string[]? -- List of theme states to suppress visually (e.g. {"hover", "pressed"}). Interaction logic still fires.
|
||||
---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions (default: sourced from theme or {1, 1})
|
||||
---@field scaleCorners number? -- Scale multiplier for 9-patch corners/edges. E.g., 2 = 2x size (overrides theme setting)
|
||||
---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-patch corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting)
|
||||
---@field contentBlur {radius:number, quality:number?}? -- Blur the element's content including children (radius: pixels, quality: 1-10, default(quality): 5)
|
||||
---@field backdropBlur {radius:number, quality:number?}? -- Blur content behind the element (radius: pixels, quality: 1-10, default(quality): 5)
|
||||
---@field editable boolean? -- Whether the element is editable (default: false)
|
||||
---@field multiline boolean? -- Whether the element supports multiple lines (default: false)
|
||||
---@field textWrap boolean|"word"|"char"? -- Text wrapping mode (default: false for single-line, "word" for multi-line)
|
||||
---@field maxLines number? -- Maximum number of lines (default: nil)
|
||||
---@field maxLength number? -- Maximum text length in characters (default: nil)
|
||||
---@field placeholder string? -- Placeholder text when empty (default: nil)
|
||||
---@field passwordMode boolean? -- Whether to display text as password (default: false, disables multiline)
|
||||
---@field inputType "text"|"number"|"email"|"url"? -- Input type for validation (default: "text")
|
||||
---@field textOverflow "clip"|"ellipsis"|"scroll"? -- Text overflow behavior (default: "clip")
|
||||
---@field scrollable boolean? -- Whether text is scrollable (default: false for single-line, true for multi-line)
|
||||
---@field autoGrow boolean? -- Whether element auto-grows with text (default: false for single-line, true for multi-line)
|
||||
---@field selectOnFocus boolean? -- Whether to select all text on focus (default: false)
|
||||
---@field cursorColor Color? -- Cursor color (default: nil, uses textColor)
|
||||
---@field selectionColor Color? -- Selection background color (default: nil, uses theme or default)
|
||||
---@field cursorBlinkRate number? -- Cursor blink rate in seconds (default: 0.5)
|
||||
---@field selectParent SelectParentProps? -- Parent-owned select/dropdown state and callbacks
|
||||
---@field selectOption SelectOptionProps? -- Option metadata attached to a child of a select parent
|
||||
---@field overflow "visible"|"hidden"|"scroll"|"auto"? -- Overflow behavior (default: "hidden")
|
||||
---@field overflowX "visible"|"hidden"|"scroll"|"auto"? -- X-axis overflow (overrides overflow)
|
||||
---@field overflowY "visible"|"hidden"|"scroll"|"auto"? -- Y-axis overflow (overrides overflow)
|
||||
---@field scrollbarWidth number? -- Width of scrollbar track in pixels (default: 12)
|
||||
---@field scrollbarColor Color? -- Scrollbar thumb color (default: Color.new(0.5, 0.5, 0.5, 0.8))
|
||||
---@field scrollbarTrackColor Color? -- Scrollbar track color (default: Color.new(0.2, 0.2, 0.2, 0.5))
|
||||
---@field scrollbarRadius number? -- Corner radius for scrollbar (default: 6)
|
||||
---@field scrollbarPadding number? -- Padding between scrollbar and edge (default: 2)
|
||||
---@field scrollSpeed number? -- Pixels per wheel notch (default: 20)
|
||||
---@field invertScroll boolean? -- Invert mouse wheel scroll direction (default: false)
|
||||
---@field smoothScrollEnabled boolean? -- Enable smooth scrolling animation for wheel events (default: false)
|
||||
---@field scrollBarStyle string? -- Scrollbar style name from theme (selects from theme.scrollbars, default: uses first scrollbar or fallback rendering)
|
||||
---@field scrollbarKnobOffset number|{x:number, y:number}|{horizontal:number, vertical:number}? -- Offset for scrollbar knob/handle position in pixels (number for both axes, or table for per-axis control, default: 0, adds to theme offset)
|
||||
---@field scrollbarPlacement "reserve-space"|"overlay"? -- Scrollbar rendering mode: "reserve-space" (reduces content area, default) or "overlay" (renders over content)
|
||||
---@field scrollbarBalance boolean? -- When true, reserve scrollbar space on both sides of content for visual balance (default: false)
|
||||
---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control, default: false)
|
||||
---@field imagePath string? -- Path to image file (auto-loads via ImageCache)
|
||||
---@field image love.Image? -- Image object to display
|
||||
---@field objectFit "fill"|"contain"|"cover"|"scale-down"|"none"? -- Image fit mode (default: "fill")
|
||||
---@field objectPosition string? -- Image position like "center center", "top left", "50% 50%" (default: "center center")
|
||||
---@field imageOpacity number? -- Image opacity 0-1 (default: 1, combines with element opacity)
|
||||
---@field imageRepeat "no-repeat"|"repeat"|"repeat-x"|"repeat-y"|"space"|"round"? -- Image repeat/tiling mode (default: "no-repeat")
|
||||
---@field imageTint Color? -- Color to tint the image (default: nil/white, no tint)
|
||||
---@field onImageLoad fun(element:Element, image:love.Image)? -- Callback when image loads successfully
|
||||
---@field onImageLoadDeferred boolean? -- Whether onImageLoad callback should be deferred (default: false)
|
||||
---@field onImageError fun(element:Element, error:string)? -- Callback when image fails to load
|
||||
---@field onImageErrorDeferred boolean? -- Whether onImageError callback should be deferred (default: false)
|
||||
---@field _scrollX number? -- Internal: scroll X position (restored in immediate mode)
|
||||
---@field _scrollY number? -- Internal: scroll Y position (restored in immediate mode)
|
||||
---@field children? ElementProps[]
|
||||
---@field userdata table? -- User-defined data storage for custom properties
|
||||
---@field ariaRole ARIA? -- ARIA role for screen readers (e.g., "button", "link", "dialog")
|
||||
---@field ariaLabel string? -- Accessible name for screen readers (overrides text content)
|
||||
---@field ariaDescribedBy string? -- ID of element that describes this element
|
||||
---@field ariaExpanded boolean? -- Whether element is expanded/collapsed (for containers)
|
||||
---@field ariaPressed boolean? -- Whether element is pressed (for toggle buttons)
|
||||
---@field ariaChecked boolean? -- Whether element is checked (for checkboxes/radios)
|
||||
---@field ariaDisabled boolean? -- Whether element is disabled (overrides disabled property)
|
||||
---@field ariaBusy boolean? -- Whether element is processing (for live regions)
|
||||
---@field ariaLive "off"|"polite"|"assertive"? -- Live region priority for announcements
|
||||
local ElementProps = {}
|
||||
|
||||
---@class Border
|
||||
---@field top boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
|
||||
---@field right boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
|
||||
---@field bottom boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
|
||||
---@field left boolean|number -- true sets width to 1px, number sets width to specified pixels (default: 0)
|
||||
local Border = {}
|
||||
|
||||
--=====================================--
|
||||
-- For KeyboardNavigation.lua
|
||||
--=====================================--
|
||||
---@class KeyboardNavigationKeyConfig
|
||||
---@field next string -- Key used to move to the next focusable element
|
||||
---@field previous string -- Key used to move to the previous focusable element
|
||||
---@field up string -- Key used for directional navigation upward
|
||||
---@field down string -- Key used for directional navigation downward
|
||||
---@field left string -- Key used for directional navigation leftward
|
||||
---@field right string -- Key used for directional navigation rightward
|
||||
---@field activate string[] -- Keys that activate the currently focused element
|
||||
---@field dismiss string -- Key used to dismiss or clear the currently focused element
|
||||
---@field toggleDebug string -- Key used to toggle keyboard-navigation debug tooling
|
||||
---@field inspect string -- Key used to inspect the currently focused element in developer tools
|
||||
local KeyboardNavigationKeyConfig = {}
|
||||
|
||||
---@class KeyboardNavigationDeveloperToolsConfig
|
||||
---@field enabled boolean? -- Enable keyboard-navigation developer tools (default: true)
|
||||
---@field showProperties boolean? -- Show focused element properties in developer tools (default: true)
|
||||
---@field highlightColor number[]? -- RGBA color used for keyboard-navigation debug highlighting (default: {1, 0.8, 0, 0.5})
|
||||
local KeyboardNavigationDeveloperToolsConfig = {}
|
||||
|
||||
---@class KeyboardNavigationFocusIndicatorConfig
|
||||
---@field enabled boolean? -- Enable the keyboard focus indicator (default: true)
|
||||
---@field color number[]? -- RGBA color of the focus indicator (default: {0.2, 0.6, 1.0, 0.8})
|
||||
---@field lineWidth number? -- Focus indicator stroke width in pixels (default: 2)
|
||||
---@field inset number? -- Offset from the element bounds in pixels (default: -3)
|
||||
---@field borderRadius number? -- Focus indicator border radius in pixels (default: 4)
|
||||
---@field animationDuration number? -- Focus indicator entrance animation duration in seconds (default: 0.15)
|
||||
---@field pulseEnabled boolean? -- Enable pulse animation for the focus indicator when supported
|
||||
---@field pulseDuration number? -- Seconds per pulse cycle
|
||||
---@field pulseScaleMin number? -- Minimum scale during pulse animation
|
||||
---@field pulseScaleMax number? -- Maximum scale during pulse animation
|
||||
---@field draw fun(element:Element, bounds:table, style:KeyboardNavigationFocusIndicatorConfig)? -- Custom focus indicator renderer
|
||||
local KeyboardNavigationFocusIndicatorConfig = {}
|
||||
|
||||
---@class KeyboardNavigationConfig
|
||||
---@field enabled boolean? -- Enable or disable keyboard navigation globally (default: true)
|
||||
---@field debugMode boolean? -- Enable keyboard-navigation debug logging (default: false)
|
||||
---@field keys KeyboardNavigationKeyConfig? -- Key bindings used by keyboard navigation
|
||||
---@field wrapAround boolean? -- Allow wrapping from last to first focusable element (default: true)
|
||||
---@field directionalNavigation boolean? -- Enable arrow-key directional navigation (default: true)
|
||||
---@field focusVisible boolean? -- Show the focus indicator for keyboard-driven focus (default: true)
|
||||
---@field autofocusOnCreate boolean? -- Auto-focus the first focusable element on creation (default: false)
|
||||
---@field dropFocusOnSelection boolean? -- Drop focus after Enter/Space activates an element (default: true)
|
||||
---@field developerTools KeyboardNavigationDeveloperToolsConfig? -- Developer tool settings for keyboard navigation
|
||||
---@field focusIndicator KeyboardNavigationFocusIndicatorConfig? -- Focus indicator style configuration
|
||||
local KeyboardNavigationConfig = {}
|
||||
|
||||
--=====================================--
|
||||
-- For FlexLove.init()
|
||||
--=====================================--
|
||||
---@class FlexLoveConfig
|
||||
---@field baseScale {width:number?, height:number?}? -- Base resolution for responsive scaling (default: nil, no scaling)
|
||||
---@field theme string|ThemeDefinition? -- Theme name (string) or ThemeDefinition to use (default: nil, no theme)
|
||||
---@field immediateMode boolean? -- Enable immediate mode (React-like, recreates UI each frame) vs retained mode (default: false)
|
||||
---@field autoFrameManagement boolean? -- Automatically call beginFrame/endFrame (default: false)
|
||||
---@field stateRetentionFrames number? -- Number of frames to retain unused state in immediate mode (default: 60)
|
||||
---@field maxStateEntries number? -- Maximum number of state entries before forcing cleanup (default: 1000)
|
||||
---@field includeStackTrace boolean? -- Include stack traces in error messages (default: true)
|
||||
---@field reportingLogLevel LOG_LEVEL? -- Error log level: 1: critical, 2: error, 3: warn, 4: info, 5: debug/all (default: 3:warn)
|
||||
---@field errorLogTarget string? -- Error log target: "console", "file", "both" (default: "console")
|
||||
---@field errorLogFile string? -- Path to error log file (default: "flexlove_errors.log")
|
||||
---@field errorLogMaxSize number? -- Maximum error log file size in bytes (default: 1048576, 1MB)
|
||||
---@field maxErrorLogFiles number? -- Maximum number of rotated error log files (default: 5)
|
||||
---@field errorLogRotateEnabled boolean? -- Enable error log rotation (default: true)
|
||||
---@field performanceMonitoring boolean? -- Enable performance monitoring (default: true)
|
||||
---@field performanceHudKey string? -- Key to toggle performance HUD (default: "f3")
|
||||
---@field performanceHudPosition {x:number, y:number}? -- Position of performance HUD (default: {x=10, y=10})
|
||||
---@field performanceWarningThreshold number? -- Frame time warning threshold in ms (default: 13.0)
|
||||
---@field performanceCriticalThreshold number? -- Frame time critical threshold in ms (default: 16.67)
|
||||
---@field performanceLogToConsole boolean? -- Log performance metrics to console (default: false)
|
||||
---@field performanceWarnings boolean? -- Enable performance warnings (default: false)
|
||||
---@field memoryProfiling boolean? -- Enable memory profiling (default: false, auto-enabled in immediate mode)
|
||||
---@field gcStrategy string? -- Garbage collection strategy: "auto", "periodic", "manual", "disabled" (default: "auto")
|
||||
---@field gcMemoryThreshold number? -- Memory threshold in MB before forcing GC (default: 100)
|
||||
---@field gcInterval number? -- Frames between GC steps in periodic mode (default: 60)
|
||||
---@field gcStepSize number? -- Work units per GC step, higher = more aggressive (default: 200)
|
||||
---@field immediateModeBlurOptimizations boolean? -- Cache blur canvases in immediate mode to avoid re-rendering each frame (default: true)
|
||||
---@field keyboardNavigation boolean|KeyboardNavigationConfig? -- Enable keyboard navigation with defaults (`true`) or provide configuration overrides
|
||||
---@field debugDraw boolean? -- Enable debug draw overlay showing element boundaries with random colors (default: false)
|
||||
---@field debugDrawKey string? -- Key to toggle debug draw overlay at runtime (default: nil, no toggle key)
|
||||
local FlexLoveConfig = {}
|
||||
|
||||
--=====================================--
|
||||
-- Public FlexLove API
|
||||
--=====================================--
|
||||
---@alias TextAlignCompound "top-left" | "top-center" | "top-right" | "center-left" | "center-center" | "center-right" | "bottom-left" | "bottom-center" | "bottom-right"
|
||||
---@alias TextAlignSpec TextAlign | TextAlignCompound | {horizontal: TextAlign, vertical: TextAlignVertical}
|
||||
|
||||
---@class FlexLoveEnums
|
||||
---@field TextAlign TextAlign
|
||||
---@field TextAlignVertical TextAlignVertical
|
||||
---@field Positioning Positioning
|
||||
---@field FlexDirection FlexDirection
|
||||
---@field JustifyContent JustifyContent
|
||||
---@field JustifySelf JustifySelf
|
||||
---@field AlignItems AlignItems
|
||||
---@field AlignSelf AlignSelf
|
||||
---@field AlignContent AlignContent
|
||||
---@field FlexWrap FlexWrap
|
||||
---@field TextSize TextSize
|
||||
---@field ImageRepeat ImageRepeat
|
||||
---@field ARIA ARIA
|
||||
local FlexLoveEnums = {}
|
||||
|
||||
---@class AnimationKeyframe
|
||||
---@field at number -- Normalized time position (0-1)
|
||||
---@field values table -- Property values at this keyframe
|
||||
---@field easing string|EasingFunction? -- Easing used between this and the next keyframe
|
||||
local AnimationKeyframe = {}
|
||||
|
||||
---@class AnimationGroupProps
|
||||
---@field animations Animation[] -- Animations to coordinate
|
||||
---@field mode "parallel"|"sequence"|"stagger"? -- Group playback mode (default: "parallel")
|
||||
---@field stagger number? -- Delay between staggered animations in seconds (default: 0.1)
|
||||
---@field onComplete fun(group:AnimationGroup)? -- Called when all animations complete
|
||||
---@field onStart fun(group:AnimationGroup)? -- Called when the group starts
|
||||
local AnimationGroupProps = {}
|
||||
|
||||
---@class AnimationGroup
|
||||
---@field animations Animation[]
|
||||
---@field mode "parallel"|"sequence"|"stagger"
|
||||
---@field stagger number
|
||||
---@field onComplete fun(group:AnimationGroup)?
|
||||
---@field onStart fun(group:AnimationGroup)?
|
||||
local AnimationGroup = {}
|
||||
|
||||
---@class Animation
|
||||
---@field duration number
|
||||
---@field start table
|
||||
---@field final table
|
||||
---@field elapsed number
|
||||
---@field easing EasingFunction
|
||||
---@field keyframes AnimationKeyframe[]?
|
||||
---@field transform TransformProps?
|
||||
---@field transition TransitionProps?
|
||||
---@field onStart fun(animation:Animation, element:Element?)?
|
||||
---@field onUpdate fun(animation:Animation, element:Element?, progress:number)?
|
||||
---@field onComplete fun(animation:Animation, element:Element?)?
|
||||
---@field onCancel fun(animation:Animation, element:Element?)?
|
||||
---@field update fun(self:Animation, dt:number, element:table?): boolean
|
||||
---@field findKeyframes fun(self:Animation, progress:number): AnimationKeyframe?, AnimationKeyframe?
|
||||
---@field lerpKeyframes fun(self:Animation, prevFrame:AnimationKeyframe, nextFrame:AnimationKeyframe, easedT:number): table
|
||||
---@field interpolate fun(self:Animation): table
|
||||
---@field apply fun(self:Animation, element:table)
|
||||
---@field pause fun(self:Animation)
|
||||
---@field resume fun(self:Animation)
|
||||
---@field isPaused fun(self:Animation): boolean
|
||||
---@field reverse fun(self:Animation)
|
||||
---@field isReversed fun(self:Animation): boolean
|
||||
---@field setSpeed fun(self:Animation, speed:number)
|
||||
---@field getSpeed fun(self:Animation): number
|
||||
---@field seek fun(self:Animation, time:number)
|
||||
---@field getState fun(self:Animation): string
|
||||
---@field cancel fun(self:Animation, element:table?)
|
||||
---@field reset fun(self:Animation)
|
||||
---@field getProgress fun(self:Animation): number
|
||||
---@field chain fun(self:Animation, nextAnimation:Animation|function): Animation
|
||||
---@field delay fun(self:Animation, seconds:number): Animation
|
||||
---@field repeatCount fun(self:Animation, count:number): Animation
|
||||
---@field yoyo fun(self:Animation, enabled:boolean?): Animation
|
||||
---@class AnimationModule
|
||||
---@field Easing table<string, EasingFunction|fun(...):EasingFunction> -- Built-in easing functions and easing factories
|
||||
---@field Transform table? -- Animation transform helpers exposed by the animation module
|
||||
---@field Group AnimationGroup -- Animation group class table
|
||||
---@field new fun(props:AnimationProps): Animation
|
||||
---@field fade fun(duration:number, fromOpacity:number, toOpacity:number, easing:string?): Animation
|
||||
---@field scale fun(duration:number, fromScale:{width:number, height:number}, toScale:{width:number, height:number}, easing:string?): Animation
|
||||
---@field keyframes fun(props:{duration:number, keyframes:AnimationKeyframe[], onStart:function?, onUpdate:function?, onComplete:function?, onCancel:function?}): Animation
|
||||
---@field chainSequence fun(animations:Animation[]): Animation
|
||||
local AnimationModule = {}
|
||||
|
||||
---@class ColorInputTable
|
||||
---@field [1] number?
|
||||
---@field [2] number?
|
||||
---@field [3] number?
|
||||
---@field [4] number?
|
||||
---@field r number?
|
||||
---@field g number?
|
||||
---@field b number?
|
||||
---@field a number?
|
||||
local ColorInputTable = {}
|
||||
|
||||
---@alias ColorInput string|Color|ColorInputTable
|
||||
|
||||
---@class ColorModule
|
||||
---@field new fun(r:number?, g:number?, b:number?, a:number?): Color
|
||||
---@field fromHex fun(hexWithTag:string): Color
|
||||
---@field validateColorChannel fun(value:any, max:number?): boolean, number?
|
||||
---@field validateHexColor fun(hex:string): boolean, string?
|
||||
---@field validateRGBColor fun(r:number, g:number, b:number, a:number?, max:number?): boolean, string?
|
||||
---@field isValidColorFormat fun(value:any): string?
|
||||
---@field sanitizeColor fun(value:any, default:Color?): Color
|
||||
---@field parse fun(value:any): Color
|
||||
---@field lerp fun(colorA:Color, colorB:Color, t:number): Color
|
||||
local ColorModule = {}
|
||||
|
||||
---@class ThemeManagerConfig
|
||||
---@field theme string? -- Theme name override
|
||||
---@field themeComponent string? -- Component name to resolve from the theme
|
||||
---@field disabled boolean? -- Force disabled theme state
|
||||
---@field active boolean? -- Force active theme state
|
||||
---@field disableHighlight boolean? -- Disable pressed highlight overlay
|
||||
---@field themeStateLock boolean|string? -- Lock the theme state to base/default or a named state
|
||||
---@field themeComponentDisabledStates string[]? -- List of theme states to suppress visually
|
||||
---@field scaleCorners number? -- Scale multiplier for 9-patch corners and edges
|
||||
---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for non-stretched theme regions
|
||||
local ThemeManagerConfig = {}
|
||||
|
||||
---@class ThemeRegion
|
||||
---@field x number
|
||||
---@field y number
|
||||
---@field w number
|
||||
---@field h number
|
||||
local ThemeRegion = {}
|
||||
|
||||
---@class ThemeComponent
|
||||
---@field atlas string|love.Image?
|
||||
---@field insets {left:number, top:number, right:number, bottom:number}?
|
||||
---@field regions {topLeft:ThemeRegion, topCenter:ThemeRegion, topRight:ThemeRegion, middleLeft:ThemeRegion, middleCenter:ThemeRegion, middleRight:ThemeRegion, bottomLeft:ThemeRegion, bottomCenter:ThemeRegion, bottomRight:ThemeRegion}?
|
||||
---@field stretch {horizontal:table<integer, string>, vertical:table<integer, string>}?
|
||||
---@field states table<string, ThemeComponent>?
|
||||
---@field contentAutoSizingMultiplier {width:number?, height:number?}?
|
||||
---@field scaleCorners number?
|
||||
---@field scalingAlgorithm "nearest"|"bilinear"?
|
||||
---@field knobOffset number|{x:number, y:number}|{horizontal:number, vertical:number}?
|
||||
local ThemeComponent = {}
|
||||
|
||||
---@class ThemeDefinition
|
||||
---@field name string
|
||||
---@field atlas string|love.Image?
|
||||
---@field components table<string, ThemeComponent>
|
||||
---@field scrollbars table<string, ThemeComponent>?
|
||||
---@field colors table<string, Color>?
|
||||
---@field fonts table<string, string>?
|
||||
---@field contentAutoSizingMultiplier {width:number?, height:number?}?
|
||||
local ThemeDefinition = {}
|
||||
|
||||
---@class Theme
|
||||
---@field name string
|
||||
---@field atlas love.Image?
|
||||
---@field atlasData love.ImageData?
|
||||
---@field components table<string, ThemeComponent>
|
||||
---@field scrollbars table<string, ThemeComponent>
|
||||
---@field colors table<string, Color>
|
||||
---@field fonts table<string, string>
|
||||
---@field contentAutoSizingMultiplier {width:number?, height:number?}?
|
||||
---@class ThemeManager
|
||||
---@field theme string?
|
||||
---@field themeComponent string?
|
||||
---@field disabled boolean
|
||||
---@field active boolean
|
||||
---@field disableHighlight boolean?
|
||||
---@field themeStateLock boolean|string?
|
||||
---@field themeComponentDisabledStates table<string, boolean>
|
||||
---@field scaleCorners number?
|
||||
---@field scalingAlgorithm "nearest"|"bilinear"?
|
||||
---@field updateState fun(self:ThemeManager, isHovered:boolean, isPressed:boolean, isFocused:boolean, isDisabled:boolean): string
|
||||
---@field getState fun(self:ThemeManager): string
|
||||
---@field setState fun(self:ThemeManager, state:string)
|
||||
---@field hasThemeComponent fun(self:ThemeManager): boolean
|
||||
---@field getTheme fun(self:ThemeManager): Theme?
|
||||
---@field getComponent fun(self:ThemeManager): ThemeComponent?
|
||||
---@field getStateComponent fun(self:ThemeManager): ThemeComponent?
|
||||
---@field getScrollbarComponent fun(self:ThemeManager, scrollbarName:string?): ThemeComponent?
|
||||
---@field getStyle fun(self:ThemeManager, property:string): any?
|
||||
---@field _getScaledContentPaddingForState fun(self:ThemeManager, state:string, borderBoxWidth:number, borderBoxHeight:number): table?
|
||||
---@field getScaledContentPaddingForState fun(self:ThemeManager, state:string, borderBoxWidth:number, borderBoxHeight:number): table? -- deprecated, use getScaledContentPadding
|
||||
---@field getScaledContentPadding fun(self:ThemeManager, borderBoxWidth:number, borderBoxHeight:number): table?
|
||||
---@field getContentAutoSizingMultiplier fun(self:ThemeManager): table?
|
||||
---@field getDefaultFontFamily fun(self:ThemeManager): string?
|
||||
---@field setTheme fun(self:ThemeManager, themeName:string?, componentName:string?)
|
||||
---@field validateThemeStateLock fun(self:ThemeManager): boolean
|
||||
---@class Color
|
||||
---@field r number
|
||||
---@field g number
|
||||
---@field b number
|
||||
---@field a number
|
||||
---@field toRGBA fun(self:Color): number, number, number, number
|
||||
---@class ThemeModule
|
||||
---@field Manager ThemeManager -- Theme manager class table
|
||||
---@field new fun(definition:ThemeDefinition): Theme
|
||||
---@field load fun(path:string): Theme?
|
||||
---@field setActive fun(themeOrName:string|Theme)
|
||||
---@field getActive fun(): Theme?
|
||||
---@field getComponent fun(componentName:string, state:string?): ThemeComponent?
|
||||
---@field getDefaultScrollbar fun(): ThemeComponent?
|
||||
---@field getScrollbar fun(scrollbarName:string, state:string?): ThemeComponent?
|
||||
---@field getFont fun(fontName:string): string?
|
||||
---@field getColor fun(colorName:string): Color?
|
||||
---@field hasActive fun(): boolean
|
||||
---@field getRegisteredThemes fun(): table<string, Theme>
|
||||
---@field getColorNames fun(): string[]
|
||||
---@field getAllColors fun(): table<string, Color>
|
||||
---@field getColorOrDefault fun(colorName:string, fallback:Color): Color
|
||||
---@field get fun(themeName:string): Theme?
|
||||
---@field validateTheme fun(theme:table?, options:table?): boolean, table
|
||||
---@field sanitizeTheme fun(theme:table?): table
|
||||
local ThemeModule = {}
|
||||
|
||||
---@class FlexLove
|
||||
---@field _VERSION string
|
||||
---@field _DESCRIPTION string
|
||||
---@field _URL string
|
||||
---@field _LICENSE string
|
||||
---@field Animation AnimationModule?
|
||||
---@field Color ColorModule
|
||||
---@field Theme ThemeModule?
|
||||
---@field enums FlexLoveEnums
|
||||
---@field isReady fun(): boolean
|
||||
---@field init fun(config:FlexLoveConfig?)
|
||||
---@field setKeyboardNavigationDebug fun(enabled:boolean)
|
||||
---@field enableKeyboardNavigation fun(config:KeyboardNavigationConfig?)
|
||||
---@field deferCallback fun(callback:function)
|
||||
---@field executeDeferredCallbacks fun()
|
||||
---@field resize fun()
|
||||
---@field setMode fun(mode:"immediate"|"retained")
|
||||
---@field getMode fun(): "immediate"|"retained"
|
||||
---@field beginFrame fun()
|
||||
---@field endFrame fun()
|
||||
---@field draw fun(gameDrawFunc:function|nil, postDrawFunc:function|nil)
|
||||
---@field getElementAtPosition fun(x:number, y:number): Element?
|
||||
---@field update fun(dt:number)
|
||||
---@field collectGarbage fun(mode:string?, stepSize:number?): number?
|
||||
---@field setGCStrategy fun(strategy:"auto"|"periodic"|"manual"|"disabled")
|
||||
---@field getGCStats fun(): GCStats
|
||||
---@field textinput fun(text:string)
|
||||
---@field keypressed fun(key:string, scancode:string, isrepeat:boolean)
|
||||
---@field wheelmoved fun(dx:number, dy:number)
|
||||
---@field touchpressed fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number)
|
||||
---@field touchmoved fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number)
|
||||
---@field touchreleased fun(id:lightuserdata, x:number, y:number, dx:number, dy:number, pressure:number)
|
||||
---@field getActiveTouchCount fun(): number
|
||||
---@field getTouchOwner fun(touchId:string): Element?
|
||||
---@field getById fun(id:string): Element?
|
||||
---@field destroy fun()
|
||||
---@field new fun(props:ElementProps, callback:function?): Element?
|
||||
---@field getStateCount fun(): number
|
||||
---@field clearState fun(id:string)
|
||||
---@field clearAllStates fun()
|
||||
---@field getStateStats fun(): table
|
||||
---@field calc fun(expr:string): CalcObject
|
||||
---@field getFocusedElement fun(): Element?
|
||||
---@field setFocusedElement fun(element:Element?)
|
||||
---@field clearFocus fun()
|
||||
---@field setDebugDraw fun(enabled:boolean)
|
||||
---@field getDebugDraw fun(): boolean
|
||||
local FlexLove = {}
|
||||
|
||||
--=====================================--
|
||||
-- For State Persistence
|
||||
--=====================================--
|
||||
---@class ElementStateData
|
||||
---@field _focused boolean?
|
||||
---@field eventHandler table? -- EventHandler state
|
||||
---@field textEditor table? -- TextEditor state
|
||||
---@field scrollManager table? -- ScrollManager state
|
||||
---@field blur BlurCacheData? -- Blur cache invalidation data
|
||||
|
||||
---@class BlurCacheData
|
||||
---@field _blurX number
|
||||
---@field _blurY number
|
||||
---@field _blurWidth number
|
||||
---@field _blurHeight number
|
||||
---@field _backdropBlurRadius number?
|
||||
---@field _backdropBlurQuality number?
|
||||
---@field _contentBlurRadius number?
|
||||
---@field _contentBlurQuality number?
|
||||
|
||||
--=====================================--
|
||||
-- For Calc.lua
|
||||
--=====================================--
|
||||
---@class CalcDependencies
|
||||
---@field ErrorHandler ErrorHandler? -- Error handler module
|
||||
|
||||
---@class CalcToken
|
||||
---@field type string -- Token type: "NUMBER", "UNIT", "PLUS", "MINUS", "MULTIPLY", "DIVIDE", "LPAREN", "RPAREN", "EOF"
|
||||
---@field value number? -- Numeric value (for NUMBER tokens)
|
||||
---@field unit string? -- Unit type: "px", "%", "vw", "vh" (for NUMBER tokens)
|
||||
|
||||
---@class CalcASTNode
|
||||
---@field type string -- Node type: "number", "add", "subtract", "multiply", "divide"
|
||||
---@field value number? -- Numeric value (for "number" nodes)
|
||||
---@field unit string? -- Unit type (for "number" nodes)
|
||||
---@field left CalcASTNode? -- Left operand (for operator nodes)
|
||||
---@field right CalcASTNode? -- Right operand (for operator nodes)
|
||||
|
||||
---@class CalcObject
|
||||
---@field _isCalc boolean -- Marker to identify calc objects (always true)
|
||||
---@field _expr string -- Original expression string
|
||||
---@field _ast CalcASTNode? -- Parsed abstract syntax tree (nil if parsing failed)
|
||||
---@field _error string? -- Error message if parsing failed
|
||||
|
||||
--=====================================--
|
||||
-- For FlexLove.lua Internals
|
||||
--=====================================--
|
||||
---@class GCConfig
|
||||
---@field strategy string -- "auto", "periodic", "manual", or "disabled"
|
||||
---@field memoryThreshold number -- MB before forcing GC
|
||||
---@field interval number -- Frames between GC steps (for periodic mode)
|
||||
---@field stepSize number -- Work units per GC step (higher = more aggressive)
|
||||
|
||||
---@class GCState
|
||||
---@field framesSinceLastGC number -- Frames elapsed since last GC
|
||||
---@field lastMemory number -- Last recorded memory usage in MB
|
||||
---@field gcCount number -- Total number of GC operations performed
|
||||
|
||||
---@class GCStats
|
||||
---@field gcCount number -- Total number of GC operations performed
|
||||
---@field framesSinceLastGC number -- Frames elapsed since last GC
|
||||
---@field currentMemoryMB number -- Current memory usage in MB
|
||||
---@field strategy string -- Current GC strategy
|
||||
---@field threshold number -- Memory threshold in MB
|
||||
|
||||
---@class FlexLoveDependencies
|
||||
---@field Context table -- Context module
|
||||
---@field Theme Theme? -- Theme module
|
||||
---@field Color Color -- Color module
|
||||
---@field Calc Calc -- Calc module
|
||||
---@field Units table -- Units module
|
||||
---@field Blur table? -- Blur module
|
||||
---@field ImageRenderer table? -- ImageRenderer module
|
||||
---@field ImageScaler table? -- ImageScaler module
|
||||
---@field NinePatch table? -- NinePatch module
|
||||
---@field RoundedRect table -- RoundedRect module
|
||||
---@field ImageCache table? -- ImageCache module
|
||||
---@field utils table -- Utils module
|
||||
---@field Grid table -- Grid module
|
||||
---@field InputEvent table -- InputEvent module
|
||||
---@field GestureRecognizer table? -- GestureRecognizer module
|
||||
---@field StateManager StateManager -- StateManager module
|
||||
---@field TextEditor table -- TextEditor module
|
||||
---@field LayoutEngine LayoutEngine -- LayoutEngine module
|
||||
---@field Renderer table -- Renderer module
|
||||
---@field EventHandler EventHandler -- EventHandler module
|
||||
---@field ScrollManager table -- ScrollManager module
|
||||
---@field ErrorHandler ErrorHandler -- ErrorHandler module
|
||||
---@field Performance Performance? -- Performance module
|
||||
---@field Transform table? -- Transform module
|
||||
@@ -1,319 +0,0 @@
|
||||
local modulePath = (...):match("(.-)[^%.]+$")
|
||||
local function req(name)
|
||||
return require(modulePath .. name)
|
||||
end
|
||||
|
||||
-- Focused sub-modules (utils now re-exports their surfaces as backward-compatible
|
||||
-- aliases so call sites needn't change). Loaded eagerly so the aliases resolve.
|
||||
local NumberValidation = req("NumberValidation")
|
||||
local TextSanitizer = req("TextSanitizer")
|
||||
local PathValidator = req("PathValidator")
|
||||
local FontCache = req("FontCache")
|
||||
local Enums = req("Enums")
|
||||
|
||||
-- ErrorHandler is injected via init() (safeLoadImage closes over this upvalue).
|
||||
local ErrorHandler = nil
|
||||
|
||||
local enums = Enums.enums
|
||||
|
||||
-- Generic math, table, and path helpers (utils' own concern).
|
||||
-- All validation, font-cache, text-sanitization, and path-validation logic
|
||||
-- lives in the focused sub-modules above and is re-exported below.
|
||||
|
||||
--- Get current keyboard modifiers state
|
||||
---@return {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
|
||||
local function getModifiers()
|
||||
return {
|
||||
shift = love.keyboard.isDown("lshift", "rshift"),
|
||||
ctrl = love.keyboard.isDown("lctrl", "rctrl"),
|
||||
alt = love.keyboard.isDown("lalt", "ralt"),
|
||||
---@diagnostic disable-next-line
|
||||
super = love.keyboard.isDown("lgui", "rgui"), -- cmd/windows key
|
||||
}
|
||||
end
|
||||
|
||||
local TEXT_SIZE_PRESETS = {
|
||||
["2xs"] = 0.75,
|
||||
xxs = 0.75,
|
||||
xs = 1.25,
|
||||
sm = 1.75,
|
||||
md = 2.25,
|
||||
lg = 2.75,
|
||||
xl = 3.5,
|
||||
xxl = 4.5,
|
||||
["2xl"] = 4.5,
|
||||
["3xl"] = 5.0,
|
||||
["4xl"] = 7.0,
|
||||
}
|
||||
|
||||
--- Resolve text size preset to viewport units
|
||||
---@param sizeValue string|number
|
||||
---@return number?, string?
|
||||
local function resolveTextSizePreset(sizeValue)
|
||||
if type(sizeValue) == "string" then
|
||||
local preset = TEXT_SIZE_PRESETS[sizeValue]
|
||||
if preset then
|
||||
return preset, "vh"
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
--- Auto-detect the base path where FlexLove is located
|
||||
---@return string filesystemPath
|
||||
local function getFlexLoveBasePath()
|
||||
local info = debug.getinfo(1, "S")
|
||||
if info and info.source then
|
||||
local source = info.source
|
||||
if source:sub(1, 1) == "@" then
|
||||
source = source:sub(2)
|
||||
end
|
||||
|
||||
local filesystemPath = source:match("(.*/)")
|
||||
if filesystemPath then
|
||||
local fsPath = filesystemPath
|
||||
fsPath = fsPath:gsub("^%./", "")
|
||||
fsPath = fsPath:gsub("/$", "")
|
||||
fsPath = fsPath:gsub("/modules$", "")
|
||||
return fsPath
|
||||
end
|
||||
end
|
||||
return "libs"
|
||||
end
|
||||
|
||||
local FLEXLOVE_FILESYSTEM_PATH = getFlexLoveBasePath()
|
||||
|
||||
--- Helper function to resolve paths relative to FlexLove
|
||||
---@param path string
|
||||
---@return string
|
||||
local function resolveImagePath(path)
|
||||
if path:match("^/") or path:match("^[A-Z]:") then
|
||||
return path
|
||||
end
|
||||
return FLEXLOVE_FILESYSTEM_PATH .. "/" .. path
|
||||
end
|
||||
|
||||
-- Math utilities
|
||||
|
||||
--- Clamp a value between optional min/max bounds. Either bound may be nil.
|
||||
--- When both bounds are inverted (min > max), max wins (matches CSS behavior).
|
||||
---@param value number Value to clamp
|
||||
---@param min number|nil Minimum value (nil = no lower bound)
|
||||
---@param max number|nil Maximum value (nil = no upper bound)
|
||||
---@return number Clamped value
|
||||
local function clamp(value, min, max)
|
||||
if min and value < min then
|
||||
value = min
|
||||
end
|
||||
if max and value > max then
|
||||
value = max
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
--- Linear interpolation between two values
|
||||
---@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
|
||||
|
||||
--- Round a number to the nearest integer
|
||||
---@param value number Value to round
|
||||
---@return number Rounded value
|
||||
local function round(value)
|
||||
return math.floor(value + 0.5)
|
||||
end
|
||||
|
||||
-- Image utilities
|
||||
|
||||
--- Safely load an image with error handling
|
||||
--- Returns both Image and ImageData to avoid deprecated getData() API
|
||||
---@param imagePath string Path to image file
|
||||
---@return love.Image?, love.ImageData?, string? Returns image, imageData, or nil with error message
|
||||
local function safeLoadImage(imagePath)
|
||||
local success, imageData = pcall(function()
|
||||
return love.image.newImageData(imagePath)
|
||||
end)
|
||||
|
||||
if not success then
|
||||
local errorMsg = string.format("Failed to load image data: %s - %s", imagePath, tostring(imageData))
|
||||
if ErrorHandler then
|
||||
ErrorHandler:warn("utils", "RES_004", {
|
||||
resourceType = "image data",
|
||||
path = imagePath,
|
||||
error = tostring(imageData),
|
||||
})
|
||||
end
|
||||
return nil, nil, errorMsg
|
||||
end
|
||||
|
||||
local imageSuccess, image = pcall(function()
|
||||
return love.graphics.newImage(imageData)
|
||||
end)
|
||||
|
||||
if imageSuccess then
|
||||
return image, imageData, nil
|
||||
else
|
||||
local errorMsg = string.format("Failed to create image: %s - %s", imagePath, tostring(image))
|
||||
if ErrorHandler then
|
||||
ErrorHandler:warn("utils", "RES_004", {
|
||||
resourceType = "image",
|
||||
path = imagePath,
|
||||
error = tostring(image),
|
||||
})
|
||||
end
|
||||
return nil, nil, errorMsg
|
||||
end
|
||||
end
|
||||
|
||||
-- Color manipulation utilities
|
||||
|
||||
--- Brighten a color by a factor
|
||||
---@param r number Red component (0-1)
|
||||
---@param g number Green component (0-1)
|
||||
---@param b number Blue component (0-1)
|
||||
---@param a number Alpha component (0-1)
|
||||
---@param factor number Brightness factor (e.g., 1.2 for 20% brighter)
|
||||
---@return number, number, number, number Brightened color components
|
||||
local function brightenColor(r, g, b, a, factor)
|
||||
return math.min(1, r * factor), math.min(1, g * factor), math.min(1, b * factor), a
|
||||
end
|
||||
|
||||
-- Property normalization utilities
|
||||
|
||||
--- Normalize a boolean or table property with vertical/horizontal fields
|
||||
---@param value boolean|table|nil Input value (boolean applies to both, table for individual control)
|
||||
---@param defaultValue boolean Default value if nil (default: false)
|
||||
---@return table Normalized table with vertical and horizontal fields
|
||||
local function normalizeBooleanTable(value, defaultValue)
|
||||
defaultValue = defaultValue or false
|
||||
|
||||
if value == nil then
|
||||
return { vertical = defaultValue, horizontal = defaultValue }
|
||||
end
|
||||
|
||||
if type(value) == "boolean" then
|
||||
return { vertical = value, horizontal = value }
|
||||
end
|
||||
|
||||
if type(value) == "table" then
|
||||
return {
|
||||
vertical = value.vertical ~= nil and value.vertical or defaultValue,
|
||||
horizontal = value.horizontal ~= nil and value.horizontal or defaultValue,
|
||||
}
|
||||
end
|
||||
|
||||
return { vertical = defaultValue, horizontal = defaultValue }
|
||||
end
|
||||
|
||||
--- Normalize an offset value to {x, y} or {horizontal, vertical} format
|
||||
---@param value number|table|nil Input value (number applies to both, table for individual control)
|
||||
---@param defaultValue number Default value if nil (default: 0)
|
||||
---@return table Normalized table with x/y or horizontal/vertical fields
|
||||
local function normalizeOffsetTable(value, defaultValue)
|
||||
defaultValue = defaultValue or 0
|
||||
|
||||
if value == nil then
|
||||
return { x = defaultValue, y = defaultValue, horizontal = defaultValue, vertical = defaultValue }
|
||||
end
|
||||
|
||||
if type(value) == "number" then
|
||||
return { x = value, y = value, horizontal = value, vertical = value }
|
||||
end
|
||||
|
||||
if type(value) == "table" then
|
||||
-- Support both {x, y} and {horizontal, vertical} formats
|
||||
local x = value.x or value.horizontal or defaultValue
|
||||
local y = value.y or value.vertical or defaultValue
|
||||
return {
|
||||
x = x,
|
||||
y = y,
|
||||
horizontal = x,
|
||||
vertical = y,
|
||||
}
|
||||
end
|
||||
|
||||
return { x = defaultValue, y = defaultValue, horizontal = defaultValue, vertical = defaultValue }
|
||||
end
|
||||
|
||||
--- Apply content auto-sizing multiplier to a dimension
|
||||
---@param value number The dimension value
|
||||
---@param multiplier table? The contentAutoSizingMultiplier table {width:number?, height:number?}
|
||||
---@param axis "width"|"height" Which axis to apply
|
||||
---@return number The multiplied value
|
||||
local function applyContentMultiplier(value, multiplier, axis)
|
||||
if multiplier and multiplier[axis] then
|
||||
return value * multiplier[axis]
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
--- Initialize dependencies
|
||||
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
|
||||
local function init(deps)
|
||||
if type(deps) == "table" then
|
||||
ErrorHandler = deps.ErrorHandler
|
||||
end
|
||||
-- Propagate shared ErrorHandler to focused sub-modules that need it.
|
||||
NumberValidation.init({ ErrorHandler = ErrorHandler, clamp = clamp })
|
||||
TextSanitizer.init({ ErrorHandler = ErrorHandler })
|
||||
FontCache.init({ ErrorHandler = ErrorHandler, resolveImagePath = resolveImagePath })
|
||||
-- PathValidator has no external dependencies.
|
||||
end
|
||||
|
||||
return {
|
||||
enums = enums,
|
||||
FONT_CACHE = FontCache.FONT_CACHE,
|
||||
resolveTextSizePreset = resolveTextSizePreset,
|
||||
getModifiers = getModifiers,
|
||||
TEXT_SIZE_PRESETS = TEXT_SIZE_PRESETS,
|
||||
init = init,
|
||||
clamp = clamp,
|
||||
-- Alias for `clamp`; exposed under the size-clamping name so Element/LayoutEngine
|
||||
-- and tests can reference min/max content-size clamping explicitly.
|
||||
clampSize = clamp,
|
||||
lerp = lerp,
|
||||
round = round,
|
||||
safeLoadImage = safeLoadImage,
|
||||
brightenColor = brightenColor,
|
||||
resolveImagePath = resolveImagePath,
|
||||
normalizeBooleanTable = normalizeBooleanTable,
|
||||
normalizeOffsetTable = normalizeOffsetTable,
|
||||
applyContentMultiplier = applyContentMultiplier,
|
||||
-- Backward-compatible aliases (delegated to focused sub-modules)
|
||||
validateEnum = NumberValidation.validateEnum,
|
||||
validateRange = NumberValidation.validateRange,
|
||||
validateType = NumberValidation.validateType,
|
||||
isNaN = NumberValidation.isNaN,
|
||||
isInfinity = NumberValidation.isInfinity,
|
||||
validateNumber = NumberValidation.validateNumber,
|
||||
sanitizeNumber = NumberValidation.sanitizeNumber,
|
||||
validateInteger = NumberValidation.validateInteger,
|
||||
validatePercentage = NumberValidation.validatePercentage,
|
||||
validateOpacity = NumberValidation.validateOpacity,
|
||||
validateDegrees = NumberValidation.validateDegrees,
|
||||
validateCoordinate = NumberValidation.validateCoordinate,
|
||||
validateDimension = NumberValidation.validateDimension,
|
||||
normalizePath = PathValidator.normalizePath,
|
||||
sanitizePath = PathValidator.sanitizePath,
|
||||
isPathSafe = PathValidator.isPathSafe,
|
||||
validatePath = PathValidator.validatePath,
|
||||
getFileExtension = PathValidator.getFileExtension,
|
||||
hasAllowedExtension = PathValidator.hasAllowedExtension,
|
||||
sanitizeText = TextSanitizer.sanitizeText,
|
||||
validateTextInput = TextSanitizer.validateTextInput,
|
||||
validateTextRange = TextSanitizer.validateTextRange,
|
||||
escapeHtml = TextSanitizer.escapeHtml,
|
||||
escapeLuaPattern = TextSanitizer.escapeLuaPattern,
|
||||
stripNonPrintable = TextSanitizer.stripNonPrintable,
|
||||
resolveFontPath = FontCache.resolveFontPath,
|
||||
getFont = FontCache.getFont,
|
||||
getFontCacheStats = FontCache.getFontCacheStats,
|
||||
setFontCacheSize = FontCache.setFontCacheSize,
|
||||
clearFontCache = FontCache.clearFontCache,
|
||||
preloadFont = FontCache.preloadFont,
|
||||
resetFontCacheStats = FontCache.resetFontCacheStats,
|
||||
}
|
||||
@@ -11,7 +11,9 @@
|
||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||
|
||||
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||
local LaunchOptions = require("src.core.LaunchOptions")
|
||||
local NxDisplay = require("src.core.NxDisplay")
|
||||
local PlatformHooks = require("src.core.PlatformHooks")
|
||||
|
||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||
do
|
||||
@@ -29,6 +31,21 @@ end
|
||||
|
||||
local Game, EditorApp, Importer, TouchEditor
|
||||
|
||||
-- #887: quit-to-launcher state, shared by love.load and love.quit (both need
|
||||
-- it, so it is declared here rather than next to love.quit).
|
||||
-- * launchedIntoGame -- a --game / POKEPORT_GAME shortcut booted this
|
||||
-- session straight into a game, so there is no launcher behind it and a
|
||||
-- window close must exit. Restarting instead re-read the same shortcut
|
||||
-- and came right back into the game, and the next close did it again:
|
||||
-- the app could not be closed at all (macOS feels this worst, where the
|
||||
-- red X, Cmd+Q and the Dock's Quit are all the same quit event).
|
||||
-- * RELAUNCH_MARKER -- written in the save dir just before the #785
|
||||
-- restart, so the fresh boot ignores any boot-straight-into-a-game
|
||||
-- option exactly once and keeps #785's promise of landing in the
|
||||
-- launcher, whatever put the game on screen this time.
|
||||
local launchedIntoGame = false
|
||||
local RELAUNCH_MARKER = "relaunch_to_launcher.txt"
|
||||
|
||||
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
||||
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
||||
-- coroutine that receives `Game` and yields once per
|
||||
@@ -313,6 +330,47 @@ function love.load(args)
|
||||
return
|
||||
end
|
||||
|
||||
-- The launcher draws before any game boots, so the mod loader has not run
|
||||
-- and Strings has no catalog. Routing the launcher's text through Strings
|
||||
-- (#767) only pays off if something fills that catalog this early, and no
|
||||
-- restart could: the ordering is the same on every launch. Read the
|
||||
-- enabled mods' string catalogs -- data only, no entry chunk -- so a
|
||||
-- translation reaches the launcher too. Game:load replaces this with the
|
||||
-- real merged catalog once a version boots.
|
||||
do
|
||||
local preload = require("src.mods.LauncherMods").translationStrings()
|
||||
if preload then require("src.core.Strings").load({ strings = preload }) end
|
||||
end
|
||||
|
||||
-- LAUNCH OPTIONS: skip the launcher and boot a game directly.
|
||||
-- --game red|blue|yellow (or POKEPORT_GAME / POKEPORT_LAUNCH)
|
||||
-- --slot <id> optional; picks the save slot to load
|
||||
-- --launcher force the launcher even if a game is set
|
||||
-- This is what a desktop shortcut, a Steam entry, or a frontend like
|
||||
-- EmulationStation needs: one click into the game the player wants, with no
|
||||
-- menu in between. A game that is not imported falls through to the
|
||||
-- launcher on its tab rather than booting into nothing.
|
||||
-- A window close that restarted us into the launcher (#785) leaves the
|
||||
-- marker behind: consume it and stay on the launcher, or the shortcut below
|
||||
-- would boot the same game again and that close would restart again,
|
||||
-- forever (#887). Consumed on read, so the very next launch is normal.
|
||||
local relaunched = love.filesystem.getInfo(RELAUNCH_MARKER) ~= nil
|
||||
if relaunched then pcall(love.filesystem.remove, RELAUNCH_MARKER) end
|
||||
|
||||
local launchGame, launchSlot = LaunchOptions.resolve(arg)
|
||||
if launchGame and not relaunched and not LaunchOptions.forceLauncher(arg) then
|
||||
if RomImporter.isReady(launchGame) then
|
||||
if launchSlot then LaunchOptions.selectSlot(launchGame, launchSlot) end
|
||||
-- No launcher behind this session: love.quit must exit, not restart.
|
||||
launchedIntoGame = true
|
||||
bootGame(launchGame)
|
||||
return
|
||||
end
|
||||
-- Not importable yet: open the launcher already showing that game, so the
|
||||
-- shortcut still lands the player where they meant to go.
|
||||
LaunchOptions.pendingTab = launchGame
|
||||
end
|
||||
|
||||
-- Interactive: the launcher always runs. Red, Blue, and Yellow are each
|
||||
-- live: a column shows Play when that game's ROM is already imported, or
|
||||
-- Choose ROM / drag-drop when it is not. Any dropped .gb is routed by its
|
||||
@@ -369,7 +427,11 @@ function love.update(dt)
|
||||
end
|
||||
return
|
||||
end
|
||||
Game:update(dt)
|
||||
-- Mods may wrap or veto the per-frame simulation step (pause it, react
|
||||
-- to external platform state, etc.) -- see docs/modding.md's core.update
|
||||
-- entry. Vanilla behavior (used when no mod claims the hook) is just
|
||||
-- Game:update(dt), unconditionally, exactly as before this hook existed.
|
||||
PlatformHooks.update(Game, dt)
|
||||
end
|
||||
|
||||
function love.draw()
|
||||
@@ -597,7 +659,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
-- Android's synthesized mouse twin so Import cannot double-fire (#553).
|
||||
return Importer:touchpressed(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchpressed(id, x, y)
|
||||
Game:touchpressed(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
@@ -609,7 +671,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
if Importer then
|
||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchmoved(id, x, y)
|
||||
Game:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
@@ -621,7 +683,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
if Importer then
|
||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchreleased(id, x, y)
|
||||
Game:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
function love.wheelmoved(x, y)
|
||||
@@ -634,7 +696,33 @@ function love.wheelmoved(x, y)
|
||||
Game:wheelmoved(x, y)
|
||||
end
|
||||
|
||||
-- #781: Linux X11 multi-monitor with the primary display away from desktop
|
||||
-- (0,0): SDL's polled mouse state can come back in desktop-virtual
|
||||
-- coordinates while the event stream stays window-relative, which strands
|
||||
-- every polled consumer (launcher Kit rising-edge clicks, the pad-cursor
|
||||
-- motion yield, PadCursor) on coordinates no hit test can match. Sanitize
|
||||
-- the poll once here: remember the last window-relative event coordinates
|
||||
-- and substitute them whenever the polled value falls outside the window.
|
||||
-- Linux only -- macOS / Windows / mobile keep the stock function, and the
|
||||
-- NX launcher shim still composes because it captures whatever
|
||||
-- love.mouse.getPosition is at bridge time (_ensureNxPointerBridge).
|
||||
local eventMouseX, eventMouseY
|
||||
if love.system and love.system.getOS() == "Linux"
|
||||
and love.mouse and love.mouse.getPosition then
|
||||
local polledGetPosition = love.mouse.getPosition
|
||||
love.mouse.getPosition = function()
|
||||
local x, y = polledGetPosition()
|
||||
local w, h = love.graphics.getDimensions()
|
||||
if x < 0 or y < 0 or x > w or y > h then
|
||||
if eventMouseX then return eventMouseX, eventMouseY end
|
||||
return math.max(0, math.min(x, w)), math.max(0, math.min(y, h))
|
||||
end
|
||||
return x, y
|
||||
end
|
||||
end
|
||||
|
||||
function love.mousepressed(x, y, button, istouch)
|
||||
if not istouch then eventMouseX, eventMouseY = x, y end
|
||||
if TouchEditor then
|
||||
-- Android primary touch already arrived via love.touchpressed; a second
|
||||
-- mouse path would double-fire Done / begin a second drag.
|
||||
@@ -658,12 +746,19 @@ function love.mousepressed(x, y, button, istouch)
|
||||
if istouch and love.system.getOS() == "Android" then return end
|
||||
return EditorApp.mousepressed(x, y, button)
|
||||
end
|
||||
if mouseTouch and Game and button == 1 then
|
||||
Game:touchpressed("mouse", x, y)
|
||||
if mouseTouch then
|
||||
-- the mouse is standing in for a finger: the touch path owns it, and
|
||||
-- feeding the same press back in as a mouse pointer would double it
|
||||
if Game and button == 1 then Game:touchpressed("mouse", x, y) end
|
||||
return
|
||||
end
|
||||
-- #807: a real mouse reaches gameplay as a pointer event for mods; Game
|
||||
-- drops synthesized istouch twins so a mobile touch that already arrived
|
||||
-- through love.touchpressed cannot fire twice
|
||||
if Game then Game:mousepressed(x, y, button, istouch) end
|
||||
end
|
||||
|
||||
function love.mousereleased(x, y, button)
|
||||
function love.mousereleased(x, y, button, istouch)
|
||||
if TouchEditor then
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousereleased(x, y, button)
|
||||
@@ -672,20 +767,25 @@ function love.mousereleased(x, y, button)
|
||||
if editorMode and EditorApp.mousereleased then
|
||||
return EditorApp.mousereleased(x, y, button)
|
||||
end
|
||||
if mouseTouch and Game and button == 1 then
|
||||
Game:touchreleased("mouse", x, y)
|
||||
if mouseTouch then
|
||||
if Game and button == 1 then Game:touchreleased("mouse", x, y) end
|
||||
return
|
||||
end
|
||||
if Game then Game:mousereleased(x, y, button, istouch) end
|
||||
end
|
||||
|
||||
function love.mousemoved(x, y)
|
||||
function love.mousemoved(x, y, dx, dy, istouch)
|
||||
if not istouch then eventMouseX, eventMouseY = x, y end
|
||||
if TouchEditor then
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousemoved(x, y)
|
||||
end
|
||||
if editorMode or Importer then return end
|
||||
if mouseTouch and Game and love.mouse.isDown(1) then
|
||||
Game:touchmoved("mouse", x, y)
|
||||
if mouseTouch then
|
||||
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
||||
return
|
||||
end
|
||||
if Game then Game:mousemoved(x, y, dx, dy, istouch) end
|
||||
end
|
||||
|
||||
function love.textinput(text)
|
||||
@@ -696,9 +796,51 @@ function love.textinput(text)
|
||||
end
|
||||
end
|
||||
|
||||
-- #785: set once love.quit has routed a window close into HostShell.restart,
|
||||
-- so the follow-up quit event the restart itself raises (quit("restart") on
|
||||
-- desktop; AppImage and Android relaunch the process instead, #575) falls
|
||||
-- through to the normal shutdown below instead of restarting forever.
|
||||
local quitToLauncher = false
|
||||
|
||||
function love.quit()
|
||||
if editorMode and EditorApp.quit then
|
||||
return EditorApp.quit() -- return true to abort quit
|
||||
-- true blocks the quit (unsaved-changes prompt). A quit that proceeds
|
||||
-- must fall through to the worker shutdowns below instead of returning:
|
||||
-- the bundled editor opens from a live launcher whose update-check and
|
||||
-- fetch-pool workers are still parked in Channel:demand(), and returning
|
||||
-- here skipped their "quit" push, so the process outlived the closed
|
||||
-- window and kept the install folder locked on Windows (#727).
|
||||
if EditorApp.quit() then return true end
|
||||
end
|
||||
-- Closing the window of a running game returns to the launcher instead of
|
||||
-- exiting the app, so testing a mod does not need a relaunch every time
|
||||
-- (#785). Game is only non-nil once bootGame ran; Importer non-nil means
|
||||
-- the launcher (or its import) owns the window and its close still quits.
|
||||
-- Scripted and headless runs (autopilot, frame driver, import-only, ROM
|
||||
-- path import) keep the plain exit so they terminate as before. Nothing
|
||||
-- is saved here on purpose: a window close never wrote the save, and the
|
||||
-- restart path must be no worse than that, not quietly better.
|
||||
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
|
||||
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM")
|
||||
-- #887: a shortcut session (--game / POKEPORT_GAME) has no launcher to go
|
||||
-- back to and the restart would re-read the shortcut, so it exits instead.
|
||||
--
|
||||
-- A platform launcher that owns "return to launcher" itself (see
|
||||
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
||||
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
||||
-- claims the hook) is exactly the condition below.
|
||||
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
||||
return Game and not Importer and not quitToLauncher and not scripted
|
||||
and not launchedIntoGame
|
||||
end)
|
||||
if wouldReturnToLauncher then
|
||||
quitToLauncher = true
|
||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
||||
-- once, so the restart really does land in the launcher (#887). A failed
|
||||
-- write only costs that suppression, so it must never block the restart.
|
||||
pcall(love.filesystem.write, RELAUNCH_MARKER, "1")
|
||||
require("src.core.HostShell").restart()
|
||||
return true -- abort this quit; the restart lands back in the launcher
|
||||
end
|
||||
pcall(function()
|
||||
require("src.core.DiscordPresence").shutdown()
|
||||
@@ -713,6 +855,12 @@ function love.quit()
|
||||
if package.loaded["src.update.Check"] then
|
||||
pcall(package.loaded["src.update.Check"].shutdown)
|
||||
end
|
||||
-- The launcher's fetch pool is the same story: its workers idle in
|
||||
-- Channel:demand(), which never returns on its own, so a launcher that ever
|
||||
-- touched the network would hang the process on exit (#339's shape again).
|
||||
if package.loaded["src.net.Fetch"] then
|
||||
pcall(package.loaded["src.net.Fetch"].shutdown)
|
||||
end
|
||||
end
|
||||
|
||||
function love.filedropped(file)
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 42 KiB |
@@ -37,6 +37,13 @@
|
||||
|
||||
#include "filesystem/physfs/PhysfsIo.h"
|
||||
|
||||
// #604 / #839: the SAF bridges below must hand GameActivity the exact
|
||||
// directory physfs mounted as the save dir -- the same contract the iOS
|
||||
// GRPickerBridge already gets (mobile/ios/patch_love_src.py,
|
||||
// gr_saveDirectory) -- instead of letting Java recompute the root on its
|
||||
// own, which can name a different volume on merged / adopted-SD storage.
|
||||
#include "filesystem/Filesystem.h"
|
||||
|
||||
namespace love
|
||||
{
|
||||
namespace android
|
||||
@@ -183,6 +190,19 @@ void vibrate(double seconds)
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
// The directory physfs actually mounted as the save dir, or "" before the
|
||||
// filesystem module is up. GameActivity must copy SAF picks HERE: its own
|
||||
// getExternalFilesDir(null) recomputation can disagree with the mounted
|
||||
// root on merged / adopted-SD storage (#604, #839).
|
||||
static const char *bridgeSaveDirectory()
|
||||
{
|
||||
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
|
||||
if (fs == nullptr)
|
||||
return "";
|
||||
const char *dir = fs->getSaveDirectory();
|
||||
return dir != nullptr ? dir : "";
|
||||
}
|
||||
|
||||
bool showFilePicker(const char *destFilename)
|
||||
{
|
||||
if (destFilename == nullptr || destFilename[0] == '\0')
|
||||
@@ -192,9 +212,11 @@ bool showFilePicker(const char *destFilename)
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
|
||||
"(Ljava/lang/String;)Z");
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||
jstring jname = env->NewStringUTF(destFilename);
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||
jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory());
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir);
|
||||
env->DeleteLocalRef(jsavedir);
|
||||
env->DeleteLocalRef(jname);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
@@ -210,9 +232,11 @@ bool showCreateDocument(const char *suggestedName)
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
|
||||
"(Ljava/lang/String;)Z");
|
||||
"(Ljava/lang/String;Ljava/lang/String;)Z");
|
||||
jstring jname = env->NewStringUTF(suggestedName);
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
|
||||
jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory());
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir);
|
||||
env->DeleteLocalRef(jsavedir);
|
||||
env->DeleteLocalRef(jname);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
@@ -259,7 +283,19 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
|
||||
return false;
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
// NOT FindClass: this is the one bridge called off the main thread
|
||||
// (love.thread workers in src/net/fetch_worker.lua and
|
||||
// src/update/check_worker.lua). A worker is a raw pthread whose JNI
|
||||
// class loader is the system one, which cannot see app classes, so
|
||||
// FindClass("org/love2d/android/GameActivity") left a pending
|
||||
// ClassNotFoundException and the next JNI call aborted the process --
|
||||
// opening FIND MODS killed the app on the first stats fetch. Resolving
|
||||
// through the live activity instance works from any attached thread.
|
||||
jobject activityObj = (jobject) SDL_AndroidGetActivity();
|
||||
if (activityObj == nullptr)
|
||||
return false;
|
||||
jclass activity = env->GetObjectClass(activityObj);
|
||||
env->DeleteLocalRef(activityObj);
|
||||
|
||||
// Old APK / new liblove skew: report "no transport" the same way a
|
||||
// missing curl does, instead of aborting on a missing method (#597).
|
||||
@@ -967,4 +1003,33 @@ void love_android_secondary_enable(int on)
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
const char *love_android_poll_secondary_touch()
|
||||
{
|
||||
static thread_local std::string event;
|
||||
event.clear();
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID method = env->GetStaticMethodID(activity, "pollSecondaryDisplayTouch",
|
||||
"()Ljava/lang/String;");
|
||||
if (!method)
|
||||
env->ExceptionClear();
|
||||
else
|
||||
{
|
||||
jstring value = (jstring) env->CallStaticObjectMethod(activity, method);
|
||||
if (value)
|
||||
{
|
||||
const char *utf = env->GetStringUTFChars(value, nullptr);
|
||||
if (utf)
|
||||
{
|
||||
event = utf;
|
||||
env->ReleaseStringUTFChars(value, utf);
|
||||
}
|
||||
env->DeleteLocalRef(value);
|
||||
}
|
||||
}
|
||||
env->DeleteLocalRef(activity);
|
||||
return event.empty() ? nullptr : event.c_str();
|
||||
}
|
||||
|
||||
#endif // LOVE_ANDROID
|
||||
|
||||
@@ -116,6 +116,18 @@ public class GameActivity extends SDLActivity {
|
||||
// bad ROM instead of installing it (#553).
|
||||
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
||||
private static final String STATE_PENDING_PICK = "pendingPickFilename";
|
||||
// Absolute save directory physfs actually mounted, as reported by the
|
||||
// native bridge call that opened the picker (love/src/common/android.cpp,
|
||||
// bridgeSaveDirectory). This activity used to recompute
|
||||
// getExternalFilesDir(null)/save/<identity> on its own at result time; on
|
||||
// merged / adopted-SD storage that can name a different volume than the
|
||||
// one LOVE mounted, so the copied pick (and pick_error.flag) landed where
|
||||
// Lua never scans -- the launcher then "did nothing" after a pick (#604)
|
||||
// and the folders a file manager can browse stayed empty while the game
|
||||
// saved fine elsewhere (#839). Empty string means "not told yet": fall
|
||||
// back to the historical computation.
|
||||
private String pendingPickSaveDir = "";
|
||||
private static final String STATE_PENDING_PICK_DIR = "pendingPickSaveDir";
|
||||
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
|
||||
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
||||
private String pendingCreateSuggestedName = "export.sav";
|
||||
@@ -186,6 +198,8 @@ public class GameActivity extends SDLActivity {
|
||||
// a recreated activity still lands under the basename it asked for.
|
||||
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
|
||||
if (pick != null) pendingPickFilename = pick;
|
||||
String pickDir = savedInstanceState.getString(STATE_PENDING_PICK_DIR);
|
||||
if (pickDir != null) pendingPickSaveDir = pickDir;
|
||||
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
|
||||
if (create != null) pendingCreateSuggestedName = create;
|
||||
}
|
||||
@@ -467,13 +481,23 @@ public class GameActivity extends SDLActivity {
|
||||
* @param destFilename basename under the app save identity (e.g.
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||
*/
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
public static boolean showFilePicker(String destFilename) {
|
||||
return showFilePicker(destFilename, null);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean showFilePicker(String destFilename, String saveDir) {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (destFilename == null || destFilename.length() == 0) {
|
||||
destFilename = PICKED_ROM_FILENAME;
|
||||
}
|
||||
// Remember where LOVE's filesystem is really mounted so
|
||||
// onActivityResult copies the pick there, not into a recomputed
|
||||
// (possibly different-volume) root (#604, #839).
|
||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
||||
// Reject path separators so a hostile JNI caller cannot escape the
|
||||
// save identity directory.
|
||||
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
|
||||
@@ -644,8 +668,14 @@ public class GameActivity extends SDLActivity {
|
||||
* return degrades on the Lua side (RomImporter export) to "Exported
|
||||
* inside the app folder", which is the correct pre-KitKat behavior.
|
||||
*/
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
public static boolean showCreateDocument(String suggestedName) {
|
||||
return showCreateDocument(suggestedName, null);
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean showCreateDocument(String suggestedName, String saveDir) {
|
||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||
// (see showFilePicker for why the import side got a pre-19 path)
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
@@ -657,9 +687,12 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
|
||||
return false;
|
||||
}
|
||||
File source = new File(
|
||||
new File(self.getExternalFilesDir(null), "save"),
|
||||
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
|
||||
// Route through the mounted save dir (#604, #839): Lua staged
|
||||
// pending_export.sav where physfs writes, which is not necessarily
|
||||
// where a fresh getExternalFilesDir(null) points on merged /
|
||||
// adopted-SD storage.
|
||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
||||
File source = new File(self.saveIdentityDir(), PENDING_EXPORT_FILENAME);
|
||||
if (!source.isFile()) {
|
||||
Log.d("GameActivity", "no pending export at " + source);
|
||||
return false;
|
||||
@@ -680,7 +713,21 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
|
||||
private File saveIdentityDir() {
|
||||
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
|
||||
// Prefer the mounted save dir the last bridge call reported: the
|
||||
// recomputation below can name a different volume than the one LOVE
|
||||
// mounted on merged / adopted-SD storage (#604, #839).
|
||||
if (pendingPickSaveDir != null && pendingPickSaveDir.length() > 0) {
|
||||
return new File(pendingPickSaveDir);
|
||||
}
|
||||
File ext = getExternalFilesDir(null);
|
||||
if (ext == null) {
|
||||
// Shared storage unavailable (ejected / mid-adoption): without
|
||||
// this guard File(null, "save") silently built the RELATIVE
|
||||
// path save/<identity>, mkdirs() failed against "/", and the
|
||||
// pick was dropped with no message at all (#604).
|
||||
ext = getFilesDir();
|
||||
}
|
||||
return new File(new File(ext, "save"), ROM_SAVE_IDENTITY);
|
||||
}
|
||||
|
||||
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
||||
@@ -875,6 +922,7 @@ public class GameActivity extends SDLActivity {
|
||||
protected void onSaveInstanceState(Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
|
||||
outState.putString(STATE_PENDING_PICK_DIR, pendingPickSaveDir);
|
||||
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
|
||||
}
|
||||
|
||||
@@ -1277,6 +1325,9 @@ public class GameActivity extends SDLActivity {
|
||||
// in src/jni/love/src/common/android.cpp.
|
||||
private static volatile SecondaryPresentation secondaryPresentation;
|
||||
private static volatile boolean secondaryEnabled = false;
|
||||
private static final int MAX_SECONDARY_TOUCHES = 32;
|
||||
private static final java.util.ArrayDeque<String> secondaryTouches =
|
||||
new java.util.ArrayDeque<>();
|
||||
|
||||
@Keep
|
||||
public static void setSecondaryEnabled(final boolean on) {
|
||||
@@ -1329,6 +1380,7 @@ public class GameActivity extends SDLActivity {
|
||||
private static void teardownSecondaryDisplay() {
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
secondaryPresentation = null;
|
||||
synchronized (secondaryTouches) { secondaryTouches.clear(); }
|
||||
if (p != null) {
|
||||
try { p.dismiss(); } catch (Throwable t) {}
|
||||
}
|
||||
@@ -1347,6 +1399,13 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static String pollSecondaryDisplayTouch() {
|
||||
synchronized (secondaryTouches) {
|
||||
return secondaryTouches.pollFirst();
|
||||
}
|
||||
}
|
||||
|
||||
private static class SecondaryPresentation extends android.app.Presentation {
|
||||
private final FrameView frameView;
|
||||
|
||||
@@ -1413,6 +1472,7 @@ public class GameActivity extends SDLActivity {
|
||||
private final android.graphics.Paint paint = new android.graphics.Paint();
|
||||
private final Object lock = new Object();
|
||||
private int fw, fh;
|
||||
private int activePointer = -1;
|
||||
|
||||
FrameView(Context context) {
|
||||
super(context);
|
||||
@@ -1434,6 +1494,52 @@ public class GameActivity extends SDLActivity {
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
private void enqueueTouch(String event) {
|
||||
synchronized (secondaryTouches) {
|
||||
if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) {
|
||||
secondaryTouches.clear();
|
||||
secondaryTouches.addLast("cancel,0,0");
|
||||
} else {
|
||||
secondaryTouches.addLast(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int logicalX(float x) {
|
||||
return Math.min(fw - 1, Math.max(0,
|
||||
(int) ((x - dst.left) * fw / dst.width())));
|
||||
}
|
||||
|
||||
private int logicalY(float y) {
|
||||
return Math.min(fh - 1, Math.max(0,
|
||||
(int) ((y - dst.top) * fh / dst.height())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(android.view.MotionEvent event) {
|
||||
synchronized (lock) {
|
||||
int action = event.getActionMasked();
|
||||
if (action == android.view.MotionEvent.ACTION_DOWN && fw > 0
|
||||
&& dst.contains((int) event.getX(), (int) event.getY())) {
|
||||
activePointer = event.getPointerId(0);
|
||||
enqueueTouch("down," + logicalX(event.getX()) + ","
|
||||
+ logicalY(event.getY()));
|
||||
} else if (action == android.view.MotionEvent.ACTION_UP
|
||||
&& activePointer >= 0) {
|
||||
int index = event.findPointerIndex(activePointer);
|
||||
if (index >= 0 && fw > 0) {
|
||||
enqueueTouch("up," + logicalX(event.getX(index)) + ","
|
||||
+ logicalY(event.getY(index)));
|
||||
}
|
||||
activePointer = -1;
|
||||
} else if (action == android.view.MotionEvent.ACTION_CANCEL) {
|
||||
activePointer = -1;
|
||||
enqueueTouch("cancel,0,0");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(android.graphics.Canvas canvas) {
|
||||
synchronized (lock) {
|
||||
|
||||
@@ -12,6 +12,83 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.75",
|
||||
"date": "2026-08-06",
|
||||
"size": 9574086,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.75/gen1recomp-0.1.75-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #826 \"Super effective\" and \"not very effective\" SFX are reversed\n- #848 Roms not showing up\n- #876 Remote mod download is unavailable on this platform\n- #899 Importing Pokémon red leads to the contents extracted without a folder called 'red'\n- #902 Audio is wrong!\n\n## Contributors\n\n- @andrewqsantos\n- @bryanthaboi\n- @caorthann-celt\n- @johnjohto\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.74",
|
||||
"date": "2026-08-06",
|
||||
"size": 9588946,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.74/gen1recomp-0.1.74-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #883 Evolution Stones consumed if evolution is cancelled\n- #887 MacOS Closing game just keeps reopening\n- #894 Max Repel usable in battle\n\n## Contributors\n\n- @bryanthaboi\n- @MarceloMachadoxD\n- @ratherDashing"
|
||||
},
|
||||
{
|
||||
"version": "0.1.73",
|
||||
"date": "2026-08-06",
|
||||
"size": 9585729,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.73/gen1recomp-0.1.73-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #878 Mod API: allow active screen states to be hidden from the main render\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.72",
|
||||
"date": "2026-08-05",
|
||||
"size": 9586678,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.72/gen1recomp-0.1.72-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #604 Android, retroid pocket 2+ Rom won't import\n- #666 Pikachu emotions are not working on Android\n- #716 Lock Auto Rotate Mobile\n- #727 [Bug] [Windows] Gen1 Recomp \"still in use\" after closing\n- #763 Some EVENTS are turned off\n- #781 Mouse cursor broken on Linux with multi-monitor X11 setup\n- #784 Leech Seed effect\n- #799 Held direction randomly stops player movement (requires re-input)\n- #801 Cannot update mods from the launcher (MacOS)\n- #810 Launcher menu cuts off in vertical mode iOS\n- #828 Closing the app causes settings in launcher to reset\n- #834 Mod import failing\n- #838 Exporting save file Pokemon Yellow\n- #839 AYN Thor Misplaced Data files\n- #849 Public folder support on iOS\n- #852 Cannot switch between saves states on smaller 4:3 screen or in vertical mode\n- #857 Mt. Moon Fossils Reappeared and Won’t Disappear.\n- #863 [Yellow] When you use stairs, Pikachu shouldn't be next to you in the new area\n- #864 Faithful Ratio\n- #867 Missing Dialogue after defeating Marowak in Pokemon Tower\n- #869 Giovanni moves up to the player too early\n- #870 Start Menu on Classic Color\n- #872 Missing text when finding an item with full inventory\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.71",
|
||||
"date": "2026-08-05",
|
||||
"size": 9569738,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.71/gen1recomp-0.1.71-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #806 Haptic Feedback for On-screen controls (mobile)\n- #809 NPC Stuck in Rock when battle triggered and pushing rock towards it\n- #853 When selecting one of the two pokemon after defeating the Karate Master it should show the Pokedex entry\n- #854 Textbox disappears when the yes/no dialogue appears\n- #860 Disabled moves can still be used in the turn they were disabled\n- #862 Casino Poster Rocket Grunt walks into the poster and doesn't \"Dang!\"\n- #865 [Yellow] James doesn't move in multiple encounters\n- #866 [Yellow] Dialogue in wrong \"order\" after multiple encounters (J+J, Giovanni, probably more)\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.70",
|
||||
"date": "2026-08-05",
|
||||
"size": 9562731,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.70/gen1recomp-0.1.70-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #590 Launcher/Save Editor/Dex\n- #788 Fly to the Pokemon Centers in the Routes is not available in original\n- #795 Fly logic is drastically different from the originals.\n- #796 Using Rare Candy from the menu closes it out.\n- #797 Gym Leaders giving items when your bag is full / Bypassing bag limit.\n- #805 Escape Rope Moltres Tower\n- #826 \"Super effective\" and \"not very effective\" SFX are reversed\n- #833 Cancelling nickname entry results in \"A\" as the nickname\n- #835 Restarting the launcher forgets the last rom used\n- #837 Wrong sound effect for Pikachu when entering battle\n- #844 Blizzard sound effect.\n- #845 Moderate issue: Fuchsia City binoculars.\n- #846 Surfing speed after using the bicycle.\n- #847 Minor issues related to the endgame.\n\n## Contributors\n\n- @bryanthaboi\n- @dburton95\n- @johnjohto\n- @KikiManjaro"
|
||||
},
|
||||
{
|
||||
"version": "0.1.69",
|
||||
"date": "2026-08-04",
|
||||
"size": 9552851,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.69/gen1recomp-0.1.69-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #768 Menu behaviour for Pokemon and HM moves\n- #785 Back to launcher\n- #792 HM moves in the wrong position in the menu.\n- #807 Expose gameplay pointer events and source-safe mod input injection\n- #811 Untranslatables\n- #814 [minor thing] bold arrow on move swap (select)\n\n## Contributors\n\n- @bryanthaboi\n- @johnjohto"
|
||||
},
|
||||
{
|
||||
"version": "0.1.68",
|
||||
"date": "2026-08-04",
|
||||
"size": 9768011,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.68/gen1recomp-0.1.68-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi\n- @caorthann-celt"
|
||||
},
|
||||
{
|
||||
"version": "0.1.67",
|
||||
"date": "2026-08-04",
|
||||
"size": 9767619,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.67/gen1recomp-0.1.67-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #373 Lines at Title Screen\n- #644 Substitute failing causes visual issues\n- #673 iOS: thin vertical seam below the START menu panel (v0.1.56, iPhone 15 Pro Max)\n- #703 Credits playing too fast (song ends after it should)\n- #726 Surfing Pikachu Minigame Broken\n- #737 Battle menu move cursor fails to reset to the first slot after switching Pokémon\n- #750 NPC (possibly player) trades still graphically broken\n- #752 Launcher exports save to AppData while in portable mode\n- #764 Trainer Fanfare doesn't play\n- #765 Text Advance broken in certain aspects\n- #768 Menu behaviour for Pokemon and HM moves\n- #773 Battle screen colours messed up when BG = World, battle in un-flashed Rock Tunnel\n- #774 bug(build): Desktop build can reject a valid game archive under pipefail\n- #775 TM42 Dream Eater dialog.\n- #777 Battle Screen is Very dark\n- #780 Do not delete save\n- #782 Giovanni battle at Silph Co plays wrong song\n\n## Contributors\n\n- @bryanthaboi\n- @luisgonzaleznf\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.66",
|
||||
"date": "2026-08-04",
|
||||
"size": 9749980,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.66/gen1recomp-0.1.66-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #691 Save file transfer\n- #743 Cannot Scroll Main Menu/Mod Menu\n- #779 \"Enemy \" untranslateable\n\n## Contributors\n\n- @bryanthaboi\n- @jherediagu\n- @ShaneMcGovernIE\n- @vegerot"
|
||||
},
|
||||
{
|
||||
"version": "0.1.65",
|
||||
"date": "2026-08-03",
|
||||
"size": 9376618,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.65/gen1recomp-0.1.65-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #592 Screen Orientation\n- #702 FLY overworld animation incorrect/incomplete\n- #748 Launcher Menu has overlap\n- #754 Can push boulders with STRENGTH through walls\n- #758 PC to Mac Online Multiplayer Disconnects Shortly After Starting Match\n\n## Contributors\n\n- @andrewqsantos\n- @Bortlesboat\n- @bryanthaboi\n- @castdrian\n- @johnjohto\n- @ShaneMcGovernIE"
|
||||
},
|
||||
{
|
||||
"version": "0.1.64",
|
||||
"date": "2026-08-03",
|
||||
|
||||
@@ -85,11 +85,30 @@ public final class GRPickerBridge: NSObject {
|
||||
types = [.zip]
|
||||
case "sav":
|
||||
destName = "picked_save.sav"
|
||||
default:
|
||||
// A Nintendo 64 cartridge, for mods that build assets out of one --
|
||||
// the voxel mod's Pokemon Stadium battle models are the caller this
|
||||
// was added for. Its own filename on purpose: an N64 ROM landing on
|
||||
// picked_rom.gb is swept up by the Game Boy importer, deleted, and
|
||||
// reported to the player as a broken cartridge.
|
||||
case "stadium":
|
||||
destName = "picked_stadium.z64"
|
||||
for ext in ["z64", "n64", "v64"] {
|
||||
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
||||
}
|
||||
case "rom", "":
|
||||
destName = "picked_rom.gb"
|
||||
for ext in ["gb", "gbc"] {
|
||||
if let t = UTType(filenameExtension: ext) { types.append(t) }
|
||||
}
|
||||
// An unknown kind is REFUSED rather than treated as a Game Boy ROM.
|
||||
//
|
||||
// It used to fall through to picked_rom.gb, so a caller asking for a
|
||||
// kind this build had never heard of got its file deleted and
|
||||
// reported as a broken cartridge -- the worst possible answer to
|
||||
// "I do not know that one". Returning false lets the caller find out
|
||||
// and offer its own fallback.
|
||||
default:
|
||||
return false
|
||||
}
|
||||
// .gb/.gbc/.sav resolve to dynamic UTTypes on most devices; offering
|
||||
// .data as well keeps every real file selectable. The importer
|
||||
@@ -107,6 +126,20 @@ public final class GRPickerBridge: NSObject {
|
||||
return present(picker, with: delegate)
|
||||
}
|
||||
|
||||
// Which kinds presentPicker understands, comma separated.
|
||||
//
|
||||
// So a CALLER can ask before it calls. A mod that wants a kind this build
|
||||
// predates cannot otherwise tell "refused" from "the picker would not
|
||||
// open", and guessing wrong used to cost the player their ROM (see the
|
||||
// default case above). Asking first turns that into a fallback the caller
|
||||
// chooses rather than a file it loses.
|
||||
//
|
||||
// Kept beside the switch it describes, because the two drifting apart is
|
||||
// the only way this can lie.
|
||||
@objc public static func supportedPickerKinds() -> NSString {
|
||||
return "rom,mod,sav,stadium" as NSString
|
||||
}
|
||||
|
||||
@objc(presentExportWithName:saveDir:)
|
||||
public static func presentExport(name: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
|
||||
@@ -84,6 +84,49 @@ int w_pickFile(lua_State *L)
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||
}
|
||||
|
||||
// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS.
|
||||
//
|
||||
// So a caller can ask what this build's picker understands BEFORE opening it.
|
||||
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
|
||||
// like a picker that would not open -- so a caller with a fallback worth
|
||||
// showing needs to know which it is facing. A mod that guesses instead has
|
||||
// no way back: before the refusal landed, an unrecognised kind wrote
|
||||
// picked_rom.gb and the ROM importer deleted it.
|
||||
//
|
||||
// nil where there is no bridge at all, which reads the same as "no kinds".
|
||||
int w_pickFileKinds(lua_State *L)
|
||||
{
|
||||
Class cls = objc_getClass("GRPickerBridge");
|
||||
if (cls == nullptr)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
// Fetched through the runtime: wrap_System.cpp is compiled as C++ rather
|
||||
// than Objective-C++, so no Foundation type may be NAMED here -- writing
|
||||
// `NSString` alone breaks the whole translation unit. objc_msgSend is a
|
||||
// plain C entry point and `id` comes from objc/runtime.h, so the string
|
||||
// is asked for its UTF8 bytes without ever being typed.
|
||||
typedef id (*GRObj)(Class, SEL);
|
||||
id kinds = ((GRObj)objc_msgSend)(cls,
|
||||
sel_registerName("supportedPickerKinds"));
|
||||
if (kinds == nullptr)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
typedef const char *(*GRUTF8)(id, SEL);
|
||||
const char *bytes = ((GRUTF8)objc_msgSend)(kinds,
|
||||
sel_registerName("UTF8String"));
|
||||
if (bytes == nullptr || bytes[0] == '\0')
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_pushstring(L, bytes);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_createFile(lua_State *L)
|
||||
{
|
||||
const char *name = luaL_optstring(L, 1, "export.sav");
|
||||
@@ -101,6 +144,7 @@ int w_syncHealthSteps(lua_State *L)
|
||||
|
||||
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "pickFileKinds", w_pickFileKinds },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# spanish_ui
|
||||
|
||||
A Espanol translation of the game.
|
||||
|
||||
Generated with `python3 tools/modkit.py translation spanish_ui`. See
|
||||
`TRANSLATING.md` for how to work on it.
|
||||
|
||||
## Status
|
||||
|
||||
Nothing is translated yet: 601 strings are waiting in `lang/`.
|
||||
|
||||
| Catalog | Entries |
|
||||
|---|---|
|
||||
| `lang/dialogue.lua` | 6 |
|
||||
| `lang/strings.lua` | 577 |
|
||||
| `lang/species_names.lua` | 3 |
|
||||
| `lang/move_names.lua` | 4 |
|
||||
| `lang/item_names.lua` | 5 |
|
||||
| `lang/trainer_names.lua` | 1 |
|
||||
| `lang/status_labels.lua` | 5 |
|
||||
|
||||
## Layout
|
||||
|
||||
- `manifest.json` - identity and the engine version range
|
||||
- `main.lua` - registers whatever is filled in and skips whatever is not
|
||||
- `lang/` - the catalogs; this is the whole job
|
||||
- `assets/font/` - your glyph sheet
|
||||
@@ -0,0 +1,111 @@
|
||||
# Translating into Espanol
|
||||
|
||||
Everything the player can read is one of two kinds of string, and they live
|
||||
in different places for a reason.
|
||||
|
||||
| lang/ file | What it is | Key |
|
||||
|---|---|---|
|
||||
| `dialogue.lua` | Every line of extracted script text | the original label, e.g. `_PalletTownText1` |
|
||||
| `strings.lua` | Text the engine itself writes: battle messages, menus, link play | the English source string |
|
||||
| `species.lua` `moves.lua` `items.lua` `trainers.lua` | Names | the vanilla id |
|
||||
| `statuses.lua` | `PSN`, `BRN`, ... as they appear in the HUD | the status id |
|
||||
| `font.lua` `charmap.lua` | Your glyph sheet and what draws what | see below |
|
||||
| `naming.lua` | The letter grid for entering names | - |
|
||||
|
||||
Fill in a value and it takes effect. Leave it `""` and that string stays in
|
||||
English, so the game is playable at every point along the way.
|
||||
|
||||
## Where the English is
|
||||
|
||||
The catalogs hold keys and *your* text, never the original English. The
|
||||
English lives next door, in `spanish_ui-worksheet/`, one tab-separated file per
|
||||
catalog:
|
||||
|
||||
```
|
||||
"_AbandonLearningText" "Abandon learning\n{RAM:wStringBuffer}?"
|
||||
```
|
||||
|
||||
That directory is deliberately outside the mod. Extracted script text and
|
||||
the vanilla names are ROM content, and `modkit pack` zips everything under
|
||||
the mod directory, so a worksheet kept inside would end up in your release
|
||||
whatever a `.gitignore` said. Keep it beside the mod, never in it.
|
||||
|
||||
`lang/strings.lua` is the exception: those sources are the engine's own Lua
|
||||
rather than anything out of the ROM, so there the key *is* the English and
|
||||
you can translate straight from it.
|
||||
|
||||
## Start with the font, not the text
|
||||
|
||||
The engine draws from **glyph pages**: an image of 8x8 cells plus a charmap
|
||||
saying which byte sequence draws which cell. The vanilla pages sit at `$60`
|
||||
and `$80`. Anything from `0x100` up is free, so a new alphabet is added
|
||||
rather than swapped in:
|
||||
|
||||
```lua
|
||||
-- lang/font.lua
|
||||
return {
|
||||
spanish_ui = {
|
||||
image = "assets/font/spanish_ui.png",
|
||||
base = 0x100, -- first code this page owns
|
||||
glyphsPerRow = 16,
|
||||
-- advance = 8, -- set this if your glyphs are not 8px wide
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```lua
|
||||
-- lang/charmap.lua: sequence -> code, in the same order as the sheet
|
||||
return {
|
||||
["A"] = 0x100,
|
||||
["B"] = 0x101,
|
||||
}
|
||||
```
|
||||
|
||||
The sheet is a plain PNG, 16 glyphs to a row by default, each cell 8x8,
|
||||
black on white like `assets/generated/font.png`. Codes run left to right,
|
||||
top to bottom from `base`.
|
||||
|
||||
Sequences are matched **longest first**, so a multi-byte character and a
|
||||
multi-character ligature both work and neither shadows the other:
|
||||
|
||||
```lua
|
||||
["\u{3042}"] = 0x120, -- one 3-byte character, one glyph
|
||||
["ch"] = 0x121, -- two ASCII letters, one glyph
|
||||
```
|
||||
|
||||
## Line length is counted in glyphs
|
||||
|
||||
The dialogue box fits 18 glyphs a line, not 18 bytes. A 3-byte character
|
||||
costs one column, and the engine will never cut a character in half. Your
|
||||
own `\n` line breaks are respected exactly as written, so break lines where
|
||||
they read best rather than where they fit English.
|
||||
|
||||
If your glyphs are not 8px wide, set `advance` on the page and the box
|
||||
re-measures.
|
||||
|
||||
## Format directives must survive
|
||||
|
||||
Some sources carry `%s` or `%d`:
|
||||
|
||||
```lua
|
||||
["Wild %s\nappeared!"] = "...",
|
||||
```
|
||||
|
||||
Keep every directive, in a count that matches. Word order is yours to
|
||||
change; the engine substitutes in the order the directives appear, so if
|
||||
your language needs the name last, write the sentence with the `%s` last.
|
||||
A translation whose directive count does not match the English is refused
|
||||
at runtime and the English is drawn instead, with a line in the log saying
|
||||
so - it will not crash a battle.
|
||||
|
||||
## Checking your work
|
||||
|
||||
```sh
|
||||
python3 tools/modkit.py validate spanish_ui --base imported
|
||||
python3 tools/modkit.py translation spanish_ui --refresh # pick up new engine strings
|
||||
POKEPORT_DEV=1 scripts/run.sh # F5 hot-reloads lang/
|
||||
```
|
||||
|
||||
`--refresh` rewrites the catalogs from the current engine, keeping every
|
||||
translation you have already written and reporting what changed. Run it
|
||||
after pulling a new engine version.
|
||||
@@ -0,0 +1,11 @@
|
||||
Put your glyph sheet here.
|
||||
|
||||
A page is a PNG of 8x8 cells, 16 per row by default, black on white. Codes
|
||||
run left to right and top to bottom starting at the page's `base`, so the
|
||||
first cell is `base`, the second `base + 1`, and so on.
|
||||
|
||||
`assets/generated/font.png` in the player's cache is the vanilla sheet at
|
||||
the same scale; open it alongside yours to match weight and baseline.
|
||||
|
||||
Declare the sheet in `lang/font.lua` and map sequences to codes in
|
||||
`lang/charmap.lua`.
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Which byte sequence draws which glyph code.
|
||||
--
|
||||
-- Sequences are matched longest-first, so a multi-byte character and a
|
||||
-- multi-character ligature both work: "ch" can be one glyph even though
|
||||
-- "c" is also mapped. Codes here must land inside a page declared in
|
||||
-- lang/font.lua.
|
||||
return {
|
||||
-- ["A"] = 0x100,
|
||||
-- ["B"] = 0x101,
|
||||
}
|
||||