mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-27 00:48:32 +02:00
Merge pull request #1837 from bryanthaboi/dev
palettes. menu reordering. etc.
This commit is contained in:
@@ -333,6 +333,56 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
linux-flatpak-changes:
|
||||
name: detect Linux Flatpak 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_flatpak\.sh$|flatpak/|scripts/pack_love\.sh$|docs/linux-flatpak\.md$|\.github/workflows/(ci|release)\.yml$)'; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
linux-flatpak-build:
|
||||
name: Linux Flatpak build
|
||||
needs: linux-flatpak-changes
|
||||
if: needs.linux-flatpak-changes.outputs.changed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install flatpak-builder deps
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder elfutils imagemagick
|
||||
- name: Build Flatpak bundle
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_flatpak.sh --version 0.0.0
|
||||
- name: Upload the Flatpak
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-linux-flatpak
|
||||
path: |
|
||||
dist/flatpak/gen1recomp-0.0.0-linux.flatpak
|
||||
dist/flatpak/gen1recomp-0.0.0-linux.flatpak.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
headless:
|
||||
name: headless suites (no ROM)
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
|
||||
linux-arm64:
|
||||
name: build Linux arm64 AppImage
|
||||
needs: [version, love-payload]
|
||||
needs: [version, love-payload, shaderfx-bridge]
|
||||
# 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.
|
||||
@@ -155,13 +155,22 @@ jobs:
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: .bazinga/work
|
||||
- name: Download the ShaderFX bridge
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: shaderfx-bridge-linux-arm64
|
||||
path: dist/native/linux-arm64
|
||||
- name: Build Linux arm64 AppImage
|
||||
env:
|
||||
SHADERFX_BRIDGE_REQUIRED: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_linux_arm64.sh \
|
||||
--version "${{ needs.version.outputs.version }}" \
|
||||
--game-love .bazinga/work/game.love
|
||||
- name: Verify the AppImage is self-contained and bullseye-compatible
|
||||
env:
|
||||
SHADERFX_BRIDGE_REQUIRED: "1"
|
||||
run: bash scripts/linux-arm64/verify_appimage.sh "dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage"
|
||||
- name: Upload Linux arm64 release
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -173,6 +182,45 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
linux-flatpak:
|
||||
name: build Linux Flatpak
|
||||
needs: [version, love-payload, shaderfx-bridge]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install flatpak-builder deps
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder elfutils imagemagick
|
||||
- name: Download shared payload
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-release-love
|
||||
path: .bazinga/work
|
||||
- name: Download the ShaderFX bridge
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: shaderfx-bridge-linux-x64
|
||||
path: dist/native/linux-x64
|
||||
- name: Build Flatpak bundle
|
||||
env:
|
||||
SHADERFX_BRIDGE_REQUIRED: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scripts/build_flatpak.sh \
|
||||
--version "${{ needs.version.outputs.version }}" \
|
||||
--game-love .bazinga/work/game.love
|
||||
- name: Upload Flatpak release
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: gen1recomp-linux-flatpak-release
|
||||
path: |
|
||||
dist/flatpak/gen1recomp-${{ needs.version.outputs.version }}-linux.flatpak
|
||||
dist/flatpak/gen1recomp-${{ needs.version.outputs.version }}-linux.flatpak.sha256
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
xbox-uwp:
|
||||
name: build Xbox UWP release
|
||||
needs: [version, love-payload]
|
||||
@@ -286,8 +334,82 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
shaderfx-bridge:
|
||||
name: build ShaderFX bridge (${{ matrix.plat }})
|
||||
needs: version
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- plat: win-x64
|
||||
runs-on: windows-2022
|
||||
lib: librashader_bridge.dll
|
||||
- plat: mac
|
||||
runs-on: macos-latest
|
||||
lib: liblibrashader_bridge.dylib
|
||||
- plat: linux-x64
|
||||
runs-on: ubuntu-24.04
|
||||
lib: liblibrashader_bridge.so
|
||||
glibc_triple: x86_64-unknown-linux-gnu.2.17
|
||||
- plat: linux-arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
lib: liblibrashader_bridge.so
|
||||
glibc_triple: aarch64-unknown-linux-gnu.2.17
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Cache cargo registry and build dir
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: tools/shaderfx-bridge
|
||||
key: ${{ matrix.plat }}-${{ matrix.glibc_triple }}
|
||||
- name: Build the bridge
|
||||
shell: bash
|
||||
env:
|
||||
PLAT: ${{ matrix.plat }}
|
||||
LIB: ${{ matrix.lib }}
|
||||
GLIBC_TRIPLE: ${{ matrix.glibc_triple }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
out="dist/native/$PLAT"
|
||||
mkdir -p "$out"
|
||||
cd tools/shaderfx-bridge
|
||||
case "$PLAT" in
|
||||
win-x64)
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
cargo build --release --target x86_64-pc-windows-msvc
|
||||
cp "target/x86_64-pc-windows-msvc/release/$LIB" "$GITHUB_WORKSPACE/$out/$LIB"
|
||||
;;
|
||||
mac)
|
||||
rustup target add x86_64-apple-darwin aarch64-apple-darwin
|
||||
cargo build --release --target x86_64-apple-darwin
|
||||
cargo build --release --target aarch64-apple-darwin
|
||||
lipo -create -output "$GITHUB_WORKSPACE/$out/$LIB" \
|
||||
"target/x86_64-apple-darwin/release/$LIB" \
|
||||
"target/aarch64-apple-darwin/release/$LIB"
|
||||
lipo -info "$GITHUB_WORKSPACE/$out/$LIB"
|
||||
;;
|
||||
linux-x64|linux-arm64)
|
||||
pipx install cargo-zigbuild
|
||||
pipx inject cargo-zigbuild ziglang
|
||||
triple="$GLIBC_TRIPLE"
|
||||
rustup target add "${triple%%.*}"
|
||||
cargo zigbuild --release --target "$triple"
|
||||
cp "target/${triple%%.*}/release/$LIB" "$GITHUB_WORKSPACE/$out/$LIB"
|
||||
;;
|
||||
esac
|
||||
- name: Upload the bridge
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: shaderfx-bridge-${{ matrix.plat }}
|
||||
path: dist/native/${{ matrix.plat }}/${{ matrix.lib }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
release:
|
||||
needs: [version, love-payload, xbox-uwp, linux-arm64, native-tls-win]
|
||||
needs: [version, love-payload, xbox-uwp, linux-arm64, linux-flatpak, native-tls-win, shaderfx-bridge]
|
||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||
|
||||
steps:
|
||||
@@ -309,6 +431,28 @@ jobs:
|
||||
name: gen1tls-win-x64
|
||||
path: dist/native/win-x64
|
||||
|
||||
- name: Download ShaderFX bridge libraries
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: shaderfx-bridge-*
|
||||
path: dist/native
|
||||
- name: Flatten the ShaderFX bridge artifact layout
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for d in dist/native/shaderfx-bridge-*/; do
|
||||
d="${d%/}"
|
||||
plat="${d#dist/native/shaderfx-bridge-}"
|
||||
mkdir -p "dist/native/$plat"
|
||||
for f in "$d"/*; do mv "$f" "dist/native/$plat/"; done
|
||||
rmdir "$d"
|
||||
done
|
||||
for plat in mac win-x64 linux-x64 linux-arm64; do
|
||||
ls "dist/native/$plat" >/dev/null \
|
||||
|| { echo "::error::no ShaderFX bridge staged for $plat"; exit 1; }
|
||||
done
|
||||
ls -lR dist/native
|
||||
|
||||
# The same game.love the arm64 AppImage and Xbox UWP builds fused, so
|
||||
# every release asset ships one identical payload (build.sh's own pack
|
||||
# would omit PATCH_NOTES.md and mobile/ios/app-repo.json).
|
||||
@@ -358,6 +502,7 @@ jobs:
|
||||
- name: Build macOS + Windows + Linux
|
||||
env:
|
||||
GEN1TLS_DLL: ${{ github.workspace }}/dist/native/win-x64/gen1tls.dll
|
||||
SHADERFX_BRIDGE_REQUIRED: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Sign in-build (identity auto-detected from the temp keychain);
|
||||
@@ -372,6 +517,8 @@ jobs:
|
||||
--game-love dist/payload/game.love
|
||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
||||
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F librashader_bridge.dll \
|
||||
|| { echo "::error::Windows zip is missing librashader_bridge.dll"; exit 1; }
|
||||
|
||||
- name: Materialize Android release signing key
|
||||
env:
|
||||
@@ -429,6 +576,8 @@ jobs:
|
||||
--version "${{ needs.version.outputs.version }}"
|
||||
|
||||
- name: Build Anbernic RG34XXSP port
|
||||
env:
|
||||
SHADERFX_BRIDGE_REQUIRED: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5
|
||||
@@ -442,6 +591,7 @@ jobs:
|
||||
# builds, while this explicit local override keeps CI source-aligned.
|
||||
GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }}
|
||||
GEN1RECOMP_RELEASE_TAG: v${{ needs.version.outputs.version }}
|
||||
SHADERFX_BRIDGE_REQUIRED: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build
|
||||
@@ -498,6 +648,13 @@ jobs:
|
||||
name: gen1recomp-linux-arm64-release
|
||||
path: dist/linux-arm64
|
||||
|
||||
- name: Download Linux Flatpak release
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: gen1recomp-linux-flatpak-release
|
||||
path: dist/flatpak
|
||||
|
||||
- name: Stage release assets
|
||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||
id: assets
|
||||
@@ -509,16 +666,24 @@ jobs:
|
||||
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"
|
||||
# x86_64 desktop Linux: raw AppImage (no zip wrapper).
|
||||
x64_appimage="dist/linux/gen1recomp-linux-x86_64.AppImage"
|
||||
[ -f "$x64_appimage" ] || { echo "::error::$x64_appimage not found (expected from scripts/build.sh linux)"; exit 1; }
|
||||
cp "$x64_appimage" "$outdir/gen1recomp-${v}-linux-x86_64.AppImage"
|
||||
chmod +x "$outdir/gen1recomp-${v}-linux-x86_64.AppImage"
|
||||
|
||||
# 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.
|
||||
# AppImage 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"
|
||||
|
||||
flatpak_bundle="dist/flatpak/gen1recomp-${v}-linux.flatpak"
|
||||
[ -f "$flatpak_bundle" ] || { echo "::error::$flatpak_bundle not found (expected from the linux-flatpak job)"; exit 1; }
|
||||
cp "$flatpak_bundle" "$outdir/gen1recomp-${v}-linux.flatpak"
|
||||
apk="$(find dist/android/release -name '*.apk' | head -1)"
|
||||
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; }
|
||||
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
|
||||
@@ -656,8 +821,9 @@ jobs:
|
||||
release_files=(
|
||||
"dist/release/gen1recomp-${v}-macos.zip"
|
||||
"dist/release/gen1recomp-${v}-windows.zip"
|
||||
"dist/release/gen1recomp-${v}-linux.zip"
|
||||
"dist/release/gen1recomp-${v}-linux-x86_64.AppImage"
|
||||
"dist/release/gen1recomp-${v}-linux-arm64.AppImage"
|
||||
"dist/release/gen1recomp-${v}-linux.flatpak"
|
||||
"dist/release/gen1recomp-${v}-android.apk"
|
||||
"dist/release/gen1recomp++-${v}-ios.ipa"
|
||||
"dist/release/gen1recomp-${v}-switch.zip"
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# Gen1Recomp
|
||||
|
||||
A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
|
||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||
supplied by the player.
|
||||
A native LÖVE2D recreation of Poke Red, Blue, Gold, Silver, and Crystal. The
|
||||
engine and map behavior are hand-written Lua; game data and graphics are
|
||||
decoded from a ROM supplied by the player.
|
||||
|
||||
And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. ***Reverse Engineering Causes Obsessive Mental Problems***
|
||||
|
||||
[Click Here for the AI Use Disclosure!](AIDisclosure.md)
|
||||
|
||||
> [!CAUTION]
|
||||
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project. Also, as I assumed would eventually happen, the idiot that made that website now pumped it full of adware. Please stay away from that website.
|
||||
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository, the Discord, and https://gen1re.com are the only official sources for this project. Also, as I assumed would eventually happen, the idiot that made that website now pumped it full of adware. Please stay away from that website.
|
||||
|
||||
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
|
||||
|
||||
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
|
||||
# [SUPPORT / ANNOUNCEMENTS / MODS ALL FOUND ON THE DISCORD](https://bois.icu)
|
||||
|
||||
<p align="center">
|
||||
|
||||
@@ -228,18 +228,27 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
||||
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
||||
|
||||
|
||||
## Linux on arm64 (Raspberry Pi)
|
||||
## Linux desktop (AppImage / Flatpak)
|
||||
|
||||
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:
|
||||
Releases ship raw AppImages (no zip wrapper) plus an optional Flatpak bundle:
|
||||
|
||||
- `gen1recomp-*-linux-x86_64.AppImage`
|
||||
- `gen1recomp-*-linux-arm64.AppImage` (Raspberry Pi 4/5, Armbian, arm64 VMs)
|
||||
- `gen1recomp-*-linux.flatpak` (see [docs/linux-flatpak.md](docs/linux-flatpak.md))
|
||||
|
||||
```sh
|
||||
chmod +x gen1recomp-*-linux-x86_64.AppImage
|
||||
./gen1recomp-*-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
```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
|
||||
Shared troubleshooting (FUSE, curl, portable mode): [docs/linux-appimage.md](docs/linux-appimage.md).
|
||||
|
||||
LÖVE publishes no aarch64 binary of any kind, so the arm64 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
|
||||
|
||||
+186
-50
@@ -192,6 +192,20 @@ chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
|
||||
|
||||
BRIDGE_LIB="liblibrashader_bridge.so"
|
||||
BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_ARM64:-}"
|
||||
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-arm64/$BRIDGE_LIB" ]; then
|
||||
BRIDGE_SRC="$ROOT/dist/native/linux-arm64/$BRIDGE_LIB"
|
||||
fi
|
||||
if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then
|
||||
cp "$BRIDGE_SRC" "$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/$BRIDGE_LIB"
|
||||
say "bundled $BRIDGE_LIB for SHADER FX preset conversion"
|
||||
elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
|
||||
fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_ARM64 or stage it at dist/native/linux-arm64/$BRIDGE_LIB"
|
||||
else
|
||||
warn "$BRIDGE_LIB not found: this port can run converted presets but not CONVERT new ones"
|
||||
fi
|
||||
|
||||
# Drop a short license pointer for the bundled LÖVE bits.
|
||||
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
|
||||
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
|
||||
@@ -203,81 +217,203 @@ EOF
|
||||
# Resolve the game directory from the launcher so this works with both
|
||||
# PortMaster-managed ports directories.
|
||||
cat > "$PORT_ROOT/$LAUNCHER_NAME" <<'EOF'
|
||||
#!/bin/bash
|
||||
# gen1recomp-sbc — Linux ARM SBC / PortMaster launcher
|
||||
# Uses SHDIR-relative paths so firmware-specific mount points do not matter.
|
||||
#!/usr/bin/env bash
|
||||
# gen1recomp-sbc — High-Performance Native ARM64 Handheld Port Launcher
|
||||
# Compatible with TrimUI Brick, TrimUI Smart Pro, Anbernic (H700/RK3566), muOS, Knulli, ArkOS, Stock OS
|
||||
|
||||
export HOME="${HOME:-/root}"
|
||||
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
SHDIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster/" ]; then
|
||||
controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Roms/ports/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Data/PortMaster/" ]; then
|
||||
controlfolder="/mnt/SDCARD/Data/PortMaster"
|
||||
elif [ -d "$SHDIR/PortMaster" ]; then
|
||||
controlfolder="$SHDIR/PortMaster"
|
||||
elif [ -d "/opt/system/Tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/system/Tools/PortMaster"
|
||||
elif [ -d "/opt/tools/PortMaster/" ]; then
|
||||
controlfolder="/opt/tools/PortMaster"
|
||||
elif [ -d "$XDG_DATA_HOME/PortMaster/" ]; then
|
||||
controlfolder="$XDG_DATA_HOME/PortMaster"
|
||||
elif [ -d "/roms/ports/PortMaster" ]; then
|
||||
controlfolder="/roms/ports/PortMaster"
|
||||
else
|
||||
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
|
||||
fi
|
||||
|
||||
if [ ! -f "$controlfolder/control.txt" ]; then
|
||||
echo "PortMaster control.txt not found under $controlfolder" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
source "$controlfolder/control.txt"
|
||||
get_controls
|
||||
if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||
fi
|
||||
|
||||
GAMEDIR="$SHDIR/gen1recomp-sbc"
|
||||
CONFDIR="$GAMEDIR/conf"
|
||||
|
||||
mkdir -p "$CONFDIR"
|
||||
|
||||
cd "$GAMEDIR" || exit 1
|
||||
> "$GAMEDIR/log.txt" && exec > >(tee "$GAMEDIR/log.txt") 2>&1
|
||||
|
||||
# Setup POSIX logging (compatible with busybox ash/dash/bash)
|
||||
exec 1>"$GAMEDIR/log.txt" 2>&1
|
||||
|
||||
echo "========================================="
|
||||
echo "gen1recomp Handheld Native ARM64 Launcher"
|
||||
echo "SHDIR: $SHDIR"
|
||||
echo "GAMEDIR: $GAMEDIR"
|
||||
echo "Date: $(date 2>/dev/null || echo 'N/A')"
|
||||
echo "========================================="
|
||||
|
||||
# PortMaster's control.txt wants a home tree under /mnt/SDCARD/Data; on a
|
||||
# fresh card that tree may not exist yet and its mkdir fails noisily (log.txt:
|
||||
# "mkdir: can't create directory '/mnt/SDCARD/Data/home'"). Pre-create it
|
||||
# before any PortMaster script runs so the failure cannot occur.
|
||||
if [ -d /mnt/SDCARD ]; then
|
||||
mkdir -p /mnt/SDCARD/Data/home 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 1. Locate PortMaster control directory if present
|
||||
controlfolder=""
|
||||
if [ -d "/mnt/SDCARD/Apps/PortMaster/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Apps/PortMaster/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/PortMaster/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/PortMaster/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Apps/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Apps/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Emus/PORTS/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Emus/PORTS/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Emus/PORTMASTER" ]; then
|
||||
controlfolder="/mnt/SDCARD/Emus/PORTMASTER"
|
||||
elif [ -d "/mnt/SDCARD/Roms/ports/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Roms/ports/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Roms/PORTS/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Roms/PORTS/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Ports/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Ports/PortMaster"
|
||||
elif [ -d "$SHDIR/PortMaster" ]; then
|
||||
controlfolder="$SHDIR/PortMaster"
|
||||
elif [ -d "/opt/system/Tools/PortMaster" ]; then
|
||||
controlfolder="/opt/system/Tools/PortMaster"
|
||||
elif [ -d "/opt/tools/PortMaster" ]; then
|
||||
controlfolder="/opt/tools/PortMaster"
|
||||
elif [ -d "$XDG_DATA_HOME/PortMaster" ]; then
|
||||
controlfolder="$XDG_DATA_HOME/PortMaster"
|
||||
elif [ -d "/mnt/SDCARD/Emus/tg5040/PORTS.pak/PortMaster" ]; then
|
||||
controlfolder="/mnt/SDCARD/Emus/tg5040/PORTS.pak/PortMaster"
|
||||
elif [ -d "/roms/PORTS/PortMaster" ]; then
|
||||
controlfolder="/roms/PORTS/PortMaster"
|
||||
elif [ -d "/roms/ports/PortMaster" ]; then
|
||||
controlfolder="/roms/ports/PortMaster"
|
||||
fi
|
||||
|
||||
if [ -n "$controlfolder" ] && [ -f "$controlfolder/control.txt" ]; then
|
||||
echo "Found PortMaster control folder: $controlfolder"
|
||||
# shellcheck disable=SC1090
|
||||
. "$controlfolder/control.txt"
|
||||
if type get_controls >/dev/null 2>&1; then
|
||||
get_controls
|
||||
fi
|
||||
if [ -n "${CFW_NAME:-}" ] && [ -f "${controlfolder}/mod_${CFW_NAME}.txt" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||
fi
|
||||
else
|
||||
echo "PortMaster control.txt not found, using standalone handheld configuration."
|
||||
fi
|
||||
|
||||
# 2. CPU governor
|
||||
# Pinning every core to performance keeps handhelds at peak clock even when gameplay
|
||||
# uses only a fraction of CPU. Schedutil is the default; set POKEPORT_CPU_GOVERNOR=performance
|
||||
# only for profiling or devices needing an extra boost.
|
||||
CPU_GOVERNOR_OVERRIDE="${POKEPORT_CPU_GOVERNOR:-schedutil}"
|
||||
CPU_GOVERNOR_STATE=""
|
||||
|
||||
write_cpu_governor() {
|
||||
local governor_path="$1"
|
||||
local governor="$2"
|
||||
if [ -n "${ESUDO:-}" ]; then
|
||||
printf '%s\n' "$governor" | $ESUDO tee "$governor_path" >/dev/null 2>&1 || true
|
||||
else
|
||||
printf '%s\n' "$governor" > "$governor_path" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
restore_cpu_governors() {
|
||||
[ -n "$CPU_GOVERNOR_STATE" ] || return 0
|
||||
while IFS='|' read -r governor_path governor; do
|
||||
[ -n "$governor_path" ] || continue
|
||||
write_cpu_governor "$governor_path" "$governor"
|
||||
done <<< "$CPU_GOVERNOR_STATE"
|
||||
CPU_GOVERNOR_STATE=""
|
||||
}
|
||||
|
||||
apply_cpu_governors() {
|
||||
[ -n "$CPU_GOVERNOR_OVERRIDE" ] || return 0
|
||||
for governor_path in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
|
||||
[ -f "$governor_path" ] || continue
|
||||
old_governor=$(cat "$governor_path" 2>/dev/null || true)
|
||||
[ -n "$old_governor" ] || continue
|
||||
CPU_GOVERNOR_STATE="${CPU_GOVERNOR_STATE}${governor_path}|${old_governor}"$'\n'
|
||||
write_cpu_governor "$governor_path" "$CPU_GOVERNOR_OVERRIDE"
|
||||
done
|
||||
echo "CPU governor requested: $CPU_GOVERNOR_OVERRIDE"
|
||||
}
|
||||
|
||||
# 3. Environment & Low-Level OS Tuning
|
||||
export POKEPORT_HANDHELD=1
|
||||
export HANDHELD=1
|
||||
export PORTMASTER=1
|
||||
export NINTENDO_LAYOUT=1
|
||||
export XDG_DATA_HOME="$CONFDIR"
|
||||
export XDG_CONFIG_HOME="$CONFDIR"
|
||||
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:${LD_LIBRARY_PATH:-}"
|
||||
export LD_LIBRARY_PATH="$GAMEDIR/libs.aarch64:$GAMEDIR/libs:/usr/trimui/lib:/mnt/SDCARD/System/lib:/usr/lib64:/usr/lib:${LD_LIBRARY_PATH:-}"
|
||||
export SDL_GAMECONTROLLERCONFIG="${sdl_controllerconfig:-}"
|
||||
# GLES is the common path on ARM SBC handhelds; firmware may override it.
|
||||
export LOVE_GRAPHICS_USE_OPENGLES="${LOVE_GRAPHICS_USE_OPENGLES:-1}"
|
||||
|
||||
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || chmod a+x ./bin/love.aarch64
|
||||
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||
# Audio buffer size
|
||||
export SDL_AUDIO_SAMPLES=1024
|
||||
|
||||
if [ -n "${GPTOKEYB:-}" ]; then
|
||||
$GPTOKEYB "love.aarch64" &
|
||||
# Chip-synth sample rate. The audio worker is the dominant CPU cost while
|
||||
# idle (music keeps playing when the screen is static), and synthesis cost
|
||||
# scales linearly with the rate. 22050 Hz roughly halves audio CPU vs the
|
||||
# 44100 Hz default; the Game Boy's own DAC content is well below 11 kHz, so
|
||||
# the handheld speaker sounds effectively identical.
|
||||
export POKEPORT_AUDIO_RATE=22050
|
||||
|
||||
# Idle-power render governor: after 10s with no input on static screens,
|
||||
# presentation drops to IDLE_FPS, cutting idle CPU/GPU compositing ~6x.
|
||||
# Any button press restores full framerate next frame.
|
||||
export POKEPORT_IDLE_FPS=6
|
||||
|
||||
# Memory allocator tuning (limit arena fragmentation on 1GB RAM SBCs)
|
||||
export MALLOC_ARENA_MAX=2
|
||||
export MALLOC_TRIM_THRESHOLD_=131072
|
||||
|
||||
# Process scheduling & CPU affinity
|
||||
renice -n -5 -p $$ >/dev/null 2>&1 || true
|
||||
taskset -cp 0-3 $$ >/dev/null 2>&1 || true
|
||||
ionice -c 3 -p $$ >/dev/null 2>&1 || true
|
||||
|
||||
# Ensure executable permissions
|
||||
if [ -n "${ESUDO:-}" ]; then
|
||||
$ESUDO chmod a+x ./bin/love.aarch64 2>/dev/null || true
|
||||
$ESUDO chmod 666 /dev/uinput 2>/dev/null || true
|
||||
else
|
||||
chmod a+x ./bin/love.aarch64 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 4. Platform helper
|
||||
if type pm_platform_helper >/dev/null 2>&1; then
|
||||
echo "Running pm_platform_helper..."
|
||||
pm_platform_helper "$GAMEDIR/bin/love.aarch64"
|
||||
fi
|
||||
|
||||
# Apply after PortMaster's helper: some firmware helpers reset cpufreq policy
|
||||
# while preparing the runtime. The saved values are restored on exit.
|
||||
apply_cpu_governors
|
||||
trap restore_cpu_governors EXIT
|
||||
|
||||
# PulseAudio runtime: PortMaster's helper points it at conf/pulse on the vfat
|
||||
# SD card, where the runtime symlink fails ("Operation not permitted" -- vfat
|
||||
# has no symlinks). Force the runtime onto tmpfs (real dirs, no symlinks).
|
||||
export PULSE_RUNTIME_PATH="/tmp/pulse-$PPID"
|
||||
mkdir -p "$PULSE_RUNTIME_PATH" 2>/dev/null || true
|
||||
|
||||
echo "Launching ./bin/love.aarch64 $GAMEDIR/lovegame..."
|
||||
echo "LD_LIBRARY_PATH: $LD_LIBRARY_PATH"
|
||||
|
||||
# 5. Launch
|
||||
./bin/love.aarch64 "$GAMEDIR/lovegame"
|
||||
EXIT_CODE=$?
|
||||
|
||||
echo "Game exited with code: $EXIT_CODE"
|
||||
|
||||
# 6. Cleanup
|
||||
restore_cpu_governors
|
||||
|
||||
if type pm_finish >/dev/null 2>&1; then
|
||||
pm_finish
|
||||
else
|
||||
if [ -n "${ESUDO:-}" ]; then
|
||||
$ESUDO kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
else
|
||||
kill -9 $(pidof gptokeyb) 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
exit $EXIT_CODE
|
||||
EOF
|
||||
chmod +x "$PORT_ROOT/$LAUNCHER_NAME"
|
||||
|
||||
|
||||
@@ -156,6 +156,20 @@ chmod +x "$PORT_ROOT/$PORT_DIR_NAME/bin/love.aarch64"
|
||||
cp "$LOVE_LIB" "$LUAJIT_LIB" "$MODPLUG_LIB" "$OGG_LIB" \
|
||||
"$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/"
|
||||
|
||||
BRIDGE_LIB="liblibrashader_bridge.so"
|
||||
BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_ARM64:-}"
|
||||
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-arm64/$BRIDGE_LIB" ]; then
|
||||
BRIDGE_SRC="$ROOT/dist/native/linux-arm64/$BRIDGE_LIB"
|
||||
fi
|
||||
if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then
|
||||
cp "$BRIDGE_SRC" "$PORT_ROOT/$PORT_DIR_NAME/libs.aarch64/$BRIDGE_LIB"
|
||||
say "bundled $BRIDGE_LIB for SHADER FX preset conversion"
|
||||
elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
|
||||
fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_ARM64 or stage it at dist/native/linux-arm64/$BRIDGE_LIB"
|
||||
else
|
||||
warn "$BRIDGE_LIB not found: this port can run converted presets but not CONVERT new ones"
|
||||
fi
|
||||
|
||||
# Drop a short license pointer for the bundled LÖVE bits.
|
||||
cat > "$PORT_ROOT/$PORT_DIR_NAME/licenses/LICENSE.love2d.txt" <<'EOF'
|
||||
This port bundles the LÖVE 11.5 aarch64 runtime from PortMaster
|
||||
|
||||
@@ -62,7 +62,16 @@ function love.conf(t)
|
||||
t.window.minheight = 360
|
||||
end
|
||||
t.version = love._os == "iOS" and "12.0" or "11.5"
|
||||
t.window.vsync = 1
|
||||
-- On Linux handhelds with KMSDRM/EGL, driver vsync blocks the CPU thread with
|
||||
-- busy-waiting spinloops inside eglSwapBuffers. Disabling driver vsync allows
|
||||
-- love.run to perform kernel nanosleep(), dropping idle CPU from 25% to 4%.
|
||||
if os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
|
||||
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
|
||||
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1" then
|
||||
t.window.vsync = 0
|
||||
else
|
||||
t.window.vsync = 1
|
||||
end
|
||||
t.modules.audio = not companion
|
||||
t.modules.joystick = not companion
|
||||
t.modules.physics = false
|
||||
|
||||
@@ -0,0 +1,765 @@
|
||||
return {
|
||||
{ name = "BGB", palettes = {
|
||||
{ "BGB_v0.3", "142c3c548c74acd494ecfccc" },
|
||||
{ "Blue", "0000600000c05f5fffc0c0ff" },
|
||||
{ "GB_Kiosk", "1434046c6c04bcbc1cececb4" },
|
||||
{ "GB_Light", "004f3b00694a009a7100b581" },
|
||||
{ "Grey", "1414145c5c5ca4a4a4ececec" },
|
||||
{ "Greys_High_Contrast", "000000686868b0b0b0ffffff" },
|
||||
{ "KIG_Green", "20402030703040a04050d050" },
|
||||
{ "LCD_Green", "0c1c24346c548cc474e4fcd4" },
|
||||
{ "Red", "600000c00000ff6060ffc0c0" },
|
||||
{ "Super_GB", "311852ad2921de944affefce" },
|
||||
{ "Yellow", "202010686830b0a848f8f078" },
|
||||
{ "Yellow_BGB", "556f3593a73bd2cf4df4ebbf" },
|
||||
{ "NGM_Brown", "483818987838d8b058f8e088" },
|
||||
} },
|
||||
{ name = "BGB/Inverted", palettes = {
|
||||
{ "BGB_v0.3 - Inverted", "ecfcccacd494548c74142c3c" },
|
||||
{ "Blue - Inverted", "c0c0ff5f5fff0000c0000060" },
|
||||
{ "GB_Kiosk - Inverted", "ececb4bcbc1c6c6c04143404" },
|
||||
{ "GB_Light - Inverted", "00b581009a7100694a004f3b" },
|
||||
{ "Grey - Inverted", "ecececa4a4a45c5c5c141414" },
|
||||
{ "Greys_High_Contrast - Inverted", "ffffffb0b0b0686868000000" },
|
||||
{ "KIG_Green - Inverted", "50d05040a040307030204020" },
|
||||
{ "LCD_Green - Inverted", "e4fcd48cc474346c540c1c24" },
|
||||
{ "Red - Inverted", "ffc0c0ff6060c00000600000" },
|
||||
{ "Super_GB - Inverted", "ffefcede944aad2921311852" },
|
||||
{ "Yellow - Inverted", "f8f078b0a848686830202010" },
|
||||
{ "Yellow_BGB- Inverted", "f4ebbfd2cf4d93a73b556f35" },
|
||||
{ "NGM_Brown - Inverted", "f8e088d8b058987838483818" },
|
||||
} },
|
||||
{ name = "GalleryWebApp Palettes", palettes = {
|
||||
{ "Artistic Caffeinated Lactose", "2416069e754fdea963fdfef5" },
|
||||
{ "AQP Pikes Peak", "2a201efa2b25868779ebeee7" },
|
||||
{ "Azure Clouds", "00000012412732b66d47ff99" },
|
||||
{ "BGB Emulator", "08182034685688c070e0f8d0" },
|
||||
{ "Black & White", "000000555555aaaaaaffffff" },
|
||||
{ "Black Zero", "2e463d385d49577b467e8416" },
|
||||
{ "CGA Palette Crush 1", "000000ff55ff55ffffffffff" },
|
||||
{ "CGA Palette Crush 2", "000000ff555555ffffffffff" },
|
||||
{ "CMYKeystone", "373737fb00fa0be8fdffff00" },
|
||||
{ "Candy Cotton Tower Raid", "3800168f0039e65790e6aec4" },
|
||||
{ "Caramel Fudge Paranoia", "3f1711b01553cf7163cf9255" },
|
||||
{ "Childhood in Greenland", "405010708028a0a840d0d058" },
|
||||
{ "Cyanide Blues", "0e1e3d1e479321aff59efbe3" },
|
||||
{ "Deep Haze Green", "00000027421f467818a1d909" },
|
||||
{ "Dies ist meine Wassermelone", "222903558429f27d7affdbcb" },
|
||||
{ "Drowning at Night", "030c2220293f586164a9b0b3" },
|
||||
{ "DUN 2000 Remastered", "000000725441c09e7dfbf1cd" },
|
||||
{ "Flowerfeldstrasse", "171f6275868fc5c5cee9d9cc" },
|
||||
{ "Floyd Steinberg in Love", "4c1c2dd23c4e5fb1f5eaf5fa" },
|
||||
{ "GB Light", "0b7a6d16a59619c7b31ddece" },
|
||||
{ "GB Pocket", "1f1f1f4d533c8b956dc4cfa1" },
|
||||
{ "Glowing Mountains", "2f1c35514f6ca1a8b8ffbf98" },
|
||||
{ "Golden Elephant Curry", "0000004f3000c27600ff9c00" },
|
||||
{ "Grafixkid Gray", "2b2b26706b66a89f94e0dbcd" },
|
||||
{ "Grafixkid Green", "4c625a7b9278abc396dbf4b4" },
|
||||
{ "Knee-Deep in the Wood", "2c24103c3ca9d5690ffffe6e" },
|
||||
{ "LNK late Awakening", "5a39216b8c427bc67bffffb5" },
|
||||
{ "MTR Aran Remixed", "2c1700b62558047e60aedf1e" },
|
||||
{ "My Friend from Bavaria", "382977b60077df7925feda1b" },
|
||||
{ "Nortorious Comadante", "0402ac04aaac54fefcfcfe54" },
|
||||
{ "Original GB", "0f380f30623077a1129bbc0f" },
|
||||
{ "Purple Rain", "68006aff00848570b2adfffc" },
|
||||
{ "Romeros Garden", "323727574431649a57ebc4ab" },
|
||||
{ "Rusted City Rain", "2c2137764462a96868edb4a1" },
|
||||
{ "Space Haze Overload", "0b06306b1fb1cc3495f8e3c4" },
|
||||
{ "Starlit Memories", "4f133f6f20966d53bd869ad9" },
|
||||
{ "Sunflower Holidays", "000000881400ff5555ffff55" },
|
||||
{ "Super Hyper Mega GB", "331e50a63725d68e49f7e7c6" },
|
||||
{ "The death of Yung Columbus", "1d1414462917ff2261b5ff32" },
|
||||
{ "The starry knight", "123f770f86b637cae5f5db37" },
|
||||
{ "There is Always Money", "221a09977b25fed638fdfe0a" },
|
||||
{ "Tramonto al Parco degli Acquedotti", "0c0a3e912978e64a4ef3c677" },
|
||||
{ "VTB 1985", "000000520000db0000ff0000" },
|
||||
{ "Waterfront Plaza", "10253342678e6f9edfcecece" },
|
||||
{ "Youth Ikarus reloaded", "1e00009e0000f78e50cef7f7" },
|
||||
} },
|
||||
{ name = "GB Color (Combo Palettes)", palettes = {
|
||||
{ "GBC - Down + A", "000000ff0000ffff00ffffff" },
|
||||
{ "GBC - Down + B", "0000007b4a00ffff00ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC - Down", "0000009494ffff9494ffffa5" },
|
||||
{ "GBC - Left + A", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff", "000000843100ffad63ffffff" },
|
||||
{ "GBC - Left + B", "000000525252a5a5a5ffffff" },
|
||||
{ "GBC - Left", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC - Right + A", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC - Right + B", "ffffffffde00008484000000" },
|
||||
{ "GBC - Right", "000000ff420052ff00ffffff" },
|
||||
{ "GBC - Up + A", "000000943a3aff8484ffffff", "0000000084007bff31ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC - Up + B", "5a3108846b29ce9c84ffe6c5", "000000843100ffad63ffffff", "000000843100ffad63ffffff" },
|
||||
{ "GBC - Up", "000000843100ffad63ffffff" },
|
||||
} },
|
||||
{ name = "GB Color (Unique Palettes)", palettes = {
|
||||
{ "GBC Palette 001 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 002 (World)", "000000006300ffff00a59cff" },
|
||||
{ "GBC Palette 003 (USA, Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 004 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 005 (USA)", "ffffffffde00008484000000" },
|
||||
{ "GBC Palette 006 (USA, Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 007 (USA, Europe)", "000000ff0000ff9c00ffffff" },
|
||||
{ "GBC Palette 008 (World)", "ffffffffff00ff840052de00", "0000ff63a5ffffffffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 009 (USA)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 010 (Europe)", "000000843100ffad63ffffff", "0000000084007bff31ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 011 (Europe)", "000000843100ffad63ffffff", "0000000084007bff31ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 012 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 013 (World) (Rev A)", "000000ff0000ff9c00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 014 (World)", "000000ff0000ff9c00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 015 (Japan)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 016 (USA, Europe)", "003a3a63947394b5ffffff9c", "4a0000943a00ffd600ffc542", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 017 (USA, Europe)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 018 (USA, Europe) (Rev A)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 019 (USA, Europe)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 020 (World) (Rev A)", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 021 (World)", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 022 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 023 (World) (Rev A)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 024 (World)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 025 (USA)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 026 (Europe)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 027 (Japan)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 028 (Australia)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 029 (Japan)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 030 (Japan)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 031 (Europe)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 032 (USA) (Rev A)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 033 (USA)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 034 (World)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 035 (Japan) (Rev A)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
|
||||
{ "GBC Palette 036 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
|
||||
{ "GBC Palette 037 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
|
||||
{ "GBC Palette 038 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 039 (Japan)", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 040 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 041 (USA, Europe)", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 042 (USA, Europe)", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 043 (USA)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 044 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 045 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
|
||||
{ "GBC Palette 046 (Japan)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 047 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "000000630000d60000ff6352" },
|
||||
{ "GBC Palette 048 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
|
||||
{ "GBC Palette 049 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
|
||||
{ "GBC Palette 050 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" },
|
||||
{ "GBC Palette 051 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "000000630000d60000ff6352" },
|
||||
{ "GBC Palette 052 (USA, Europe)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 053 (France)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 054 (Germany)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 055 (USA, Europe) (Rev A)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 056 (USA, Europe) (Rev B)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 057 (USA, Europe)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 058 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 059 (Europe)", "000000ff420052ff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 060 (Japan)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 061 (USA, Europe)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 062 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 063 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 064 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 065 (World)", "0000000000ff63a5ffffffff", "000000630000ff0000ffff00", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 066 (Japan)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "000000944200ff7300ffffff" },
|
||||
{ "GBC Palette 067 (USA, Europe)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "000000944200ff7300ffffff" },
|
||||
{ "GBC Palette 068 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 069 (France)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 070 (Germany)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 071 (Europe)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 072 (USA, Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 073 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 074 (USA)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 075 (Japan)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 076 (Europe)", "00000052528c8c8cdeffffff", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542" },
|
||||
{ "GBC Palette 077 (USA)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 078 (Europe)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "4a0000943a00ffd600ffc542" },
|
||||
{ "GBC Palette 079 (Japan) (Rev A)", "0000009c6300ffce00ffffff" },
|
||||
{ "GBC Palette 080 (Japan) (Rev 0A)", "000000ff0000ffff00ffffff" },
|
||||
{ "GBC Palette 081 (Japan) (Rev B)", "000000ff0000ffff00ffffff" },
|
||||
{ "GBC Palette 082 (Japan) (Rev C)", "000000ff0000ffff00ffffff" },
|
||||
{ "GBC Palette 083 (Japan) (Rev D)", "000000ff0000ffff00ffffff" },
|
||||
{ "GBC Palette 084 (Japan) (Rev A)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 085 (Japan)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 086 (Japan)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 087 (Japan) (Rev A)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 088 (Japan)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 089 (Germany)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 090 (USA, Europe)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 091 (Spain)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 092 (Spain)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 093 (USA, Europe)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 094 (Germany)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 095 (France)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 096 (France)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 097 (Italy)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 098 (Italy)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 099 (World)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 100 (Japan)", "003a3a63947394b5ffffff9c" },
|
||||
{ "GBC Palette 101 (USA, Europe)", "003a3a63947394b5ffffff9c" },
|
||||
{ "GBC Palette 102 (Europe) (En,Fr,De)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 103 (World)", "ffffffffde00008484000000" },
|
||||
{ "GBC Palette 104 (Europe)", "ffffffffde00008484000000" },
|
||||
{ "GBC Palette 105 (USA)", "ffffffffde00008484000000" },
|
||||
{ "GBC Palette 106 (USA, Europe) (Rev A)", "000000843100ffad63ffffff", "0000000084007bff31ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 107 (USA, Europe) (Rev A)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 108 (Japan)", "003a3a63947394b5ffffff9c", "4a0000943a00ffd600ffc542", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 109 (World) (Rev A)", "000000ad5a42ffff94b5b5ff", "943a3aff8484ffffff000000", "943a3aff8484ffffff000000" },
|
||||
{ "GBC Palette 110 (World)", "000000ad5a42ffff94b5b5ff", "943a3aff8484ffffff000000", "943a3aff8484ffffff000000" },
|
||||
{ "GBC Palette 111 (USA, Europe) (Rev A)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 112 (USA, Europe) (Rev B)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 113 (USA, Europe)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 114 (Japan) (Rev B)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 115 (Japan)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 116 (USA, Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" },
|
||||
{ "GBC Palette 117 (World)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 118 (World) (Rev A)", "000000ff0000ffff00ffffff" },
|
||||
{ "GBC Palette 119 (World)", "000000ff0000ffff00ffffff" },
|
||||
{ "GBC Palette 120 (Europe)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 121 (USA)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 122 (USA)", "000000ff420052ff00ffffff", "000000ff420052ff00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 123 (USA, Europe) (Rev A)", "000000ff420052ff00ffffff", "000000ff420052ff00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 124 (USA, Europe)", "000000ff0000ff9c00ffffff" },
|
||||
{ "GBC Palette 125 (Japan)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 126 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 127 (USA)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 128 (Europe)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 129 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 130 (USA, Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 131 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 132 (World)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 133 (USA, Europe)", "00000042737badad84ffffff", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 134 (USA, Europe)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff", "ff00000084ffffff7bffffff" },
|
||||
{ "GBC Palette 135 (Japan)", "000000525252a5a5a5ffffff" },
|
||||
{ "GBC Palette 136 (Japan) (Rev A)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 137 (Japan)", "000000843100ffad63ffffff" },
|
||||
{ "GBC Palette 138 (USA)", "000000ff420052ff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 139 (Japan)", "000000ff0000ff9c00ffffff", "000000ff0000ff9c00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 140 (Japan)", "000000ff420052ff00ffffff", "000000ff420052ff00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 141 (Japan)", "000000ff420052ff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "GBC Palette 142 (USA, Europe)", "000000ff0000ff9c00ffffff", "000000ff0000ff9c00ffffff", "0000ffff00005abdffffffff" },
|
||||
{ "GBC Palette 143 (Japan) (Rev A)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
|
||||
{ "GBC Palette 144 (Japan)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" },
|
||||
} },
|
||||
{ name = "GB Color (Unused Palettes)", palettes = {
|
||||
{ "Unused0", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" },
|
||||
{ "Unused1", "000000ff0000ffff00ffffff", "0000000000ff63a5ffffffff", "0000ffff00005abdffffffff" },
|
||||
{ "Unused2", "000000ad5a42ffff94b5b5ff", "943a3aff8484ffffff000000", "943a3aff8484ffffff000000" },
|
||||
{ "Unused3", "ffffffffde00008484000000", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" },
|
||||
{ "Unused4", "000000525252a5a5a5ffffff" },
|
||||
{ "Unused5", "0000009c6300ffce00ffffff" },
|
||||
} },
|
||||
{ name = "R.A.Helllord/Limited Edition Pockets", palettes = {
|
||||
{ "Classic Blue LE", "0000000232741b559e5775af" },
|
||||
{ "Classic Green LE", "00000001633f0b8a68549d80" },
|
||||
{ "Classic Indigo LE", "0000002631725457a47b80b4" },
|
||||
{ "Classic Pink LE", "000000d8648beb8cb3efa1bf" },
|
||||
{ "Classic Red LE", "00000095030dbc2b2bcb5d5b" },
|
||||
{ "Classic Silver LE", "000000908a85cececee0dfde" },
|
||||
{ "Classic Spice Orange LE", "000000c96722e38f48e8a36c" },
|
||||
{ "Classic Yellow LE", "000000cf8b08d2921de8be63" },
|
||||
{ "Glow in the Dark LE", "00000005a56d4dcc808cf68a" },
|
||||
{ "Transparent Blue LE", "0000001e124f361aa6646dcb", "1e124f361aa6646dcb7f8cff", "1e124f361aa6646dcb7f8cff" },
|
||||
{ "Transparent Clear LE", "0000003e3b395959599c9a99", "3e3b395959599c9a99d7d7d7", "3e3b395959599c9a99d7d7d7" },
|
||||
{ "Transparent Green LE", "000000003c0600b10003ce09", "003c0600b10003ce09afe4ab", "003c0600b10003ce09afe4ab" },
|
||||
{ "Transparent Orange LE", "0000003c1405ad2d01e56400", "3c1405ad2d01e56400ebbf8f", "3c1405ad2d01e56400ebbf8f" },
|
||||
{ "Transparent Purple LE", "0000003c2e498d6ea7a986bb", "3c2e498d6ea7a986bbe0d6e5", "3c2e498d6ea7a986bbe0d6e5" },
|
||||
{ "Transparent Red LE", "0000006d1a1a991019c51d21", "6d1a1a991019c51d21eab5b1", "6d1a1a991019c51d21eab5b1" },
|
||||
{ "Transparent Smoke LE", "000000282421363333686464", "282421363333686464bfbfbe", "282421363333686464bfbfbe" },
|
||||
} },
|
||||
{ name = "R.A.Helllord/Limited Edition Pockets Inverted", palettes = {
|
||||
{ "Classic Blue LE - inverted", "5775af1b559e023274000000" },
|
||||
{ "Classic Green LE - inverted", "549d800b8a6801633f000000" },
|
||||
{ "Classic Indigo LE - inverted", "7b80b45457a4263172000000" },
|
||||
{ "Classic Pink LE - inverted", "efa1bfeb8cb3d8648b000000" },
|
||||
{ "Classic Red LE - inverted", "cb5d5bbc2b2b95030d000000" },
|
||||
{ "Classic Silver LE - inverted", "e0dfdececece908a85000000" },
|
||||
{ "Classic Spice Orange LE - inverted", "e8a36ce38f48c96722000000" },
|
||||
{ "Classic Yellow LE - inverted", "e8be63d2921dcf8b08000000" },
|
||||
{ "Glow in the Dark LE - inverted", "8cf68a4dcc8005a56d000000" },
|
||||
{ "Transparent Blue LE - inverted", "646dcb361aa61e124f000000", "7f8cff646dcb361aa61e124f", "7f8cff646dcb361aa61e124f" },
|
||||
{ "Transparent Clear LE - inverted", "9c9a995959593e3b39000000", "d7d7d79c9a995959593e3b39", "d7d7d79c9a995959593e3b39" },
|
||||
{ "Transparent Green LE - inverted", "03ce0900b100003c06000000", "afe4ab03ce0900b100003c06", "afe4ab03ce0900b100003c06" },
|
||||
{ "Transparent Orange LE - inverted", "e56400ad2d013c1405000000", "ebbf8fe56400ad2d013c1405", "ebbf8fe56400ad2d013c1405" },
|
||||
{ "Transparent Purple LE - inverted", "a986bb8d6ea73c2e49000000", "e0d6e5a986bb8d6ea73c2e49", "e0d6e5a986bb8d6ea73c2e49" },
|
||||
{ "Transparent Red LE - inverted", "c51d219910196d1a1a000000", "eab5b1c51d219910196d1a1a", "eab5b1c51d219910196d1a1a" },
|
||||
{ "Transparent Smoke LE - inverted", "686464363333282421000000", "bfbfbe686464363333282421", "bfbfbe686464363333282421" },
|
||||
} },
|
||||
{ name = "R.A.Helllord/PPB", palettes = {
|
||||
{ "PPB - Amber", "ffb13b8c61216043172d1f0b" },
|
||||
{ "PPB - Blue", "28ccff16708c0f4b5e07242d" },
|
||||
{ "PPB - Green", "00ee00008e00005f00002f00" },
|
||||
{ "PPB - White", "c1ffff6a8c8c496060202b2b" },
|
||||
{ "PPB inverted - Amber", "2d1f0b6043178c6121ffb13b" },
|
||||
{ "PPB inverted - Blue", "07242d0f4b5e16708c28ccff" },
|
||||
{ "PPB inverted - Green", "002f00005f00008e0000ee00" },
|
||||
{ "PPB inverted - White", "202b2b4960606a8c8cc1ffff" },
|
||||
} },
|
||||
{ name = "SMY", palettes = {
|
||||
{ "Desert", "3e2f30746657a49b83d2d0b1" },
|
||||
{ "Evening", "012636185569539889aee4a6" },
|
||||
{ "Fake Crystal", "21293129317b4a73bd9ce7f7" },
|
||||
{ "Fog", "343c3756727386a39dbfd2c3" },
|
||||
{ "Magic Eggplant", "36213c842e949d69c7b0e4f1" },
|
||||
{ "Radioactive Pea", "00522106731f349e16b8ce03" },
|
||||
{ "Seaweed", "15003f32654278a758dfe095" },
|
||||
{ "Twilight", "15003f861246bd5462e9d397" },
|
||||
} },
|
||||
{ name = "Super GB", palettes = {
|
||||
{ "1-A (Default)", "311852ad2921de944affefce" },
|
||||
{ "1-B", "000000b55210ceb573dedec6" },
|
||||
{ "1-C", "39399c9c3963ef9c52ffc6ff" },
|
||||
{ "1-D", "521800ff0000c6844affffad" },
|
||||
{ "1-E", "5a39216b8c427bc67bffdeb5" },
|
||||
{ "1-F", "004210ad0000e78c52deefff" },
|
||||
{ "1-G", "ffff5a7b7b0000a5ef000052" },
|
||||
{ "1-H", "311800844200ffbd8cffefe7" },
|
||||
{ "2-A", "000000297b00c68c4af7cea5" },
|
||||
{ "2-B", "52005aff3100ffef52ffffff" },
|
||||
{ "2-C", "29299c7b31efef8c8cffc6ff" },
|
||||
{ "2-D", "000052ff310000ff00ffffa5" },
|
||||
{ "2-E", "10081029106394b5e7ffce84" },
|
||||
{ "2-F", "180000a50000ff9452d6ffff" },
|
||||
{ "2-G", "001800e7bd84e752426bbd39" },
|
||||
{ "2-H", "000000737373bdbdbdffffff" },
|
||||
{ "3-A", "314a63ff632973c6c6ffd69c" },
|
||||
{ "3-B", "001010005200e78421dedec6" },
|
||||
{ "3-C", "21215a00bdffffff7be7adce" },
|
||||
{ "3-D", "00000008ce00e7ad7bf7ffbd" },
|
||||
{ "3-E", "524a73b57b21e7b56bffffc6" },
|
||||
{ "3-F", "424242ffd600ff6bff7b7bce" },
|
||||
{ "3-G", "390000ce3139ffffff63de52" },
|
||||
{ "3-H", "0818004a8c187bce39e7ffa5" },
|
||||
{ "4-A", "00007bd600d67badfff7ad6b" },
|
||||
{ "4-B", "180808427b39efa563f7eff7" },
|
||||
{ "4-C", "0800009ca5e7dea5d6ffe7e7" },
|
||||
{ "4-D", "08214a4a6b7b94ceceffffbd" },
|
||||
{ "4-E", "0021317b5a8ce7ad7bffdead" },
|
||||
{ "4-F", "3900008400a5de84debdd6d6" },
|
||||
{ "4-G", "008463291000bd215ab5e718" },
|
||||
{ "4-H", "425229848c42bdc65affffce" },
|
||||
} },
|
||||
{ name = "Super GB 2 - Vaporwave Edition", palettes = {
|
||||
{ "SGB2VE 1-A Sunset", "1039adef2994f7a552f7f794" },
|
||||
{ "SGB2VE 1-B Kimochi WRO", "4a3973bd63a5f7a56bf7efbd" },
|
||||
{ "SGB2VE 1-C Pachinko", "42427b9c42eff7947bf7deef" },
|
||||
{ "SGB2VE 1-D Cotton Candy", "1039adffa5ff84e7eff7f7f7" },
|
||||
{ "SGB2VE 1-E Absynthe", "7b4aada5b5ffadff5affff8c" },
|
||||
{ "SGB2VE 1-F Arizona", "52529cff4273ffcecedefff7" },
|
||||
{ "SGB2VE 1-G Hot Vibes", "8c18f7f72973f78c18f7ce18" },
|
||||
{ "SGB2VE 1-H Sorbet Whip", "63184aff6b8cffc684f7ffc6" },
|
||||
{ "SGB2VE 2-A Bufotoxin", "104a63009c84ef8c84f7de9c" },
|
||||
{ "SGB2VE 2-B Dank", "4a184263528463b5bdf7f794" },
|
||||
{ "SGB2VE 2-C Creampuff", "632963a573f7f79cadffdef7" },
|
||||
{ "SGB2VE 2-D Modern Computing", "101018ff849c6bffbdf7f7f7" },
|
||||
{ "SGB2VE 2-E Uranium Glass", "4a08ef9c42ef00deded6ff9c" },
|
||||
{ "SGB2VE 2-F. Tenshi", "2142e7de5aceefadceadf7c6" },
|
||||
{ "SGB2VE 2-G 87 Ninjas", "42427bd63994bdb5f7c6f7de" },
|
||||
{ "SGB2VE 2-H Floral", "08101852b59cff849cefe7e7" },
|
||||
{ "SGB2VE 3-A Mind Game", "4200f700b5f7f784f7f7f7f7" },
|
||||
{ "SGB2VE 3-B Sewer Magic", "5a00848c7be784f7c6f7f794" },
|
||||
{ "SGB2VE 3-C Galactic Rabbit Trip", "4a527352a5cebdceeff7f7ef" },
|
||||
{ "SGB2VE 3-D Frenemy", "1039ad42d6d6ffd6b5bdffe7" },
|
||||
{ "SGB2VE 3-E Agent Orange", "632963e76b84f7a56bffe742" },
|
||||
{ "SGB2VE 3-F Backstreet", "294a8cff8cbdffceb5dedeff" },
|
||||
{ "SGB2VE 3-G Eccojam", "8c42a5ad7be7946cff9cefff" },
|
||||
{ "SGB2VE 3-H Sakura", "4a39b5ef73d6efb5bdf7efb5" },
|
||||
{ "SGB2VE 4-A Vector", "1039ad9400f700b5f7f7d684" },
|
||||
{ "SGB2VE 4-B Slumber", "944ab58c8cf794ceffd6ffff" },
|
||||
{ "SGB2VE 4-C Lingonberry Butter", "21529c7b84e7ffadcefff7de" },
|
||||
{ "SGB2VE 4-D Zangetsu", "104a6331a5c684e7eff7efbd" },
|
||||
{ "SGB2VE 4-E Icy Hot", "8c00ffff39ced6c6ffbdfff7" },
|
||||
{ "SGB2VE 4-F Night Driver", "214aff6b00c6ff31ef10006b" },
|
||||
{ "SGB2VE 4-G Bug Hunt", "0839633952bdf7738cf7e7ad" },
|
||||
{ "SGB2VE 4-H MGB-101", "004a84107ba500bdc621ffde" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/American Pop Culture Pack", palettes = {
|
||||
{ "Absorbent and Yellow", "343b00687600aec600fff752" },
|
||||
{ "BMO Ver.", "394c3d607f6699cca3c0ffcc" },
|
||||
{ "BRB Pink", "480030790050c10080f200a1" },
|
||||
{ "Bedrock Caveman Vision", "002f37005e6e009eb8ff7f00" },
|
||||
{ "BKB Ver.", "6060002098f048f8e0f8f880" },
|
||||
{ "BLS Pink", "492e357a4d59c47c8ef59bb2" },
|
||||
{ "BBS Blue", "1e3a45326274509cba64c4e9" },
|
||||
{ "BTC Green", "39422a5f6e4698b070bedc8d" },
|
||||
{ "DNP Silver", "33383d555d668895a3abbbcc" },
|
||||
{ "Fairly OddPalette", "3d1b2d7b365bce5a997bb850" },
|
||||
{ "GRF Vision", "2d1309964220e59436f5ea8b" },
|
||||
{ "HGW Goldius", "3631215b523891845ab6a571" },
|
||||
{ "Invincible Yellow and Blue", "113c4622788d39c9ebfee566" },
|
||||
{ "NCK Orange", "4c1e007f3300cc5200ff6700" },
|
||||
{ "NJT Green", "28380b435e126b961d86bc25" },
|
||||
{ "OPP Palette", "0015330047abd92121d3d3d3" },
|
||||
{ "PTS Pink", "4c26297f4045cc666fff808b" },
|
||||
{ "Retro Bogeda", "0000006408ffff6cfffbfd1b" },
|
||||
{ "Rocket Portable Power", "09122d213f99bdd539fee998" },
|
||||
{ "RGR Playtime Palette", "662d91ed1c24ffde17ffffff" },
|
||||
{ "Sailor Spinach Green", "2434123d581e628c307bb03c" },
|
||||
{ "SCD Mystery Ver.", "2a1a318f59a5f79321c6de31" },
|
||||
{ "Smurfy Blue", "0d3747165c772394bf2cb9ef" },
|
||||
{ "SPB Yellow", "4a45157b7424c5ba39f7e948" },
|
||||
{ "SQW Sea Foam Green", "37403d5c6b6694aca4b9d7cd" },
|
||||
{ "Swampy Ogre Green", "39400d606b179aab24c1d62e" },
|
||||
{ "TMT Pink", "3815205e2436963957bc486d" },
|
||||
{ "UNS Blue", "0020430136710256b4036ce2" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Anime and Manga Pack", palettes = {
|
||||
{ "ANX BLUE", "0f2d451a4c74297ab93499e8" },
|
||||
{ "ALM Hero Palette", "212f79d12021f3db43eff0f0" },
|
||||
{ "Anime DGV Ver.", "1b241d2d3d3148624f5b7b63" },
|
||||
{ "Anime Expo Red", "47110f771d19be2f28ee3b33" },
|
||||
{ "Anime Expo Ver.", "242a2d656e729ba3a6e5eaeb" },
|
||||
{ "BRS Blood", "3806065d0a0a951010bb1414" },
|
||||
{ "Blue Stripes Ver.", "2d2e2e5b5d5d999b9c8bd3e1" },
|
||||
{ "CDC Pink", "430427e10e82eac3d6f2f4f7" },
|
||||
{ "Colorful Horizons", "373939d1525260bdc7f6cf72" },
|
||||
{ "CRR Orange", "49230a7a3a11c35d1bf47522" },
|
||||
{ "DKU Alpha Emerald", "11332f1c564f2d8a7e39ad9e" },
|
||||
{ "DKU Gamma Palette", "24262b166668b6bec8f0e8d6" },
|
||||
{ "DKU Vigilante Palette", "13131538534e878b92ada89a" },
|
||||
{ "DMS Gold", "3734195d572b948c44baaf56" },
|
||||
{ "DRM Tricolor", "450000e6000000a8f4ffe800" },
|
||||
{ "DGB Orange", "48270878410ec06817f0831d" },
|
||||
{ "DYN Hero Palette", "16161629463dc84a31d1d0cc" },
|
||||
{ "EV-01", "303345765898f99b2254cf54" },
|
||||
{ "EDN Academy Uniform", "483e39cc4c47dcbc76f7f8f5" },
|
||||
{ "FRG Pretense Palette", "0000008d0000697d738ea99b" },
|
||||
{ "GDS VISION", "4723008e4700ee77009fa0a0" },
|
||||
{ "GR5 Fever", "311d3da662cdc5cedff7f7f7" },
|
||||
{ "GKU GT Gi", "1221313d6ea5f0ac18dbe3e6" },
|
||||
{ "GKU Gi", "061222173f72e7612cf9f0e1" },
|
||||
{ "Grand Ivory", "4140396c6b5fadab98d9d6be" },
|
||||
{ "GRZ Coat", "3d0b32ce26a9fce72dfbfbfa" },
|
||||
{ "HKG Orange", "462718754129bb6841ea8352" },
|
||||
{ "KNS Sherbet", "440021890042e5006ef08200" },
|
||||
{ "KWB Blue", "00283b004462006c9d0088c5" },
|
||||
{ "Legendary SSJ", "31411b536d2d84ae48a6da5b" },
|
||||
{ "LSA Orange!", "461c00752f00bc4b00eb5e01" },
|
||||
{ "Perfect MJN Emperor", "2718328550a9a3b453fdc1bf" },
|
||||
{ "Perfected ULI", "393c4060646c99a0acc0c8d8" },
|
||||
{ "PRC Marble Raspberry", "400a1b6b112eab1b49d6225c" },
|
||||
{ "PTG Gold", "3633275a5541908868b4aa82" },
|
||||
{ "SYN Beast Silver", "2d2e344b4d57787c8c969baf" },
|
||||
{ "SHN Green", "1b3a162d6125489c3b5ac34a" },
|
||||
{ "STH Red", "4a18127c291ec64130f8523c" },
|
||||
{ "SSJ 3", "4a3c107c641cc6a02cf8c838" },
|
||||
{ "SSJ Blue Evolved", "082d3e0d4b681578a71b97d1" },
|
||||
{ "SSJ Blue", "01383d025d660496a305bccc" },
|
||||
{ "SSJ God", "40001d6b0131ac024ed70362" },
|
||||
{ "SSJ Rose", "4a34357b5759c58c8ff7afb3" },
|
||||
{ "SSJ", "4c4b397f7e60cbc99afefcc1" },
|
||||
{ "SVC Uniform", "321d1a593d34ac7c59acaba9" },
|
||||
{ "SWA Cyan", "1a3a432c6171479cb459c3e2" },
|
||||
{ "TMR Uniform", "474f4d755e88e94e60eeefeb" },
|
||||
{ "Tri DGV Ver.", "272a2442473c697260848f79" },
|
||||
{ "UAH Uniform", "0c4856a02929a4aaafededed" },
|
||||
{ "UTV VISION", "262c48e24465f1a7b5f4f4f4" },
|
||||
{ "ULI Sign", "1b1f212d34374853585a686f" },
|
||||
{ "URM Green", "1734282657443d8b6c4dae88" },
|
||||
{ "VGT Armor", "0619321554a9e3c56cefefef" },
|
||||
{ "ANM vision", "231815385eaaf9be00ffffff" },
|
||||
{ "HLO blue", "063f5c3368d349c4f2b0edfa" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Bandai Namco Pack", palettes = {
|
||||
{ "1st Vision Pastel", "101c33385ead7faf5aea7cb1" },
|
||||
{ "765P Ver.", "383a445d6272959cb6bbc4e4" },
|
||||
{ "765P Pink", "481720792736c23f57f34f6d" },
|
||||
{ "765P TRICOLOR", "0b143f2743d2e22b30b4e04b" },
|
||||
{ "ARS BLUE", "0e192e182a4e26447c30559c" },
|
||||
{ "AQR Blue", "0030450050740080ba00a0e9" },
|
||||
{ "BDN Tricolor", "001f350069b1df4f61f6b700" },
|
||||
{ "CND Blue", "0b263c1340641e67a02681c8" },
|
||||
{ "IDOL WORLD TRICOLOR!!!", "0b263c2681c8f34f6dffc30b" },
|
||||
{ "LLA Purple!", "31152e52234d84387ca5469b" },
|
||||
{ "MLV GOLD!", "3d351d665930a48e4dcdb261" },
|
||||
{ "MLN Yellow!", "4c3a037f6105cc9c08ffc30b" },
|
||||
{ "Moonlight Vision", "1010103050783890e8f8d868" },
|
||||
{ "MUS Pink", "44002672003fb60065e4007f" },
|
||||
{ "NMC Idol Pink", "4c22377f3a5ccc5c93ff74b8" },
|
||||
{ "NJG Orange", "482d00794c00c27900f39800" },
|
||||
{ "PCM-PALETTE", "420000dc0000ff8c00ffd800" },
|
||||
{ "PCM Vision", "0000003732ffffb897ffff00" },
|
||||
{ "PCM Yellow", "4c44007f7100ccb500ffe300" },
|
||||
{ "RADIANT SMILE RAMP", "1b1c81e6016b01b3c4fff89b" },
|
||||
{ "RX-78-2 GDM Mode", "333f3fa73736dbad40dbdcd5" },
|
||||
{ "RYG Sunset", "2b10399238befd99e1ffe43f" },
|
||||
{ "SHINY Sky Blue", "2a384c465d7f7095cc8dbbff" },
|
||||
{ "SNS Red", "3911105f1c1b982d2bbf3936" },
|
||||
{ "School Idol Blue", "2830663960d787adf5f9f9f8" },
|
||||
{ "School Idol Mix", "31152ea5469b00a0e9f39800" },
|
||||
{ "SDM Green", "04392c075f4a0c98760fbe94" },
|
||||
{ "Sunny Passion Paradise", "0920341e6caee06846f0ba40" },
|
||||
{ "SYL Rose Pink", "452728744243b9696be88486" },
|
||||
{ "Tarnished Gold", "392f18604e299a7d42c19d53" },
|
||||
{ "Vulnerable Blue", "ffb897cc93787f5c4b3732ff" },
|
||||
{ "Wind Ring Emerald", "0c3627145a4220906a28b585" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Company Pack", palettes = {
|
||||
{ "AMZ Vision", "252f3e008296ff9900ffffff" },
|
||||
{ "AND Green", "1242271e6e4230b0693ddc84" },
|
||||
{ "APL Silver", "30333351555681888aa2aaad" },
|
||||
{ "DUO Green", "1a3d002c660146a30158cc02" },
|
||||
{ "DRC Copper", "3c291b64442ea06d4ac8895d" },
|
||||
{ "GGL Blue", "13274921427a346ac34285f4" },
|
||||
{ "GGL Green", "0f32181a542929864234a853" },
|
||||
{ "GGL Red", "46140f75211abb352aea4335" },
|
||||
{ "GGL Yellow", "4b38017d5e02c89604fbbc05" },
|
||||
{ "SPT Green", "09401c0f6b3018ac4c1ed760" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Food and Drink Pack", palettes = {
|
||||
{ "7EL Color Combo", "062218147350eb0f2aff6c00" },
|
||||
{ "BJB Beach", "273d3b4e7a7683ccc5dbe441" },
|
||||
{ "BJB Storm", "1f3a31346152539b8368c2a4" },
|
||||
{ "BKR Sundae", "40202178284bc04078f05097" },
|
||||
{ "BGK Color Combo", "502314d62300ff8732f5ebdc" },
|
||||
{ "CHT Orange", "442300723b00b75e00e57600" },
|
||||
{ "CRK Color Combo", "460d0a8d1b15ec2e24f99b2a" },
|
||||
{ "CCL Vision", "000000f40009d7d7d7ffffff" },
|
||||
{ "Dew Green", "2d40004b6b0078ac0097d700" },
|
||||
{ "DMP Vision", "000000006491e31837ffffff" },
|
||||
{ "DRP Red", "290a0e4511186e1b278a2231" },
|
||||
{ "DNK Vision", "48032b910756f20c90ff6e0c" },
|
||||
{ "KFR Red", "33070e550c17881325ab182f" },
|
||||
{ "KKR Vision", "000000166938cf152dffffff" },
|
||||
{ "Lemon-Lime Green", "18310e30631d51a631f1c545" },
|
||||
{ "MTD Ver.", "29673cd82a34a1d23fffffff" },
|
||||
{ "PCS BLUE", "001b35002e59004a8e015db2" },
|
||||
{ "PPS Vision", "0000001414c8ff1400ffffff" },
|
||||
{ "PZH Red", "441012711c1fb52c31e3383e" },
|
||||
{ "SPR Green", "002e17004d27007c3e009b4e" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Gaming Pack", palettes = {
|
||||
{ "AVG Raging Volcano", "1c0000a81000f83800f8b800" },
|
||||
{ "Blue Bomber Vision", "0000000d4dc4639afce2cda7" },
|
||||
{ "BBN Blue", "093e4b0f687e18a7ca1fd1fd" },
|
||||
{ "BBG Green", "1f420e356e1854b0276adc31" },
|
||||
{ "CHE Green", "20800040980080c800a0e000" },
|
||||
{ "Classic Blurple", "22294138446d5b6dae7289da" },
|
||||
{ "DMG Pea Green", "204631527f39aec440d7e894" },
|
||||
{ "EBT Prolouge", "2c17084a290b854a1dc08226" },
|
||||
{ "GGR Orange", "45230e743b17ba5e25e9762f" },
|
||||
{ "Giga Kiwi DMG", "384828607028a0a830d0e040" },
|
||||
{ "Investigation Yellow", "4c4a077f7c0cccc714fff919" },
|
||||
{ "Phantom Red", "4b0b117e131cca1e2dfd2639" },
|
||||
{ "PYO Green", "15431024771b39b42b48e236" },
|
||||
{ "SES Blue", "073e4c0c687f14a7cc19d1ff" },
|
||||
{ "SGA Tokyo Blue", "00273f00416a0068a90082d4" },
|
||||
{ "Scarlett Green", "2e48034d78057cc0089bf00b" },
|
||||
{ "Slime Blue", "0e2a3d1746662570a32f8ccc" },
|
||||
{ "SNC Mega Blue", "13274120426c3369ad4084d9" },
|
||||
{ "STM Gray", "3b3a396261609d9c99c5c3c0" },
|
||||
{ "TYV Brown", "372c1f5c4a34937654b89469" },
|
||||
{ "Tropical Cyber Space", "1414394344c1ef58f763fdfb" },
|
||||
{ "XBX Green", "2b3c1249641f74a03192c83e" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Holiday Pack", palettes = {
|
||||
{ "Christmas Gold", "39321660542599873cc0a94b" },
|
||||
{ "Christmas Silver", "2c32354a545977868e95a8b2" },
|
||||
{ "Christmas Ver.", "300f0fa0323220a465cbb96a" },
|
||||
{ "Classy Christmas", "534d579e5c5e8bab95e8e7df" },
|
||||
{ "Clover Green", "1136161c5a252d903b39b54a" },
|
||||
{ "Cupid's Love Palette", "5e081eb51a3ae48397e4cdd3" },
|
||||
{ "Festive Christmas", "0a1609224b21cc1902bb9b60" },
|
||||
{ "Glacial Winter Blue", "2839434360716c9ab487c1e2" },
|
||||
{ "Grinchy Green", "3639085b5f0e929816b7be1c" },
|
||||
{ "Halloween Ver.", "2c13319540a5f68c00ffcc00" },
|
||||
{ "Haunted Halloween", "2f094c9e1fffffa52ecaff37" },
|
||||
{ "Independence Boy", "0000000a3161b31942ffffff" },
|
||||
{ "Irish Green", "143923225f3b37985e45be76" },
|
||||
{ "Spooky Purple", "2f253f4f3e697e63a89e7cd2" },
|
||||
{ "Winter Christmas", "341113ae3b4065b08fdddddd" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/J-Pop Pack", palettes = {
|
||||
{ "A48 Pink", "4923317b3b53c45e84f676a6" },
|
||||
{ "ATG PARETTO", "030a5b7a5375c375bcf4a7eb" },
|
||||
{ "BBM KITSUNE RED", "2c00034a000677000995000c" },
|
||||
{ "H46 Blue", "1b39442d5f724898b75bbee5" },
|
||||
{ "J-Pop Idol Sherbet", "260c2b8129905bbee5f19db5" },
|
||||
{ "MKU Blue", "05333f08566a0d8aaa11add5" },
|
||||
{ "NZU PrePlay Palette", "47120fef3f3500b8b0fff200" },
|
||||
{ "N46 Purple", "260c2b401448672073812990" },
|
||||
{ "OOR RED", "370f0c5c1a14942920b93429" },
|
||||
{ "YSB AMARANTH", "480c1b79142dc12048f2285a" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Japanese Company Pack", palettes = {
|
||||
{ "ANA Flight Blue", "00146e223f9a3b8bc000b3f0" },
|
||||
{ "FMM Vision", "00301300602600a040008cd6" },
|
||||
{ "LWS BLUE", "001f3600345b0053920068b7" },
|
||||
{ "NHK Silver Gray", "262626404040666666808080" },
|
||||
{ "SIT SUPER BLUE", "0b2438133d5e1f6196277abc" },
|
||||
{ "SIT SUPER GREEN", "0634270b5742118b6a16ae85" },
|
||||
{ "SKO Timely Vision", "2021210050a54393e6ffbf00" },
|
||||
{ "SB109 PASTEL", "5503a63dacb8fd87b2f2d53f" },
|
||||
{ "SNR Pink", "83534de74b5af485a1f9c2d0" },
|
||||
{ "TKS CLOUDY BLUE", "27363b415a6368909f82b5c7" },
|
||||
{ "NCO sea green", "073a310c6152149c8319c3a4" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/K-Pop Pack", palettes = {
|
||||
{ "BGT ARMY PURPLE", "26154040246c6639ac8048d8" },
|
||||
{ "BKP BLINK PINK", "4932377a535dc38594f4a7ba" },
|
||||
{ "EER COSMIC LATTE", "4c4a457f7c73ccc6b8fff8e7" },
|
||||
{ "LSS FEARLESS BLUE", "26314a40527c6784c781a5f9" },
|
||||
{ "NCZ YELLOW", "43451e717333b4b851e2e766" },
|
||||
{ "SVT COOL CARAT", "2b323e57647d92a8d1f7cac9" },
|
||||
{ "SHW GREEN", "22413e396d685cafa673dbd0" },
|
||||
{ "SWN PINK", "48172579273ec13f63f24f7c" },
|
||||
{ "SKD STAY PINK", "4c142d7f224ccc367aff4499" },
|
||||
{ "TROPICAL TWC APRICOT", "4c1c30993961ff5fa2fcc89b" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Legacy Palette Pack", palettes = {
|
||||
{ "TDS VC Green", "4a4a4a5a8c429cef29bdff21" },
|
||||
{ "TDS VC Ver.", "2929186b6b52a5a58ccecead" },
|
||||
{ "BLL PC Screen", "000000783800b86000f87800" },
|
||||
{ "DMG Ver.", "2a453b365d48577c447f860f" },
|
||||
{ "DGV Ver.", "2a2a2246463970705c8c8c73" },
|
||||
{ "GMT Ver.", "12424c255955437a636ba64a" },
|
||||
{ "GNW LCD", "3b3c336365569ea28ac6cbad" },
|
||||
{ "GMK Ver.", "184421405d3b6b9c638cce94" },
|
||||
{ "GBU Classic Ver.", "26302540503f67806481a17e" },
|
||||
{ "HGM Ver.", "2d2d2b414f53687f84829fa6" },
|
||||
{ "Light Ver.", "004f3b00694a009a7100b581" },
|
||||
{ "LKA DX Ver.", "58382068884078c078f8f8b0" },
|
||||
{ "MBM Ver.", "303030505050808080a0a0a0" },
|
||||
{ "MGM V Ver.", "08203040689070a0e0d0d0d0" },
|
||||
{ "NGP Ver.", "101010707070b0b0b0f0f0f0" },
|
||||
{ "NKA 3310 Ver.", "2231273953425c846973a684" },
|
||||
{ "Pocket Tales Ver.", "00000038500088a000d0d860" },
|
||||
{ "Pocket Ver.", "1f1f1f4d533c8b956dc4cfa1" },
|
||||
{ "PKS Ver.", "2d2d284b4b4378786c969687" },
|
||||
{ "PKM Pinball Ver.", "181820786030a0b050e8f8b8" },
|
||||
{ "PKM Ver.", "181010807098f0b088f8e8f8" },
|
||||
{ "PKM mini Ver.", "1b2d1b525c52849484a5b9a5" },
|
||||
{ "PKT Ver.", "10281838503050805070b070" },
|
||||
{ "Rocky-Valley Holiday", "204008805850d89078c0f0f8" },
|
||||
{ "T83 Ver.", "2e332a4e55467c88709caa8c" },
|
||||
{ "TMG Ver.", "3c383878787cc0c0c7f1f0f9" },
|
||||
{ "TGC Ver.", "0000000f4f2f6f8f4fdfff8f" },
|
||||
{ "Timing Hero Ver.", "202e004c67188c994ccccc99" },
|
||||
{ "Travel Wood", "482810705030a08058f8d8b0" },
|
||||
{ "VMU Ver.", "0814804466546ca38688cca8" },
|
||||
{ "VTB Ver.", "ff0000aa0000550000000000" },
|
||||
{ "WTS Ver.", "0c322c2c626454a68c7cc67c" },
|
||||
{ "WDS Ver.", "2c2c2c686868c0c0c0fefefe" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Misc. Palette Pack", palettes = {
|
||||
{ "Camouflage Ver.", "37353879533dac7e54bcab90" },
|
||||
{ "Cherry Blossom Pink", "482534783f58c0648cf07eb0" },
|
||||
{ "Emerald Green", "183c2428643c40a06050c878" },
|
||||
{ "Fool's Gold and Silver", "2d30345a606997a1b0c5c66d" },
|
||||
{ "Glitchy Blue", "0f254b193f7d2864c8337efb" },
|
||||
{ "Gold, Silver, and Bronze", "2d1f1499684386949abeb049" },
|
||||
{ "Golden Trophy", "453e0774680cb9a613e8d018" },
|
||||
{ "Greenscale Ver.", "0c360c2c62346e870a9cbe0c" },
|
||||
{ "Leprechaun Green", "10281d1b44302c6c4d378861" },
|
||||
{ "Nightvision Green", "66bf2f519825335f171e390e" },
|
||||
{ "Rising Sun Red", "38000d5d0016960024bc002d" },
|
||||
{ "Treasure Gold", "3c360a655a12a2901ccbb524" },
|
||||
{ "Wild West Vision", "3a160e924a36c3976ad9d7c7" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Console Pack", palettes = {
|
||||
{ "Advanced Indigo", "241f373c355d605494796aba" },
|
||||
{ "Aegis Cherry", "42111e6e1d32b02f50dd3b64" },
|
||||
{ "Ancient HSU Brown", "352f2b594f488f7f73b39f90" },
|
||||
{ "ANC Green", "002e25004d3f007c64009b7e" },
|
||||
{ "Brilliant Diamond Blue", "2638433f5d706595b47fbbe1" },
|
||||
{ "COLLECTION of SAG Ver.", "041820345d51769a67b2c0a8" },
|
||||
{ "Champion's Tunic", "281b13875b40009edde2dcb1" },
|
||||
{ "CHZ Blue", "1735432759703e8fb44eb3e1" },
|
||||
{ "DKG Arcade Blue", "15304223516f3881b147a2de" },
|
||||
{ "DKG Barrel Brown", "3a220e613a179c5c25c3742f" },
|
||||
{ "DMG-099", "3132313f642f6bad1984b510" },
|
||||
{ "DMG-GOLD", "30361c505a3080904ca1b560" },
|
||||
{ "DMG-SWITCH", "214231426b296c94218cad28" },
|
||||
{ "DRL GB Ver.", "48530e788b1db9d03af6ff70" },
|
||||
{ "EVE Brown", "3c2a0f644619a07028c88d32" },
|
||||
{ "FMC Disk Yellow", "483a00796100c29b00f3c200" },
|
||||
{ "FMC Frenzy", "231916a32135d9be72efecda" },
|
||||
{ "Frog Coin Green", "00390039840000ef00fff7de" },
|
||||
{ "Fury Blue", "0c1c2d152f4c224c792b5f98" },
|
||||
{ "GMC Glimmer", "3e1318cf415111a396b6bed3" },
|
||||
{ "Golden Wild", "372f1e5c4f32947f50b99f65" },
|
||||
{ "GMB Brown", "331a11552c1d88472faa593b" },
|
||||
{ "Green Awakening", "000b1636705898db75f1ffdd" },
|
||||
{ "Green Banana", "2149003969394a9e0063df08" },
|
||||
{ "INK Tricolor", "1c114c603bffff505eeaff3d" },
|
||||
{ "LCD Clock Green", "183626285a4040906650b580" },
|
||||
{ "LBO Fawn", "4033226b5539ac885cd7aa73" },
|
||||
{ "MRO Red", "430400700700b40c00e10f00" },
|
||||
{ "Metallic PLD Brass", "302d0f514c1a817929a29834" },
|
||||
{ "Neon Blue", "033745055c730894b80ab9e6" },
|
||||
{ "Neon Green", "0942000f6e0018b0001edc00" },
|
||||
{ "Neon Orange", "4b30017d5002c88004faa005" },
|
||||
{ "Neon Pink", "4c0f247f193ccc2860ff3278" },
|
||||
{ "Neon Purple", "3600455a00739000b8b400e6" },
|
||||
{ "Neon Red", "4c120c7f1e14cc3020ff3c28" },
|
||||
{ "Neon Yellow", "454c00737f00b8cc00e6ff00" },
|
||||
{ "Odyssey Boy", "2224215054457e8e67acbe8c" },
|
||||
{ "Odyssey Gold", "3a30006150009b8000c2a000" },
|
||||
{ "POCKET SWT", "3038206372518d9c7bb5c69c" },
|
||||
{ "PKC Yellow", "4c42007f6e00ccb000ffdc00" },
|
||||
{ "PKD Red", "461918752a28bb4340ea5450" },
|
||||
{ "Royal Blue", "151949232a7a3844c44655f5" },
|
||||
{ "Shining Pearl Pink", "3f2a30694750a87180d28ea0" },
|
||||
{ "Sky Pop Ivory", "52502586825cbebb95e5e0b8" },
|
||||
{ "Super FMC Supreme", "0846bad9404044ac71feda5a" },
|
||||
{ "Super Mushroom Vision", "000000923404cc9e22f7cec3" },
|
||||
{ "Super Star Pink", "440d2372163bb7235fe52c77" },
|
||||
{ "Superball Ivory", "646432828250bcbc8aeef0bc" },
|
||||
{ "Timeless Gold and Red", "37090a6f1215b91e23c8aa50" },
|
||||
{ "Ultra Black", "17181d2629313d414f4d5263" },
|
||||
{ "WRW MicroBlue", "05293c0844650d6da11189ca" },
|
||||
{ "Wonder Purple", "451a42732c6fb846b2e658df" },
|
||||
{ "Yellow Banana", "734900ad6939de9e00ffdf08" },
|
||||
{ "YSH Egg Green", "1e3a0e336218519c2666c430" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Sports Pack", palettes = {
|
||||
{ "MLB Vision", "041e42bf0d3e057affffffff" },
|
||||
{ "NASCAR Ver.", "000000007ac2e4002bffd659" },
|
||||
{ "NBA Vision", "000000253b73c8102effffff" },
|
||||
{ "NCAA Blue", "002e42004e6f007cb1009cde" },
|
||||
{ "NFL Vision", "000000013369d50a0affffff" },
|
||||
{ "OLY Bronze", "3d2618664029a46741cd8152" },
|
||||
{ "OLY Gold", "3f360a6a5b12aa911cd5b624" },
|
||||
{ "OLY Silver", "2f312e4f524e7e847c9ea59c" },
|
||||
{ "PGA Tour Vision", "000000003c80f1373dffffff" },
|
||||
{ "WWE White and Red", "40070c810e19d7182affffff" },
|
||||
} },
|
||||
{ name = "TheWolfBunny64/Traditional JPN Colors Pack", palettes = {
|
||||
{ "Aquatic Iro", "192f603e62ad2ca9e1a0d8ef" },
|
||||
{ "Fruity Orange", "241a089f563af08300f3bf88" },
|
||||
{ "Ghostly Aoi", "0f235019448e5a79ba84a2d4" },
|
||||
{ "Golden Kiiro", "6a5d21a69425dccb18f8e58c" },
|
||||
{ "Lime Midori", "4759507b8d42aacf53e0ebaf" },
|
||||
{ "Oni Aka", "640125a22041d9333fec6d71" },
|
||||
{ "Sakura Pink", "a25768e7609eeebbcbfdeff2" },
|
||||
{ "Silver Shiro", "383c3c727171afafb0dcdddd" },
|
||||
{ "Tea Midori", "33363102876088cb7fd6e9ca" },
|
||||
{ "Wisteria Murasaki", "2e29307058a3a59acadbd0e6" },
|
||||
} },
|
||||
{ name = "Trashuncle/Artistic", palettes = {
|
||||
{ "TU PSL Park", "9200aeb722d4d442f0ec89ff" },
|
||||
{ "TU Quicksilver", "555555717286a4a5c3bbbce1" },
|
||||
} },
|
||||
{ name = "Trashuncle/Crush", palettes = {
|
||||
{ "TU Blueberry Crush", "0000002811ff4dffffffffff" },
|
||||
{ "TU Grape Crush", "0000003d43cb0083ebffffff" },
|
||||
{ "TU Kiwi Crush", "000000059e322eef7affffff" },
|
||||
{ "TU Lavender Crush", "000000812482ab6a9bffffff" },
|
||||
{ "TU Orange Crush", "000000c23615ff632cffffff" },
|
||||
{ "TU Pineapple Crush", "000000ffff00ffff80ffffff" },
|
||||
{ "TU Strawberry Crush", "000000ea1211ff5250ffffff" },
|
||||
} },
|
||||
{ name = "Trashuncle/DMG", palettes = {
|
||||
{ "TU DMG Bright", "0c3a1d2a61414c7b2b919707" },
|
||||
{ "TU DMG Clean", "1749022b5402496503788603" },
|
||||
{ "TU DMG Weak Alt", "2c4d2054733c597730677f31" },
|
||||
{ "TU DMG Weak", "364e1a5772405e7a406d833d" },
|
||||
} },
|
||||
{ name = "Trashuncle/GBP", palettes = {
|
||||
{ "TU GBP Bright", "5b5d5a879481abb8a7ffffff" },
|
||||
{ "TU GBP Clean", "3d493b98a291adbfacedfff4" },
|
||||
{ "TU GBP Weak Alt", "4c4b3170734e757952848459" },
|
||||
{ "TU GBP Weak", "434738535a42535f49667159" },
|
||||
} },
|
||||
{ name = "Trashuncle/Light", palettes = {
|
||||
{ "TU Light Bright", "00637400a1b100daea00e3f3" },
|
||||
{ "TU Light Clean", "007577019fa000aeaf01b4b9" },
|
||||
{ "TU Light Dark", "0076a80092d10098c500a5f2" },
|
||||
{ "TU Light Idealized", "006e9d0093c500a6cc00ccd5" },
|
||||
} },
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# Linux AppImage packaging
|
||||
|
||||
Releases ship raw AppImages (no zip wrapper):
|
||||
|
||||
- `gen1recomp-<version>-linux-x86_64.AppImage`
|
||||
- `gen1recomp-<version>-linux-arm64.AppImage`
|
||||
|
||||
```sh
|
||||
chmod +x gen1recomp-*-linux-x86_64.AppImage
|
||||
./gen1recomp-*-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
Flatpak users should prefer the `.flatpak` bundle (see
|
||||
[linux-flatpak.md](linux-flatpak.md)); it avoids host glibc / FUSE / curl
|
||||
mismatches on immutable desktops.
|
||||
|
||||
## Common failure modes
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| Won't start / `libfuse.so.2` | No FUSE | `sudo apt install libfuse2` (or `libfuse2t64`) **or** `./app.AppImage --appimage-extract-and-run` |
|
||||
| Update check / mods / save sync fail | Host `curl` missing or broken by AppImage `LD_LIBRARY_PATH` | Install curl; current builds scrub `LD_LIBRARY_PATH` for **host** curl and keep it for a bundled AppDir curl |
|
||||
| Settings / sync reset after quit | Portable mode next to a read-only AppImage parent (`/opt`, system dir) | Remove `portable.txt` or move the AppImage to a writable folder; the game falls back to the XDG save dir when the probe write fails |
|
||||
| "Download AppImage update" | Shell/`minShell` gate needs a full native package | Download the new `.AppImage`, `chmod +x`, replace the old file |
|
||||
| Steam / Game Mode weirdness after update | Overlay `LD_PRELOAD` / PID change | HostShell unsets `LD_PRELOAD` for children and `execv`s `$APPIMAGE` on restart |
|
||||
|
||||
## Network transport (HostShell)
|
||||
|
||||
Desktop Linux fetches go through host or bundled `curl`:
|
||||
|
||||
1. Flatpak `/app/bin/curl` (bundled, keep sandbox libs)
|
||||
2. `$APPDIR/usr/bin/curl` or `$APPDIR/bin/curl` (keep `$APPDIR` on `LD_LIBRARY_PATH`)
|
||||
3. Host `curl` (`env -u LD_LIBRARY_PATH`, and always `-u LD_PRELOAD`)
|
||||
|
||||
## Portable mode
|
||||
|
||||
Drop `portable.txt` beside the `.AppImage` to keep saves next to the binary.
|
||||
The launcher probes writability with a unique `.write_probe_<time>_<rand>.tmp`
|
||||
file. Read-only parents fail soft and use `love.filesystem` XDG saves instead.
|
||||
Flatpak ignores `portable.txt`.
|
||||
|
||||
## Auto-update
|
||||
|
||||
In-place updates download `gen1recomp-X.Y.Z.love` into the save directory.
|
||||
Full shell bumps open the matching AppImage (or Flatpak) download URL — there
|
||||
is no silent in-place AppImage replace yet.
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
# x86_64 (macOS or Linux host with squashfs-tools)
|
||||
scripts/build.sh linux --version X.Y.Z
|
||||
|
||||
# arm64 (aarch64 host + docker/podman)
|
||||
scripts/build_linux_arm64.sh --version X.Y.Z
|
||||
```
|
||||
|
||||
See [linux-arm64-build.md](linux-arm64-build.md) for the arm64 builder.
|
||||
@@ -34,6 +34,25 @@ The package bundles PortMaster's LÖVE 11.5 aarch64 runtime. The launcher source
|
||||
|
||||
Suspend/resume uses the existing LÖVE focus/visibility lifecycle: input is reset on focus loss and the game resumes when the window becomes visible again. Exact power-button behavior remains firmware-dependent; hardware validation has been performed on the TrimUI Brick, not every SBC or H700 device.
|
||||
|
||||
## Power and Performance Tuning
|
||||
|
||||
The handheld build applies several optimizations to reduce power draw and ensure smooth 60 FPS frame pacing on low-power ARM SoCs:
|
||||
|
||||
- **KMSDRM / EGL Vsync Fix**: Handheld builds disable driver vsync (`vsync = 0`) to prevent GPU driver busy-wait spinloops in `eglSwapBuffers`, allowing `nanosleep()` and dropping idle CPU usage from ~25% to ~4%.
|
||||
- **Idle Render Governor**: Drops presentation rate when a static screen (menus, text dialogs, stationary scenes) receives no input, cutting compositing work ~6x while preserving full 60 Hz game and audio clocks. Any button press restores full framerate immediately.
|
||||
- **Sample Rate Scaling**: Audio synthesis is tuned to 22.05 kHz by default (`POKEPORT_AUDIO_RATE=22050`), halving synthesis CPU overhead on Cortex-A53 cores with no audible quality loss on handheld speakers.
|
||||
- **Dynamic CPU Governor**: Defaults to `schedutil` instead of pinning `performance` on all cores, reducing thermals and extending battery life.
|
||||
|
||||
Environment variables for fine-tuning (configured in `gen1recomp-sbc.sh`):
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `POKEPORT_IDLE_AFTER` | `10` | Seconds without input before an idle screen drops presentation rate |
|
||||
| `POKEPORT_IDLE_FPS` | `6` | Framerate while idle |
|
||||
| `POKEPORT_AUDIO_RATE` | `22050` | Chip-synth sample rate (set to `44100` for full rate) |
|
||||
| `POKEPORT_CPU_GOVERNOR` | `schedutil` | CPU scaling governor (`schedutil`, `performance`, `ondemand`) |
|
||||
|
||||
|
||||
## Building
|
||||
|
||||
Release workflows build this automatically. Standalone builds resolve the latest published Gen1Recomp release by default:
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# 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
|
||||
existing x86_64 `gen1recomp-<version>-linux-x86_64.AppImage`. 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.
|
||||
aarch64 handhelds that run a full distro. See also
|
||||
[linux-appimage.md](linux-appimage.md) for shared AppImage failure modes.
|
||||
|
||||
> The Anbernic RG34XXSP has its own PortMaster-style pack
|
||||
> (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Linux Flatpak
|
||||
|
||||
Releases may ship `gen1recomp-<version>-linux.flatpak` (x86_64 bundle).
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
flatpak install --user ./gen1recomp-<version>-linux.flatpak
|
||||
flatpak run com.theboisclub.gen1recomp
|
||||
```
|
||||
|
||||
Or open the `.flatpak` in Discover / GNOME Software. AppStream metainfo
|
||||
includes a `<releases>` entry so those UIs can show the version.
|
||||
|
||||
## Permissions
|
||||
|
||||
The manifest requests:
|
||||
|
||||
- network (updater, mods, save sync)
|
||||
- X11 / Wayland / DRI / PulseAudio
|
||||
- `--device=all` (SDL2 gamepads, rumble, gyro via evdev/hidraw)
|
||||
- `--filesystem=home` (ROM import)
|
||||
|
||||
Curl is bundled at `/app/bin/curl` so the game does not need host curl.
|
||||
|
||||
## Updates
|
||||
|
||||
Lua/engine payloads still use the in-app `.love` updater. A native shell bump
|
||||
points at the new `.flatpak` asset (`Download Flatpak update`).
|
||||
|
||||
After a `.love` payload download, restart uses `love.event.quit("restart")`
|
||||
(`execv` of `/proc/self/exe` inside bwrap). If a restart hangs after an
|
||||
update, fully quit and relaunch — PhysFS can retain a lock on the previous
|
||||
payload across a bad handoff.
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
scripts/build_flatpak.sh --version X.Y.Z
|
||||
# → dist/flatpak/gen1recomp-X.Y.Z-linux.flatpak
|
||||
```
|
||||
|
||||
Requires `flatpak` + `flatpak-builder` and the Freedesktop 24.08 runtime from
|
||||
Flathub (installed automatically on first build).
|
||||
|
||||
Release CI builds the bundle on `ubuntu-24.04` (`linux-flatpak` job) from the
|
||||
shared `game.love` payload and publishes `gen1recomp-<ver>-linux.flatpak`
|
||||
alongside the AppImages. PR CI path-gates the same script when `flatpak/` or
|
||||
`scripts/build_flatpak.sh` change.
|
||||
|
||||
Flathub store submission is out of scope for the GitHub bundle channel.
|
||||
+42
-3
@@ -214,12 +214,51 @@ finally the bare name handed to the system loader. Per-OS names are
|
||||
`librashader_bridge.dylib` (macOS), and `liblibrashader_bridge.so` or
|
||||
`librashader_bridge.so` (Linux and Android). Android resolves the bare name
|
||||
because the `.so` ships as an ordinary `jniLibs` entry, so `dlopen` finds it
|
||||
without a path. The desktop path is still a developer build sitting in cargo's
|
||||
output directory; nothing packages it next to a shipped game yet.
|
||||
`ShaderFX.canConvert()` reports whether the library resolved on this machine,
|
||||
without a path. `ShaderFX.canConvert()` reports whether the library resolved on this machine,
|
||||
and `ShaderFX.bridgeError()` says why not. Activating an already-converted
|
||||
preset never needs any of this.
|
||||
|
||||
**Shipped builds carry it.** `cargo` only ever emits the host's library, and
|
||||
the macOS release runner packs macOS, Windows and Linux in a single
|
||||
`scripts/build.sh all`, so release CI cross-builds one cdylib per platform on
|
||||
its own native runner (the `shaderfx-bridge` matrix in
|
||||
`.github/workflows/release.yml`) and stages it at `dist/native/<plat>/`.
|
||||
`bundle_shader_bridge` in `scripts/build.sh` reads that directory, and
|
||||
`SHADERFX_BRIDGE_<PLAT>` overrides it. Where each artifact puts the library:
|
||||
|
||||
| Artifact | Location | Found by |
|
||||
| --- | --- | --- |
|
||||
| macOS .app | `Contents/MacOS/` | source base directory |
|
||||
| Windows zip | next to `love.exe` | source base directory |
|
||||
| Linux AppImage (both arches) | AppDir root, next to `game.love` | source base directory |
|
||||
| Flatpak | `/app/share/gen1recomp/` | source base directory |
|
||||
| RG34XXSP / ARM SBC ports | `libs.aarch64/` | bare name via `LD_LIBRARY_PATH` |
|
||||
| Android APK | `jniLibs/<abi>/` | bare name |
|
||||
| iOS | linked into the binary | `ffi.C` |
|
||||
|
||||
The macOS library is a universal binary, because LÖVE.app is: an arm64-only
|
||||
bridge would fail `ffi.load` on Intel Macs the moment they hit CONVERT. The
|
||||
two Linux libraries are built with `cargo-zigbuild` against
|
||||
`{x86_64,aarch64}-unknown-linux-gnu.2.17`, below every consumer: the upstream
|
||||
x86_64 LÖVE AppImage floors at GLIBC_2.29, the arm64 AppImage's own gate is
|
||||
GLIBC_2.31, PortMaster's aarch64 LÖVE runtime floors at GLIBC_2.17, and ArkOS
|
||||
is glibc 2.30. 2.17 is the oldest glibc with aarch64 support, so one library
|
||||
per arch clears the whole fleet. `scripts/linux-arm64/verify_appimage.sh`
|
||||
discovers every ELF object in the AppDir rather than globbing `bin/love` and
|
||||
`lib/*.so*`, so the bridge is held to the same floor, resolution and
|
||||
dlopen-not-linked rules as everything else, then dlopened once to prove
|
||||
relocations and constructors run.
|
||||
|
||||
Setting `SHADERFX_BRIDGE_REQUIRED=1` turns the packagers' "not found" warning
|
||||
into a hard error. Release CI sets it on every job that ships a bridge, so a
|
||||
release cannot silently go out without one again.
|
||||
|
||||
**Where CONVERT is unavailable.** Switch and Xbox UWP ship without a bridge.
|
||||
The Switch build uses devkitPro's toolchain, which is not a rustc target; the
|
||||
UWP appcontainer is unproven for loading a desktop MSVC DLL. On both, presets
|
||||
converted elsewhere still activate and run normally, and `ShaderFX.canConvert()`
|
||||
returns false with `ShaderFX.bridgeError()` explaining why.
|
||||
|
||||
**Statically linked bridges.** On iOS there is no loadable library at all:
|
||||
the bridge is linked straight into the app binary, so its symbols live in the
|
||||
main image and are reachable only through `ffi.C`. The candidate list is empty
|
||||
|
||||
+3
-2
@@ -63,8 +63,9 @@ mounted or deleted as stale; the launcher directs the player to a full package.
|
||||
## Release assets
|
||||
|
||||
Each tagged release `vX.Y.Z` carries the existing per-platform archives
|
||||
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux.zip`,
|
||||
`-linux-arm64.AppImage`, `-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
|
||||
(`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`,
|
||||
`-linux-x86_64.AppImage`, `-linux-arm64.AppImage`, `-linux.flatpak`,
|
||||
`-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and
|
||||
PortMaster archives) plus two assets the updater itself consumes:
|
||||
|
||||
- `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=gen1recomp
|
||||
Comment=Pokémon Gen 1/2 recompilation
|
||||
Exec=gen1recomp
|
||||
Icon=com.theboisclub.gen1recomp
|
||||
Terminal=false
|
||||
Categories=Game;
|
||||
StartupWMClass=love
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.theboisclub.gen1recomp</id>
|
||||
<name>gen1recomp</name>
|
||||
<summary>Pokémon Gen 1/2 recompilation</summary>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>LicenseRef-proprietary</project_license>
|
||||
<developer_name>The Bois Club</developer_name>
|
||||
<description>
|
||||
<p>
|
||||
A LÖVE-based recompilation of Pokémon Gen 1 and Gen 2 with a launcher,
|
||||
ROM importer, mods, and optional cloud save sync. Requires your own
|
||||
legal cartridge dumps.
|
||||
</p>
|
||||
</description>
|
||||
<launchable type="desktop-id">com.theboisclub.gen1recomp.desktop</launchable>
|
||||
<url type="homepage">https://github.com/bryanthaboi/gen1recomp</url>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Launcher</caption>
|
||||
<image>https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/gen1recomp_cover.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<content_rating type="oars-1.1"/>
|
||||
<releases>
|
||||
<release version="0.0.0" date="1970-01-01"/>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -0,0 +1,68 @@
|
||||
# Template consumed by scripts/build_flatpak.sh (placeholders substituted).
|
||||
# Do not pass this file directly to flatpak-builder.
|
||||
app-id: com.theboisclub.gen1recomp
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: "24.08"
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: gen1recomp
|
||||
finish-args:
|
||||
- --share=network
|
||||
- --share=ipc
|
||||
- --socket=x11
|
||||
- --socket=wayland
|
||||
- --device=dri
|
||||
- --socket=pulseaudio
|
||||
# Full input device access so SDL2 sees udev/evdev gamepads + rumble/gyro
|
||||
- --device=all
|
||||
- --filesystem=home
|
||||
modules:
|
||||
- name: curl
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- tar -xJf curl-static.tar.xz
|
||||
- install -Dm755 curl /app/bin/curl
|
||||
sources:
|
||||
- type: file
|
||||
url: https://github.com/stunnel/static-curl/releases/download/8.21.0/curl-linux-x86_64-musl-8.21.0.tar.xz
|
||||
sha256: e955f211202ded2536164588331acfc987dc4b7857efa3577717b1ffeab22029
|
||||
dest-filename: curl-static.tar.xz
|
||||
|
||||
- name: love
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- chmod +x love-x86_64.AppImage
|
||||
- ./love-x86_64.AppImage --appimage-extract
|
||||
- mkdir -p /app/lib/love /app/bin /app/share/gen1recomp
|
||||
- cp -a squashfs-root/bin/love /app/bin/love
|
||||
- cp -a squashfs-root/lib/. /app/lib/love/
|
||||
- |
|
||||
cat > /app/bin/gen1recomp <<'EOF'
|
||||
#!/bin/sh
|
||||
export LD_LIBRARY_PATH="/app/lib/love:${LD_LIBRARY_PATH:-}"
|
||||
exec /app/bin/love /app/share/gen1recomp/game.love "$@"
|
||||
EOF
|
||||
- chmod +x /app/bin/gen1recomp
|
||||
- install -Dm644 game.love /app/share/gen1recomp/game.love
|
||||
# BRIDGE-BEGIN
|
||||
- install -Dm755 liblibrashader_bridge.so /app/share/gen1recomp/liblibrashader_bridge.so
|
||||
# BRIDGE-END
|
||||
- install -Dm644 com.theboisclub.gen1recomp.desktop /app/share/applications/com.theboisclub.gen1recomp.desktop
|
||||
- install -Dm644 com.theboisclub.gen1recomp.metainfo.xml /app/share/metainfo/com.theboisclub.gen1recomp.metainfo.xml
|
||||
- install -Dm644 icon.png /app/share/icons/hicolor/512x512/apps/com.theboisclub.gen1recomp.png
|
||||
sources:
|
||||
- type: file
|
||||
url: https://github.com/love2d/love/releases/download/11.5/love-11.5-x86_64.AppImage
|
||||
sha256: 65a673406431eff7167a15a032bf7a2e4ba50108e091eb7b176465831f9b5e00
|
||||
dest-filename: love-x86_64.AppImage
|
||||
- type: file
|
||||
path: game.love
|
||||
# BRIDGE-BEGIN
|
||||
- type: file
|
||||
path: liblibrashader_bridge.so
|
||||
# BRIDGE-END
|
||||
- type: file
|
||||
path: com.theboisclub.gen1recomp.metainfo.xml
|
||||
- type: file
|
||||
path: com.theboisclub.gen1recomp.desktop
|
||||
- type: file
|
||||
path: icon.png
|
||||
@@ -22,6 +22,56 @@ local PlatformHooks = require("src.core.PlatformHooks")
|
||||
local HostDisplay = require("src.core.HostDisplay")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
|
||||
-- Global emergency quit: holding Start + Select for 5 seconds forcefully terminates LOVE.
|
||||
local emergencyQuitTimer = 0
|
||||
|
||||
local function checkEmergencyQuit(dt)
|
||||
local held = false
|
||||
if love.joystick and love.joystick.getJoysticks then
|
||||
local joysticks = love.joystick.getJoysticks()
|
||||
for _, j in ipairs(joysticks) do
|
||||
if j:isGamepad() then
|
||||
local start = j:isGamepadDown("start")
|
||||
local selectBtn = j:isGamepadDown("back") or j:isGamepadDown("guide")
|
||||
if start and selectBtn then
|
||||
held = true
|
||||
break
|
||||
end
|
||||
else
|
||||
local bCount = j:getButtonCount()
|
||||
local s1 = (bCount >= 7 and j:isDown(7)) or (bCount >= 9 and j:isDown(9))
|
||||
local s2 = (bCount >= 8 and j:isDown(8)) or (bCount >= 10 and j:isDown(10))
|
||||
if s1 and s2 then
|
||||
held = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if love.keyboard and love.keyboard.isDown then
|
||||
if (love.keyboard.isDown("escape") and love.keyboard.isDown("return"))
|
||||
or (love.keyboard.isDown("lalt") and love.keyboard.isDown("f4")) then
|
||||
held = true
|
||||
end
|
||||
end
|
||||
|
||||
if held then
|
||||
emergencyQuitTimer = emergencyQuitTimer + (dt or 0.016)
|
||||
if emergencyQuitTimer >= 5.0 then
|
||||
print("[FORCE QUIT] Start + Select held for 5 seconds. Exiting forcefully.")
|
||||
pcall(function()
|
||||
if love.audio and love.audio.stop then love.audio.stop() end
|
||||
if love.window and love.window.close then love.window.close() end
|
||||
end)
|
||||
local exitFn = os["exit"]
|
||||
exitFn(0)
|
||||
end
|
||||
else
|
||||
emergencyQuitTimer = 0
|
||||
end
|
||||
end
|
||||
|
||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||
do
|
||||
local defaultErrorHandler = love.errorhandler or love.errhand
|
||||
@@ -30,10 +80,33 @@ do
|
||||
if ok and hint and type(msg) == "string" then
|
||||
msg = msg .. "\n\n" .. hint
|
||||
end
|
||||
|
||||
if love.window and love.window.isOpen and love.window.isOpen() and love.graphics and love.graphics.isActive() then
|
||||
local fullMsg = tostring(msg) .. "\n\n" .. tostring(debug.traceback()) .. "\n\n[Hold START + SELECT for 5s to Force Quit]"
|
||||
return function()
|
||||
love.event.pump()
|
||||
for e, a in love.event.poll() do
|
||||
if e == "quit" or (e == "keypressed" and a == "escape") then
|
||||
return 1
|
||||
elseif e == "gamepadpressed" and (a == "start" or a == "back") then
|
||||
return 1
|
||||
end
|
||||
end
|
||||
checkEmergencyQuit(0.016)
|
||||
love.graphics.origin()
|
||||
love.graphics.clear(0.10, 0.10, 0.12)
|
||||
love.graphics.setColor(1, 0.4, 0.4, 1)
|
||||
love.graphics.printf(fullMsg, 20, 20, love.graphics.getWidth() - 40)
|
||||
love.graphics.present()
|
||||
love.timer.sleep(0.016)
|
||||
end
|
||||
end
|
||||
|
||||
if defaultErrorHandler then
|
||||
return defaultErrorHandler(msg)
|
||||
end
|
||||
end
|
||||
love.errhand = love.errorhandler
|
||||
end
|
||||
|
||||
local Game, EditorApp, Importer, TouchEditor, Studio
|
||||
@@ -559,6 +632,7 @@ function love.load(args)
|
||||
end
|
||||
|
||||
function love.update(dt)
|
||||
checkEmergencyQuit(dt)
|
||||
HostDisplay.update(dt)
|
||||
SwitchDiagnostics.maybeFlush(false)
|
||||
-- NX only (no-op elsewhere): follow dock/undock without waiting for SDL.
|
||||
@@ -1229,6 +1303,8 @@ function love.run()
|
||||
if love.timer then dt = love.timer.step() end
|
||||
idleFor = idleFor + dt
|
||||
|
||||
checkEmergencyQuit(dt)
|
||||
|
||||
-- call update and draw
|
||||
if love.update then love.update(dt) end
|
||||
|
||||
|
||||
+72
-26
@@ -11,7 +11,7 @@
|
||||
#
|
||||
# Output: dist/mac/gen1recomp-macos.zip
|
||||
# dist/win/gen1recomp-win64.zip
|
||||
# dist/linux/gen1recomp-linux.zip (fused x86_64 AppImage)
|
||||
# dist/linux/gen1recomp-linux-x86_64.AppImage (fused x86_64 AppImage)
|
||||
# dist/android/debug/*.apk (full gradle output stays under
|
||||
# mobile/android/app/build/outputs/apk/embedNoRecord/)
|
||||
# dist/ios/<Config>-<sdk>/gen1recomp++.app (full xcodebuild output stays
|
||||
@@ -170,27 +170,53 @@ make_ico() { # $1 = output .ico path
|
||||
# --------------------------------------------------------------- macOS
|
||||
# ShaderFX's librashader bridge. Only the CONVERT action needs it, so a build
|
||||
# without it still runs presets that were converted elsewhere.
|
||||
# SHADERFX_BRIDGE points at a prebuilt library; otherwise cargo builds it.
|
||||
# SHADERFX_BRIDGE_<PLAT> or dist/native/<plat>/ points at a prebuilt library;
|
||||
# otherwise cargo builds it, host platform only.
|
||||
shader_bridge_host_plat() {
|
||||
case "$(uname -s)-$(uname -m)" in
|
||||
Darwin-*) printf 'mac' ;;
|
||||
Linux-x86_64) printf 'linux-x64' ;;
|
||||
Linux-aarch64|Linux-arm64) printf 'linux-arm64' ;;
|
||||
*) printf '' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
bundle_shader_bridge() {
|
||||
local dest="$1" name="$2"
|
||||
local src="${SHADERFX_BRIDGE:-}"
|
||||
local dest="$1" name="$2" plat="$3"
|
||||
local crate="$ROOT/tools/shaderfx-bridge"
|
||||
if [ -z "$src" ] && [ -f "$crate/target/release/$name" ]; then
|
||||
src="$crate/target/release/$name"
|
||||
local src="" var
|
||||
[ -n "$plat" ] || fail "bundle_shader_bridge: no platform key for $name"
|
||||
|
||||
var="SHADERFX_BRIDGE_$(printf '%s' "$plat" | tr 'a-z-' 'A-Z_')"
|
||||
eval "src=\${$var:-}"
|
||||
|
||||
if [ -z "$src" ] && [ -f "$DIST/native/$plat/$name" ]; then
|
||||
src="$DIST/native/$plat/$name"
|
||||
fi
|
||||
if [ -z "$src" ] && command -v cargo >/dev/null 2>&1; then
|
||||
say "building the ShaderFX bridge with cargo"
|
||||
if (cd "$crate" && cargo build --release >/dev/null 2>&1); then
|
||||
if [ -z "$src" ] && [ -n "${SHADERFX_BRIDGE:-}" ]; then
|
||||
src="$SHADERFX_BRIDGE"
|
||||
fi
|
||||
if [ -z "$src" ] && [ "$plat" = "$(shader_bridge_host_plat)" ]; then
|
||||
if [ -f "$crate/target/release/$name" ]; then
|
||||
src="$crate/target/release/$name"
|
||||
elif command -v cargo >/dev/null 2>&1; then
|
||||
say "building the ShaderFX bridge with cargo"
|
||||
if (cd "$crate" && cargo build --release >/dev/null 2>&1); then
|
||||
src="$crate/target/release/$name"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$src" ] && [ -f "$src" ]; then
|
||||
mkdir -p "$dest"
|
||||
cp "$src" "$dest/$name"
|
||||
say "bundled $name for SHADER FX preset conversion"
|
||||
else
|
||||
warn "$name not found: this build can run converted presets but not CONVERT new ones (set SHADERFX_BRIDGE or install cargo)"
|
||||
say "bundled $name for SHADER FX preset conversion ($plat)"
|
||||
return 0
|
||||
fi
|
||||
if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
|
||||
fail "$name ($plat) not found: set $var or stage it at dist/native/$plat/$name"
|
||||
fi
|
||||
warn "$name not found: this build can run converted presets but not CONVERT new ones (set $var or install cargo)"
|
||||
}
|
||||
|
||||
build_mac() {
|
||||
@@ -205,7 +231,7 @@ build_mac() {
|
||||
# drop any bundled placeholder .love and fuse ours in
|
||||
find "$out_app/Contents/Resources" -maxdepth 1 -name '*.love' -delete
|
||||
cp "$LOVE_FILE" "$out_app/Contents/Resources/game.love"
|
||||
bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib"
|
||||
bundle_shader_bridge "$out_app/Contents/MacOS" "liblibrashader_bridge.dylib" mac
|
||||
|
||||
local plist="$out_app/Contents/Info.plist"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$plist" 2>/dev/null \
|
||||
@@ -322,7 +348,7 @@ build_win() {
|
||||
warn "gen1tls.dll not found: Windows zip will not support wss:// (set GEN1TLS_DLL or build native/tls_dial)"
|
||||
fi
|
||||
|
||||
bundle_shader_bridge "$out_dir" "librashader_bridge.dll"
|
||||
bundle_shader_bridge "$out_dir" "librashader_bridge.dll" win-x64
|
||||
|
||||
# The exe's icon lives in love.exe's PE resources, so it must be patched
|
||||
# BEFORE the .love is appended: peresed rewrites the whole file and would
|
||||
@@ -419,7 +445,7 @@ build_linux() {
|
||||
unsquashfs -q -no-xattrs -o "$sfs_offset" -d "$appdir" "$love_appimage" >/dev/null
|
||||
|
||||
cp "$LOVE_FILE" "$appdir/game.love"
|
||||
bundle_shader_bridge "$appdir" "liblibrashader_bridge.so"
|
||||
bundle_shader_bridge "$appdir" "liblibrashader_bridge.so" linux-x64
|
||||
|
||||
# Replace LÖVE's own desktop entry rather than keeping it: it says
|
||||
# Name=LÖVE / Icon=love, which is what appimaged, app menus and file
|
||||
@@ -457,15 +483,31 @@ EOF
|
||||
# file-manager thumbnailers show for the file itself.
|
||||
[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC"
|
||||
rm -f "$appdir/love.svg" "$appdir/love.png" "$appdir/.DirIcon"
|
||||
sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null
|
||||
if command -v sips >/dev/null 2>&1; then
|
||||
sips -z 512 512 "$ICON_SRC" --out "$appdir/$APP_NAME.png" >/dev/null
|
||||
elif command -v convert >/dev/null 2>&1; then
|
||||
convert "$ICON_SRC" -resize 512x512 "$appdir/$APP_NAME.png"
|
||||
else
|
||||
fail "need sips (macOS) or ImageMagick convert to resize $ICON_SRC"
|
||||
fi
|
||||
cp "$appdir/$APP_NAME.png" "$appdir/.DirIcon"
|
||||
|
||||
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
|
||||
# sed -i '' is BSD; GNU sed wants sed -i (no empty backup suffix).
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
|
||||
else
|
||||
sed -i '' 's|^#FUSE_PATH="$APPDIR/my_game.love"$|FUSE_PATH="$APPDIR/game.love"|' "$appdir/AppRun"
|
||||
fi
|
||||
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|
||||
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)"
|
||||
|
||||
sed -i '' 's|^exec "\$APPDIR/bin/love"|if [ -n "$WAYLAND_DISPLAY" ] \&\& [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
|
||||
exec "$APPDIR/bin/love"|' "$appdir/AppRun"
|
||||
local wayland_hook='if [ -n "$WAYLAND_DISPLAY" ] && [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\
|
||||
exec "$APPDIR/bin/love"'
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i "s|^exec \"\$APPDIR/bin/love\"|$wayland_hook|" "$appdir/AppRun"
|
||||
else
|
||||
sed -i '' "s|^exec \"\$APPDIR/bin/love\"|$wayland_hook|" "$appdir/AppRun"
|
||||
fi
|
||||
|
||||
# Match the upstream image's compression (gzip, 128K blocks) so the
|
||||
# bundled runtime can read it.
|
||||
@@ -474,16 +516,20 @@ exec "$APPDIR/bin/love"|' "$appdir/AppRun"
|
||||
mksquashfs "$appdir" "$sfs_out" \
|
||||
-comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null
|
||||
|
||||
local out_bin="$WORK/$APP_NAME-x86_64.AppImage"
|
||||
rm -f "$out_bin"
|
||||
mkdir -p "$DIST/linux"
|
||||
local out_bin="$DIST/linux/$APP_NAME-linux-x86_64.AppImage"
|
||||
rm -f "$out_bin" "$out_bin.sha256"
|
||||
head -c "$sfs_offset" "$love_appimage" > "$out_bin"
|
||||
cat "$sfs_out" >> "$out_bin"
|
||||
chmod +x "$out_bin"
|
||||
|
||||
local zip_out="$DIST/linux/$APP_NAME-linux.zip"
|
||||
rm -f "$zip_out"
|
||||
(cd "$WORK" && zip -q -9 -j "$zip_out" "$(basename "$out_bin")")
|
||||
say "Linux build: $zip_out"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
printf '%s %s\n' "$(sha256sum "$out_bin" | awk '{print $1}')" "$(basename "$out_bin")" \
|
||||
> "$out_bin.sha256"
|
||||
else
|
||||
printf '%s %s\n' "$(shasum -a 256 "$out_bin" | awk '{print $1}')" "$(basename "$out_bin")" \
|
||||
> "$out_bin.sha256"
|
||||
fi
|
||||
say "Linux build: $out_bin"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- Android
|
||||
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build a single-file Flatpak bundle for x86_64 desktop Linux.
|
||||
#
|
||||
# Usage: scripts/build_flatpak.sh [--version X.Y.Z] [--game-love PATH]
|
||||
#
|
||||
# Output:
|
||||
# dist/flatpak/gen1recomp-<version>-linux.flatpak
|
||||
#
|
||||
# Requires: flatpak, flatpak-builder, and network on first run (Freedesktop
|
||||
# runtime + LÖVE AppImage download).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
APP_ID="com.theboisclub.gen1recomp"
|
||||
VERSION="0.0.0"
|
||||
GAME_LOVE_IN=""
|
||||
DIST="$ROOT/dist/flatpak"
|
||||
BUILD_DIR="$ROOT/.bazinga/flatpak-build"
|
||||
REPO_DIR="$ROOT/.bazinga/flatpak-repo"
|
||||
STATE_DIR="$ROOT/.bazinga/flatpak-state"
|
||||
CACHE="$ROOT/.bazinga/flatpak-cache"
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--version) VERSION="$2"; shift ;;
|
||||
--game-love) GAME_LOVE_IN="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,12p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
fail "invalid --version '$VERSION' (expected X.Y.Z)"
|
||||
fi
|
||||
|
||||
command -v flatpak >/dev/null || fail "flatpak not found"
|
||||
command -v flatpak-builder >/dev/null || fail "flatpak-builder not found"
|
||||
|
||||
mkdir -p "$DIST" "$BUILD_DIR" "$REPO_DIR" "$STATE_DIR" "$CACHE"
|
||||
|
||||
LOVE_FILE="$CACHE/game.love"
|
||||
if [ -n "$GAME_LOVE_IN" ]; then
|
||||
[ -f "$GAME_LOVE_IN" ] || fail "game.love not found: $GAME_LOVE_IN"
|
||||
if [ "$(readlink -f "$GAME_LOVE_IN")" != "$(readlink -f "$LOVE_FILE")" ]; then
|
||||
cp "$GAME_LOVE_IN" "$LOVE_FILE"
|
||||
fi
|
||||
else
|
||||
say "packing game.love"
|
||||
"$ROOT/scripts/pack_love.sh" --output "$LOVE_FILE" --version "$VERSION"
|
||||
fi
|
||||
|
||||
# Stage local sources next to the generated manifest (flatpak path: is relative).
|
||||
cp "$ROOT/flatpak/$APP_ID.desktop" "$CACHE/$APP_ID.desktop"
|
||||
# AppStream rejects icons larger than the declared hicolor size.
|
||||
if command -v magick >/dev/null 2>&1; then
|
||||
magick "$ROOT/assets/logo/gen1recomp_cover.png" -resize 512x512 "$CACHE/icon.png"
|
||||
elif command -v convert >/dev/null 2>&1; then
|
||||
convert "$ROOT/assets/logo/gen1recomp_cover.png" -resize 512x512 "$CACHE/icon.png"
|
||||
else
|
||||
fail "need ImageMagick (magick/convert) to resize the Flatpak icon to 512x512"
|
||||
fi
|
||||
|
||||
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
python3 - "$ROOT/flatpak/$APP_ID.metainfo.xml" "$CACHE/$APP_ID.metainfo.xml" "$VERSION" "$DATE" <<'PY'
|
||||
import sys, pathlib, re
|
||||
src, dst, ver, date = sys.argv[1:5]
|
||||
text = pathlib.Path(src).read_text(encoding="utf-8")
|
||||
release = f' <release version="{ver}" date="{date}"/>'
|
||||
text = re.sub(
|
||||
r"<releases>.*?</releases>",
|
||||
"<releases>\n" + release + "\n </releases>",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.S,
|
||||
)
|
||||
pathlib.Path(dst).write_text(text, encoding="utf-8")
|
||||
PY
|
||||
|
||||
BRIDGE_LIB="liblibrashader_bridge.so"
|
||||
BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_X64:-}"
|
||||
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-x64/$BRIDGE_LIB" ]; then
|
||||
BRIDGE_SRC="$ROOT/dist/native/linux-x64/$BRIDGE_LIB"
|
||||
fi
|
||||
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/tools/shaderfx-bridge/target/release/$BRIDGE_LIB" ]; then
|
||||
BRIDGE_SRC="$ROOT/tools/shaderfx-bridge/target/release/$BRIDGE_LIB"
|
||||
fi
|
||||
rm -f "$CACHE/$BRIDGE_LIB"
|
||||
if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then
|
||||
cp "$BRIDGE_SRC" "$CACHE/$BRIDGE_LIB"
|
||||
cp "$ROOT/flatpak/$APP_ID.yml" "$CACHE/$APP_ID.yml"
|
||||
say "staged $BRIDGE_LIB for SHADER FX preset conversion"
|
||||
elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
|
||||
fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_X64 or stage it at dist/native/linux-x64/$BRIDGE_LIB"
|
||||
else
|
||||
warn "$BRIDGE_LIB not found: this bundle can run converted presets but not CONVERT new ones"
|
||||
sed '/# BRIDGE-BEGIN/,/# BRIDGE-END/d' "$ROOT/flatpak/$APP_ID.yml" > "$CACHE/$APP_ID.yml"
|
||||
fi
|
||||
|
||||
say "installing Freedesktop runtime (no-op if present)"
|
||||
flatpak --user remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo || true
|
||||
flatpak --user install -y flathub \
|
||||
org.freedesktop.Platform//24.08 \
|
||||
org.freedesktop.Sdk//24.08 \
|
||||
|| warn "runtime install reported an error; flatpak-builder may still reuse cache"
|
||||
|
||||
say "building Flatpak ($APP_ID $VERSION)"
|
||||
# Run from CACHE so path: sources resolve.
|
||||
(
|
||||
cd "$CACHE"
|
||||
flatpak-builder --user --force-clean \
|
||||
--state-dir="$STATE_DIR" \
|
||||
--repo="$REPO_DIR" \
|
||||
"$BUILD_DIR" "$APP_ID.yml"
|
||||
)
|
||||
|
||||
OUT="$DIST/gen1recomp-${VERSION}-linux.flatpak"
|
||||
rm -f "$OUT"
|
||||
say "exporting bundle $OUT"
|
||||
flatpak build-bundle "$REPO_DIR" "$OUT" "$APP_ID" \
|
||||
--runtime-repo=https://flathub.org/repo/flathub.flatpakrepo
|
||||
chmod 644 "$OUT"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
printf '%s %s\n' "$(sha256sum "$OUT" | awk '{print $1}')" "$(basename "$OUT")" > "$OUT.sha256"
|
||||
else
|
||||
printf '%s %s\n' "$(shasum -a 256 "$OUT" | awk '{print $1}')" "$(basename "$OUT")" > "$OUT.sha256"
|
||||
fi
|
||||
if [ -f "$CACHE/$BRIDGE_LIB" ]; then
|
||||
[ -f "$BUILD_DIR/files/share/gen1recomp/$BRIDGE_LIB" ] \
|
||||
|| fail "$BRIDGE_LIB was staged but is missing from the built /app/share/gen1recomp"
|
||||
say "verified $BRIDGE_LIB in the Flatpak"
|
||||
fi
|
||||
say "Flatpak build: $OUT ($(du -h "$OUT" | cut -f1))"
|
||||
say "sha256: $(cut -d' ' -f1 "$OUT.sha256")"
|
||||
@@ -100,6 +100,21 @@ then
|
||||
fi
|
||||
cp "$GAME_LOVE" "$IN_DIR/game.love"
|
||||
|
||||
BRIDGE_LIB="liblibrashader_bridge.so"
|
||||
BRIDGE_SRC="${SHADERFX_BRIDGE_LINUX_ARM64:-}"
|
||||
if [ -z "$BRIDGE_SRC" ] && [ -f "$ROOT/dist/native/linux-arm64/$BRIDGE_LIB" ]; then
|
||||
BRIDGE_SRC="$ROOT/dist/native/linux-arm64/$BRIDGE_LIB"
|
||||
fi
|
||||
rm -f "$IN_DIR/$BRIDGE_LIB"
|
||||
if [ -n "$BRIDGE_SRC" ] && [ -f "$BRIDGE_SRC" ]; then
|
||||
cp "$BRIDGE_SRC" "$IN_DIR/$BRIDGE_LIB"
|
||||
say "staged $BRIDGE_LIB for SHADER FX preset conversion"
|
||||
elif [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ]; then
|
||||
fail "$BRIDGE_LIB not found: set SHADERFX_BRIDGE_LINUX_ARM64 or stage it at dist/native/linux-arm64/$BRIDGE_LIB"
|
||||
else
|
||||
warn "$BRIDGE_LIB not found: this build can run converted presets but not CONVERT new ones"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- downloads
|
||||
# Fetched on the host and checksum-pinned here so the container never needs
|
||||
# network access and every input is verified in exactly one place.
|
||||
|
||||
@@ -327,6 +327,14 @@ bundle_needed "$APPDIR/lib/liblove-$LOVE_VERSION.so"
|
||||
say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n' ' ')"
|
||||
|
||||
# ------------------------------------------------- host dependency contract
|
||||
SHADER_BRIDGE=()
|
||||
if [ -f "$IN/liblibrashader_bridge.so" ]; then
|
||||
cp "$IN/liblibrashader_bridge.so" "$APPDIR/liblibrashader_bridge.so"
|
||||
chmod 0755 "$APPDIR/liblibrashader_bridge.so"
|
||||
SHADER_BRIDGE=("$APPDIR/liblibrashader_bridge.so")
|
||||
say "bundled liblibrashader_bridge.so for SHADER FX preset conversion"
|
||||
fi
|
||||
|
||||
# The portability promise, stated as an assertion instead of a paragraph in a
|
||||
# README: these are the ONLY sonames the shipped objects may require from the
|
||||
# host. Everything driver-, session- or audio-related has to be reached
|
||||
@@ -339,7 +347,7 @@ say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n'
|
||||
HOST_ALLOWED_RE='^(ld-linux-aarch64\.so\.1|libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|librt\.so\.1|libstdc\+\+\.so\.6|libgcc_s\.so\.1|libatomic\.so\.1|libfreetype\.so\.6|libpng[0-9]*\.so\.[0-9]+|libz\.so\.1|libbrotli(dec|common)\.so\.1)$'
|
||||
|
||||
unexpected=""
|
||||
for object in "$APPDIR/bin/love" "$APPDIR"/lib/*.so*; do
|
||||
for object in "$APPDIR/bin/love" "$APPDIR"/lib/*.so* ${SHADER_BRIDGE[@]+"${SHADER_BRIDGE[@]}"}; do
|
||||
while read -r soname; do
|
||||
[ -n "$soname" ] || continue
|
||||
# Satisfied from inside the AppDir, so not a host requirement at all.
|
||||
|
||||
@@ -20,6 +20,23 @@ for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|
||||
|| { echo "::error::AppImage is missing $required"; exit 1; }
|
||||
done
|
||||
|
||||
bridge="squashfs-root/liblibrashader_bridge.so"
|
||||
if [ "${SHADERFX_BRIDGE_REQUIRED:-}" = "1" ] && [ ! -e "$bridge" ]; then
|
||||
echo "::error::AppImage is missing liblibrashader_bridge.so"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Discovered, not enumerated: bin/love and lib/*.so* miss anything native at
|
||||
# the AppDir root, which is where ShaderFX's bridge sits and where the next
|
||||
# root-level library would land too.
|
||||
scan=()
|
||||
while IFS= read -r f; do
|
||||
scan+=("$f")
|
||||
done < <(find squashfs-root -type f -exec sh -c \
|
||||
'head -c4 "$1" | od -An -tx1 | tr -d " \n" | grep -q "^7f454c46$"' _ {} \; -print | sort)
|
||||
[ "${#scan[@]}" -gt 0 ] || { echo "::error::found no ELF objects in the AppDir"; exit 1; }
|
||||
echo "scanning ${#scan[@]} ELF objects: ${scan[*]#squashfs-root/}"
|
||||
|
||||
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
|
||||
# applied; an unresolved soname here is a user-visible launch crash.
|
||||
#
|
||||
@@ -28,14 +45,14 @@ done
|
||||
# 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 \
|
||||
ldd "${scan[@]}" 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
|
||||
linked="$(for f in "${scan[@]}"; 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" ] \
|
||||
@@ -45,7 +62,7 @@ done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo
|
||||
# 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 \
|
||||
floor="$(objdump -T "${scan[@]}" 2>/dev/null \
|
||||
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
|
||||
echo "highest required glibc symbol version: $floor"
|
||||
[ -n "$floor" ] \
|
||||
@@ -54,4 +71,12 @@ 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; }
|
||||
|
||||
# The floor grep proves symbol versions; an actual dlopen additionally proves
|
||||
# relocations and constructors. Native arch only, so it is skipped elsewhere.
|
||||
if [ -e "$bridge" ] && [ "$(uname -m)" = "aarch64" ] && command -v python3 >/dev/null 2>&1; then
|
||||
python3 -c "import ctypes,sys; ctypes.CDLL(sys.argv[1])" "$PWD/$bridge" \
|
||||
|| { echo "::error::liblibrashader_bridge.so failed to dlopen"; exit 1; }
|
||||
echo "librashader bridge dlopens cleanly"
|
||||
fi
|
||||
|
||||
echo "AppImage verified: $image"
|
||||
|
||||
@@ -118,6 +118,7 @@ run_tier "T0 Yellow title OBP eye remap" python3 tests/title_pikachu_obp_test.py
|
||||
run_tier "T0 Crystal manifest + specials coverage" "$LUA" tests/crystal_import_test.lua
|
||||
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
||||
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
||||
run_tier "T0 ShaderFX bridge packaging gate" "$LUA" tests/shaderfx_bridge_packaging_test.lua
|
||||
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
||||
# Sound.lua / overlay regression is not gated only on switch-changes paths.
|
||||
run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua
|
||||
|
||||
+55
-22
@@ -34,6 +34,12 @@ local RomText = require("src.core.RomText")
|
||||
local Strings = require("src.core.Strings")
|
||||
local WideBattle = require("src.battle.WideBattle")
|
||||
|
||||
local Chrome2
|
||||
do
|
||||
local ok, v = pcall(require, "src.ui.gen2.Chrome")
|
||||
Chrome2 = ok and v or nil
|
||||
end
|
||||
|
||||
local romText = RomText
|
||||
|
||||
local BattleState = {}
|
||||
@@ -461,7 +467,7 @@ function BattleState:picImage(img)
|
||||
-- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode
|
||||
-- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync.
|
||||
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
|
||||
or PaletteFX.mode == "classic"
|
||||
or PaletteFX.mode == "classic" or PaletteFX.forcesRawGrays()
|
||||
if self.grayPics or mono then return grayImage(img) end
|
||||
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go
|
||||
-- dark with the HP bars while the blackout text is up (#292). The intro
|
||||
@@ -5989,7 +5995,7 @@ function BattleState:drawZonePass(src, sx, sy)
|
||||
-- only the other two showed it. Keep this mode set in sync with picImage /
|
||||
-- PaletteFX.ensureZones / WideBattle.monoMode.
|
||||
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
|
||||
or PaletteFX.mode == "classic"
|
||||
or PaletteFX.mode == "classic" or PaletteFX.forcesRawGrays()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setShader(shader)
|
||||
local shaking = sx ~= 0 or sy ~= 0
|
||||
@@ -6399,8 +6405,35 @@ end
|
||||
|
||||
function BattleState:drawTextArea()
|
||||
if not self:bottomUIVisible() then return end
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
-- Gold only: routes the command box and its labels through GbcPalette,
|
||||
-- the same fix TextBox.lua got for dialogue. moveSelect/mimicSelect below
|
||||
-- aren't migrated yet since they need their own interior-patch handling
|
||||
-- (see #240).
|
||||
local gold = self.game and self.game.save
|
||||
and (self.game.save.generation == 2 or self.game.save.version == "gold")
|
||||
local Chrome = gold and Chrome2 or nil
|
||||
local function box(tx, ty, tw, th)
|
||||
if Chrome then
|
||||
Chrome.paletteBox(tx, ty, tw, th)
|
||||
else
|
||||
Font.drawBox(tx, ty, tw, th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
end
|
||||
local drawGlyph, finishGlyph = Font.drawCode, nil
|
||||
if Chrome then
|
||||
local _, dg, fg = Chrome.paletteGlyphs(Chrome.DEFAULT_BOX_PALETTE)
|
||||
drawGlyph, finishGlyph = dg, fg
|
||||
end
|
||||
local function text(str, x, y)
|
||||
local pen = x
|
||||
for _, code in ipairs(Font.encode(str)) do
|
||||
drawGlyph(code, pen, y)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
end
|
||||
|
||||
box(0, 12, 20, 6)
|
||||
if self.phase == "messages"
|
||||
and (self.current or self.animPlaying or self.msgHold) then
|
||||
-- during the move animation self.current is nil but shown still holds
|
||||
@@ -6419,48 +6452,47 @@ function BattleState:drawTextArea()
|
||||
for li, line in ipairs(self.shown or {}) do
|
||||
local y = (ys[li] or 128) + off
|
||||
for i = 1, #line do
|
||||
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
|
||||
drawGlyph(line[i], 8 + (i - 1) * 8, y)
|
||||
end
|
||||
end
|
||||
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait
|
||||
-- (_ContText) or a typed-out page (PromptText) holds the box; both write
|
||||
-- it at (18,16), bottom-right, like TextBox / home/text.asm (#317)
|
||||
if (self.msgWaiting or self.msgPrompt) and self.frame % 60 < 30 then
|
||||
Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
|
||||
drawGlyph(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
|
||||
end
|
||||
elseif self.phase == "menu" and self.demo then
|
||||
-- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the
|
||||
-- standard menu, with the '▶' hand drawn by the scripted keystrokes
|
||||
-- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16)
|
||||
Font.drawBox(8, 12, 12, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("FIGHT", "battle"), 80, 112)
|
||||
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
||||
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128)
|
||||
Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
|
||||
box(8, 12, 12, 6)
|
||||
text(Strings("FIGHT", "battle"), 80, 112)
|
||||
drawGlyph(0xE1, 128, 112); drawGlyph(0xE2, 136, 112)
|
||||
text(Strings("ITEM", "battle"), 80, 128); text(Strings("RUN", "battle"), 128, 128)
|
||||
drawGlyph(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128)
|
||||
elseif self.phase == "menu" then
|
||||
local col = (self.menuIndex - 1) % 2
|
||||
local row = math.floor((self.menuIndex - 1) / 2)
|
||||
if self.safari then
|
||||
-- SAFARI_BATTLE_MENU_TEMPLATE: full-width box, "BALLx BAIT /
|
||||
-- THROW ROCK RUN" from (2,14)
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112)
|
||||
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN", "battle"), 112, 128)
|
||||
box(0, 12, 20, 6)
|
||||
text(Strings("BALLx"), 16, 112); text(Strings("BAIT"), 112, 112)
|
||||
text(Strings("THROW ROCK"), 16, 128); text(Strings("RUN", "battle"), 112, 128)
|
||||
-- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print
|
||||
-- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two
|
||||
-- digits, space padded -- right after the "BALLx" label at columns
|
||||
-- 2..6 (engine/battle/core.asm:2074-2079, 2107-2112) (#540)
|
||||
Font.draw(("%2d"):format(self.safari.balls), 56, 112)
|
||||
Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16)
|
||||
text(("%2d"):format(self.safari.balls), 56, 112)
|
||||
drawGlyph(0xED, (col == 0 and 8 or 104), 112 + row * 16)
|
||||
else
|
||||
-- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> /
|
||||
-- ITEM RUN" from (10,14); cursor columns 9 / 15
|
||||
Font.drawBox(8, 12, 12, 6)
|
||||
Font.draw(Strings("FIGHT", "battle"), 80, 112)
|
||||
Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112)
|
||||
Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128)
|
||||
Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16)
|
||||
box(8, 12, 12, 6)
|
||||
text(Strings("FIGHT", "battle"), 80, 112)
|
||||
drawGlyph(0xE1, 128, 112); drawGlyph(0xE2, 136, 112)
|
||||
text(Strings("ITEM", "battle"), 80, 128); text(Strings("RUN", "battle"), 128, 128)
|
||||
drawGlyph(0xED, (col == 0 and 72 or 120), 112 + row * 16)
|
||||
end
|
||||
elseif self.phase == "moveSelect" then
|
||||
-- pokered MoveSelectionMenu: move list in a box at (4,12) 16x6,
|
||||
@@ -6535,6 +6567,7 @@ function BattleState:drawTextArea()
|
||||
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
|
||||
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
|
||||
end
|
||||
if finishGlyph then finishGlyph() end
|
||||
end
|
||||
|
||||
function BattleState:draw()
|
||||
|
||||
@@ -26,14 +26,10 @@ local WideBattle = {
|
||||
FIELD_BOTTOM = 104,
|
||||
}
|
||||
|
||||
-- The forced-mono display modes re-threshold the whole finished frame
|
||||
-- through the shade shader, and picImage hands them raw DMG grays for that
|
||||
-- (#207). The wide layout has to know: it exposes a matching whole-surface
|
||||
-- zone and leaves the HP bar fill gray, exactly like the zone pass does in
|
||||
-- the classic layout (#229). Keep in sync with picImage / ensureZones.
|
||||
local function monoMode()
|
||||
local m = PaletteFX.mode
|
||||
return m == "og" or m == "og_inv" or m == "classic"
|
||||
or PaletteFX.forcesRawGrays()
|
||||
end
|
||||
|
||||
local function shownHP(battler)
|
||||
|
||||
@@ -1258,6 +1258,7 @@ function Game:applyOptions(opts)
|
||||
if Sound.applyOptions then Sound.applyOptions(opts) end
|
||||
require("src.render.PaletteFX").applyOptions(opts)
|
||||
require("src.render.Tilt").applyOptions(opts)
|
||||
require("src.render.Letterbox").applyOptions(opts)
|
||||
-- after Tilt, so a persisted world pipeline can switch the tilt level it
|
||||
-- just restored back off (the two are mutually exclusive)
|
||||
require("src.render.Pipelines").applyOptions(opts)
|
||||
|
||||
@@ -1787,6 +1787,7 @@ function Game2:hotkey(key)
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
GbcPalette.setMode(options.color or "gbc")
|
||||
options.color = GbcPalette.cycle(1)
|
||||
options.palette = ""
|
||||
persist()
|
||||
return true
|
||||
elseif key == "3" then
|
||||
@@ -2040,6 +2041,7 @@ function Game2:applyOptions()
|
||||
Zoom.allowSurvey = caps.survey
|
||||
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
||||
require("src.render.Tilt").applyOptions(options)
|
||||
require("src.render.Letterbox").applyOptions(options)
|
||||
require("src.render.GbcPalette").applyOptions(options)
|
||||
-- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame.
|
||||
Font.setFrame(options.frame or 1)
|
||||
|
||||
+18
-1
@@ -57,7 +57,10 @@ function GamepadMap._setForceNXForTests(v)
|
||||
end
|
||||
|
||||
local function nxActive()
|
||||
if GamepadMap._forceNXForTests then return true end
|
||||
if GamepadMap._forceNXForTests == true then return true end
|
||||
if GamepadMap._forceNXForTests == false and love and (love._os ~= "NX" and (not love.system or love.system.getOS() ~= "NX")) then
|
||||
return false
|
||||
end
|
||||
if love and love._os == "NX" then return true end
|
||||
if love and love.system and love.system.getOS() == "NX" then return true end
|
||||
return false
|
||||
@@ -78,6 +81,20 @@ function GamepadMap.mapGamepadButton(button)
|
||||
return GamepadMap.gamepadBindings()[button]
|
||||
end
|
||||
|
||||
function GamepadMap.mapLauncherButton(button)
|
||||
if nxActive() then
|
||||
if button == "a" then return "b"
|
||||
elseif button == "b" then return "a"
|
||||
elseif button == "x" then return "y"
|
||||
elseif button == "y" then return "x"
|
||||
elseif button == "back" then return "select"
|
||||
end
|
||||
elseif button == "back" then
|
||||
return "select"
|
||||
end
|
||||
return button
|
||||
end
|
||||
|
||||
-- Select+face display chords (docs / Nintendo UX):
|
||||
-- Select+A → "2" (COLORS), Select+B → "3" (TILT),
|
||||
-- Select+Y → "5", Select+X → "6", Select+L (leftshoulder) → "7".
|
||||
|
||||
+114
-24
@@ -4,18 +4,79 @@ local HostShell = {}
|
||||
|
||||
-- Our AppRun exports LD_LIBRARY_PATH="$APPDIR/lib:..." so every subprocess we
|
||||
-- spawn tries to link against the libraries we're shipping instead of the
|
||||
-- system ones. We want to unset the var so that any system tools can find
|
||||
-- their proper libraries. Only needed when running in an AppImage.
|
||||
-- LD_PRELOAD is Steam's overlay (#1470): its 32-bit half cannot load into a
|
||||
-- 64-bit child, so ld.so prints an error into that child's output.
|
||||
-- system ones. Host tools (curl, zenity) need that unset. A *bundled* AppDir
|
||||
-- curl must keep APPDIR on LD_LIBRARY_PATH so it resolves the bundled
|
||||
-- libssl/libcrypto -- scrubbing it forces host OpenSSL and segfaults across
|
||||
-- distros. LD_PRELOAD is always scrubbed: Steam's overlay (#1470) cannot load
|
||||
-- into a 64-bit child.
|
||||
function HostShell.envPrefix()
|
||||
return HostShell.curlEnvPrefix("host")
|
||||
end
|
||||
|
||||
-- kind "bundled" = AppDir / Flatpak curl (keep LD_LIBRARY_PATH, scrub preload).
|
||||
-- kind "host" = system curl (scrub both).
|
||||
function HostShell.curlEnvPrefix(kind)
|
||||
local unset = ""
|
||||
if os.getenv("APPIMAGE") then unset = unset .. "-u LD_LIBRARY_PATH " end
|
||||
if kind ~= "bundled" and os.getenv("APPIMAGE") then
|
||||
unset = unset .. "-u LD_LIBRARY_PATH "
|
||||
end
|
||||
if os.getenv("LD_PRELOAD") then unset = unset .. "-u LD_PRELOAD " end
|
||||
if unset == "" then return "" end
|
||||
return "env " .. unset
|
||||
end
|
||||
|
||||
local curlResolved = nil -- { path=, kind= } once per Lua state
|
||||
|
||||
local function pathIsExecutable(path)
|
||||
if type(path) ~= "string" or path == "" then return false end
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return false end
|
||||
f:close()
|
||||
-- Best-effort: existence is enough; exec bit is checked by the spawn.
|
||||
return true
|
||||
end
|
||||
|
||||
-- Prefer Flatpak /app/bin/curl, then $APPDIR/{usr/,}bin/curl, else host "curl".
|
||||
function HostShell.resolveCurl()
|
||||
if curlResolved ~= nil then return curlResolved.path, curlResolved.kind end
|
||||
local candidates = {}
|
||||
if os.getenv("FLATPAK_ID") then
|
||||
candidates[#candidates + 1] = { "/app/bin/curl", "bundled" }
|
||||
end
|
||||
local appdir = os.getenv("APPDIR")
|
||||
if type(appdir) == "string" and appdir ~= "" then
|
||||
candidates[#candidates + 1] = { appdir .. "/usr/bin/curl", "bundled" }
|
||||
candidates[#candidates + 1] = { appdir .. "/bin/curl", "bundled" }
|
||||
end
|
||||
for _, c in ipairs(candidates) do
|
||||
if pathIsExecutable(c[1]) then
|
||||
curlResolved = { path = c[1], kind = c[2] }
|
||||
return curlResolved.path, curlResolved.kind
|
||||
end
|
||||
end
|
||||
curlResolved = { path = "curl", kind = "host" }
|
||||
return curlResolved.path, curlResolved.kind
|
||||
end
|
||||
|
||||
-- Quoted curl argv0 for a shell command, plus the matching env prefix.
|
||||
function HostShell.curlInvocation()
|
||||
local path, kind = HostShell.resolveCurl()
|
||||
return HostShell.curlEnvPrefix(kind) .. HostShell.quote(path), kind
|
||||
end
|
||||
|
||||
-- Diagnostics for support / About screens.
|
||||
function HostShell.curlDiagnostics()
|
||||
local path, kind = HostShell.resolveCurl()
|
||||
return {
|
||||
path = path,
|
||||
kind = kind,
|
||||
appimage = os.getenv("APPIMAGE") ~= nil,
|
||||
appdir = os.getenv("APPDIR"),
|
||||
flatpakId = os.getenv("FLATPAK_ID"),
|
||||
haveCurl = HostShell.haveCurl(),
|
||||
}
|
||||
end
|
||||
|
||||
-- Windows: every host tool we shell out to (curl for the update and mod-index
|
||||
-- fetches, the PowerShell ROM picker, the update downloader's `start /b`) is
|
||||
-- spawned through io.popen / os.execute, which run it under cmd.exe. A
|
||||
@@ -126,12 +187,18 @@ local function withPopenLock(fn)
|
||||
if not okAtomic then fn() end
|
||||
end
|
||||
|
||||
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
|
||||
function HostShell.popen(command, mode)
|
||||
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed.
|
||||
-- opts.envPrefix overrides the default HostShell.envPrefix() (pass "" to skip,
|
||||
-- or curlEnvPrefix(kind) when spawning a resolved curl binary).
|
||||
function HostShell.popen(command, mode, opts)
|
||||
HostShell.releasePointerGrab()
|
||||
local prefix = HostShell.envPrefix()
|
||||
if type(opts) == "table" and opts.envPrefix ~= nil then
|
||||
prefix = opts.envPrefix
|
||||
end
|
||||
local pipe
|
||||
withPopenLock(function()
|
||||
local ok, p = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
|
||||
local ok, p = pcall(io.popen, prefix .. command, mode or "r")
|
||||
pipe = (ok and p) or nil
|
||||
end)
|
||||
return pipe
|
||||
@@ -193,6 +260,12 @@ function HostShell.restart()
|
||||
|
||||
local appimage = os.getenv("APPIMAGE")
|
||||
if not appimage then
|
||||
-- Flatpak and plain desktop: love.event.quit("restart") uses
|
||||
-- execv(/proc/self/exe). Inside bwrap that path is valid, but PhysFS
|
||||
-- locks on a mounted .love payload can survive the exec boundary and
|
||||
-- hang the next Boot.mount. Callers that just wrote updates/*.love
|
||||
-- should already have closed archive handles; Boot.run still owns
|
||||
-- crash-guard rollback if a mid-handoff restart dies.
|
||||
love.event.quit("restart")
|
||||
return
|
||||
end
|
||||
@@ -341,7 +414,10 @@ local curlAvailable = nil
|
||||
|
||||
function HostShell.haveCurl()
|
||||
if curlAvailable ~= nil then return curlAvailable end
|
||||
local pipe = HostShell.popen("curl --version")
|
||||
local path, kind = HostShell.resolveCurl()
|
||||
local pipe = HostShell.popen(
|
||||
HostShell.quote(path) .. " --version", "r",
|
||||
{ envPrefix = HostShell.curlEnvPrefix(kind) })
|
||||
if not pipe then curlAvailable = false return false end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
HostShell.pclose(pipe)
|
||||
@@ -349,6 +425,12 @@ function HostShell.haveCurl()
|
||||
return curlAvailable
|
||||
end
|
||||
|
||||
local function curlCmd(flags)
|
||||
local path, kind = HostShell.resolveCurl()
|
||||
return HostShell.quote(path) .. " " .. flags,
|
||||
{ envPrefix = HostShell.curlEnvPrefix(kind) }
|
||||
end
|
||||
|
||||
-- An older mobile build reports nil here and falls back to the "no transport"
|
||||
-- error the callers already show.
|
||||
local function haveBridge()
|
||||
@@ -409,9 +491,11 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPa
|
||||
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
|
||||
userAgent = userAgent or "gen1recomp"
|
||||
if HostShell.haveCurl() then
|
||||
local cmd = ("curl -fsSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 15 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 300)
|
||||
local head, popts = curlCmd(
|
||||
("-fsSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 15 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 300))
|
||||
local cmd = head
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if accept then
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
|
||||
@@ -423,7 +507,7 @@ function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime, etagPa
|
||||
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " "
|
||||
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
local pipe = HostShell.popen(cmd)
|
||||
local pipe = HostShell.popen(cmd, "r", popts)
|
||||
if not pipe then return nil, "could not start download" end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
HostShell.pclose(pipe)
|
||||
@@ -466,16 +550,18 @@ function HostShell.httpGet(url, userAgent, accept, maxTime)
|
||||
-- BODY, and on the two services this talks to that body is the whole
|
||||
-- diagnosis: GitHub's 403 says "API rate limit exceeded for <ip>", which
|
||||
-- tells a user to wait rather than to go hunting for a broken index.
|
||||
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 40)
|
||||
local head, popts = curlCmd(
|
||||
("-sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 40))
|
||||
local cmd = head
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if accept then
|
||||
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
|
||||
end
|
||||
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
local pipe = HostShell.popen(cmd)
|
||||
local pipe = HostShell.popen(cmd, "r", popts)
|
||||
if not pipe then return nil, "could not run curl" end
|
||||
local readOk, out = pcall(function() return pipe:read("*a") end)
|
||||
HostShell.pclose(pipe)
|
||||
@@ -562,9 +648,11 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||
-- newlines. The body is staged above because io.popen cannot be opened
|
||||
-- for both writing and reading. No -f, matching httpGet: the response
|
||||
-- body is discarded anyway, and curl's stderr carries the diagnosis.
|
||||
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 40)
|
||||
local head, popts = curlCmd(
|
||||
("-sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d ")
|
||||
:format(tonumber(maxTime) or 40))
|
||||
local cmd = head
|
||||
.. "-X POST "
|
||||
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
|
||||
if contentType then
|
||||
@@ -574,7 +662,7 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||
.. "--data-binary " .. HostShell.quote("@" .. bodyPath) .. " "
|
||||
.. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
local pipe = HostShell.popen(cmd)
|
||||
local pipe = HostShell.popen(cmd, "r", popts)
|
||||
if not pipe then
|
||||
pcall(os.remove, bodyPath)
|
||||
return nil, "could not run curl"
|
||||
@@ -751,8 +839,10 @@ function HostShell.httpRequest(url, opts)
|
||||
pcall(os.remove, headerPath)
|
||||
end
|
||||
|
||||
local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d "):format(maxTime)
|
||||
local head, popts = curlCmd(
|
||||
("-sSL --proto =http,https --proto-redir =http,https "
|
||||
.. "--connect-timeout 10 --max-time %d "):format(maxTime))
|
||||
local cmd = head
|
||||
.. "-X " .. HostShell.quote(method) .. " "
|
||||
.. "-H " .. HostShell.quote("@" .. headerPath) .. " "
|
||||
if body then
|
||||
@@ -761,7 +851,7 @@ function HostShell.httpRequest(url, opts)
|
||||
cmd = cmd .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " "
|
||||
.. HostShell.quote(url) .. " 2>&1"
|
||||
|
||||
local pipe = HostShell.popen(cmd)
|
||||
local pipe = HostShell.popen(cmd, "r", popts)
|
||||
if not pipe then
|
||||
cleanup()
|
||||
return nil, "could not run curl"
|
||||
|
||||
+37
-2
@@ -173,13 +173,40 @@ function SaveData.gameFolders()
|
||||
end
|
||||
|
||||
-- The game folder carrying portable.txt, or false. First candidate holding
|
||||
-- the marker wins.
|
||||
-- the marker *and* a writable probe wins. Flatpak installs always use the
|
||||
-- sandbox XDG save dir (portable USB mode does not apply). A read-only
|
||||
-- parent (system /opt, immutable OS, etc.) falls back to LOVE's save
|
||||
-- directory without throwing -- probe I/O is fully guarded.
|
||||
local function dirIsWritable(dir)
|
||||
if type(dir) ~= "string" or dir == "" then return false end
|
||||
local ok, writable = pcall(function()
|
||||
local name = string.format(".write_probe_%d_%d.tmp",
|
||||
os.time() % 100000000, math.random(0, 999999))
|
||||
local path = dir .. SEP .. name
|
||||
local f, err = io.open(path, "wb")
|
||||
if not f then return false end
|
||||
local wrote = f:write("ok")
|
||||
f:close()
|
||||
pcall(os.remove, path)
|
||||
return wrote ~= nil
|
||||
end)
|
||||
return ok and writable == true
|
||||
end
|
||||
|
||||
local function detectPortable()
|
||||
if portableChecked then return portableBase end
|
||||
portableChecked = true
|
||||
portableBase = false
|
||||
-- Flatpak: never honor portable.txt (sandbox home is the persistence root).
|
||||
if type(os.getenv) == "function" and os.getenv("FLATPAK_ID") then
|
||||
return portableBase
|
||||
end
|
||||
for _, base in ipairs(SaveData.gameFolders()) do
|
||||
if pathExists(base .. SEP .. PORTABLE_MARKER) then
|
||||
local markerOk = false
|
||||
pcall(function()
|
||||
markerOk = pathExists(base .. SEP .. PORTABLE_MARKER)
|
||||
end)
|
||||
if markerOk and dirIsWritable(base) then
|
||||
portableBase = base
|
||||
break
|
||||
end
|
||||
@@ -187,6 +214,13 @@ local function detectPortable()
|
||||
return portableBase
|
||||
end
|
||||
|
||||
-- Soft-reset cache so tests can re-run detect after env changes.
|
||||
function SaveData._resetPortableCacheForTests()
|
||||
portableChecked = false
|
||||
portableBase = false
|
||||
portableFsCache = nil
|
||||
end
|
||||
|
||||
function SaveData.isPortable()
|
||||
return detectPortable() ~= false
|
||||
end
|
||||
@@ -279,6 +313,7 @@ function SaveData.defaultOptions()
|
||||
zoom = 0,
|
||||
-- OVERWORLD beyond-edge fill: trees | water | black
|
||||
voidFill = "trees",
|
||||
uiLetterbox = "auto",
|
||||
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
||||
videoMode = "windowed",
|
||||
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
|
||||
|
||||
@@ -309,6 +309,7 @@ Save.DEFAULT_OPTIONS = {
|
||||
-- Game Boy. The Gen 1 save's equivalent key is `colors` (SGB packs), which
|
||||
-- means something different, hence the different name.
|
||||
color = "gbc",
|
||||
palette = "",
|
||||
videoMode = "windowed",
|
||||
fpsCap = 60,
|
||||
-- BATTLE BG (#1709): white | black, the surround around the battle screen.
|
||||
@@ -316,6 +317,7 @@ Save.DEFAULT_OPTIONS = {
|
||||
-- VOID FILL: fade | water | trees | black. fade is each map header's own
|
||||
-- border block with the dissolve across a boundary (#1418).
|
||||
voidFill = "fade",
|
||||
uiLetterbox = "auto",
|
||||
musicVol = 7, -- 0-7, like the GB's NR50 master volume
|
||||
sfxVol = 7, -- 0-7
|
||||
musicFilter = 0, -- low-pass steps, 0 = off
|
||||
|
||||
@@ -605,6 +605,7 @@ local function gen2Rows(opts, hooks, shared)
|
||||
end
|
||||
opts.color =
|
||||
GbcPalette.MODES[wrapIndex(idx - 1 + dir, #GbcPalette.MODES) + 1]
|
||||
opts.palette = ""
|
||||
return true
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -377,12 +377,19 @@ end
|
||||
-- visible while typing, and blinks a caret on the importer's pulse clock.
|
||||
local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, action)
|
||||
Kit._audit("control", x, y, w, h, key)
|
||||
Kit.focusable(key, x, y, w, h)
|
||||
local isFocused = Kit.focusable(key, x, y, w, h)
|
||||
Theme.fill(x, y, w, h, PAL.bg, 1)
|
||||
Theme.stroke(x, y, w, h, PAL.line,
|
||||
focused and Theme.A.focus or
|
||||
(Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline),
|
||||
focused and 2 or 1)
|
||||
if isFocused then
|
||||
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
|
||||
Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4)
|
||||
Theme.strokeRounded(x, y, w, h, PAL.ink, 0.95 + 0.05 * glowPulse, 2, 2)
|
||||
Theme.fillRounded(x, y, w, h, PAL.ink, 0.12 + 0.06 * glowPulse, 2)
|
||||
else
|
||||
Theme.stroke(x, y, w, h, PAL.line,
|
||||
focused and Theme.A.focus or
|
||||
(Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline),
|
||||
focused and 2 or 1)
|
||||
end
|
||||
local pad = math.floor(10 * Kit.scale)
|
||||
local ty = y + (h - Kit.textHeight("button")) / 2
|
||||
local text = rawText or ""
|
||||
@@ -397,8 +404,35 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac
|
||||
Kit.textHeight("button"), PAL.ink, 1)
|
||||
end
|
||||
end
|
||||
if action and (Kit.press(x, y, w, h) or Kit._activateId == key) then
|
||||
queueAction(imp, key, action)
|
||||
if (Kit.press(x, y, w, h) or Kit._activateId == key) then
|
||||
if Kit.VirtualKeyboard then
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = text,
|
||||
targetId = key,
|
||||
title = placeholder or "Enter Text",
|
||||
onDone = function(newText, confirmed)
|
||||
if confirmed then
|
||||
if imp._indexPrompt and key:find("index") then
|
||||
imp._indexPrompt.text = newText
|
||||
elseif imp._rename and key:find("rename") then
|
||||
imp._rename.text = newText
|
||||
elseif imp._profileRenamePrompt and key:find("profren") then
|
||||
imp._profileRenamePrompt.text = newText
|
||||
elseif imp._profileSavePrompt and key:find("profsave") then
|
||||
imp._profileSavePrompt.text = newText
|
||||
elseif imp._settingsText and key:find("settext") then
|
||||
imp._settingsText.text = newText
|
||||
elseif imp.tab == "find" then
|
||||
imp._findQuery = newText
|
||||
if imp._refreshFind then imp:_refreshFind() end
|
||||
end
|
||||
end
|
||||
end
|
||||
})
|
||||
end
|
||||
if action then
|
||||
queueAction(imp, key, action)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3406,13 +3440,19 @@ local function buildFooter(imp, m, y)
|
||||
local bx = m.x + math.floor((m.w - topW) / 2)
|
||||
local my = cy + math.floor((rowH - dh) / 2)
|
||||
local chipY = cy + math.floor((rowH - chipH) / 2)
|
||||
local isFocused = Kit.focusable("bcg", bx, my, dw, dh)
|
||||
local hot = Kit.hover(bx, my, dw, dh)
|
||||
if isFocused then
|
||||
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
|
||||
Theme.strokeRounded(bx - 3, my - 3, dw + 6, dh + 6, PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, 4)
|
||||
Theme.strokeRounded(bx, my, dw, dh, PAL.ink, 0.95 + 0.05 * glowPulse, 2, 2)
|
||||
end
|
||||
love.graphics.setShader(imp.invertShader)
|
||||
love.graphics.setColor(1, 1, 1, hot and 1 or 0.85)
|
||||
love.graphics.setColor(1, 1, 1, (hot or isFocused) and 1 or 0.85)
|
||||
love.graphics.draw(imp.bcg, Theme.snap(bx), Theme.snap(my), 0, scale, scale)
|
||||
love.graphics.setShader()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if Kit.press(bx, my, dw, dh) then
|
||||
if Kit.press(bx, my, dw, dh) or Kit._activateId == "bcg" then
|
||||
queueAction(imp, "bcg", function() love.system.openURL(COMMUNITY_URL) end)
|
||||
end
|
||||
local cx = bx + dw
|
||||
@@ -5604,6 +5644,8 @@ end
|
||||
-- a click on the scrim lands on whatever button happens to be behind it.
|
||||
-- Keep this list in sync with buildModals below.
|
||||
local function modalUp(imp)
|
||||
if Kit.FileBrowser and Kit.FileBrowser.active then return true end
|
||||
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then return true end
|
||||
return (imp._settingsText or imp._settings or imp._rename
|
||||
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
|
||||
or imp._appPatchNotes
|
||||
@@ -5616,6 +5658,14 @@ local function modalUp(imp)
|
||||
end
|
||||
|
||||
local function buildModals(imp, m)
|
||||
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
|
||||
Kit.VirtualKeyboard.draw(m)
|
||||
return true
|
||||
end
|
||||
if Kit.FileBrowser and Kit.FileBrowser.active then
|
||||
Kit.FileBrowser.draw(m)
|
||||
return true
|
||||
end
|
||||
if imp._profileRenamePrompt then
|
||||
buildPrompt(imp, m, {
|
||||
key = "profren", title = Strings("Rename profile"),
|
||||
@@ -5787,6 +5837,10 @@ end
|
||||
|
||||
local function drawPadCursor(imp)
|
||||
if not imp._padCursorActive then return end
|
||||
if (Kit.FileBrowser and Kit.FileBrowser.active)
|
||||
or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active) then
|
||||
return
|
||||
end
|
||||
-- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch
|
||||
-- framebuffer when the stick advances by fractional pixels each frame.
|
||||
local x, y = imp._padCursor.x, imp._padCursor.y
|
||||
@@ -5970,6 +6024,25 @@ function LauncherView.draw(imp)
|
||||
|
||||
Kit.endFrame()
|
||||
drawPadCursor(imp)
|
||||
|
||||
if imp._cursorModeToast and imp._cursorModeToastTime then
|
||||
local elapsed = love.timer.getTime() - imp._cursorModeToastTime
|
||||
if elapsed < 2.2 then
|
||||
local alpha = 1.0
|
||||
if elapsed > 1.7 then alpha = math.max(0, (2.2 - elapsed) / 0.5) end
|
||||
local msg = imp._cursorModeToast
|
||||
local tw = Kit.textWidth("small", msg) + 36 * m.s
|
||||
local th = 34 * m.s
|
||||
local tx = (m.W - tw) / 2
|
||||
local ty = 16 * m.s
|
||||
Theme.fillRounded(tx, ty, tw, th, PAL.surface, 0.95 * alpha, 6)
|
||||
Theme.strokeRounded(tx - 2, ty - 2, tw + 4, th + 4, PAL.railBlue, 0.35 * alpha, 2, 8)
|
||||
Theme.strokeRounded(tx, ty, tw, th, PAL.lineStrong, 0.85 * alpha, 1.5, 6)
|
||||
Kit.textCenterBold("small", msg, tx, ty + 8 * m.s, tw, PAL.heading)
|
||||
else
|
||||
imp._cursorModeToast = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return LauncherView
|
||||
|
||||
+279
-33
@@ -1263,7 +1263,12 @@ end
|
||||
-- up the background worker or reach out to the network.
|
||||
local function updaterAllowed()
|
||||
if not Platform.networkValidated() then return false end
|
||||
if not (love.filesystem.isFused and love.filesystem.isFused()) then return false end
|
||||
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
|
||||
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
|
||||
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
|
||||
if not (love.filesystem.isFused and love.filesystem.isFused()) and not isHandheld then
|
||||
return false
|
||||
end
|
||||
if os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") then return false end
|
||||
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
|
||||
return true
|
||||
@@ -2055,8 +2060,42 @@ function RomImporter:chooseMod()
|
||||
end
|
||||
return
|
||||
end
|
||||
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
|
||||
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
|
||||
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
|
||||
|
||||
if isHandheld then
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit and Kit.FileBrowser then
|
||||
self._padCursorActive = false
|
||||
Kit.FileBrowser.open({
|
||||
title = "Select Mod (.zip)",
|
||||
mode = "mod",
|
||||
onSelect = function(pickedPath)
|
||||
self:_installMod(pickedPath)
|
||||
end,
|
||||
})
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local path = chooseZip()
|
||||
if path then self:_installMod(path) end
|
||||
if path then
|
||||
self:_installMod(path)
|
||||
return
|
||||
end
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit and Kit.FileBrowser then
|
||||
self._padCursorActive = false
|
||||
Kit.FileBrowser.open({
|
||||
title = "Select Mod (.zip)",
|
||||
mode = "mod",
|
||||
onSelect = function(pickedPath)
|
||||
self:_installMod(pickedPath)
|
||||
end,
|
||||
})
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local function requiredManifest(self, modId)
|
||||
@@ -2479,8 +2518,42 @@ function RomImporter:chooseSaveImport(version)
|
||||
end
|
||||
return
|
||||
end
|
||||
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
|
||||
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
|
||||
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
|
||||
|
||||
if isHandheld then
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit and Kit.FileBrowser then
|
||||
self._padCursorActive = false
|
||||
Kit.FileBrowser.open({
|
||||
title = "Select Save (.sav)",
|
||||
mode = "save",
|
||||
onSelect = function(pickedPath)
|
||||
self:_importSave(version, pickedPath)
|
||||
end,
|
||||
})
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local path = chooseSav()
|
||||
if path then self:_importSave(version, path) end
|
||||
if path then
|
||||
self:_importSave(version, path)
|
||||
return
|
||||
end
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit and Kit.FileBrowser then
|
||||
self._padCursorActive = false
|
||||
Kit.FileBrowser.open({
|
||||
title = "Select Save (.sav)",
|
||||
mode = "save",
|
||||
onSelect = function(pickedPath)
|
||||
self:_importSave(version, pickedPath)
|
||||
end,
|
||||
})
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- "Export save" button: write the active slot back out to a raw .sav in the save
|
||||
@@ -2621,11 +2694,42 @@ function RomImporter:choose(version)
|
||||
end
|
||||
return
|
||||
end
|
||||
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
|
||||
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
|
||||
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
|
||||
|
||||
if isHandheld then
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit and Kit.FileBrowser then
|
||||
self._padCursorActive = false
|
||||
Kit.FileBrowser.open({
|
||||
title = "Select " .. (GameVersion.info(self.chooseVersion).displayName or "ROM"),
|
||||
mode = "rom",
|
||||
onSelect = function(pickedPath)
|
||||
self:startPath(pickedPath)
|
||||
end,
|
||||
})
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local path = chooseRom(GameVersion.info(self.chooseVersion).displayName)
|
||||
if path then
|
||||
self:startPath(path)
|
||||
return
|
||||
end
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit and Kit.FileBrowser then
|
||||
self._padCursorActive = false
|
||||
Kit.FileBrowser.open({
|
||||
title = "Select " .. (GameVersion.info(self.chooseVersion).displayName or "ROM"),
|
||||
mode = "rom",
|
||||
onSelect = function(pickedPath)
|
||||
self:startPath(pickedPath)
|
||||
end,
|
||||
})
|
||||
return
|
||||
end
|
||||
-- Handheld Linux (Anbernic stock OS / PortMaster) rarely has zenity or
|
||||
-- kdialog. Fall back to the same "drop a .gb/.gbc next to the game" scan
|
||||
-- used on Android, which works when the game is launched as an unpacked
|
||||
@@ -2998,6 +3102,14 @@ function RomImporter:_cycleTab(delta)
|
||||
end
|
||||
|
||||
function RomImporter:_updatePadCursor(dt)
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit then
|
||||
if (Kit.FileBrowser and Kit.FileBrowser.active)
|
||||
or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active) then
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if self.isNX then
|
||||
self:_ensureNxPointerBridge()
|
||||
-- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling
|
||||
@@ -3040,14 +3152,6 @@ function RomImporter:_updatePadCursor(dt)
|
||||
local ny = self._padCursor.y + dy * speed * dt
|
||||
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
|
||||
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
|
||||
-- Pushing INTO the top/bottom edge scrolls the page instead of stalling.
|
||||
-- The cursor is clamped to the safe area above, so on a short window the
|
||||
-- rows below the fold are unreachable on a stickless handheld: no mouse
|
||||
-- wheel, no touchscreen, and no right stick to feed the existing wheel
|
||||
-- path. Only the OVERSHOOT scrolls -- parking the cursor at the edge does
|
||||
-- nothing, it has to be actively pushed -- and this block only runs on pad
|
||||
-- input, so a real mouse is unaffected. /48 matches the pixels-per-notch
|
||||
-- LauncherView.draw multiplies back out.
|
||||
local overY = 0
|
||||
if ny > oy + h then overY = ny - (oy + h)
|
||||
elseif ny < oy then overY = ny - oy end
|
||||
@@ -3055,17 +3159,12 @@ function RomImporter:_updatePadCursor(dt)
|
||||
if overY ~= 0 and self._flex then
|
||||
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
|
||||
end
|
||||
-- Desktop: FlexLove polls the real mouse, so warp it with the pad pointer.
|
||||
-- NX: the getPosition bridge already returns pad coords -- skip setPosition.
|
||||
if not self.isNX and love.mouse.setPosition then
|
||||
pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y)
|
||||
self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y
|
||||
end
|
||||
end
|
||||
|
||||
-- Right stick scrolls whatever the pad pointer sits over, through the
|
||||
-- view's wheel path, so the page and the modal scrollers all behave like a
|
||||
-- mouse wheel would.
|
||||
local ry = self._padAxis.righty or 0
|
||||
if math.abs(ry) > PAD_DEAD and self._flex then
|
||||
self:_activatePadCursor()
|
||||
@@ -3074,30 +3173,168 @@ function RomImporter:_updatePadCursor(dt)
|
||||
end
|
||||
|
||||
function RomImporter:gamepadpressed(_, button)
|
||||
self:_activatePadCursor()
|
||||
-- Map through GamepadMap so NX swaps SDL face labels to Nintendo A/B.
|
||||
local action = GamepadMap.mapGamepadButton(button)
|
||||
if action == "a" then
|
||||
-- Instant click at the virtual pointer: dispatched straight into the
|
||||
-- view, since the launcher no longer hit-tests presses itself.
|
||||
if self._flex then
|
||||
require("src.import.LauncherView").clickAt(self,
|
||||
self._padCursor.x, self._padCursor.y)
|
||||
local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit then
|
||||
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
|
||||
if Kit.VirtualKeyboard.gamepadpressed(action) then return end
|
||||
end
|
||||
elseif button == "leftshoulder" then
|
||||
if Kit.FileBrowser and Kit.FileBrowser.active then
|
||||
if Kit.FileBrowser.gamepadpressed(action) then return end
|
||||
end
|
||||
end
|
||||
|
||||
-- Y button toggle between Native Controller Navigation and Virtual Pointer Cursor:
|
||||
if action == "y" or button == "y" then
|
||||
self._padCursorActive = not self._padCursorActive
|
||||
if okKit then Kit._ringShown = not self._padCursorActive end
|
||||
self._cursorModeToast = self._padCursorActive and "Cursor Navigation [Y]" or "Controller Menu Navigation [Y]"
|
||||
self._cursorModeToastTime = love.timer.getTime()
|
||||
return
|
||||
end
|
||||
|
||||
-- Select button: toggle Virtual Keyboard
|
||||
if button == "back" or button == "select" or action == "select" then
|
||||
if okKit and Kit.VirtualKeyboard then
|
||||
if Kit.VirtualKeyboard.active then
|
||||
Kit.VirtualKeyboard.close(false)
|
||||
return
|
||||
end
|
||||
if self._indexPrompt then
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = self._indexPrompt.text or "",
|
||||
title = "Add a mod index",
|
||||
onDone = function(newText, confirmed)
|
||||
if confirmed and self._indexPrompt then self._indexPrompt.text = newText end
|
||||
end
|
||||
})
|
||||
return
|
||||
elseif self._rename then
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = self._rename.text or "",
|
||||
title = "Name save slot",
|
||||
onDone = function(newText, confirmed)
|
||||
if confirmed and self._rename then self._rename.text = newText end
|
||||
end
|
||||
})
|
||||
return
|
||||
elseif self._profileRenamePrompt then
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = self._profileRenamePrompt.text or "",
|
||||
title = "Rename profile",
|
||||
onDone = function(newText, confirmed)
|
||||
if confirmed and self._profileRenamePrompt then self._profileRenamePrompt.text = newText end
|
||||
end
|
||||
})
|
||||
return
|
||||
elseif self._profileSavePrompt then
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = self._profileSavePrompt.text or "",
|
||||
title = "Save mod profile",
|
||||
onDone = function(newText, confirmed)
|
||||
if confirmed and self._profileSavePrompt then self._profileSavePrompt.text = newText end
|
||||
end
|
||||
})
|
||||
return
|
||||
elseif self._settingsText then
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = self._settingsText.text or "",
|
||||
title = (self._settingsText.row and self._settingsText.row.label) or "Edit Text",
|
||||
onDone = function(newText, confirmed)
|
||||
if confirmed and self._settingsText then self._settingsText.text = newText end
|
||||
end
|
||||
})
|
||||
return
|
||||
elseif self.tab == "find" then
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = self._findQuery or "",
|
||||
title = "Search Mods",
|
||||
onDone = function(newText, confirmed)
|
||||
if confirmed then
|
||||
self._findQuery = newText
|
||||
if self._refreshFind then self:_refreshFind() end
|
||||
end
|
||||
end
|
||||
})
|
||||
return
|
||||
else
|
||||
Kit.VirtualKeyboard.open({
|
||||
text = "",
|
||||
title = "Virtual Keyboard",
|
||||
onDone = function() end
|
||||
})
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Shoulder buttons: cycle tabs
|
||||
if button == "leftshoulder" then
|
||||
self:_cycleTab(-1)
|
||||
return
|
||||
elseif button == "rightshoulder" then
|
||||
self:_cycleTab(1)
|
||||
elseif button == "dpup" or button == "dpdown"
|
||||
or button == "dpleft" or button == "dpright" then
|
||||
self._padDir[button] = true
|
||||
elseif button == "start" or button == "back" then
|
||||
-- Start / Select: Play if ready, else Choose ROM on the active game tab.
|
||||
return
|
||||
end
|
||||
|
||||
-- Start button: Play / Choose ROM
|
||||
if button == "start" then
|
||||
if self.workState == "working" then return end
|
||||
local version = self.tab
|
||||
if GameVersion.VERSIONS[version] then
|
||||
if self.ready[version] then self:play(version) else self:_romAction(version) end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if not self._padCursorActive and not self.isNX then
|
||||
if okKit then Kit._ringShown = true end
|
||||
if action == "a" then
|
||||
if okKit and Kit.focusId then
|
||||
Kit.activateFocused()
|
||||
elseif self._flex then
|
||||
require("src.import.LauncherView").clickAt(self,
|
||||
self._padCursor.x, self._padCursor.y)
|
||||
end
|
||||
return
|
||||
elseif action == "b" then
|
||||
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
|
||||
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
|
||||
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
|
||||
elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return
|
||||
elseif self._settingsText then self._settingsText = nil; self:_disarmTextInput(); return
|
||||
end
|
||||
elseif button == "dpup" or action == "dpup" then
|
||||
if okKit then Kit.navigate("up") end
|
||||
return
|
||||
elseif button == "dpdown" or action == "dpdown" then
|
||||
if okKit then Kit.navigate("down") end
|
||||
return
|
||||
elseif button == "dpleft" or action == "dpleft" then
|
||||
if okKit then Kit.navigate("left") end
|
||||
return
|
||||
elseif button == "dpright" or action == "dpright" then
|
||||
if okKit then Kit.navigate("right") end
|
||||
return
|
||||
end
|
||||
else
|
||||
self:_activatePadCursor()
|
||||
if action == "a" then
|
||||
if self._flex then
|
||||
require("src.import.LauncherView").clickAt(self,
|
||||
self._padCursor.x, self._padCursor.y)
|
||||
end
|
||||
elseif action == "b" then
|
||||
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
|
||||
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
|
||||
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
|
||||
elseif self._profileSavePrompt then self._profileSavePrompt = nil; self:_disarmTextInput(); return
|
||||
elseif self._settingsText then self._settingsText = nil; self:_disarmTextInput(); return
|
||||
end
|
||||
elseif button == "dpup" or button == "dpdown"
|
||||
or button == "dpleft" or button == "dpright" then
|
||||
self._padDir[button] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3111,9 +3348,9 @@ end
|
||||
function RomImporter:gamepadaxis(_, axis, value)
|
||||
if axis == "leftx" or axis == "lefty" or axis == "righty" then
|
||||
self._padAxis[axis] = value
|
||||
if math.abs(value) > PAD_DEAD then
|
||||
if self._padCursorActive and math.abs(value) > PAD_DEAD then
|
||||
self:_activatePadCursor()
|
||||
elseif axis == "lefty" then
|
||||
elseif axis == "lefty" and math.abs(value) <= PAD_DEAD then
|
||||
self._padStickCentered = true
|
||||
end
|
||||
end
|
||||
@@ -4062,6 +4299,15 @@ function RomImporter:fileUrl(path)
|
||||
end
|
||||
|
||||
function RomImporter:keypressed(key)
|
||||
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
|
||||
if okKit then
|
||||
if Kit.FileBrowser and Kit.FileBrowser.active then
|
||||
if Kit.FileBrowser.keypressed(key) then return end
|
||||
end
|
||||
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
|
||||
if Kit.VirtualKeyboard.keypressed(key) then return end
|
||||
end
|
||||
end
|
||||
if self._profileSavePrompt then
|
||||
if key == "backspace" then
|
||||
self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "")
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
-- colour IS its palettes -- so one substitution at this seam recolours the
|
||||
-- whole game without a single screen knowing about it:
|
||||
--
|
||||
-- GBC the cart's own palettes. The default: this is a Game Boy
|
||||
-- Color game and its colour is the point.
|
||||
-- DMG the original grey Game Boy. Every palette collapses to the
|
||||
-- four hardware shades, and the sheets that are drawn straight
|
||||
-- (chrome, text) are already those shades, so the screen is
|
||||
@@ -32,9 +30,12 @@
|
||||
local GbcPalette = {}
|
||||
|
||||
GbcPalette.MODES = { "gbc", "dmg", "classic" }
|
||||
GbcPalette.MODE_LABELS = { gbc = "GBC", dmg = "DMG", classic = "CLASSIC" }
|
||||
GbcPalette.MODE_LABELS = { gbc = "GEN 2", dmg = "DMG", classic = "CLASSIC",
|
||||
custom = "GBC" }
|
||||
GbcPalette.mode = "gbc"
|
||||
|
||||
GbcPalette.CUSTOM_MODE = "custom"
|
||||
|
||||
-- rBGP's four shades as the hardware shows them.
|
||||
local DMG_SHADES = {
|
||||
{ 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 },
|
||||
@@ -162,11 +163,22 @@ local function channel(colors, index)
|
||||
return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255
|
||||
end
|
||||
|
||||
GbcPalette.customRamp = nil
|
||||
|
||||
function GbcPalette.setCustomRamp(ramp)
|
||||
local prev = GbcPalette.customRamp
|
||||
GbcPalette.customRamp = ramp
|
||||
if (prev ~= nil) ~= (ramp ~= nil) or prev ~= ramp then
|
||||
pcall(function() require("src.render.SpriteRenderer").invalidate() end)
|
||||
end
|
||||
end
|
||||
|
||||
-- What a palette actually draws as under the current COLOR mode. Anything
|
||||
-- that reads a colour out of a palette directly -- a canvas cleared to BG
|
||||
-- colour 0, say -- has to go through this too, or the backdrop would keep its
|
||||
-- cart colour while everything on top of it went grey.
|
||||
function GbcPalette.resolve(colors)
|
||||
if GbcPalette.customRamp then return GbcPalette.customRamp end
|
||||
if GbcPalette.mode == "gbc" then return colors end
|
||||
return DMG_SHADES
|
||||
end
|
||||
@@ -249,6 +261,10 @@ function GbcPalette.presentColors()
|
||||
end
|
||||
|
||||
function GbcPalette.setMode(mode)
|
||||
if mode == GbcPalette.CUSTOM_MODE then
|
||||
GbcPalette.mode = mode
|
||||
return mode
|
||||
end
|
||||
for _, name in ipairs(GbcPalette.MODES) do
|
||||
if name == mode then
|
||||
GbcPalette.mode = mode
|
||||
@@ -260,7 +276,7 @@ function GbcPalette.setMode(mode)
|
||||
end
|
||||
|
||||
function GbcPalette.modeLabel(mode)
|
||||
return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GBC"
|
||||
return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GEN 2"
|
||||
end
|
||||
|
||||
-- Advance GBC -> DMG -> CLASSIC -> GBC. `delta` may be -1 to step back, so
|
||||
@@ -273,11 +289,20 @@ function GbcPalette.cycle(delta)
|
||||
local count = #GbcPalette.MODES
|
||||
at = (at - 1 + (delta or 1)) % count + 1
|
||||
GbcPalette.mode = GbcPalette.MODES[at]
|
||||
GbcPalette.setCustomRamp(nil)
|
||||
return GbcPalette.mode
|
||||
end
|
||||
|
||||
function GbcPalette.applyOptions(opts)
|
||||
return GbcPalette.setMode(opts and opts.color or "gbc")
|
||||
local mode = GbcPalette.setMode(opts and opts.color or "gbc")
|
||||
local id = opts and opts.palette
|
||||
if id and id ~= "" then
|
||||
local ok, Palette = pcall(require, "src.render.Palette")
|
||||
GbcPalette.setCustomRamp(ok and Palette.ramp(id) or nil)
|
||||
else
|
||||
GbcPalette.setCustomRamp(nil)
|
||||
end
|
||||
return mode
|
||||
end
|
||||
|
||||
-- Point the shader at one 4-color palette. Colors are 0-255 triples, matching
|
||||
@@ -431,4 +456,14 @@ function GbcPalette.with(colors, body)
|
||||
return applied
|
||||
end
|
||||
|
||||
function GbcPalette.withRaw(colors, body)
|
||||
local previous = love and love.graphics and love.graphics.getShader
|
||||
and love.graphics.getShader() or nil
|
||||
local applied = GbcPalette.useRaw(colors)
|
||||
local ok, err = pcall(body)
|
||||
if love and love.graphics then love.graphics.setShader(previous) end
|
||||
if not ok then error(err, 0) end
|
||||
return applied
|
||||
end
|
||||
|
||||
return GbcPalette
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
-- UI LETTERBOX: what fills the window around a 160x144 screen.
|
||||
--
|
||||
-- AUTO keeps what each screen was authored with (Gen 1 black, Gen 2 mostly
|
||||
-- white, the Pokedex and credits black); the other three override every
|
||||
-- screen uniformly. PALETTE takes the active COLORS ramp's paper shade, so
|
||||
-- the bars match the panel they frame instead of a fixed grey.
|
||||
|
||||
local Letterbox = {}
|
||||
|
||||
-- Set from applyOptions on both generations, the same shape GbcPalette.setMode
|
||||
-- and Tilt.setLevel use, so a draw call needs no options table in hand.
|
||||
Letterbox.mode = "auto"
|
||||
|
||||
Letterbox.MODES = { "auto", "black", "white", "palette" }
|
||||
|
||||
local LABELS = {
|
||||
auto = "AUTO", black = "BLACK", white = "WHITE", palette = "PALETTE",
|
||||
}
|
||||
|
||||
function Letterbox.normalize(mode)
|
||||
for _, id in ipairs(Letterbox.MODES) do
|
||||
if mode == id then return id end
|
||||
end
|
||||
return "auto"
|
||||
end
|
||||
|
||||
function Letterbox.label(mode)
|
||||
return LABELS[Letterbox.normalize(mode)]
|
||||
end
|
||||
|
||||
function Letterbox.cycle(mode, dir)
|
||||
local at = 1
|
||||
for i, id in ipairs(Letterbox.MODES) do
|
||||
if id == Letterbox.normalize(mode) then at = i break end
|
||||
end
|
||||
local n = #Letterbox.MODES
|
||||
return Letterbox.MODES[(at - 1 + (dir or 1)) % n + 1]
|
||||
end
|
||||
|
||||
-- r, g, b for `mode`, falling back to the screen's authored colour on AUTO.
|
||||
--
|
||||
-- `paper` is supplied by the caller, not looked up here: the two generations
|
||||
-- keep their picked palette in different modules (Gen 1 in PaletteFX, Gen 2 in
|
||||
-- GbcPalette) and GbcPalette.available() is true on both, so anything that
|
||||
-- sniffed for it answered with Gen 2's white default on a Gen 1 game.
|
||||
function Letterbox.color(mode, r, g, b, paper)
|
||||
mode = Letterbox.normalize(mode)
|
||||
if mode == "black" then return 0, 0, 0 end
|
||||
if mode == "white" then return 1, 1, 1 end
|
||||
if mode == "palette" and paper then
|
||||
local pr, pg, pb = paper()
|
||||
if pr then return pr, pg, pb end
|
||||
end
|
||||
return r or 0, g or 0, b or 0
|
||||
end
|
||||
|
||||
function Letterbox.setMode(mode)
|
||||
Letterbox.mode = Letterbox.normalize(mode)
|
||||
return Letterbox.mode
|
||||
end
|
||||
|
||||
function Letterbox.applyOptions(options)
|
||||
return Letterbox.setMode(options and options.uiLetterbox)
|
||||
end
|
||||
|
||||
-- What a drawWidescreen should paint the window with, given the colour that
|
||||
-- screen was authored to use and its generation's paper reader.
|
||||
function Letterbox.fill(r, g, b, paper)
|
||||
return Letterbox.color(Letterbox.mode, r, g, b, paper)
|
||||
end
|
||||
|
||||
return Letterbox
|
||||
@@ -0,0 +1,249 @@
|
||||
|
||||
local Palette = {}
|
||||
|
||||
local pack = nil
|
||||
|
||||
function Palette.packs()
|
||||
if pack == nil then
|
||||
local ok, data = pcall(require, "data.gb_palettes")
|
||||
pack = ok and data or false
|
||||
end
|
||||
return pack or {}
|
||||
end
|
||||
|
||||
|
||||
local MONO_STEPS = { 1.00, 0.72, 0.38, 0.12 }
|
||||
|
||||
Palette.MONO_COLOURS = {
|
||||
{ "GREEN", 0x9b, 0xbc, 0x0f }, -- DMG
|
||||
{ "LIME", 0xa8, 0xd8, 0x38 },
|
||||
{ "OLIVE", 0xa6, 0xac, 0x84 }, -- Pocket
|
||||
{ "AMBER", 0xff, 0xb0, 0x00 }, -- plasma terminal
|
||||
{ "ORANGE", 0xff, 0x80, 0x30 },
|
||||
{ "RED", 0xff, 0x50, 0x50 },
|
||||
{ "ROSE", 0xff, 0x90, 0xb0 },
|
||||
{ "MAGENTA", 0xf0, 0x60, 0xd0 },
|
||||
{ "PURPLE", 0xb0, 0x70, 0xf0 },
|
||||
{ "INDIGO", 0x80, 0x80, 0xf0 },
|
||||
{ "BLUE", 0x50, 0xa0, 0xff },
|
||||
{ "CYAN", 0x40, 0xe0, 0xe0 }, -- VFD
|
||||
{ "TEAL", 0x40, 0xc0, 0xa0 },
|
||||
{ "MINT", 0x90, 0xe0, 0xb0 },
|
||||
{ "SAND", 0xe0, 0xc8, 0x90 },
|
||||
{ "BROWN", 0xc0, 0x90, 0x50 },
|
||||
{ "WHITE", 0xff, 0xff, 0xff }, -- plain DMG greys
|
||||
{ "SILVER", 0xd0, 0xd8, 0xe0 },
|
||||
}
|
||||
|
||||
local function monoRamp(r, g, b)
|
||||
local out = {}
|
||||
for i = 1, 4 do
|
||||
local f = MONO_STEPS[i]
|
||||
out[i] = { math.floor(r * f + 0.5),
|
||||
math.floor(g * f + 0.5),
|
||||
math.floor(b * f + 0.5) }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
Palette.monoRamp = monoRamp -- named for the suite
|
||||
|
||||
|
||||
function Palette.packId(group, name)
|
||||
return "p:" .. group .. "/" .. name
|
||||
end
|
||||
|
||||
function Palette.monoId(r, g, b)
|
||||
return ("m:%02x%02x%02x"):format(r, g, b)
|
||||
end
|
||||
|
||||
local function unhex(text)
|
||||
if type(text) ~= "string" or #text ~= 24 then return nil end
|
||||
local out = {}
|
||||
for i = 1, 4 do
|
||||
local at = (i - 1) * 6
|
||||
local r = tonumber(text:sub(at + 1, at + 2), 16)
|
||||
local g = tonumber(text:sub(at + 3, at + 4), 16)
|
||||
local b = tonumber(text:sub(at + 5, at + 6), 16)
|
||||
if not (r and g and b) then return nil end
|
||||
out[i] = { r, g, b }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
Palette.unhex = unhex -- named for the suite
|
||||
|
||||
local RAMP_INDEX = 2
|
||||
|
||||
local GREY_CHROMA = 12
|
||||
local HUE_ARC = 50
|
||||
|
||||
Palette.GREY_CHROMA = GREY_CHROMA -- named for the suite
|
||||
Palette.HUE_ARC = HUE_ARC
|
||||
|
||||
local function hueChroma(c)
|
||||
local r, g, b = c[1] or 0, c[2] or 0, c[3] or 0
|
||||
local mx = math.max(r, g, b)
|
||||
local mn = math.min(r, g, b)
|
||||
local chroma = mx - mn
|
||||
if chroma <= 0 then return 0, 0 end
|
||||
local h
|
||||
if mx == r then h = ((g - b) / chroma) % 6
|
||||
elseif mx == g then h = (b - r) / chroma + 2
|
||||
else h = (r - g) / chroma + 4 end
|
||||
return h * 60, chroma
|
||||
end
|
||||
|
||||
Palette.hueChroma = hueChroma -- named for the suite
|
||||
|
||||
local function hueArc(hues)
|
||||
local best = 360
|
||||
for i = 1, #hues do
|
||||
local widest = 0
|
||||
for j = 1, #hues do
|
||||
local d = (hues[j] - hues[i]) % 360
|
||||
if d > widest then widest = d end
|
||||
end
|
||||
if widest < best then best = widest end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
Palette.hueArc = hueArc -- named for the suite
|
||||
|
||||
function Palette.categoryOf(ramp)
|
||||
if type(ramp) ~= "table" or not ramp[4] then return nil end
|
||||
local hues = {}
|
||||
for i = 1, 4 do
|
||||
local h, chroma = hueChroma(ramp[i])
|
||||
if chroma > GREY_CHROMA then hues[#hues + 1] = h end
|
||||
end
|
||||
if #hues == 0 then return "grey" end
|
||||
return hueArc(hues) <= HUE_ARC and "single" or "full"
|
||||
end
|
||||
|
||||
Palette.CATEGORIES = {
|
||||
{ key = "full", label = "FULL COLOUR" },
|
||||
{ key = "single", label = "SINGLE COLOUR" },
|
||||
{ key = "grey", label = "GREYSCALE" },
|
||||
}
|
||||
|
||||
local byId, buckets = nil, nil
|
||||
|
||||
local function buildIndex()
|
||||
byId, buckets = {}, {}
|
||||
for _, category in ipairs(Palette.CATEGORIES) do buckets[category.key] = {} end
|
||||
for _, p in ipairs(Palette.packs()) do
|
||||
for _, entry in ipairs(p.palettes or {}) do
|
||||
local name = entry[1]
|
||||
if type(name) == "string" then
|
||||
local id = Palette.packId(p.name, name)
|
||||
byId[id] = entry
|
||||
local raw = unhex(entry[RAMP_INDEX] or entry[2])
|
||||
local bucket = raw and buckets[Palette.categoryOf(raw) or ""] or nil
|
||||
if bucket then
|
||||
bucket[#bucket + 1] = { name = name, pack = p.name, id = id }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function ensureIndex()
|
||||
if not byId then buildIndex() end
|
||||
end
|
||||
|
||||
function Palette.categories()
|
||||
ensureIndex()
|
||||
local out = {}
|
||||
for i, category in ipairs(Palette.CATEGORIES) do
|
||||
out[i] = { key = category.key, name = category.label,
|
||||
palettes = buckets[category.key] or {} }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Palette.category(key)
|
||||
ensureIndex()
|
||||
if not buckets[key] then return nil end
|
||||
for _, category in ipairs(Palette.CATEGORIES) do
|
||||
if category.key == key then
|
||||
return { key = key, name = category.label, palettes = buckets[key] }
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Palette.locate(id)
|
||||
if type(id) ~= "string" or id == "" then return nil end
|
||||
ensureIndex()
|
||||
for _, category in ipairs(Palette.CATEGORIES) do
|
||||
for i, item in ipairs(buckets[category.key] or {}) do
|
||||
if item.id == id then return category.key, i end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function findPack(group, name)
|
||||
ensureIndex()
|
||||
return byId[Palette.packId(group, name)]
|
||||
end
|
||||
|
||||
Palette.findPack = findPack -- named for the suite
|
||||
|
||||
function Palette.label(id)
|
||||
if type(id) ~= "string" or id == "" then return nil end
|
||||
local hex = id:match("^m:(%x+)$")
|
||||
if hex then
|
||||
local r = tonumber(hex:sub(1, 2), 16)
|
||||
local g = tonumber(hex:sub(3, 4), 16)
|
||||
local b = tonumber(hex:sub(5, 6), 16)
|
||||
for _, c in ipairs(Palette.MONO_COLOURS) do
|
||||
if c[2] == r and c[3] == g and c[4] == b then return c[1] end
|
||||
end
|
||||
return "MONO"
|
||||
end
|
||||
local name = id:match("^p:.*/([^/]+)$")
|
||||
return name or id
|
||||
end
|
||||
|
||||
local cacheId, cacheRamp = nil, nil
|
||||
|
||||
function Palette.ramp(id)
|
||||
if type(id) ~= "string" or id == "" then return nil end
|
||||
if id == cacheId then return cacheRamp end
|
||||
|
||||
local out = nil
|
||||
local hex = id:match("^m:(%x+)$")
|
||||
if hex and #hex == 6 then
|
||||
out = monoRamp(tonumber(hex:sub(1, 2), 16) or 0,
|
||||
tonumber(hex:sub(3, 4), 16) or 0,
|
||||
tonumber(hex:sub(5, 6), 16) or 0)
|
||||
else
|
||||
local group, name = id:match("^p:(.*)/([^/]+)$")
|
||||
if group then
|
||||
local entry = findPack(group, name)
|
||||
local ramp = entry and unhex(entry[RAMP_INDEX] or entry[2])
|
||||
if ramp then
|
||||
out = { ramp[4], ramp[3], ramp[2], ramp[1] }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
cacheId, cacheRamp = id, out
|
||||
return out
|
||||
end
|
||||
|
||||
function Palette.invalidate()
|
||||
cacheId, cacheRamp = nil, nil
|
||||
byId, buckets = nil, nil
|
||||
end
|
||||
|
||||
function Palette.swatch(id)
|
||||
local ramp = Palette.ramp(id)
|
||||
if not ramp then return nil end
|
||||
return { ramp[4], ramp[3], ramp[2], ramp[1] }
|
||||
end
|
||||
|
||||
return Palette
|
||||
+50
-22
@@ -797,6 +797,18 @@ function PaletteFX.shadeMap()
|
||||
return shadeMap
|
||||
end
|
||||
|
||||
local function invalidateColorCaches()
|
||||
pcall(function() require("src.battle.BattleState").invalidate() end)
|
||||
pcall(function() require("src.render.SpriteRenderer").invalidate() end)
|
||||
pcall(function()
|
||||
require("src.world.MapLoader").invalidateAll()
|
||||
local Game = require("src.core.Game")
|
||||
if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then
|
||||
Game.overworld:reloadMap(Game.overworld.map.id, "colors")
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function PaletteFX.setMode(mode)
|
||||
local prev = PaletteFX.mode
|
||||
local ok = false
|
||||
@@ -808,27 +820,35 @@ function PaletteFX.setMode(mode)
|
||||
end
|
||||
end
|
||||
if not ok then PaletteFX.mode = "gbc" end
|
||||
-- battle pics and overworld sprites bake the active pack into ImageData;
|
||||
-- drop those caches when the pack (or any COLORS mode) changes so the
|
||||
-- next draw re-tints
|
||||
if prev ~= PaletteFX.mode then
|
||||
pcall(function() require("src.battle.BattleState").invalidate() end)
|
||||
pcall(function() require("src.render.SpriteRenderer").invalidate() end)
|
||||
-- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once
|
||||
-- per loaded map, so a mode toggle needs every cached Map/TileRenderer
|
||||
-- dropped and the currently-visible one rebuilt in place -- otherwise
|
||||
-- the on-screen map keeps its stale (wrong-mode) atlas until the next
|
||||
-- map transition happens to reload it.
|
||||
pcall(function()
|
||||
require("src.world.MapLoader").invalidateAll()
|
||||
local Game = require("src.core.Game")
|
||||
if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then
|
||||
Game.overworld:reloadMap(Game.overworld.map.id, "colors")
|
||||
end
|
||||
end)
|
||||
if prev ~= PaletteFX.mode then invalidateColorCaches() end
|
||||
end
|
||||
|
||||
PaletteFX.customRamp = nil
|
||||
|
||||
function PaletteFX.setCustomRamp(ramp)
|
||||
local prev = PaletteFX.customRamp
|
||||
PaletteFX.customRamp = ramp
|
||||
if (prev ~= nil) ~= (ramp ~= nil) or prev ~= ramp then
|
||||
invalidateColorCaches()
|
||||
end
|
||||
end
|
||||
|
||||
function PaletteFX.pickerActive()
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
local states = ok and Game.stack and Game.stack.states
|
||||
local top = states and states[#states]
|
||||
return top ~= nil and top.screenId == "PaletteScreen"
|
||||
end
|
||||
|
||||
-- Whether the active COLORS state needs a battle pic rendered as raw DMG
|
||||
-- grays instead of real colour, the same "forced-mono" contract OG/OG
|
||||
-- INV/CLASSIC already use (issue #207). A custom palette needs this too,
|
||||
-- since ensureZones re-thresholds the already-drawn frame. Keep this in
|
||||
-- sync with ensureZones and the mode checks in BattleState and WideBattle.
|
||||
function PaletteFX.forcesRawGrays()
|
||||
return PaletteFX.customRamp ~= nil
|
||||
end
|
||||
|
||||
function PaletteFX.cycleMode()
|
||||
local cur = PaletteFX.mode or "gbc"
|
||||
local idx = 1
|
||||
@@ -841,6 +861,13 @@ end
|
||||
|
||||
function PaletteFX.applyOptions(opts)
|
||||
PaletteFX.setMode(opts and opts.colors or "gbc")
|
||||
local id = opts and opts.palette
|
||||
if id and id ~= "" then
|
||||
local ok, Palette = pcall(require, "src.render.Palette")
|
||||
PaletteFX.setCustomRamp(ok and Palette.ramp(id) or nil)
|
||||
else
|
||||
PaletteFX.setCustomRamp(nil)
|
||||
end
|
||||
end
|
||||
|
||||
function PaletteFX.modeLabel(mode)
|
||||
@@ -853,12 +880,11 @@ function PaletteFX.modeLabel(mode)
|
||||
end
|
||||
|
||||
-- When a state exposes no SGB zones but COLORS needs a forced palette
|
||||
-- (OG / OG INV / CLASSIC), invent a whole-screen zone so the shade-remap
|
||||
-- shader still runs. GBC / RED++ / GBC INV leave nil alone (raw DMG canvas).
|
||||
function PaletteFX.ensureZones(zones)
|
||||
if zones and zones[1] then return zones end
|
||||
local mode = PaletteFX.mode or "gbc"
|
||||
if mode == "og" or mode == "og_inv" or mode == "classic" then
|
||||
if mode == "og" or mode == "og_inv" or mode == "classic"
|
||||
or (PaletteFX.forcesRawGrays() and not PaletteFX.pickerActive()) then
|
||||
return { PaletteFX.whole(PaletteFX.GRAYS) }
|
||||
end
|
||||
return zones
|
||||
@@ -888,7 +914,9 @@ function PaletteFX.effectiveColors(c)
|
||||
if not c then return nil end
|
||||
local mode = PaletteFX.mode or "gbc"
|
||||
local out = c
|
||||
if mode == "og" then
|
||||
if PaletteFX.customRamp and not PaletteFX.pickerActive() then
|
||||
out = PaletteFX.customRamp
|
||||
elseif mode == "og" then
|
||||
out = PaletteFX.GRAYS
|
||||
elseif mode == "og_inv" then
|
||||
out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
|
||||
|
||||
@@ -972,6 +972,13 @@ function Renderer:endFrame(zones, worldZones)
|
||||
and not FaithfulRes.scaleCap() then
|
||||
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
||||
end
|
||||
-- UI LETTERBOX overrides whatever the rules above settled on, except
|
||||
-- under FAITHFUL RATIO's mobile lock, which promises black bars.
|
||||
if not FaithfulRes.scaleCap() then
|
||||
local Letterbox = require("src.render.Letterbox")
|
||||
clearR, clearG, clearB = Letterbox.fill(clearR, clearG, clearB,
|
||||
function() return PaletteFX.paperShade(Game and Game.data) end)
|
||||
end
|
||||
end
|
||||
if cut then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
+37
-9
@@ -11,6 +11,12 @@ local UIVisibility = require("src.battle.UIVisibility")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
local Chrome2
|
||||
do
|
||||
local ok, v = pcall(require, "src.ui.gen2.Chrome")
|
||||
Chrome2 = ok and v or nil
|
||||
end
|
||||
|
||||
local TextBox = {}
|
||||
TextBox.__index = TextBox
|
||||
TextBox.isTextBox = true
|
||||
@@ -470,8 +476,21 @@ function TextBox:draw()
|
||||
-- (pokegold engine/pokegear/pokegear.asm TownMapPals sends every tile
|
||||
-- >= $60 to palette 0). Gen 1's Game has no textboxPaper, so it stays nil.
|
||||
local paper = self.game and self.game.textboxPaper and self.game:textboxPaper()
|
||||
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
local gold = self.game and self.game.save
|
||||
and (self.game.save.generation == 2 or self.game.save.version == "gold")
|
||||
local Chrome = gold and Chrome2 or nil
|
||||
local drawGlyph, finishGlyph = Font.drawCode, nil
|
||||
if Chrome then
|
||||
local base = paper and { paper, paper, paper, { 0, 0, 0 } }
|
||||
or Chrome.DEFAULT_BOX_PALETTE
|
||||
Chrome.paletteBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, base)
|
||||
local _, dg, fg = Chrome.paletteGlyphs(base)
|
||||
drawGlyph, finishGlyph = dg, fg
|
||||
else
|
||||
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
if self.scrollPx and self.scrollPx > 0 then
|
||||
self.scrollPx = self.scrollPx - 2
|
||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||
@@ -491,17 +510,25 @@ function TextBox:draw()
|
||||
-- measured with; every fixed-width page still lands on the 8px grid
|
||||
local pen = self.textX
|
||||
for _, code in ipairs(line) do
|
||||
Font.drawCode(code, pen, y)
|
||||
drawGlyph(code, pen, y)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
end
|
||||
if self.money then
|
||||
-- money box (engine/menus/text_box.asm:130): DisplayMoneyBox at
|
||||
-- hlcoord 11,0, the amount right-aligned on its middle row
|
||||
Font.drawBox(11, 0, 9, 3)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if Chrome then
|
||||
Chrome.paletteBox(11, 0, 9, 3, Chrome.DEFAULT_BOX_PALETTE)
|
||||
else
|
||||
Font.drawBox(11, 0, 9, 3)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
local money = ("¥%d"):format(self.money() or 0)
|
||||
Font.draw(money, 152 - Font.width(money), 8)
|
||||
local pen = 152 - Font.width(money)
|
||||
for _, code in ipairs(Font.encode(money)) do
|
||||
drawGlyph(code, pen, 8)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
end
|
||||
if (self.waiting or (self.done and not self.choice and not self.auto
|
||||
and (not self.stay
|
||||
@@ -509,10 +536,11 @@ function TextBox:draw()
|
||||
and self.blink < 30 then
|
||||
-- page-advance cursor: glyph $EE by default, the blinking down arrow
|
||||
-- the original prints via `ld a, "▼"` (home/text.asm)
|
||||
Font.drawCode(Theme.moreArrow or 0xEE,
|
||||
(self.boxTx + self.boxTw - 2) * 8,
|
||||
(self.boxTy + self.boxTh - 1) * 8 - 4)
|
||||
drawGlyph(Theme.moreArrow or 0xEE,
|
||||
(self.boxTx + self.boxTw - 2) * 8,
|
||||
(self.boxTy + self.boxTh - 1) * 8 - 4)
|
||||
end
|
||||
if finishGlyph then finishGlyph() end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
+44
-8
@@ -116,6 +116,36 @@ function SyncEngine.overlaps(a, b)
|
||||
return aStart <= bEnd and bStart <= aEnd
|
||||
end
|
||||
|
||||
-- Inline, under .meta, or under .remoteMeta, depending on the endpoint.
|
||||
function SyncEngine.metaOf(row)
|
||||
if type(row) ~= "table" then return nil end
|
||||
if type(row.meta) == "table" then return row.meta end
|
||||
if type(row.remoteMeta) == "table" then return row.remoteMeta end
|
||||
return row
|
||||
end
|
||||
|
||||
-- Gen 2 stores playTime as a table, so meta.playTime is nil on a Gold save
|
||||
-- and summary.timeText is the only field that survives.
|
||||
local function playedMinutes(meta)
|
||||
if type(meta) ~= "table" then return nil end
|
||||
local summary = type(meta.summary) == "table" and meta.summary or nil
|
||||
local text = summary and summary.timeText
|
||||
if type(text) == "string" then
|
||||
local hours, minutes = text:match("^(%d+):(%d%d)$")
|
||||
if hours then return tonumber(hours) * 60 + tonumber(minutes) end
|
||||
end
|
||||
local seconds = tonumber(meta.playTime)
|
||||
if seconds then return math.floor(seconds / 60) end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Same minute of playtime means the same point in the playthrough, so there
|
||||
-- is no fork. Unknown playtime on either side is NOT a match.
|
||||
function SyncEngine.samePlaytime(a, b)
|
||||
local left, right = playedMinutes(a), playedMinutes(b)
|
||||
return left ~= nil and left == right
|
||||
end
|
||||
|
||||
function SyncEngine.new(opts)
|
||||
opts = opts or {}
|
||||
local eng = setmetatable({}, SyncEngine)
|
||||
@@ -457,7 +487,11 @@ function SyncEngine:_planFrom(remoteState)
|
||||
if not row then
|
||||
self:_queueUpload(entry, key, false)
|
||||
elseif localChanged and remoteChanged then
|
||||
self:_addConflict(entry, key, row)
|
||||
if SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then
|
||||
self:_queueUpload(entry, key, true)
|
||||
else
|
||||
self:_addConflict(entry, key, row)
|
||||
end
|
||||
elseif localChanged then
|
||||
self:_queueUpload(entry, key, false)
|
||||
elseif remoteChanged and key ~= self.protectedKey then
|
||||
@@ -477,12 +511,7 @@ function SyncEngine:_planFrom(remoteState)
|
||||
end
|
||||
|
||||
function SyncEngine:_addConflict(entry, key, row)
|
||||
local remoteMeta = row
|
||||
if type(row.meta) == "table" then
|
||||
remoteMeta = row.meta
|
||||
elseif type(row.remoteMeta) == "table" then
|
||||
remoteMeta = row.remoteMeta
|
||||
end
|
||||
local remoteMeta = SyncEngine.metaOf(row)
|
||||
self.conflicts[#self.conflicts + 1] = {
|
||||
key = key,
|
||||
version = entry.version,
|
||||
@@ -528,7 +557,14 @@ function SyncEngine:_queueUpload(entry, key, force)
|
||||
end, function(e, res)
|
||||
if res.code == 409 then
|
||||
local row = res.data or {}
|
||||
e:_addConflict(entry, key, row)
|
||||
-- Retried with force only once: a forced write that still 409s is a
|
||||
-- real refusal, and retrying it would spin.
|
||||
if not force
|
||||
and SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then
|
||||
e:_queueUpload(entry, key, true)
|
||||
else
|
||||
e:_addConflict(entry, key, row)
|
||||
end
|
||||
if not e:busy() then e:_finish() end
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Scrolling for a menu line too long for its box: hold, step one character
|
||||
-- at a time, hold on the tail, snap back. Shared by the Gen 1 four-box
|
||||
-- viewport (src/ui/OptionRows.lua) and the Gen 2 textbox
|
||||
-- (src/ui/gen2/OptionsMenu.lua) -- different widths, same problem.
|
||||
|
||||
local Marquee = {}
|
||||
|
||||
Marquee.HOLD = 1.0
|
||||
Marquee.STEP = 0.25
|
||||
|
||||
-- Only ever one row scrolls (the cursor's), so one shared phase keeps that
|
||||
-- row's label and value stepping together.
|
||||
local phase = { key = nil, start = 0 }
|
||||
|
||||
local function clock()
|
||||
local timer = love and love.timer and love.timer.getTime
|
||||
return timer and timer() or 0
|
||||
end
|
||||
|
||||
-- Pure, so a test can step it without a clock.
|
||||
function Marquee.at(text, maxChars, elapsed)
|
||||
text = text or ""
|
||||
local over = #text - maxChars
|
||||
if over <= 0 then return text end
|
||||
local span = Marquee.HOLD * 2 + over * Marquee.STEP
|
||||
local now = elapsed % span
|
||||
local offset
|
||||
if now < Marquee.HOLD then
|
||||
offset = 0
|
||||
elseif now < Marquee.HOLD + over * Marquee.STEP then
|
||||
offset = math.floor((now - Marquee.HOLD) / Marquee.STEP)
|
||||
else
|
||||
offset = over
|
||||
end
|
||||
return text:sub(offset + 1, offset + maxChars)
|
||||
end
|
||||
|
||||
-- `key` identifies the highlighted row; changing it restarts the cycle.
|
||||
function Marquee.scroll(text, maxChars, key)
|
||||
if not text or #text <= maxChars then return text or "" end
|
||||
if phase.key ~= key then phase.key, phase.start = key, clock() end
|
||||
return Marquee.at(text, maxChars, clock() - phase.start)
|
||||
end
|
||||
|
||||
function Marquee.clip(text, maxChars)
|
||||
return (text or ""):sub(1, maxChars)
|
||||
end
|
||||
|
||||
return Marquee
|
||||
+19
-3
@@ -7,12 +7,18 @@
|
||||
-- rows that open something instead (MODS, CANCEL stays the caller's).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Marquee = require("src.ui.Marquee")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local OptionRows = {}
|
||||
|
||||
OptionRows.VISIBLE = 4 -- option boxes on screen at once (4 tiles each)
|
||||
|
||||
-- Font.drawBox(0, y, 20, 4) spends column 19 on the frame, so a line drawn
|
||||
-- to the 160px screen edge prints over its own border.
|
||||
local CONTENT_RIGHT = 152
|
||||
local function fits(x) return math.floor((CONTENT_RIGHT - x) / 8) end
|
||||
|
||||
-- keep the cursor's box inside the viewport; the fixed bottom row shows
|
||||
-- the tail of the list
|
||||
function OptionRows.clampScroll(index, scroll, total, bottomRow)
|
||||
@@ -27,7 +33,7 @@ function OptionRows.clampScroll(index, scroll, total, bottomRow)
|
||||
end
|
||||
|
||||
-- one bordered box per row, label line + value line, with the fixed
|
||||
-- bottom line below (CANCEL in the options menu, the manager's footer)
|
||||
-- bottom line below (BACK in the options menu, the manager's footer)
|
||||
function OptionRows.draw(game, rows, index, scroll, bottomLabel, bottomRow)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
@@ -37,8 +43,18 @@ function OptionRows.draw(game, rows, index, scroll, bottomLabel, bottomRow)
|
||||
if not row then break end
|
||||
Font.drawBox(0, (slot - 1) * 4, 20, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(row.label, 16, ((slot - 1) * 4 + 1) * 8)
|
||||
Font.draw(row.value and row.value(game) or "", 24, ((slot - 1) * 4 + 2) * 8)
|
||||
local label = row.label or ""
|
||||
local value = row.value and row.value(game) or ""
|
||||
if i == index then
|
||||
local key = tostring(row.id or row.label) .. "\0" .. tostring(value)
|
||||
label = Marquee.scroll(label, fits(16), key)
|
||||
value = Marquee.scroll(value, fits(24), key)
|
||||
else
|
||||
label = Marquee.clip(label, fits(16))
|
||||
value = Marquee.clip(value, fits(24))
|
||||
end
|
||||
Font.draw(label, 16, ((slot - 1) * 4 + 1) * 8)
|
||||
Font.draw(value, 24, ((slot - 1) * 4 + 2) * 8)
|
||||
if i == index then
|
||||
Font.drawCode(Theme.cursor, 8, ((slot - 1) * 4 + 1) * 8)
|
||||
end
|
||||
|
||||
+138
-22
@@ -11,10 +11,12 @@
|
||||
-- bottom line like pokered's.
|
||||
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Palette = require("src.render.Palette")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local Letterbox = require("src.render.Letterbox")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
@@ -127,14 +129,6 @@ local function stepVolume(v, dir)
|
||||
return math.max(0, math.min(7, (v or 7) + dir))
|
||||
end
|
||||
|
||||
local function colorIndex(opts)
|
||||
local cur = opts.colors or "gbc"
|
||||
for i, m in ipairs(PaletteFX.MODES) do
|
||||
if m == cur then return i end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
local function wrapIndex(i, n)
|
||||
i = i % n
|
||||
if i < 0 then i = i + n end
|
||||
@@ -341,15 +335,14 @@ local function buildRows(game)
|
||||
end },
|
||||
{ id = "colors", label = Strings("COLORS"),
|
||||
value = function(g)
|
||||
local id = g.save.options.palette
|
||||
if id and id ~= "" then
|
||||
return Palette.label(id) or PaletteFX.modeLabel(g.save.options.colors or "gbc")
|
||||
end
|
||||
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
local i = colorIndex(o)
|
||||
i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1
|
||||
o.colors = PaletteFX.MODES[i]
|
||||
PaletteFX.setMode(o.colors)
|
||||
return true
|
||||
activate = function(g)
|
||||
require("src.ui.Screens").push(g, "PaletteScreen")
|
||||
end },
|
||||
{ id = "tilt", label = Strings("TILT"),
|
||||
value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end,
|
||||
@@ -376,6 +369,16 @@ local function buildRows(game)
|
||||
-- cycling in place on this row.
|
||||
-- ShaderFXScreen.lua does the actual list/activate; this row only opens
|
||||
-- it and shows what is currently active.
|
||||
{ id = "uiLetterbox", label = Strings("UI LETTERBOX"),
|
||||
value = function(g)
|
||||
return Strings(Letterbox.label(g.save.options.uiLetterbox))
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
o.uiLetterbox = Letterbox.cycle(o.uiLetterbox, dir)
|
||||
Letterbox.setMode(o.uiLetterbox)
|
||||
return true
|
||||
end },
|
||||
{ id = "shaderfx", label = Strings("SHADER FX"),
|
||||
value = function(g)
|
||||
return shaderfxLabel(ShaderFX.activeEntry("main"))
|
||||
@@ -643,8 +646,90 @@ local function buildRows(game)
|
||||
return rows
|
||||
end
|
||||
|
||||
-- Grouping runs AFTER the ui.options.rows hook and never touches self.rows,
|
||||
-- so a mod still sees and edits the flat list it always did; a row in no
|
||||
-- group stays where it is, which is where a mod's own additions land.
|
||||
local GROUPS = {
|
||||
{ id = "group.battle", label = "BATTLE OPTIONS",
|
||||
members = { "animations", "battleStyle", "battleLayout", "battleFit",
|
||||
"battleHud", "battleBg" } },
|
||||
{ id = "group.audio", label = "AUDIO",
|
||||
members = { "musicVol", "sfxVol", "pikaVol", "musicFilter" } },
|
||||
{ id = "group.video", label = "VIDEO",
|
||||
members = { "uiLayout", "videoMode", "orientation", "faithfulRes",
|
||||
"screenPos", "fpsCap" } },
|
||||
{ id = "group.speed", label = "SPEED",
|
||||
members = { "textSpeed", "speedOverworld", "speedBattle", "speedMenu" } },
|
||||
{ id = "group.graphics", label = "GRAPHICS",
|
||||
members = { "colors", "uiLetterbox", "shaderfx", "shaderfx2" } },
|
||||
-- A mod's Pipelines row splices in after TILT and is in no group, so it
|
||||
-- stays on the top level rather than being swallowed into this page.
|
||||
{ id = "group.extras", label = "EXTRAS",
|
||||
members = { "tilt", "zoom", "voidFill" } },
|
||||
}
|
||||
|
||||
-- The top level's order, groups and singles alike. Anything not named here
|
||||
-- (a mod's row, the touch rows) keeps its flat position after these.
|
||||
local ORDER = {
|
||||
"group.speed", "group.video", "group.graphics", "group.audio",
|
||||
"performance", "ruleset", "group.battle", "group.extras", "mods",
|
||||
}
|
||||
|
||||
-- Each group replaced by one opener.
|
||||
local function groupRows(game, rows)
|
||||
local owner, picked = {}, {}
|
||||
for _, group in ipairs(GROUPS) do
|
||||
for _, id in ipairs(group.members) do owner[id] = group end
|
||||
picked[group.id] = {}
|
||||
end
|
||||
for _, row in ipairs(rows) do
|
||||
local group = row.id and owner[row.id]
|
||||
if group then picked[group.id][#picked[group.id] + 1] = row end
|
||||
end
|
||||
local made = {}
|
||||
for _, group in ipairs(GROUPS) do
|
||||
local members = picked[group.id]
|
||||
if #members > 0 then
|
||||
made[group.id] = {
|
||||
id = group.id, label = Strings(group.label), group = true,
|
||||
value = function() return Strings("%d OPTIONS", #members) end,
|
||||
-- No onCancel: BACK out of a page returns here, it does not close
|
||||
-- OPTIONS, so the caller's close callback must not fire.
|
||||
activate = function(g)
|
||||
g.stack:push(OptionsMenu.new(g, { rows = members }))
|
||||
end,
|
||||
}
|
||||
end
|
||||
end
|
||||
local byId, view, taken = {}, {}, {}
|
||||
for _, row in ipairs(rows) do
|
||||
if row.id and not owner[row.id] then byId[row.id] = row end
|
||||
end
|
||||
for _, id in ipairs(ORDER) do
|
||||
local row = made[id] or byId[id]
|
||||
if row then
|
||||
view[#view + 1] = row
|
||||
taken[id] = true
|
||||
end
|
||||
end
|
||||
-- Whatever ORDER does not name, in the order the flat list had it: a mod's
|
||||
-- own rows, and the platform rows that come and go.
|
||||
for _, row in ipairs(rows) do
|
||||
local id = row.id
|
||||
if not (id and (owner[id] or taken[id])) then view[#view + 1] = row end
|
||||
end
|
||||
return view
|
||||
end
|
||||
|
||||
-- opts.rows makes a submenu: a fixed row list, no hook and no regrouping, so
|
||||
-- a group's page is this same screen driving the rows it was handed.
|
||||
function OptionsMenu.new(game, opts)
|
||||
opts = opts or {}
|
||||
if opts.rows then
|
||||
return setmetatable({ game = game, rows = opts.rows, view = opts.rows,
|
||||
index = 1, scroll = 0, sub = true,
|
||||
onCancel = opts.onCancel }, OptionsMenu)
|
||||
end
|
||||
local rows = buildRows(game)
|
||||
local hooked = Runtime.call("ui.options.rows", sameRows, game, rows)
|
||||
if type(hooked) == "table" then
|
||||
@@ -653,14 +738,44 @@ function OptionsMenu.new(game, opts)
|
||||
Logger.error("ui.options.rows returned %s; keeping the vanilla rows",
|
||||
type(hooked))
|
||||
end
|
||||
return setmetatable({ game = game, rows = rows, index = 1, scroll = 0,
|
||||
onCancel = opts.onCancel }, OptionsMenu)
|
||||
local self = setmetatable({ game = game, rows = rows, index = 1, scroll = 0,
|
||||
onCancel = opts.onCancel }, OptionsMenu)
|
||||
self.view = groupRows(game, rows)
|
||||
return self
|
||||
end
|
||||
|
||||
-- Cursor onto a row by id, opening its group page first if it lives in one.
|
||||
-- Returns the screen the row ended up on, so a caller can keep driving it.
|
||||
function OptionsMenu:focusRow(id)
|
||||
local rows = self.view or self.rows
|
||||
for i, row in ipairs(rows) do
|
||||
if row.id == id then
|
||||
self.index = i
|
||||
self.scroll = OptionRows.clampScroll(i, self.scroll or 0, #rows, #rows + 1)
|
||||
return self
|
||||
end
|
||||
end
|
||||
for i, row in ipairs(rows) do
|
||||
if row.group and row.activate then
|
||||
local group
|
||||
for _, g in ipairs(GROUPS) do if g.id == row.id then group = g end end
|
||||
for _, member in ipairs(group and group.members or {}) do
|
||||
if member == id then
|
||||
self.index = i
|
||||
row.activate(self.game)
|
||||
local sub = self.game.stack:top()
|
||||
return sub.focusRow and sub:focusRow(id) or sub
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function OptionsMenu:update(dt)
|
||||
local input = self.game.input
|
||||
local rows = self.rows
|
||||
-- CANCEL sits below the hook-built rows so a mod cannot orphan the exit
|
||||
local rows = self.view or self.rows
|
||||
-- BACK sits below the hook-built rows so a mod cannot orphan the exit
|
||||
local cancelRow = #rows + 1
|
||||
local changed = false
|
||||
if input:wasPressed("up") then
|
||||
@@ -702,14 +817,15 @@ function OptionsMenu:update(dt)
|
||||
end
|
||||
|
||||
function OptionsMenu:draw()
|
||||
-- Through Strings, like every other label on this menu. CANCEL is
|
||||
-- Through Strings, like every other label on this menu. BACK is
|
||||
-- appended AFTER the rows hook (see the header), which is what keeps a mod
|
||||
-- from orphaning the exit -- but it also means a translation mod never sees
|
||||
-- this string, and cannot: there is no row for it to rewrite. So the one
|
||||
-- word a Spanish player could not read on a fully translated OPTIONS menu
|
||||
-- was the way out of it.
|
||||
OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0,
|
||||
Strings("CANCEL"), #self.rows + 1)
|
||||
local rows = self.view or self.rows
|
||||
OptionRows.draw(self.game, rows, self.index, self.scroll or 0,
|
||||
Strings("BACK"), #rows + 1)
|
||||
end
|
||||
|
||||
return OptionsMenu
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Palette = require("src.render.Palette")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local PaletteScreen = {}
|
||||
PaletteScreen.__index = PaletteScreen
|
||||
|
||||
PaletteScreen.isOpaque = true
|
||||
|
||||
local COLS, ROWS = 5, 4
|
||||
local TILE_W, TILE_H = 28, 20
|
||||
local GAP = 4
|
||||
local GRID_X, GRID_Y = 2, 14
|
||||
local PER_PAGE = COLS * ROWS
|
||||
|
||||
local SCREEN_W, SCREEN_H = 160, 144
|
||||
|
||||
local BACK = 0
|
||||
|
||||
local function sanitize(text)
|
||||
text = tostring(text or ""):upper():gsub("_", "'")
|
||||
text = text:gsub("[^A-Z0-9 %.'%-]", " ")
|
||||
text = text:gsub("%s+", " "):gsub("^ ", ""):gsub(" $", "")
|
||||
return text
|
||||
end
|
||||
|
||||
PaletteScreen.sanitize = sanitize -- named for the suite
|
||||
|
||||
local function wrap(text, width, lines)
|
||||
local out = {}
|
||||
local rest = text
|
||||
for i = 1, lines do
|
||||
if rest == "" then break end
|
||||
if #rest <= width then
|
||||
out[i] = rest
|
||||
rest = ""
|
||||
else
|
||||
local cut = rest:sub(1, width + 1):match("^.*() ")
|
||||
if not cut or cut < 2 then cut = width + 1 end
|
||||
out[i] = rest:sub(1, cut - 1)
|
||||
rest = rest:sub(cut):gsub("^ +", "")
|
||||
end
|
||||
end
|
||||
if rest ~= "" and #out > 0 then
|
||||
local last = out[#out]
|
||||
out[#out] = last:sub(1, math.max(0, width - 1)) .. "."
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
PaletteScreen.wrap = wrap -- named for the suite
|
||||
|
||||
local function packLabel(name, width)
|
||||
local text = sanitize(name)
|
||||
if #text > width then
|
||||
local leaf = tostring(name):match("([^/]+)$")
|
||||
if leaf then text = sanitize(leaf) end
|
||||
end
|
||||
return wrap(text, width, 1)[1] or ""
|
||||
end
|
||||
|
||||
PaletteScreen.packLabel = packLabel -- named for the suite
|
||||
|
||||
|
||||
local function fill(x, y, w, h, c)
|
||||
love.graphics.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||
love.graphics.rectangle("fill", x, y, w, h)
|
||||
end
|
||||
|
||||
local function frame(x, y, w, h, shade)
|
||||
love.graphics.setColor(shade, shade, shade, 1)
|
||||
love.graphics.rectangle("line", x + 0.5, y + 0.5, w - 1, h - 1)
|
||||
end
|
||||
|
||||
local function drawSwatch(x, y, w, h, swatch)
|
||||
if not swatch then
|
||||
frame(x, y, w, h, 0.5)
|
||||
return
|
||||
end
|
||||
local band = w / 4
|
||||
for i = 1, 4 do
|
||||
fill(x + math.floor((i - 1) * band), y,
|
||||
math.ceil(band), h, swatch[i])
|
||||
end
|
||||
end
|
||||
|
||||
PaletteScreen.drawSwatch = drawSwatch -- named for the suite
|
||||
|
||||
local function drawBack(selected)
|
||||
if selected then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 10, 9)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
for i = 0, 3 do
|
||||
love.graphics.rectangle("fill", 2 + i, 4 - i, 1, 1 + i * 2)
|
||||
end
|
||||
end
|
||||
|
||||
PaletteScreen.drawBack = drawBack -- named for the suite
|
||||
|
||||
|
||||
local function engineModes()
|
||||
local labels = PaletteFX.MODE_LABELS or {}
|
||||
local out = {}
|
||||
for i, id in ipairs(PaletteFX.MODES) do
|
||||
out[i] = { PaletteFX.modeLabel(id) or labels[id] or id:upper(), id }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function dropConflictingMode(game)
|
||||
local mode = PaletteFX.mode
|
||||
local bakes = PaletteFX.usesGbcPack(mode) or PaletteFX.usesSpriteObp(mode)
|
||||
if not bakes then return end
|
||||
local o = game.save.options
|
||||
o.colors = "gbc"
|
||||
PaletteFX.setMode("gbc")
|
||||
end
|
||||
|
||||
local function liveOpts(game)
|
||||
dropConflictingMode(game)
|
||||
return {
|
||||
palette = Palette,
|
||||
get = function() return game.save.options.palette or "" end,
|
||||
set = function(v)
|
||||
local o = game.save.options
|
||||
o.palette = v
|
||||
if v ~= "" then dropConflictingMode(game) end
|
||||
PaletteFX.setCustomRamp(v ~= "" and Palette.ramp(v) or nil)
|
||||
if game.writeOptions then pcall(game.writeOptions, game) end
|
||||
end,
|
||||
modes = engineModes(),
|
||||
getMode = function() return game.save.options.colors or "gbc" end,
|
||||
setMode = function(v)
|
||||
local o = game.save.options
|
||||
o.colors = v
|
||||
PaletteFX.setMode(v)
|
||||
if game.writeOptions then pcall(game.writeOptions, game) end
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
function PaletteScreen.new(game, opts)
|
||||
opts = opts or liveOpts(game)
|
||||
local self = setmetatable({
|
||||
game = game,
|
||||
Palette = opts.palette,
|
||||
get = opts.get, set = opts.set,
|
||||
modes = opts.modes or {},
|
||||
getMode = opts.getMode, setMode = opts.setMode,
|
||||
onChange = opts.onChange,
|
||||
view = "root",
|
||||
index = 1,
|
||||
scroll = 0,
|
||||
group = nil,
|
||||
stackTrail = {},
|
||||
}, PaletteScreen)
|
||||
self.rows = self:buildRoot()
|
||||
self:enterFromSelection()
|
||||
return self
|
||||
end
|
||||
|
||||
|
||||
function PaletteScreen:buildRoot()
|
||||
local rows = {}
|
||||
for _, category in ipairs(self.Palette and self.Palette.categories() or {}) do
|
||||
if #(category.palettes or {}) > 0 then
|
||||
rows[#rows + 1] = { label = category.name, folder = true,
|
||||
view = "tiles", group = category }
|
||||
end
|
||||
end
|
||||
if self.Palette and self.Palette.MONO_COLOURS then
|
||||
rows[#rows + 1] = { label = "MONOCHROME", folder = true, view = "mono" }
|
||||
end
|
||||
for _, mode in ipairs(self.modes) do
|
||||
rows[#rows + 1] = { label = mode[1], mode = mode[2] }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
function PaletteScreen:rootIndexFor(view, group)
|
||||
for i, row in ipairs(self.rows or {}) do
|
||||
if row.view == view then
|
||||
if view ~= "tiles" then return i end
|
||||
if row.group and group and row.group.key == group.key then return i end
|
||||
end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
function PaletteScreen:enterFromSelection()
|
||||
local Palette = self.Palette
|
||||
if not Palette then return end
|
||||
local id = self.get and self.get() or ""
|
||||
if type(id) ~= "string" or id == "" then return end
|
||||
|
||||
if id:match("^m:") then
|
||||
for i, c in ipairs(Palette.MONO_COLOURS) do
|
||||
if Palette.monoId(c[2], c[3], c[4]) == id then
|
||||
self:openAt("mono", nil, i)
|
||||
return
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local key, at = Palette.locate(id)
|
||||
if key then self:openAt("tiles", Palette.category(key), at) end
|
||||
end
|
||||
|
||||
function PaletteScreen:openAt(view, group, index)
|
||||
self.stackTrail = { { view = "root", index = self:rootIndexFor(view, group),
|
||||
scroll = 0, group = nil } }
|
||||
self.view, self.group, self.index, self.scroll = view, group, index or 1, 0
|
||||
local items = self:items()
|
||||
self:clampScroll(#items, #items + 1, PER_PAGE)
|
||||
end
|
||||
|
||||
|
||||
function PaletteScreen:items()
|
||||
local Palette = self.Palette
|
||||
if self.view == "root" then return self.rows or {} end
|
||||
if self.view == "tiles" then
|
||||
return self.group and self.group.palettes or {}
|
||||
end
|
||||
if self.view == "mono" then
|
||||
return Palette and Palette.MONO_COLOURS or {}
|
||||
end
|
||||
return {}
|
||||
end
|
||||
|
||||
function PaletteScreen:isGrid()
|
||||
return self.view == "tiles" or self.view == "mono"
|
||||
end
|
||||
|
||||
function PaletteScreen:idAt(i)
|
||||
local Palette = self.Palette
|
||||
if not Palette then return nil end
|
||||
if self.view == "tiles" then
|
||||
local entry = self.group and (self.group.palettes or {})[i]
|
||||
return entry and entry.id or nil
|
||||
elseif self.view == "mono" then
|
||||
local c = Palette.MONO_COLOURS[i]
|
||||
return c and Palette.monoId(c[2], c[3], c[4]) or nil
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function PaletteScreen:title()
|
||||
if self.view == "root" then return Strings("COLOUR") end
|
||||
if self.view == "mono" then return Strings("MONOCHROME") end
|
||||
return sanitize(self.group and self.group.name or "")
|
||||
end
|
||||
|
||||
|
||||
function PaletteScreen:update()
|
||||
local input = self.game.input
|
||||
local items = self:items()
|
||||
local total = #items
|
||||
local cancel = total + 1
|
||||
|
||||
if input:wasPressed("b") then
|
||||
self:back()
|
||||
return
|
||||
end
|
||||
|
||||
if self:isGrid() then
|
||||
self:moveGrid(input, total, cancel)
|
||||
else
|
||||
self:moveList(input, total, cancel)
|
||||
end
|
||||
|
||||
if input:wasPressed("a") then
|
||||
self:activate(cancel)
|
||||
end
|
||||
end
|
||||
|
||||
function PaletteScreen:moveList(input, total, cancel)
|
||||
if self.index == BACK then
|
||||
if input:wasPressed("down") or input:wasPressed("right") then
|
||||
self.index = 1
|
||||
end
|
||||
return
|
||||
end
|
||||
if input:wasPressed("left") then
|
||||
self.index = BACK
|
||||
return
|
||||
end
|
||||
if input:wasPressed("up") then
|
||||
self.index = self.index > 1 and self.index - 1 or BACK
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = self.index < cancel and self.index + 1 or 1
|
||||
end
|
||||
self:clampScroll(total, cancel, 12)
|
||||
end
|
||||
|
||||
function PaletteScreen:moveGrid(input, total, cancel)
|
||||
local i = self.index
|
||||
|
||||
if i == BACK then
|
||||
if input:wasPressed("down") or input:wasPressed("right") then
|
||||
self.index = self.scroll + 1
|
||||
if self.index > total then self.index = 1 end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if i > total then
|
||||
if input:wasPressed("up") then
|
||||
self.index = total
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = 1
|
||||
end
|
||||
self:clampScroll(total, cancel, PER_PAGE)
|
||||
return
|
||||
end
|
||||
|
||||
if input:wasPressed("left") then
|
||||
self.index = ((i - 1) % COLS == 0) and BACK or i - 1
|
||||
elseif input:wasPressed("right") then
|
||||
self.index = i < total and i + 1 or 1
|
||||
elseif input:wasPressed("up") then
|
||||
self.index = (i - COLS >= 1) and i - COLS or BACK
|
||||
elseif input:wasPressed("down") then
|
||||
if i + COLS <= total then
|
||||
self.index = i + COLS
|
||||
else
|
||||
self.index = cancel
|
||||
end
|
||||
end
|
||||
self:clampScroll(total, cancel, PER_PAGE)
|
||||
end
|
||||
|
||||
function PaletteScreen:clampScroll(total, cancel, visible)
|
||||
if self.index == BACK then return end
|
||||
if self.index >= cancel then
|
||||
self.scroll = math.max(0, total - visible)
|
||||
if self:isGrid() then
|
||||
self.scroll = math.floor(self.scroll / COLS) * COLS
|
||||
end
|
||||
return
|
||||
end
|
||||
local step = self:isGrid() and COLS or 1
|
||||
if self.index <= self.scroll then
|
||||
self.scroll = math.floor((self.index - 1) / step) * step
|
||||
elseif self.index > self.scroll + visible then
|
||||
self.scroll = math.floor((self.index - 1) / step) * step - visible + step
|
||||
end
|
||||
if self.scroll < 0 then self.scroll = 0 end
|
||||
end
|
||||
|
||||
function PaletteScreen:activate(cancel)
|
||||
if self.index == BACK or self.index >= cancel then
|
||||
self:back()
|
||||
return
|
||||
end
|
||||
|
||||
if self.view == "root" then
|
||||
local row = (self.rows or {})[self.index]
|
||||
if not row then return end
|
||||
if row.folder then
|
||||
self:push(row.view, row.group)
|
||||
elseif row.mode then
|
||||
if self.set then self.set("") end
|
||||
if self.setMode then self.setMode(row.mode) end
|
||||
self:changed()
|
||||
end
|
||||
|
||||
elseif self:isGrid() then
|
||||
local id = self:idAt(self.index)
|
||||
if id then
|
||||
if self.set then self.set(id) end
|
||||
self:changed()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function PaletteScreen:changed()
|
||||
if self.game.data and Sound and Sound.play then
|
||||
pcall(Sound.play, self.game.data, "Press_AB")
|
||||
end
|
||||
if self.onChange then self.onChange() end
|
||||
end
|
||||
|
||||
function PaletteScreen:push(view, group)
|
||||
self.stackTrail[#self.stackTrail + 1] =
|
||||
{ view = self.view, index = self.index, scroll = self.scroll,
|
||||
group = self.group }
|
||||
self.view, self.index, self.scroll = view, 1, 0
|
||||
self.group = group
|
||||
self:cursorToSelection()
|
||||
end
|
||||
|
||||
function PaletteScreen:cursorToSelection()
|
||||
local id = self.get and self.get() or ""
|
||||
if type(id) ~= "string" or id == "" then return end
|
||||
local items = self:items()
|
||||
for i = 1, #items do
|
||||
if self:idAt(i) == id then
|
||||
self.index = i
|
||||
self:clampScroll(#items, #items + 1,
|
||||
self:isGrid() and PER_PAGE or 12)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function PaletteScreen:back()
|
||||
local prev = table.remove(self.stackTrail)
|
||||
if prev then
|
||||
self.view, self.index = prev.view, prev.index
|
||||
self.scroll, self.group = prev.scroll, prev.group
|
||||
return
|
||||
end
|
||||
self:close()
|
||||
end
|
||||
|
||||
function PaletteScreen:close()
|
||||
if self.game.data and Sound and Sound.play then
|
||||
pcall(Sound.play, self.game.data, "Press_AB")
|
||||
end
|
||||
self.game.stack:pop()
|
||||
end
|
||||
|
||||
|
||||
function PaletteScreen:sgbPalettes()
|
||||
return nil
|
||||
end
|
||||
|
||||
|
||||
function PaletteScreen:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H)
|
||||
|
||||
drawBack(self.index == BACK)
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self:title(), 12, 0)
|
||||
|
||||
if self:isGrid() then
|
||||
self:drawGrid()
|
||||
else
|
||||
self:drawList()
|
||||
end
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local items = self:items()
|
||||
if self.index >= #items + 1 then
|
||||
Font.draw(Strings("CANCEL"), 16, SCREEN_H - 8)
|
||||
Font.drawCode(Theme.cursor, 8, SCREEN_H - 8)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function PaletteScreen:drawList()
|
||||
local items = self:items()
|
||||
local mode = self.getMode and self.getMode() or nil
|
||||
local visible = 12
|
||||
for slot = 1, visible do
|
||||
local i = self.scroll + slot
|
||||
local row = items[i]
|
||||
if not row then break end
|
||||
local y = 16 + (slot - 1) * 8
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings(row.label or ""), 16, y)
|
||||
if i == self.index then
|
||||
Font.drawCode(Theme.cursor, 8, y)
|
||||
end
|
||||
if row.folder then
|
||||
Font.drawCode(Theme.cursor, 8 + 8 * 17, y)
|
||||
elseif row.mode and mode and row.mode == mode then
|
||||
love.graphics.rectangle("fill", 8 + 8 * 17 + 2, y + 2, 4, 4)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function PaletteScreen:drawGrid()
|
||||
local Palette = self.Palette
|
||||
local items = self:items()
|
||||
local total = #items
|
||||
|
||||
for slot = 1, PER_PAGE do
|
||||
local i = self.scroll + slot
|
||||
if i > total then break end
|
||||
local col = (slot - 1) % COLS
|
||||
local row = math.floor((slot - 1) / COLS)
|
||||
local x = GRID_X + col * (TILE_W + GAP)
|
||||
local y = GRID_Y + row * (TILE_H + GAP)
|
||||
|
||||
local swatch
|
||||
if self.view == "mono" then
|
||||
local c = Palette and Palette.MONO_COLOURS[i]
|
||||
if c then
|
||||
local ramp = Palette.monoRamp(c[2], c[3], c[4])
|
||||
swatch = { ramp[4], ramp[3], ramp[2], ramp[1] }
|
||||
end
|
||||
else
|
||||
swatch = Palette and Palette.swatch(self:idAt(i)) or nil
|
||||
end
|
||||
drawSwatch(x, y, TILE_W, TILE_H, swatch)
|
||||
|
||||
if i == self.index then
|
||||
frame(x - 2, y - 2, TILE_W + 4, TILE_H + 4, 0)
|
||||
frame(x - 1, y - 1, TILE_W + 2, TILE_H + 2, 1)
|
||||
end
|
||||
end
|
||||
|
||||
self:drawFooter(total)
|
||||
end
|
||||
|
||||
function PaletteScreen:drawFooter(total)
|
||||
local Palette = self.Palette
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
local name, pack = "", nil
|
||||
if self.index >= 1 and self.index <= total then
|
||||
if self.view == "mono" then
|
||||
local c = Palette and Palette.MONO_COLOURS[self.index]
|
||||
name = c and c[1] or ""
|
||||
else
|
||||
local entry = self.group and (self.group.palettes or {})[self.index]
|
||||
if entry then
|
||||
name = sanitize(entry.name)
|
||||
pack = entry.pack
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local lines = wrap(name, 20, 2)
|
||||
for i = 1, #lines do
|
||||
Font.draw(lines[i], 0, 110 + (i - 1) * 8)
|
||||
end
|
||||
|
||||
local at = 0
|
||||
if total > PER_PAGE then
|
||||
local page = math.floor(self.scroll / PER_PAGE) + 1
|
||||
local pages = math.ceil(total / PER_PAGE)
|
||||
Font.draw(("%d/%d"):format(page, pages), 0, 128)
|
||||
at = 40
|
||||
end
|
||||
if pack then
|
||||
Font.draw(packLabel(pack, (SCREEN_W - at) / 8), at, 128)
|
||||
end
|
||||
|
||||
local stored = self.get and self.get() or ""
|
||||
if type(stored) == "string" and stored ~= "" then
|
||||
for slot = 1, PER_PAGE do
|
||||
local i = self.scroll + slot
|
||||
if i > total then break end
|
||||
if self:idAt(i) == stored then
|
||||
local col = (slot - 1) % COLS
|
||||
local row = math.floor((slot - 1) / COLS)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle(
|
||||
"fill",
|
||||
GRID_X + col * (TILE_W + GAP) + TILE_W - 5,
|
||||
GRID_Y + row * (TILE_H + GAP) + TILE_H - 5, 4, 4)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle(
|
||||
"fill",
|
||||
GRID_X + col * (TILE_W + GAP) + TILE_W - 4,
|
||||
GRID_Y + row * (TILE_H + GAP) + TILE_H - 4, 2, 2)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return PaletteScreen
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
local bit = require("bit")
|
||||
local Assets = require("src.render.Assets")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
|
||||
@@ -164,12 +165,8 @@ local function needsCanvas(runner)
|
||||
end
|
||||
|
||||
-- The battle background is BG colour 0 everywhere the two pic boxes and the
|
||||
-- text box are not, which Chrome.clear draws as plain white; anything the
|
||||
-- scanline blit exposes has to be that colour and not a hole.
|
||||
function BattleAnimView:fillBackground()
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H)
|
||||
Chrome.paletteFill(0, 0, SCREEN_W, SCREEN_H)
|
||||
end
|
||||
|
||||
-- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn
|
||||
|
||||
+44
-31
@@ -3520,7 +3520,7 @@ function BattleState:drawHud()
|
||||
-- And nothing at all before UpdateEnemyHUD has ever run: the intro bands
|
||||
-- slide in over a blanked tilemap (core.asm:8554/8564).
|
||||
if showStatus and self.showEnemyHud and not self:hudCleared("enemy") then
|
||||
Chrome.print(self:name(enemy), 1, 0)
|
||||
Chrome.printThrough(self:name(enemy), 1, 0, Chrome.DEFAULT_BOX_PALETTE)
|
||||
-- PrintLevel writes <LV> at the coordinate it is given and then LEFT-aligns
|
||||
-- the digits after it, so the glyph is pinned to column 6 whether the level
|
||||
-- is 5 or 100; only a three-digit level moves, and it does so by eating the
|
||||
@@ -3528,10 +3528,12 @@ function BattleState:drawHud()
|
||||
-- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag
|
||||
-- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops
|
||||
-- the level entirely while one is up.
|
||||
Chrome.print(self:statusTag(enemy, "enemy")
|
||||
or ("<LV>" .. tostring(enemy.level or 1)), 6, 1)
|
||||
Chrome.printThrough(self:statusTag(enemy, "enemy")
|
||||
or ("<LV>" .. tostring(enemy.level or 1)), 6, 1, Chrome.DEFAULT_BOX_PALETTE)
|
||||
local enemyGender = self:genderSymbol(enemy)
|
||||
if enemyGender then Chrome.print(enemyGender, 9, 1) end
|
||||
if enemyGender then
|
||||
Chrome.printThrough(enemyGender, 9, 1, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
-- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at
|
||||
-- (1,1) (engine/battle/trainer_huds.asm:140-152).
|
||||
if self.battle and self.battle.wild and self.caughtMark then
|
||||
@@ -3580,17 +3582,20 @@ function BattleState:drawHud()
|
||||
Font.useBattleExtra(wasBattle)
|
||||
return
|
||||
end
|
||||
Chrome.print(self:name(player), 10, 7)
|
||||
Chrome.printThrough(self:name(player), 10, 7, Chrome.DEFAULT_BOX_PALETTE)
|
||||
-- PrintPlayerHUD places the same status tag at (14,8) and skips the level
|
||||
-- while it is up.
|
||||
Chrome.print(self:statusTag(player, "player")
|
||||
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8)
|
||||
Chrome.printThrough(self:statusTag(player, "player")
|
||||
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8,
|
||||
Chrome.DEFAULT_BOX_PALETTE)
|
||||
local playerGender = self:genderSymbol(player)
|
||||
if playerGender then Chrome.print(playerGender, 17, 8) end
|
||||
if playerGender then
|
||||
Chrome.printThrough(playerGender, 17, 8, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
self:drawHpBar(player, "player", 10, 9)
|
||||
local maxHp = player.maxHp or (player.stats and player.stats.hp) or 0
|
||||
Chrome.printRight(("%d/%d"):format(self:hudHp(player, "player"), maxHp),
|
||||
18, 10)
|
||||
Chrome.printRightThrough(("%d/%d"):format(self:hudHp(player, "player"), maxHp),
|
||||
18, 10, Chrome.DEFAULT_BOX_PALETTE)
|
||||
-- Stub on the RIGHT (tile $73), border from (18,10) laid leftward, exp bar
|
||||
-- at (10,11).
|
||||
-- The chased fill, not the mon's: AnimateExpBar crawls it, and reading the
|
||||
@@ -3659,8 +3664,8 @@ end
|
||||
function BattleState:printMessage()
|
||||
local lines = Chrome.wrap(self.message or "", TEXT_WIDTH)
|
||||
for i = 1, math.min(#lines, TEXT_ROWS) do
|
||||
Chrome.print(lines[i], TEXT_INNER_X,
|
||||
TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP)
|
||||
Chrome.printThrough(lines[i], TEXT_INNER_X,
|
||||
TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3670,15 +3675,17 @@ function BattleState:drawMoveInfoBox(move)
|
||||
if not move then return end
|
||||
local fighter = self.battle and self.battle.player
|
||||
if fighter and self.battle:moveDisabled(fighter, move.id) then
|
||||
Chrome.print("Disabled!", 1, 10)
|
||||
Chrome.printThrough("Disabled!", 1, 10, Chrome.DEFAULT_BOX_PALETTE)
|
||||
return
|
||||
end
|
||||
local def = self.game and self.game.data and self.game.data.moves
|
||||
and self.game.data.moves[move.id]
|
||||
Chrome.print("TYPE/", 1, 9)
|
||||
Chrome.printThrough("TYPE/", 1, 9, Chrome.DEFAULT_BOX_PALETTE)
|
||||
local moveType = def and def.type
|
||||
Chrome.print(moveType and (TYPE_NAMES[moveType] or moveType) or "", 2, 10)
|
||||
Chrome.print(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0), 5, 11)
|
||||
Chrome.printThrough(moveType and (TYPE_NAMES[moveType] or moveType) or "",
|
||||
2, 10, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0),
|
||||
5, 11, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
|
||||
function BattleState:drawPanel()
|
||||
@@ -3687,7 +3694,7 @@ function BattleState:drawPanel()
|
||||
-- required there; everywhere else a missing side is a caller bug.
|
||||
local hasPlayer = self.battle and (self.battle.player or self.tutorial)
|
||||
if not (self.battle and hasPlayer and self.battle.enemy) then
|
||||
Chrome.print("NO BATTLE", 1, 1)
|
||||
Chrome.printThrough("NO BATTLE", 1, 1, Chrome.DEFAULT_BOX_PALETTE)
|
||||
return
|
||||
end
|
||||
self:drawHud()
|
||||
@@ -3719,8 +3726,10 @@ function BattleState:drawPanel()
|
||||
local col = ((i - 1) % 2) * spacing
|
||||
local row = math.floor((i - 1) / 2) * 2
|
||||
local tx, ty = boxX + 2 + col, 14 + row
|
||||
if i == self.menuIndex then Chrome.cursor(tx - 1, ty) end
|
||||
Chrome.print(label, tx, ty)
|
||||
if i == self.menuIndex then
|
||||
Chrome.cursorThrough(tx - 1, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
Chrome.printThrough(label, tx, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
elseif self.phase == "moves"
|
||||
or (self.phase == "choose-forget" and (self.messageTimer or 0) <= 0) then
|
||||
@@ -3740,21 +3749,24 @@ function BattleState:drawPanel()
|
||||
for i, move in ipairs(moves) do
|
||||
local ty = 13 + (i - 1)
|
||||
-- Cursor in the box's own gutter, not clipped against the border.
|
||||
if i == cursorRow then Chrome.cursor(cursorCol, ty) end
|
||||
if i == cursorRow then
|
||||
Chrome.cursorThrough(cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
-- The held slot's marker. `.battle_player_moves` writes '▷' into the
|
||||
-- row wSwappingMove names (engine/battle/core.asm:5157-5165) so a move
|
||||
-- picked up for a swap is visible while the cursor moves off it. It
|
||||
-- hlcoord 5, 13 is the cursor's own gutter, so PlaceMenuCursor covers
|
||||
-- the marker on the cursor's row.
|
||||
if not forgetting and self.moveSwapIndex == i and i ~= cursorRow then
|
||||
Chrome.print("\u{25B7}", cursorCol, ty)
|
||||
Chrome.printThrough("\u{25B7}", cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
local def = self.game and self.game.data and self.game.data.moves
|
||||
and self.game.data.moves[move.id]
|
||||
Chrome.print((def and def.name) or move.id, nameCol, ty)
|
||||
Chrome.printThrough((def and def.name) or move.id, nameCol, ty,
|
||||
Chrome.DEFAULT_BOX_PALETTE)
|
||||
if not moveMenu then
|
||||
Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0),
|
||||
19, ty)
|
||||
Chrome.printRightThrough(("%d/%d"):format(move.pp or 0, move.maxPp or 0),
|
||||
19, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
end
|
||||
if moveMenu then self:drawMoveInfoBox(moves[cursorRow]) end
|
||||
@@ -3773,13 +3785,14 @@ function BattleState:drawPanel()
|
||||
local left = (self.phase == "ask-shift" or self.phase == "ask-next-mon")
|
||||
and 1 or 14
|
||||
Chrome.box(left, 7, 6, 5)
|
||||
Chrome.print("YES", left + 2, 8)
|
||||
Chrome.print("NO", left + 2, 10)
|
||||
Chrome.printThrough("YES", left + 2, 8, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough("NO", left + 2, 10, Chrome.DEFAULT_BOX_PALETTE)
|
||||
local index = self.phase == "ask-nickname" and self.nicknameIndex
|
||||
or self.phase == "ask-shift" and self.shiftIndex
|
||||
or self.phase == "ask-next-mon" and self.nextMonIndex
|
||||
or self.forgetChoice
|
||||
Chrome.cursor(left + 1, index == 1 and 8 or 10)
|
||||
Chrome.cursorThrough(left + 1, index == 1 and 8 or 10,
|
||||
Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
end
|
||||
if self.phase == "stats-box" and self.statsBoxMon then
|
||||
@@ -3808,8 +3821,9 @@ function BattleState:drawStatsBox(mon)
|
||||
Chrome.textbox(9, 0, 9, 10)
|
||||
for i, row in ipairs(STATS_BOX_ROWS) do
|
||||
local ty = 1 + (i - 1) * 2
|
||||
Chrome.print(Strings(row[1]), 11, ty)
|
||||
Chrome.printRight(("%d"):format(stats[row[2]] or 0), 19, ty + 1)
|
||||
Chrome.printThrough(Strings(row[1]), 11, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printRightThrough(("%d"):format(stats[row[2]] or 0), 19, ty + 1,
|
||||
Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3893,8 +3907,7 @@ end
|
||||
|
||||
function BattleState:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
G.push()
|
||||
|
||||
@@ -945,8 +945,7 @@ end
|
||||
|
||||
function BoxMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -968,8 +968,7 @@ end
|
||||
|
||||
function CardFlip:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -309,8 +309,7 @@ end
|
||||
|
||||
function CenterPcMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
+156
-41
@@ -27,13 +27,39 @@ Chrome.SCREEN_H = 18
|
||||
-- charmap.asm "¥", the money field's own prefix tile.
|
||||
local YEN = "\xc2\xa5"
|
||||
|
||||
function Chrome.clear()
|
||||
function Chrome.paletteFill(px, py, pw, ph, palette)
|
||||
palette = palette or Chrome.DEFAULT_BOX_PALETTE
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
|
||||
if GbcPalette.available() then
|
||||
GbcPalette.with(palette, function() G.rectangle("fill", px, py, pw, ph) end)
|
||||
else
|
||||
G.rectangle("fill", px, py, pw, ph)
|
||||
end
|
||||
G.setColor(0, 0, 0, 1)
|
||||
end
|
||||
|
||||
-- Every drawWidescreen paints the window around its 160x144 panel through
|
||||
-- here, so UI LETTERBOX has exactly one place to override. r/g/b is what the
|
||||
-- screen was authored with and is what AUTO keeps.
|
||||
local function letterboxPaper()
|
||||
local c = GbcPalette.resolve(Chrome.DEFAULT_BOX_PALETTE)
|
||||
c = c and c[1]
|
||||
if not c then return nil end
|
||||
return c[1] / 255, c[2] / 255, c[3] / 255
|
||||
end
|
||||
|
||||
function Chrome.letterbox(winW, winH, r, g, b)
|
||||
local Letterbox = require("src.render.Letterbox")
|
||||
local G = love.graphics
|
||||
G.setColor(Letterbox.fill(r or 1, g or 1, b or 1, letterboxPaper))
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
end
|
||||
|
||||
function Chrome.clear()
|
||||
Chrome.paletteFill(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
|
||||
end
|
||||
|
||||
-- The blit scale every `drawWidescreen` paints its 160x144 panel at.
|
||||
--
|
||||
-- One GB pixel has to cover a WHOLE number of window pixels or the 8x8 grid
|
||||
@@ -77,10 +103,24 @@ function Chrome.positionLift(winW, winH, scale)
|
||||
or Chrome.fitScale(winW, winH)), ScreenPosition.safeTop())
|
||||
end
|
||||
|
||||
Chrome.DEFAULT_BOX_PALETTE = {
|
||||
{ 255, 255, 255 }, { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 },
|
||||
}
|
||||
|
||||
function Chrome.paletteBox(tx, ty, tw, th, palette)
|
||||
palette = palette or Chrome.DEFAULT_BOX_PALETTE
|
||||
if GbcPalette.available() then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
GbcPalette.with(palette, function() Font.drawBox(tx, ty, tw, th) end)
|
||||
else
|
||||
Font.drawBox(tx, ty, tw, th, palette[1])
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
|
||||
-- A bordered box, tile coords. Leaves the draw color black for text.
|
||||
function Chrome.box(tx, ty, tw, th)
|
||||
Font.drawBox(tx, ty, tw, th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Chrome.paletteBox(tx, ty, tw, th)
|
||||
end
|
||||
|
||||
-- Gold's Textbox helper takes an interior width/height and draws the border
|
||||
@@ -90,11 +130,16 @@ function Chrome.textbox(tx, ty, interiorW, interiorH)
|
||||
Chrome.box(tx, ty, interiorW + 2, interiorH + 2)
|
||||
end
|
||||
|
||||
function Chrome.print(text, tx, ty)
|
||||
local function flatPrint(text, tx, ty)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
return Font.draw(text, tx * 8, ty * 8)
|
||||
end
|
||||
|
||||
function Chrome.print(text, tx, ty)
|
||||
if not GbcPalette.available() then return flatPrint(text, tx, ty) end
|
||||
return Chrome.printThrough(text, tx, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
|
||||
-- Prints a string the way a tilemap screen does: the glyph tiles replace the
|
||||
-- cells they land on, and both their ink and their blank pixels go through
|
||||
-- that screen's own BG palette.
|
||||
@@ -124,32 +169,39 @@ function Chrome.throughPalette(palette, invert)
|
||||
return GbcPalette.remap(pal, GbcPalette.bgp)
|
||||
end
|
||||
|
||||
-- `palette` is a 4-colour table of 0-255 triples. `invert` is what
|
||||
-- Chrome.printInverted passes; see there.
|
||||
function Chrome.printThrough(text, tx, ty, palette, invert)
|
||||
function Chrome.rawPalette(palette, invert)
|
||||
if invert then return { palette[4], palette[3], palette[2], palette[1] } end
|
||||
return palette
|
||||
end
|
||||
|
||||
-- The per-glyph shader/tint switch printThrough needs, factored out so a
|
||||
-- caller that positions its own glyphs (TextBox's typewriter/scroll
|
||||
-- animation) gets the same shaded ink without re-deriving the TTF-vs-tile
|
||||
-- split.
|
||||
--
|
||||
-- Returns (pal, drawGlyph, finish):
|
||||
-- pal the resolved ramp, or nil if there's no shader to draw
|
||||
-- through; a caller that also draws a paper rect should skip
|
||||
-- it and take the plain Chrome.print degrade instead.
|
||||
-- drawGlyph (code, x, y): draws one glyph, shaded through `pal` for a
|
||||
-- tile glyph or tinted with pal[4] for a TTF one (see the
|
||||
-- inline comment below, gen1recomp#1642).
|
||||
-- finish call once after the last glyph to restore the previous
|
||||
-- shader and draw colour.
|
||||
function Chrome.paletteGlyphs(palette, invert, raw)
|
||||
if not (palette and GbcPalette.available()) then
|
||||
return Chrome.print(text, tx, ty)
|
||||
return nil, Font.drawCode, function() end
|
||||
end
|
||||
-- `pal` is used below and never touches the caller's palette again --
|
||||
-- including useRaw for the draw, since useRaw skips the fold GbcPalette.use
|
||||
-- would otherwise apply and folding it a second time would undo this.
|
||||
local pal = Chrome.throughPalette(palette, invert)
|
||||
local width = Font.width(text)
|
||||
local paper = pal[1] or { 255, 255, 255 }
|
||||
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8)
|
||||
-- Per glyph, not per string: a TTF-mod build still keeps the multi-byte
|
||||
-- charmap sequences (the naming screen's <PK>/<MN>, the 'd/'l/'s ligatures)
|
||||
-- and anything the mod names in ttf.tiles (Font.lua's own note on keeping
|
||||
-- digits tile-based for column alignment) on their ROM tiles, so a string
|
||||
-- can freely mix the two kinds of glyph.
|
||||
local pal = raw and Chrome.rawPalette(palette, invert)
|
||||
or Chrome.throughPalette(palette, invert)
|
||||
local ink = pal[4] or { 0, 0, 0 }
|
||||
local previous = love.graphics.getShader()
|
||||
local shaded = false
|
||||
local pen = tx * 8
|
||||
for _, code in ipairs(Font.encode(text)) do
|
||||
local function drawGlyph(code, x, y)
|
||||
if code >= Font.TTF_BASE then
|
||||
-- The shader below recovers a shade from the RED CHANNEL of an already
|
||||
-- flat-shaded 2bpp tile sheet -- exactly what a TTF glyph is not.
|
||||
-- LÖVE's font rasterizer stores glyph coverage as alpha over a plain
|
||||
-- white texture, which this shader reads back as shade 0 no matter how
|
||||
@@ -167,11 +219,28 @@ function Chrome.printThrough(text, tx, ty, palette, invert)
|
||||
GbcPalette.useRaw(pal)
|
||||
shaded = true
|
||||
end
|
||||
Font.drawCode(code, pen, ty * 8)
|
||||
Font.drawCode(code, x, y)
|
||||
end
|
||||
local function finish()
|
||||
if shaded then love.graphics.setShader(previous) end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
return pal, drawGlyph, finish
|
||||
end
|
||||
|
||||
function Chrome.printThrough(text, tx, ty, palette, invert, raw)
|
||||
local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw)
|
||||
if not pal then return Chrome.print(text, tx, ty) end
|
||||
local width = Font.width(text)
|
||||
local paper = pal[1] or { 255, 255, 255 }
|
||||
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8)
|
||||
local pen = tx * 8
|
||||
for _, code in ipairs(Font.encode(text)) do
|
||||
drawGlyph(code, pen, ty * 8)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
if shaded then love.graphics.setShader(previous) end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
finish()
|
||||
return width
|
||||
end
|
||||
|
||||
@@ -186,12 +255,34 @@ end
|
||||
|
||||
-- Right-aligned within a field that ends at tile `txEnd` (exclusive), which is
|
||||
-- how Gold prints numbers (PrintNum fills from the right).
|
||||
function Chrome.printRight(text, txEnd, ty)
|
||||
local function flatPrintRight(text, txEnd, ty)
|
||||
local width = Font.width(text)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
return Font.draw(text, txEnd * 8 - width, ty * 8)
|
||||
end
|
||||
|
||||
function Chrome.printRight(text, txEnd, ty)
|
||||
if not GbcPalette.available() then return flatPrintRight(text, txEnd, ty) end
|
||||
return Chrome.printRightThrough(text, txEnd, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
|
||||
function Chrome.printRightThrough(text, txEnd, ty, palette, invert, raw)
|
||||
local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw)
|
||||
if not pal then return Chrome.printRight(text, txEnd, ty) end
|
||||
local width = Font.width(text)
|
||||
local tx = txEnd * 8 - width
|
||||
local paper = pal[1] or { 255, 255, 255 }
|
||||
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
love.graphics.rectangle("fill", tx, ty * 8, width, 8)
|
||||
local pen = tx
|
||||
for _, code in ipairs(Font.encode(text)) do
|
||||
drawGlyph(code, pen, ty * 8)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
finish()
|
||||
return width
|
||||
end
|
||||
|
||||
-- Wrap text to `width` tiles, measuring with the real font so a proportional
|
||||
-- page wraps where it actually overflows. Nothing on a 160px screen may print
|
||||
-- past tile 20: text that does is drawn outside the GB frame entirely.
|
||||
@@ -227,21 +318,27 @@ function Chrome.printWrapped(text, tx, ty, width, rows)
|
||||
return #lines
|
||||
end
|
||||
|
||||
function Chrome.cursor(tx, ty, hollow)
|
||||
local function flatCursor(tx, ty, hollow)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8)
|
||||
end
|
||||
|
||||
function Chrome.cursor(tx, ty, hollow)
|
||||
if not GbcPalette.available() then return flatCursor(tx, ty, hollow) end
|
||||
return Chrome.cursorThrough(tx, ty, Chrome.DEFAULT_BOX_PALETTE, nil, hollow)
|
||||
end
|
||||
|
||||
-- The cursor glyph through a palette, the way Chrome.printThrough draws text.
|
||||
-- A screen whose font page is inverted (the #DEX: Pokedex_LoadInvertedFont
|
||||
-- xors both bitplanes, so it runs white on black) needs its cursor inverted
|
||||
-- with everything else -- Chrome.cursor's flat black is invisible against that
|
||||
-- ground, which is what made the dex's action arrow look absent.
|
||||
function Chrome.cursorThrough(tx, ty, palette, invert, hollow)
|
||||
function Chrome.cursorThrough(tx, ty, palette, invert, hollow, raw)
|
||||
if not (palette and GbcPalette.available()) then
|
||||
return Chrome.cursor(tx, ty, hollow)
|
||||
end
|
||||
local pal = Chrome.throughPalette(palette, invert)
|
||||
local pal = raw and Chrome.rawPalette(palette, invert)
|
||||
or Chrome.throughPalette(palette, invert)
|
||||
local paper = pal[1] or { 255, 255, 255 }
|
||||
love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
love.graphics.rectangle("fill", tx * 8, ty * 8, 8, 8)
|
||||
@@ -286,8 +383,8 @@ end
|
||||
-- PRINTNUM_LEADINGZEROS.
|
||||
function Chrome.coinBalanceBox(coins)
|
||||
Chrome.textbox(11, 0, 7, 1)
|
||||
Chrome.print("COIN", 12, 0)
|
||||
Chrome.print(Chrome.number(coins, 4, true), 13, 1)
|
||||
Chrome.printThrough("COIN", 12, 0, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough(Chrome.number(coins, 4, true), 13, 1, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
|
||||
-- DisplayMoneyAndCoinBalance: one Textbox at (5,0) with a 13x3 interior
|
||||
@@ -295,10 +392,10 @@ end
|
||||
-- "COIN" at (6,3) with the four-digit count at (15,3).
|
||||
function Chrome.moneyAndCoinBalanceBox(money, coins)
|
||||
Chrome.textbox(5, 0, 13, 3)
|
||||
Chrome.print("MONEY", 6, 1)
|
||||
Chrome.print(Chrome.money(money), 12, 1)
|
||||
Chrome.print("COIN", 6, 3)
|
||||
Chrome.print(Chrome.number(coins, 4, true), 15, 3)
|
||||
Chrome.printThrough("MONEY", 6, 1, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough("COIN", 6, 3, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough(Chrome.number(coins, 4, true), 15, 3, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
|
||||
-- PlaceMoneyTopRight: MoneyTopRightMenuHeader is `menu_coords 11, 0,
|
||||
@@ -306,7 +403,7 @@ end
|
||||
-- prints the number at MenuBoxCoord2Tile + SCREEN_WIDTH + 1, i.e. (12,1).
|
||||
function Chrome.moneyBalanceBox(money)
|
||||
Chrome.box(11, 0, 9, 3)
|
||||
Chrome.print(Chrome.money(money), 12, 1)
|
||||
Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
|
||||
-- A vertical cursor list.
|
||||
@@ -344,6 +441,7 @@ function List.new(opts)
|
||||
self.onChoose = opts.onChoose
|
||||
self.onCancel = opts.onCancel
|
||||
self.onMove = opts.onMove
|
||||
self.palette = opts.palette == nil and Chrome.DEFAULT_BOX_PALETTE or opts.palette
|
||||
self.index = math.max(1, math.min(opts.index or 1, math.max(1, #self.items)))
|
||||
self.scroll = 0
|
||||
self:ensureVisible()
|
||||
@@ -409,15 +507,32 @@ function List:draw()
|
||||
local item = self.items[i]
|
||||
if item then
|
||||
local ty = self.y + (row - 1) * self.spacing
|
||||
if i == self.index then Chrome.cursor(self.x - 1, ty) end
|
||||
Chrome.print(item.label, self.x, ty)
|
||||
if i == self.index then
|
||||
if self.palette then
|
||||
Chrome.cursorThrough(self.x - 1, ty, self.palette)
|
||||
else
|
||||
flatCursor(self.x - 1, ty)
|
||||
end
|
||||
end
|
||||
if self.palette then
|
||||
Chrome.printThrough(item.label, self.x, ty, self.palette)
|
||||
else
|
||||
flatPrint(item.label, self.x, ty)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Scrolling lists get the ▼ hint Gold shows when there is more below.
|
||||
if self.rows < #self.items and self.scroll + self.rows < #self.items then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8,
|
||||
(self.y + self.rows * self.spacing - 1) * 8)
|
||||
if self.palette then
|
||||
local _, drawGlyph, finish = Chrome.paletteGlyphs(self.palette)
|
||||
drawGlyph(Chrome.DOWN_ARROW, (self.x - 1) * 8,
|
||||
(self.y + self.rows * self.spacing - 1) * 8)
|
||||
finish()
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8,
|
||||
(self.y + self.rows * self.spacing - 1) * 8)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -176,8 +176,7 @@ end
|
||||
|
||||
function ContestMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -841,8 +841,7 @@ end
|
||||
|
||||
function Credits:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(0, 0, 0, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 0, 0, 0)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -1275,8 +1275,7 @@ end
|
||||
function CrystalIntro:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
local fill = self:surroundColor()
|
||||
G.setColor(fill[1] / 255, fill[2] / 255, fill[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, fill[1] / 255, fill[2] / 255, fill[3] / 255)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -410,8 +410,7 @@ end
|
||||
function CrystalSplash:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK
|
||||
G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -263,8 +263,7 @@ end
|
||||
|
||||
function DecorationMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -180,8 +180,7 @@ function Diploma:drawWidescreen(winW, winH)
|
||||
-- Nil palette degrades to the DMG ramp's colour 1, i.e. white, matching the
|
||||
-- Chrome.clear() the panel falls back to when the cache has no gfx/diploma.
|
||||
local paper = GbcPalette.color(self:palette(), 1)
|
||||
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, paper[1] / 255, paper[2] / 255, paper[3] / 255)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
|
||||
@@ -440,8 +440,7 @@ end
|
||||
|
||||
function EggHatchAnim:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -567,8 +567,7 @@ end
|
||||
|
||||
function EvolutionAnim:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -369,8 +369,7 @@ end
|
||||
function GameFreakPresents:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK
|
||||
G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -125,8 +125,7 @@ end
|
||||
|
||||
function GenderSelect:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
G.push()
|
||||
|
||||
@@ -1029,8 +1029,7 @@ end
|
||||
|
||||
function GoldSilverIntro:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(0, 0, 0, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 0, 0, 0)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -668,8 +668,7 @@ end
|
||||
|
||||
function HallOfFame:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(0, 0, 0, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 0, 0, 0)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -358,8 +358,7 @@ end
|
||||
|
||||
function InitClock:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
G.push()
|
||||
|
||||
@@ -598,8 +598,7 @@ end
|
||||
|
||||
function ItemPcMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -444,8 +444,7 @@ function MagnetTrainRide:drawsWidescreen() return true end
|
||||
function MagnetTrainRide:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
local backdrop = self:backdrop()
|
||||
G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
|
||||
@@ -336,8 +336,7 @@ end
|
||||
|
||||
function MailCompose:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(0.62, 0.62, 0.62, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 0.62, 0.62, 0.62)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
|
||||
@@ -90,8 +90,7 @@ end
|
||||
|
||||
function MailRead:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -215,8 +215,7 @@ end
|
||||
|
||||
function MainMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -150,8 +150,7 @@ end
|
||||
-- edge, at the one integer scale Chrome.fitScale picks.
|
||||
function MoveTutor:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -239,8 +239,7 @@ end
|
||||
|
||||
function NamePick:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -566,8 +566,7 @@ function NamingScreen:drawWidescreen(winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
local paper = self:paperColor()
|
||||
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, paper[1] / 255, paper[2] / 255, paper[3] / 255)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
if self.tiles.border then
|
||||
local tilesX = math.ceil(winW / (8 * scale))
|
||||
|
||||
@@ -719,8 +719,7 @@ end
|
||||
|
||||
function OakSpeech:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
G.push()
|
||||
|
||||
+208
-26
@@ -6,7 +6,7 @@
|
||||
-- literal "TYPE". The cursor is a ▶ in column 1 at row 2 + index * 2.
|
||||
--
|
||||
-- Up/down move between rows, left/right change the value under the cursor, and
|
||||
-- START or B leaves. CANCEL is a row like any other that simply exits.
|
||||
-- START or B leaves. BACK is a row like any other that simply exits.
|
||||
--
|
||||
-- Values are stored on the save's options table by name (see
|
||||
-- src/core/gen2/Save.lua DEFAULT_OPTIONS) rather than as the packed wOptions
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Marquee = require("src.ui.Marquee")
|
||||
local Palette = require("src.render.Palette")
|
||||
local Performance = require("src.core.Performance")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
@@ -41,6 +43,45 @@ local function stepVolume(v, delta)
|
||||
return math.max(0, math.min(7, (v or 7) + delta))
|
||||
end
|
||||
|
||||
local function dropClassic(options)
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
if GbcPalette.mode ~= "classic" then return end
|
||||
local target = (options.palette and options.palette ~= "")
|
||||
and GbcPalette.CUSTOM_MODE or "gbc"
|
||||
options.color = target
|
||||
GbcPalette.setMode(target)
|
||||
end
|
||||
|
||||
local function colorPickerOpts(game)
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local options = game.options
|
||||
dropClassic(options)
|
||||
local labels = GbcPalette.MODE_LABELS or {}
|
||||
local modes = {}
|
||||
for i, id in ipairs(GbcPalette.MODES) do
|
||||
modes[i] = { labels[id] or id:upper(), id }
|
||||
end
|
||||
return {
|
||||
palette = Palette,
|
||||
get = function() return options.palette or "" end,
|
||||
set = function(v)
|
||||
options.palette = v
|
||||
local target = (v ~= "") and GbcPalette.CUSTOM_MODE or "gbc"
|
||||
options.color = target
|
||||
GbcPalette.setMode(target)
|
||||
GbcPalette.setCustomRamp(v ~= "" and Palette.ramp(v) or nil)
|
||||
if game.persistOptions then pcall(game.persistOptions, game) end
|
||||
end,
|
||||
modes = modes,
|
||||
getMode = function() return options.color or "gbc" end,
|
||||
setMode = function(v)
|
||||
options.color = v
|
||||
GbcPalette.setMode(v)
|
||||
if game.persistOptions then pcall(game.persistOptions, game) end
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- Each row: the label, the option key it edits, and the cycle of values with
|
||||
-- the exact strings the cart prints (trailing spaces included -- they are what
|
||||
-- blank the longer previous value, e.g. "MID " over "SLOW").
|
||||
@@ -191,24 +232,30 @@ local ROWS = {
|
||||
text = function(options)
|
||||
return require("src.render.Tilt").levelLabel(options.tilt or 0)
|
||||
end },
|
||||
-- COLOR is the Gen 2 answer to the Gen 1 screen's COLORS row. Gold is a
|
||||
-- CGB game whose colour comes from its own palettes, so there are no packs
|
||||
-- to swap -- what there is instead is the choice to turn that colour OFF,
|
||||
-- down to the grey Game Boy or the green one. GBC is the default.
|
||||
{ label = Strings.source("COLOR"), key = "color", port = true,
|
||||
cycle = function(options, delta)
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
GbcPalette.setMode(options.color or "gbc")
|
||||
options.color = GbcPalette.cycle(delta)
|
||||
end,
|
||||
text = function(options)
|
||||
local name = Palette.label(options.palette)
|
||||
if name then return name end
|
||||
return require("src.render.GbcPalette").modeLabel(options.color or "gbc")
|
||||
end,
|
||||
activate = function(game)
|
||||
require("src.ui.Screens").push(game, "PaletteScreen", colorPickerOpts(game))
|
||||
end },
|
||||
-- SHADER FX reaches Gen 2 too, not just Gen 1. Same "activate" shape as
|
||||
-- CONTROLS/TOUCH LAYOUT below (a pushed screen, not a `cycle` ladder) --
|
||||
-- ShaderFXScreen is the shared list screen both generations push, `id`
|
||||
-- matching the Gen 1 row's so a mod filtering "shaderfx" on Red also
|
||||
-- reaches Gold.
|
||||
{ id = "uiLetterbox", label = Strings.source("UI LETTERBOX"), port = true,
|
||||
cycle = function(options, delta)
|
||||
local Letterbox = require("src.render.Letterbox")
|
||||
options.uiLetterbox = Letterbox.cycle(options.uiLetterbox, delta)
|
||||
Letterbox.setMode(options.uiLetterbox)
|
||||
end,
|
||||
text = function(options)
|
||||
local Letterbox = require("src.render.Letterbox")
|
||||
return Strings(Letterbox.label(options.uiLetterbox))
|
||||
end },
|
||||
{ id = "shaderfx", label = Strings.source("SHADER FX"), port = true,
|
||||
text = function(options)
|
||||
local ShaderFX = require("src.render.ShaderFX")
|
||||
@@ -294,7 +341,7 @@ local ROWS = {
|
||||
{ label = Strings.source("BATTLE BG"), key = "battleBg", port = true,
|
||||
values = { "white", "black" },
|
||||
display = { white = "WHITE", black = "BLACK" } },
|
||||
{ label = Strings.source("CANCEL"), cancel = true },
|
||||
{ label = Strings.source("BACK"), cancel = true },
|
||||
}
|
||||
|
||||
-- The cart's screen is one full-height textbox with every row on it. This one
|
||||
@@ -303,6 +350,69 @@ local ROWS = {
|
||||
-- only when the cursor would leave it.
|
||||
local VISIBLE_ROWS = 7
|
||||
|
||||
-- The same grouping the Gen 1 screen uses (src/ui/OptionsMenu.lua GROUPS),
|
||||
-- with Gold's own rows in it: FRAME is a graphics setting here and SOUND is
|
||||
-- an audio one. Runs after the ui.options.rows hook and only builds
|
||||
-- self.view, so self.rows stays the flat list a mod reads and edits.
|
||||
local GROUPS = {
|
||||
{ id = "group.speed", label = Strings.source("SPEED"),
|
||||
members = { "textSpeed", "speed" } },
|
||||
{ id = "group.video", label = Strings.source("VIDEO"),
|
||||
members = { "videoMode", "screenPos", "fpsCap" } },
|
||||
{ id = "group.graphics", label = Strings.source("GRAPHICS"),
|
||||
members = { "color", "uiLetterbox", "shaderfx", "shaderfx2", "frame" } },
|
||||
{ id = "group.audio", label = Strings.source("AUDIO"),
|
||||
members = { "sound", "musicVol", "sfxVol", "musicFilter" } },
|
||||
{ id = "group.battle", label = Strings.source("BATTLE OPTIONS"),
|
||||
members = { "battleScene", "battleStyle", "battleBg" } },
|
||||
{ id = "group.extras", label = Strings.source("EXTRAS"),
|
||||
members = { "zoom", "voidFill", "tilt" } },
|
||||
}
|
||||
|
||||
local ORDER = {
|
||||
"group.speed", "group.video", "group.graphics", "group.audio",
|
||||
"performance", "group.battle", "group.extras",
|
||||
}
|
||||
|
||||
-- A group's page is this same screen driving the rows it was handed, with
|
||||
-- its own BACK on the bottom.
|
||||
local function groupView(rows, open)
|
||||
local owner, picked = {}, {}
|
||||
for _, group in ipairs(GROUPS) do
|
||||
for _, id in ipairs(group.members) do owner[id] = group end
|
||||
picked[group.id] = {}
|
||||
end
|
||||
for _, row in ipairs(rows) do
|
||||
if row.id and owner[row.id] then
|
||||
local into = picked[owner[row.id].id]
|
||||
into[#into + 1] = row
|
||||
end
|
||||
end
|
||||
local made, byId = {}, {}
|
||||
for _, group in ipairs(GROUPS) do
|
||||
local members = picked[group.id]
|
||||
if #members > 0 then
|
||||
-- No value line: the value column is eight characters wide here, and a
|
||||
-- group has no setting of its own to show in it anyway.
|
||||
made[group.id] = { id = group.id, label = group.label, group = true,
|
||||
activate = function(game) open(game, members) end }
|
||||
end
|
||||
end
|
||||
for _, row in ipairs(rows) do
|
||||
if row.id and not owner[row.id] then byId[row.id] = row end
|
||||
end
|
||||
local view, taken = {}, {}
|
||||
for _, id in ipairs(ORDER) do
|
||||
local row = made[id] or byId[id]
|
||||
if row then view[#view + 1] = row; taken[id] = true end
|
||||
end
|
||||
for _, row in ipairs(rows) do
|
||||
local id = row.id
|
||||
if not (id and (owner[id] or taken[id])) then view[#view + 1] = row end
|
||||
end
|
||||
return view
|
||||
end
|
||||
|
||||
-- ui.options.rows identity: an unhooked build hands its own rows back.
|
||||
local function sameRows(_, rows) return rows end
|
||||
|
||||
@@ -323,8 +433,17 @@ local function buildRows()
|
||||
local showTouch = env == "1"
|
||||
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
|
||||
for i, row in ipairs(ROWS) do
|
||||
if showTouch or (row.id ~= "touchControls" and row.id ~= "touchLayout"
|
||||
and row.id ~= "haptics") then
|
||||
-- PRINT is wGBPrinterBrightness, how dark the Game Boy Printer prints
|
||||
-- (GBPRINTER_LIGHTEST..DARKEST, constants/ram_constants.asm:67-70).
|
||||
-- There is no printer here (src/script/gen2/Specials.lua answers the
|
||||
-- print specials with "no Game Boy Printer"), so nothing reads the
|
||||
-- value and the row is hidden rather than offering a dead setting.
|
||||
-- The descriptor and the save key stay, so a build that grows a printer
|
||||
-- only has to drop this test.
|
||||
local hidden = row.key == "print"
|
||||
or (not showTouch and (row.id == "touchControls"
|
||||
or row.id == "touchLayout" or row.id == "haptics"))
|
||||
if not hidden then
|
||||
local copy = {}
|
||||
for key, value in pairs(row) do copy[key] = value end
|
||||
copy.id = copy.id or copy.key or (copy.cancel and "cancel") or nil
|
||||
@@ -338,6 +457,22 @@ function OptionsMenu:wantsFillScale() return true end
|
||||
function OptionsMenu:drawsWidescreen() return true end
|
||||
|
||||
-- opts: options (the table to edit in place), onDone(options)
|
||||
-- The page edits the SAME options table the parent screen does, so a value
|
||||
-- changed on it lands where the caller's onDone will read it.
|
||||
local function pushGroup(parent, members)
|
||||
local page = setmetatable({}, OptionsMenu)
|
||||
page.game = parent.game
|
||||
page.rows = members
|
||||
page.options = parent.options
|
||||
page.index, page.scroll, page.sub = 1, 0, true
|
||||
page.view = {}
|
||||
for i, row in ipairs(members) do page.view[i] = row end
|
||||
page.view[#page.view + 1] = { label = Strings.source("BACK"), cancel = true,
|
||||
id = "cancel" }
|
||||
parent.game.stack:push(page)
|
||||
return page
|
||||
end
|
||||
|
||||
function OptionsMenu.new(game, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, OptionsMenu)
|
||||
@@ -363,21 +498,57 @@ function OptionsMenu.new(game, opts)
|
||||
self.onDone = opts.onDone
|
||||
self.index = 1
|
||||
self.scroll = 0
|
||||
self.view = groupView(rows, function(_, members)
|
||||
return pushGroup(self, members)
|
||||
end)
|
||||
return self
|
||||
end
|
||||
|
||||
function OptionsMenu:visible()
|
||||
return self.view or self.rows
|
||||
end
|
||||
|
||||
function OptionsMenu:ensureVisible()
|
||||
local rows = self:visible()
|
||||
if self.index <= self.scroll then
|
||||
self.scroll = self.index - 1
|
||||
elseif self.index > self.scroll + VISIBLE_ROWS then
|
||||
self.scroll = self.index - VISIBLE_ROWS
|
||||
end
|
||||
self.scroll = math.max(0,
|
||||
math.min(self.scroll, math.max(0, #self.rows - VISIBLE_ROWS)))
|
||||
math.min(self.scroll, math.max(0, #rows - VISIBLE_ROWS)))
|
||||
end
|
||||
|
||||
function OptionsMenu:row()
|
||||
return self.rows[self.index]
|
||||
return self:visible()[self.index]
|
||||
end
|
||||
|
||||
-- Cursor onto a row by id, opening its group page first if it lives in one.
|
||||
-- Returns the screen the row ended up on, so a caller can keep driving it.
|
||||
function OptionsMenu:focusRow(id)
|
||||
local rows = self:visible()
|
||||
for i, row in ipairs(rows) do
|
||||
if row.id == id then
|
||||
self.index = i
|
||||
self:ensureVisible()
|
||||
return self
|
||||
end
|
||||
end
|
||||
for i, row in ipairs(rows) do
|
||||
if row.group and row.activate then
|
||||
local group
|
||||
for _, g in ipairs(GROUPS) do if g.id == row.id then group = g end end
|
||||
for _, member in ipairs(group and group.members or {}) do
|
||||
if member == id then
|
||||
self.index = i
|
||||
row.activate(self.game)
|
||||
local page = self.game.stack:top()
|
||||
return page.focusRow and page:focusRow(id) or page
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function OptionsMenu:cycle(row, delta)
|
||||
@@ -437,12 +608,13 @@ function OptionsMenu:update(_dt)
|
||||
self:leave_()
|
||||
return
|
||||
end
|
||||
local rows = self:visible()
|
||||
if input:wasPressed("up") then
|
||||
self.index = self.index > 1 and self.index - 1 or #self.rows
|
||||
self.index = self.index > 1 and self.index - 1 or #rows
|
||||
self:ensureVisible()
|
||||
return
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = self.index < #self.rows and self.index + 1 or 1
|
||||
self.index = self.index < #rows and self.index + 1 or 1
|
||||
self:ensureVisible()
|
||||
return
|
||||
end
|
||||
@@ -473,35 +645,46 @@ function OptionsMenu:drawPanel()
|
||||
Chrome.clear()
|
||||
-- hlcoord 0,0 with b = SCREEN_HEIGHT - 2, c = SCREEN_WIDTH - 2.
|
||||
Chrome.textbox(0, 0, Chrome.SCREEN_W - 2, Chrome.SCREEN_H - 2)
|
||||
for slot = 1, math.min(VISIBLE_ROWS, #self.rows) do
|
||||
-- The textbox spends column 19 on its frame, so a line drawn past column
|
||||
-- 18 prints over the border: 17 characters from the label's column 2,
|
||||
-- 8 from the value's column 11. Anything longer scrolls under the cursor.
|
||||
local rows = self:visible()
|
||||
for slot = 1, math.min(VISIBLE_ROWS, #rows) do
|
||||
local i = slot + self.scroll
|
||||
local row = self.rows[i]
|
||||
local row = rows[i]
|
||||
if row then
|
||||
local labelY = 2 + (slot - 1) * 2
|
||||
Chrome.print(Strings(row.label), 2, labelY)
|
||||
local hot = i == self.index
|
||||
local key = tostring(row.id or row.label)
|
||||
local function fit(text, col)
|
||||
local room = 19 - col
|
||||
if hot then return Marquee.scroll(text, room, key) end
|
||||
return Marquee.clip(text, room)
|
||||
end
|
||||
Chrome.print(fit(Strings(row.label), 2), 2, labelY)
|
||||
if row.frame then
|
||||
Chrome.print(Strings(":TYPE"), 10, labelY + 1)
|
||||
Chrome.print(tostring(self.options.frame or 1), 16, labelY + 1)
|
||||
elseif row.text then
|
||||
Chrome.print(":", 10, labelY + 1)
|
||||
Chrome.print(row.text(self.options), 11, labelY + 1)
|
||||
Chrome.print(fit(row.text(self.options), 11), 11, labelY + 1)
|
||||
elseif row.values then
|
||||
Chrome.print(":", 10, labelY + 1)
|
||||
local value = self.options[row.key]
|
||||
local text = row.display and Strings(row.display[value]) or tostring(value)
|
||||
Chrome.print(text, 11, labelY + 1)
|
||||
Chrome.print(fit(text, 11), 11, labelY + 1)
|
||||
elseif type(row.value) == "function" then
|
||||
-- the Gen 1 row's value reader (src/ui/OptionRows.lua:4), so a mod row
|
||||
-- shows its setting here instead of drawing a bare label
|
||||
local ok, text = pcall(row.value, self.game)
|
||||
Chrome.print(":", 10, labelY + 1)
|
||||
Chrome.print(ok and tostring(text) or "?", 11, labelY + 1)
|
||||
Chrome.print(fit(ok and tostring(text) or "?", 11), 11, labelY + 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
Chrome.cursor(1, 2 + (self.index - self.scroll - 1) * 2)
|
||||
-- The ▼ hint every scrolling Gen 2 list shows when there is more below.
|
||||
if self.scroll + VISIBLE_ROWS < #self.rows then
|
||||
if self.scroll + VISIBLE_ROWS < #rows then
|
||||
local Font = require("src.render.Font")
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.drawCode(Chrome.DOWN_ARROW, 1 * 8, (2 + VISIBLE_ROWS * 2 - 1) * 8)
|
||||
@@ -514,8 +697,7 @@ end
|
||||
|
||||
function OptionsMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
|
||||
local PackGfx = {}
|
||||
PackGfx.__index = PackGfx
|
||||
@@ -111,9 +112,7 @@ end
|
||||
-- Everything behind the item list: the header strip, the background column,
|
||||
-- the bag picture for this pocket, and the pocket plaque.
|
||||
function PackGfx:draw(pocketId)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8)
|
||||
Chrome.paletteFill(0, 0, SCREEN_W * 8, SCREEN_H * 8, Chrome.DEFAULT_BOX_PALETTE)
|
||||
|
||||
-- ◀▶ POCKET ▼▲ ITEMS: 20 running tiles from $28.
|
||||
local header = self.gfx.headerFirstTile or 0x28
|
||||
|
||||
+19
-18
@@ -973,22 +973,22 @@ function PackMenu:drawList(listX, listY)
|
||||
local entry = self.rows[i]
|
||||
-- engine/items/tmhm.asm:355-385 (#1695)
|
||||
if entry.tmhmLabel then
|
||||
Chrome.print(entry.tmhmLabel, listX - 3, ty)
|
||||
Chrome.printThrough(entry.tmhmLabel, listX - 3, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
if i == self.index then
|
||||
self:cursorAt(listX - 1, ty)
|
||||
elseif i == self.switching then
|
||||
self:cursorAt(listX - 1, ty, true)
|
||||
end
|
||||
Chrome.print((entry.tmhmLabel and entry.teaches) or entry.name,
|
||||
listX, ty)
|
||||
Chrome.printThrough((entry.tmhmLabel and entry.teaches) or entry.name,
|
||||
listX, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
if entry.showCount then
|
||||
Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2),
|
||||
listX + 9, ty + 1)
|
||||
Chrome.printThrough("\xc3\x97" .. Chrome.number(entry.count, 2),
|
||||
listX + 9, ty + 1, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
elseif i == self:total() then
|
||||
if i == self.index then self:cursorAt(listX - 1, ty) end
|
||||
Chrome.print("CANCEL", listX, ty)
|
||||
Chrome.printThrough("CANCEL", listX, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1006,7 +1006,8 @@ function PackMenu:drawDescription(ty)
|
||||
local top, step = ty, 2
|
||||
if #lines > 2 then top, step = ty - 1, 1 end
|
||||
for i, line in ipairs(lines) do
|
||||
Chrome.print((line:gsub("{PLAYER}", name)), 1, top + (i - 1) * step)
|
||||
Chrome.printThrough((line:gsub("{PLAYER}", name)), 1, top + (i - 1) * step,
|
||||
Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1019,8 +1020,8 @@ function PackMenu:drawDescription(ty)
|
||||
-- the second line is row 16. '\n' covers hand-written data.
|
||||
local first, second = description:match("^(.-)<NEXT>(.*)$")
|
||||
if not first then first, second = description:match("^(.-)\n(.*)$") end
|
||||
Chrome.print(first or description, 1, ty)
|
||||
if second then Chrome.print(second, 1, ty + 2) end
|
||||
Chrome.printThrough(first or description, 1, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
if second then Chrome.printThrough(second, 1, ty + 2, Chrome.DEFAULT_BOX_PALETTE) end
|
||||
end
|
||||
|
||||
-- The submenu box. Every one of the seven headers is `menu_coords 0, top,
|
||||
@@ -1037,23 +1038,24 @@ function PackMenu:drawSubmenu()
|
||||
Chrome.box(0, top, 7, bottom - top + 1)
|
||||
for i, id in ipairs(menu.rows) do
|
||||
local ty = top + 1 + (i - 1) * 2
|
||||
if i == menu.index then Chrome.cursor(1, ty) end
|
||||
Chrome.print(SUBMENU_LABEL[id] or id, 2, ty)
|
||||
if i == menu.index then Chrome.cursorThrough(1, ty, Chrome.DEFAULT_BOX_PALETTE) end
|
||||
Chrome.printThrough(SUBMENU_LABEL[id] or id, 2, ty, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
end
|
||||
|
||||
-- engine/items/buy_sell_toss.asm:133 BuySellToss_UpdateQuantityDisplay
|
||||
function PackMenu:drawQuantity()
|
||||
Chrome.box(15, 9, 5, 3)
|
||||
Chrome.print("\xc3\x97" .. Chrome.number(self.qtyState.qty, 2, true), 16, 10)
|
||||
Chrome.printThrough("\xc3\x97" .. Chrome.number(self.qtyState.qty, 2, true), 16, 10,
|
||||
Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
|
||||
-- YesNoBox's own coords, the same box every other Gen 2 screen here draws.
|
||||
function PackMenu:drawYesNo()
|
||||
Chrome.box(14, 7, 6, 5)
|
||||
Chrome.print("YES", 16, 8)
|
||||
Chrome.print("NO", 16, 10)
|
||||
Chrome.cursor(15, self.confirm.choice == 1 and 8 or 10)
|
||||
Chrome.printThrough("YES", 16, 8, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough("NO", 16, 10, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.cursorThrough(15, self.confirm.choice == 1 and 8 or 10, Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
|
||||
function PackMenu:drawOverlays()
|
||||
@@ -1080,7 +1082,7 @@ function PackMenu:drawPanel()
|
||||
-- boxes, the layout this screen shipped with.
|
||||
Chrome.clear()
|
||||
Chrome.box(0, 0, 20, 3)
|
||||
Chrome.print(self:pocket().label, 2, 1)
|
||||
Chrome.printThrough(self:pocket().label, 2, 1, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.box(0, 3, 20, 12)
|
||||
self:drawList(5, 4)
|
||||
Chrome.box(0, 12, 20, 6)
|
||||
@@ -1095,8 +1097,7 @@ end
|
||||
|
||||
function PackMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -845,8 +845,7 @@ end
|
||||
|
||||
function PartyMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -456,8 +456,7 @@ end
|
||||
|
||||
function PcMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -202,8 +202,7 @@ function PhotoStudio:drawsWidescreen() return true end
|
||||
|
||||
function PhotoStudio:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
G.push()
|
||||
|
||||
@@ -1458,8 +1458,7 @@ end
|
||||
|
||||
function PokedexMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(0, 0, 0, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 0, 0, 0)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
+53
-26
@@ -22,6 +22,7 @@
|
||||
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Gen2Save = require("src.core.gen2.Save")
|
||||
local Clock = require("src.core.gen2.Clock")
|
||||
local Font = require("src.render.Font")
|
||||
@@ -971,6 +972,7 @@ function Pokegear.new(game, opts)
|
||||
if gfx then
|
||||
self.sheet = TileSheet.new({
|
||||
path = gfx.tiles, wide = gfx.tilesWide or 16, firstTile = 0,
|
||||
raw = true,
|
||||
paletteFor = function(tile) return self:colorsFor(tile) end,
|
||||
})
|
||||
end
|
||||
@@ -997,10 +999,9 @@ end
|
||||
|
||||
-- Every string on a Pokegear card is a run of font tiles laid straight into
|
||||
-- the tilemap, so it wears BG palette 0 (PokegearPals' first entry) rather
|
||||
-- than drawing as black ink over whatever was underneath.
|
||||
function Pokegear:text(str, tx, ty)
|
||||
local pals = self:pals()
|
||||
return Chrome.printThrough(str, tx, ty, pals and pals[1])
|
||||
return Chrome.printThrough(str, tx, ty, pals and pals[1], false, true)
|
||||
end
|
||||
|
||||
-- wPokegearFlags' four card bits are ENGINE flags: EngineFlags rows 0-3 are
|
||||
@@ -2411,6 +2412,8 @@ function Pokegear:drawPlain()
|
||||
end
|
||||
end
|
||||
|
||||
Pokegear.CUSTOM_RAMP_FILM = true
|
||||
|
||||
function Pokegear:drawPanel()
|
||||
if not self:styled() then
|
||||
self:drawPlain()
|
||||
@@ -2418,29 +2421,54 @@ function Pokegear:drawPanel()
|
||||
return
|
||||
end
|
||||
local G = love.graphics
|
||||
-- InitPokegearTilemap ByteFills the whole SCREEN_AREA with $4f before the
|
||||
-- card's tilemap goes down (engine/pokegear/pokegear.asm InitPokegearTilemap),
|
||||
-- and every cell the card leaves blank is a font-page tile on palette 0, so
|
||||
-- the ground under a card is the gear's paper, not white. See
|
||||
-- Pokegear:paperColor.
|
||||
local ground = self:groundColor()
|
||||
G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8)
|
||||
local id = self:card() and self:card().id
|
||||
if id == "map" then
|
||||
self:drawMap()
|
||||
elseif id == "radio" then
|
||||
self:drawRadio()
|
||||
elseif id == "phone" then
|
||||
self:drawPhone()
|
||||
else
|
||||
self:drawClock()
|
||||
local function paint()
|
||||
-- InitPokegearTilemap ByteFills the whole SCREEN_AREA with $4f before the
|
||||
-- card's tilemap goes down (engine/pokegear/pokegear.asm InitPokegearTilemap),
|
||||
-- and every cell the card leaves blank is a font-page tile on palette 0, so
|
||||
-- the ground under a card is the gear's paper, not white. See
|
||||
-- Pokegear:paperColor.
|
||||
local ground = self:groundColor()
|
||||
G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8)
|
||||
local id = self:card() and self:card().id
|
||||
if id == "map" then
|
||||
self:drawMap()
|
||||
elseif id == "radio" then
|
||||
self:drawRadio()
|
||||
elseif id == "phone" then
|
||||
self:drawPhone()
|
||||
else
|
||||
self:drawClock()
|
||||
end
|
||||
-- Last: the arrow is an OBJ and composites over whatever the card drew.
|
||||
-- _FlyMap has no card strip and never animates it
|
||||
-- (engine/pokegear/pokegear.asm:1999); neither does _TownMap
|
||||
-- (../pokecrystal/engine/pokegear/pokegear.asm:1757).
|
||||
if not (self.fly or self.townMap) then self:drawModeArrow() end
|
||||
end
|
||||
|
||||
if Pokegear.CUSTOM_RAMP_FILM and GbcPalette.customRamp
|
||||
and GbcPalette.available() then
|
||||
if not self.filmCanvas then
|
||||
self.filmCanvas = G.newCanvas(SCREEN_W * 8, SCREEN_H * 8)
|
||||
self.filmCanvas:setFilter("nearest", "nearest")
|
||||
end
|
||||
local previousCanvas = G.getCanvas()
|
||||
G.setCanvas(self.filmCanvas)
|
||||
G.clear(0, 0, 0, 0)
|
||||
G.push()
|
||||
G.origin()
|
||||
local ok, err = pcall(paint)
|
||||
G.pop()
|
||||
G.setCanvas(previousCanvas)
|
||||
if not ok then error(err, 0) end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
GbcPalette.with(GbcPalette.customRamp, function()
|
||||
G.draw(self.filmCanvas, 0, 0)
|
||||
end)
|
||||
else
|
||||
paint()
|
||||
end
|
||||
-- Last: the arrow is an OBJ and composites over whatever the card drew.
|
||||
-- _FlyMap has no card strip and never animates it
|
||||
-- (engine/pokegear/pokegear.asm:1999); neither does _TownMap
|
||||
-- (../pokecrystal/engine/pokegear/pokegear.asm:1757).
|
||||
if not (self.fly or self.townMap) then self:drawModeArrow() end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -2454,8 +2482,7 @@ function Pokegear:drawWidescreen(winW, winH)
|
||||
-- black card sitting inside a cream frame. It follows groundColor for the
|
||||
-- same reason drawPanel does: $4f is what the screen is filled with.
|
||||
local ground = self:groundColor()
|
||||
G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, ground[1] / 255, ground[2] / 255, ground[3] / 255)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
G.push()
|
||||
|
||||
@@ -556,8 +556,7 @@ end
|
||||
|
||||
function PrizeMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -260,8 +260,7 @@ end
|
||||
|
||||
function SaveMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -1531,8 +1531,7 @@ end
|
||||
|
||||
function SlotMachine:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -295,9 +295,7 @@ function StartMenu:draw()
|
||||
if not desc then return end
|
||||
-- ._DrawMenuAccount ClearBox (0,13) 5 rows by 10, .PrintMenuAccount decoord
|
||||
-- 0, 14 and the desc's `next` steps two rows (start_menu.asm:366-382).
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 13 * 8, 10 * 8, 5 * 8)
|
||||
Chrome.paletteFill(0, 13 * 8, 10 * 8, 5 * 8)
|
||||
Chrome.print(desc[1] or "", 0, 14)
|
||||
Chrome.print(desc[2] or "", 0, 16)
|
||||
end
|
||||
|
||||
@@ -1266,8 +1266,7 @@ end
|
||||
|
||||
function SummaryMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -18,7 +18,6 @@ local TileSheet = {}
|
||||
TileSheet.__index = TileSheet
|
||||
|
||||
-- opts: path, wide, firstTile, palette (a 4-colour table) or palettes + a
|
||||
-- paletteFor(tile, tx, ty) hook, transparent
|
||||
function TileSheet.new(opts)
|
||||
local self = setmetatable({}, TileSheet)
|
||||
self.path = opts and opts.path
|
||||
@@ -26,6 +25,7 @@ function TileSheet.new(opts)
|
||||
self.firstTile = (opts and opts.firstTile) or 0
|
||||
self.palette = opts and opts.palette
|
||||
self.paletteFor = opts and opts.paletteFor
|
||||
self.raw = opts and opts.raw
|
||||
self.quads = {}
|
||||
return self
|
||||
end
|
||||
@@ -79,7 +79,11 @@ function TileSheet:draw(tile, tx, ty)
|
||||
if self.paletteFor then colors = self.paletteFor(tile, tx, ty) or colors end
|
||||
local function body() G.draw(image, quad, tx * 8, ty * 8) end
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
if self.raw then
|
||||
GbcPalette.withRaw(colors, body)
|
||||
else
|
||||
GbcPalette.with(colors, body)
|
||||
end
|
||||
else
|
||||
body()
|
||||
end
|
||||
|
||||
@@ -316,11 +316,8 @@ function TitleState:update(_dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- Which of the two baked sets is showing. GBC is the cart's colour; every
|
||||
-- other COLOR mode wants the grey source, and CLASSIC's green comes from the
|
||||
-- present pass over the finished frame rather than from a third set of art.
|
||||
function TitleState:gray()
|
||||
return GbcPalette.mode ~= "gbc"
|
||||
return GbcPalette.mode == "dmg" or GbcPalette.mode == "classic"
|
||||
end
|
||||
|
||||
function TitleState:art()
|
||||
|
||||
@@ -870,8 +870,7 @@ end
|
||||
|
||||
function TradeAnimView:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
+41
-34
@@ -271,16 +271,24 @@ end
|
||||
-- TrainerCard_PrintTopHalfOfCard runs once, in .InitRAM, and no page redraws
|
||||
-- it -- so the name, ID, money and portrait stay on screen behind the badge
|
||||
-- pages too.
|
||||
function TrainerCard:print(text, tx, ty)
|
||||
Chrome.printThrough(text, tx, ty, self:colorsAt(tx, ty))
|
||||
end
|
||||
|
||||
function TrainerCard:cursor(tx, ty, hollow)
|
||||
Chrome.cursorThrough(tx, ty, self:colorsAt(tx, ty), false, hollow)
|
||||
end
|
||||
|
||||
function TrainerCard:drawTopHalf()
|
||||
local player = (self.save or {}).player or {}
|
||||
self:frame(0, 5)
|
||||
Chrome.print("NAME/", 2, 2)
|
||||
Chrome.print(player.name or "GOLD", 7, 2)
|
||||
self:print("NAME/", 2, 2)
|
||||
self:print(player.name or "GOLD", 7, 2)
|
||||
self:tile(self.card, TILE_ID_NO[1], 2, 4)
|
||||
self:tile(self.card, TILE_ID_NO[2], 3, 4)
|
||||
Chrome.print(Chrome.number(player.id or 0, 5, true), 5, 4)
|
||||
Chrome.print("MONEY", 2, 6)
|
||||
Chrome.print(moneyText(player.money), 7, 6)
|
||||
self:print(Chrome.number(player.id or 0, 5, true), 5, 4)
|
||||
self:print("MONEY", 2, 6)
|
||||
self:print(moneyText(player.money), 7, 6)
|
||||
for x = 1, 12 do self:tile(self.card, TILE_DIVIDER, x, 3) end
|
||||
self:tile(self.card, TILE_DIVIDER_END, 13, 3)
|
||||
self:drawPortrait()
|
||||
@@ -298,22 +306,22 @@ function TrainerCard:drawCard()
|
||||
|
||||
-- `#` is the compression byte for POKé, four tiles, so spelling it out is
|
||||
-- what the cart actually draws.
|
||||
Chrome.print("POKéDEX", 2, 10)
|
||||
Chrome.print("PLAY TIME", 2, 12)
|
||||
Chrome.print(Chrome.number(self:caughtCount(), 3), 15, 10)
|
||||
self:print("POKéDEX", 2, 10)
|
||||
self:print("PLAY TIME", 2, 12)
|
||||
self:print(Chrome.number(self:caughtCount(), 3), 15, 10)
|
||||
|
||||
local time = save.playTime or {}
|
||||
Chrome.print(Chrome.number(time.hours or 0, 4), 11, 12)
|
||||
self:print(Chrome.number(time.hours or 0, 4), 11, 12)
|
||||
-- The colon is $2e, which belongs to CardStatusGFX rather than the card
|
||||
-- sheet, and TrainerCard_Page1_PrintGameTime xors it with ' ' every 32
|
||||
-- frames -- which is what makes the clock look like it is running.
|
||||
if math.floor(self.frames / 32) % 2 == 0 then
|
||||
self:tile(self.status, TILE_COLON, 15, 12)
|
||||
end
|
||||
Chrome.print(Chrome.number(time.minutes or 0, 2, true), 16, 12)
|
||||
self:print(Chrome.number(time.minutes or 0, 2, true), 16, 12)
|
||||
|
||||
Chrome.print("BADGES", 12, 15)
|
||||
Chrome.cursor(18, 15)
|
||||
self:print("BADGES", 12, 15)
|
||||
self:cursor(18, 15)
|
||||
end
|
||||
|
||||
-- TrainerCard_Page2_3_PlaceLeadersFaces: four tiles across the top row, then
|
||||
@@ -397,22 +405,22 @@ function TrainerCard:drawPlain()
|
||||
Chrome.clear()
|
||||
if self.page == 1 then
|
||||
Chrome.box(0, 0, 20, 9)
|
||||
Chrome.print("NAME/", 2, 2)
|
||||
Chrome.print(player.name or "GOLD", 7, 2)
|
||||
Chrome.print("ID No", 2, 4)
|
||||
Chrome.print(Chrome.number(player.id or 0, 5, true), 5, 4)
|
||||
Chrome.print("MONEY", 2, 6)
|
||||
Chrome.print(moneyText(player.money), 7, 6)
|
||||
Chrome.printThrough("NAME/", 2, 2, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough(player.name or "GOLD", 7, 2, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough("ID No", 2, 4, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough(Chrome.number(player.id or 0, 5, true), 5, 4, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough("MONEY", 2, 6, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough(moneyText(player.money), 7, 6, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.box(0, 8, 20, 10)
|
||||
Chrome.print("POKéDEX", 2, 10)
|
||||
Chrome.print(Chrome.number(self:caughtCount(), 3), 15, 10)
|
||||
Chrome.print("PLAY TIME", 2, 12)
|
||||
Chrome.printThrough("POKéDEX", 2, 10, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough(Chrome.number(self:caughtCount(), 3), 15, 10, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough("PLAY TIME", 2, 12, Chrome.DEFAULT_BOX_PALETTE)
|
||||
local time = save.playTime or {}
|
||||
Chrome.print(Chrome.number(time.hours or 0, 4), 11, 12)
|
||||
Chrome.print(":", 15, 12)
|
||||
Chrome.print(Chrome.number(time.minutes or 0, 2, true), 16, 12)
|
||||
Chrome.print("BADGES", 12, 15)
|
||||
Chrome.cursor(18, 15)
|
||||
Chrome.printThrough(Chrome.number(time.hours or 0, 4), 11, 12, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough(":", 15, 12, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough(Chrome.number(time.minutes or 0, 2, true), 16, 12, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.printThrough("BADGES", 12, 15, Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.cursorThrough(18, 15, Chrome.DEFAULT_BOX_PALETTE)
|
||||
return
|
||||
end
|
||||
local names = self.page == 2 and JOHTO_BADGES or KANTO_BADGES
|
||||
@@ -423,12 +431,14 @@ function TrainerCard:drawPlain()
|
||||
-- player.badges on both pages to match.
|
||||
local held = player.badges or {}
|
||||
Chrome.box(0, 0, 20, 9)
|
||||
Chrome.print(self.page == 2 and "JOHTO BADGES" or "KANTO BADGES", 2, 2)
|
||||
Chrome.printThrough(self.page == 2 and "JOHTO BADGES" or "KANTO BADGES", 2, 2,
|
||||
Chrome.DEFAULT_BOX_PALETTE)
|
||||
Chrome.box(0, 8, 20, 10)
|
||||
for i, name in ipairs(names) do
|
||||
local tx = 2 + ((i - 1) % 4) * 4
|
||||
local ty = 10 + math.floor((i - 1) / 4) * 3
|
||||
Chrome.print((held[i] or held[name]) and name:sub(1, 4) or "----", tx, ty)
|
||||
Chrome.printThrough((held[i] or held[name]) and name:sub(1, 4) or "----", tx, ty,
|
||||
Chrome.DEFAULT_BOX_PALETTE)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -438,9 +448,7 @@ function TrainerCard:drawPanel()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8)
|
||||
Chrome.paletteFill(0, 0, SCREEN_W * 8, SCREEN_H * 8, Chrome.DEFAULT_BOX_PALETTE)
|
||||
local player = (self.save and self.save.player) or {}
|
||||
if self.page == 1 then
|
||||
self:drawCard()
|
||||
@@ -453,7 +461,7 @@ function TrainerCard:drawPanel()
|
||||
-- the same table page 2 reads.
|
||||
self:drawBadges(JOHTO_BADGES, player.badges or {})
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function TrainerCard:draw()
|
||||
@@ -462,8 +470,7 @@ end
|
||||
|
||||
function TrainerCard:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
@@ -191,8 +191,7 @@ function UnownPrinter:drawsWidescreen() return true end
|
||||
|
||||
function UnownPrinter:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 1, 1, 1)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
G.push()
|
||||
|
||||
@@ -576,8 +576,7 @@ function UnownPuzzle:drawsWidescreen() return true end
|
||||
|
||||
function UnownPuzzle:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(0, 0, 0, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
Chrome.letterbox(winW, winH, 0, 0, 0)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(Chrome.fitOrigin(winW, winH, scale))
|
||||
|
||||
Executable
+366
@@ -0,0 +1,366 @@
|
||||
-- Immediate-mode in-launcher File Browser for handhelds and gamepads.
|
||||
-- Allows navigating directories to pick ROM files (.gb/.gbc), import save files (.sav),
|
||||
-- and select mod/skin archives (.zip) without requiring host desktop GUI pickers (zenity/kdialog).
|
||||
|
||||
local Theme = require("src.ui.kit.Theme")
|
||||
local PAL = Theme.PAL
|
||||
local Kit = nil
|
||||
local function getKit()
|
||||
if not Kit then Kit = require("src.ui.kit.Kit") end
|
||||
return Kit
|
||||
end
|
||||
|
||||
local FileBrowser = {
|
||||
active = false,
|
||||
title = "Select File",
|
||||
mode = "rom", -- "rom", "save", "mod", "all"
|
||||
currentDir = "/",
|
||||
entries = {},
|
||||
selectedIdx = 1,
|
||||
page = 1,
|
||||
perPage = 8,
|
||||
onSelect = nil,
|
||||
onCancel = nil,
|
||||
shortcutIdx = 1,
|
||||
}
|
||||
|
||||
local SHORTCUTS = {
|
||||
{ label = "SD Card", path = "/mnt/SDCARD" },
|
||||
{ label = "GB ROMs", path = "/roms/gb" },
|
||||
{ label = "GBC ROMs", path = "/roms/gbc" },
|
||||
{ label = "Ports", path = "/roms/ports" },
|
||||
{ label = "Game Dir", path = "." },
|
||||
{ label = "Storage", path = "/storage" },
|
||||
{ label = "Userdata", path = "/userdata/roms" },
|
||||
{ label = "Root /", path = "/" },
|
||||
}
|
||||
|
||||
local function findSdCardRoot()
|
||||
local candidates = {
|
||||
"/mnt/SDCARD",
|
||||
"/mnt/sdcard",
|
||||
"/mnt/mmc",
|
||||
"/sdcard",
|
||||
"/roms",
|
||||
"/storage/roms",
|
||||
"/userdata/roms",
|
||||
"/storage",
|
||||
"/userdata",
|
||||
}
|
||||
for _, path in ipairs(candidates) do
|
||||
local ok, h = pcall(io.popen, string.format('test -d "%s" && echo "yes"', path))
|
||||
if ok and h then
|
||||
local res = h:read("*a")
|
||||
h:close()
|
||||
if res and res:match("yes") then
|
||||
return path
|
||||
end
|
||||
end
|
||||
end
|
||||
return "/"
|
||||
end
|
||||
|
||||
local function normalizePath(p)
|
||||
if not p or p == "" then return "/" end
|
||||
p = p:gsub("\\", "/")
|
||||
if p:sub(1, 1) ~= "/" and p:sub(1, 2) ~= "./" and p:sub(1, 3) ~= "../" then
|
||||
p = "/" .. p
|
||||
end
|
||||
p = p:gsub("/+", "/")
|
||||
return p
|
||||
end
|
||||
|
||||
local function parentDir(p)
|
||||
p = normalizePath(p)
|
||||
if p == "/" or p == "." then return "/" end
|
||||
local parent = p:match("^(.*)/[^/]+/?$")
|
||||
if not parent or parent == "" then return "/" end
|
||||
return parent
|
||||
end
|
||||
|
||||
local function isMatchingFilter(name, isDir, mode)
|
||||
if isDir then return true end
|
||||
local ext = name:match("%.([^.]+)$")
|
||||
if not ext then return (mode == "all") end
|
||||
ext = ext:lower()
|
||||
if mode == "rom" then
|
||||
return (ext == "gb" or ext == "gbc" or ext == "zip")
|
||||
elseif mode == "save" then
|
||||
return (ext == "sav")
|
||||
elseif mode == "mod" then
|
||||
return (ext == "zip")
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function scanDirectory(dir, mode)
|
||||
dir = normalizePath(dir)
|
||||
local entries = {}
|
||||
|
||||
local ok, handle = pcall(io.popen, string.format('ls -1ap "%s" 2>/dev/null', dir:gsub('"', '\\"')))
|
||||
if ok and handle then
|
||||
local output = handle:read("*a")
|
||||
handle:close()
|
||||
if output and #output > 0 then
|
||||
for line in output:gmatch("[^\r\n]+") do
|
||||
if line ~= "./" and line ~= "../" and line ~= "." and line ~= ".." then
|
||||
local isDir = (line:sub(-1) == "/")
|
||||
local cleanName = isDir and line:sub(1, -2) or line
|
||||
if isMatchingFilter(cleanName, isDir, mode) then
|
||||
table.insert(entries, {
|
||||
name = cleanName,
|
||||
isDir = isDir,
|
||||
path = (dir == "/" and "/" .. cleanName) or (dir .. "/" .. cleanName),
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #entries == 0 and love and love.filesystem and love.filesystem.getDirectoryItems then
|
||||
local okFs, items = pcall(love.filesystem.getDirectoryItems, dir)
|
||||
if okFs and items then
|
||||
for _, name in ipairs(items) do
|
||||
local full = (dir == "/" and "/" .. name) or (dir .. "/" .. name)
|
||||
local info = love.filesystem.getInfo and love.filesystem.getInfo(full)
|
||||
local isDir = info and info.type == "directory"
|
||||
if isMatchingFilter(name, isDir, mode) then
|
||||
table.insert(entries, {
|
||||
name = name,
|
||||
isDir = isDir,
|
||||
path = full,
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(entries, function(a, b)
|
||||
if a.isDir ~= b.isDir then
|
||||
return a.isDir
|
||||
end
|
||||
return a.name:lower() < b.name:lower()
|
||||
end)
|
||||
|
||||
return entries
|
||||
end
|
||||
|
||||
function FileBrowser.open(opts)
|
||||
opts = opts or {}
|
||||
FileBrowser.active = true
|
||||
FileBrowser.title = opts.title or "Select File"
|
||||
FileBrowser.mode = opts.mode or "rom"
|
||||
FileBrowser.onSelect = opts.onSelect
|
||||
FileBrowser.onCancel = opts.onCancel
|
||||
FileBrowser.selectedIdx = 1
|
||||
FileBrowser.page = 1
|
||||
|
||||
local initPath = opts.initialPath or findSdCardRoot()
|
||||
FileBrowser.currentDir = initPath
|
||||
FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode)
|
||||
if #FileBrowser.entries == 0 and initPath ~= "/" then
|
||||
FileBrowser.currentDir = "/"
|
||||
FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode)
|
||||
end
|
||||
end
|
||||
|
||||
function FileBrowser.close(path)
|
||||
if not FileBrowser.active then return end
|
||||
local onSelect = FileBrowser.onSelect
|
||||
local onCancel = FileBrowser.onCancel
|
||||
FileBrowser.active = false
|
||||
FileBrowser.onSelect = nil
|
||||
FileBrowser.onCancel = nil
|
||||
if path and onSelect then
|
||||
onSelect(path)
|
||||
elseif not path and onCancel then
|
||||
onCancel()
|
||||
end
|
||||
end
|
||||
|
||||
function FileBrowser.setDirectory(dir)
|
||||
FileBrowser.currentDir = normalizePath(dir)
|
||||
FileBrowser.entries = scanDirectory(FileBrowser.currentDir, FileBrowser.mode)
|
||||
FileBrowser.selectedIdx = 1
|
||||
FileBrowser.page = 1
|
||||
end
|
||||
|
||||
function FileBrowser.cycleShortcut()
|
||||
FileBrowser.shortcutIdx = (FileBrowser.shortcutIdx % #SHORTCUTS) + 1
|
||||
local target = SHORTCUTS[FileBrowser.shortcutIdx]
|
||||
FileBrowser.setDirectory(target.path)
|
||||
end
|
||||
|
||||
function FileBrowser.gamepadpressed(button)
|
||||
if not FileBrowser.active then return false end
|
||||
|
||||
local total = #FileBrowser.entries
|
||||
local perPage = FileBrowser.perPage
|
||||
local maxPage = math.max(1, math.ceil(total / perPage))
|
||||
|
||||
if button == "dpup" then
|
||||
if FileBrowser.selectedIdx > 1 then
|
||||
FileBrowser.selectedIdx = FileBrowser.selectedIdx - 1
|
||||
FileBrowser.page = math.floor((FileBrowser.selectedIdx - 1) / perPage) + 1
|
||||
end
|
||||
return true
|
||||
elseif button == "dpdown" then
|
||||
if FileBrowser.selectedIdx < total then
|
||||
FileBrowser.selectedIdx = FileBrowser.selectedIdx + 1
|
||||
FileBrowser.page = math.floor((FileBrowser.selectedIdx - 1) / perPage) + 1
|
||||
end
|
||||
return true
|
||||
elseif button == "dpleft" or button == "leftshoulder" or button == "triggerleft" or button == "lefttrigger" or button == "l2" then
|
||||
if FileBrowser.page > 1 then
|
||||
FileBrowser.page = FileBrowser.page - 1
|
||||
FileBrowser.selectedIdx = (FileBrowser.page - 1) * perPage + 1
|
||||
end
|
||||
return true
|
||||
elseif button == "dpright" or button == "rightshoulder" or button == "triggerright" or button == "righttrigger" or button == "r2" then
|
||||
if FileBrowser.page < maxPage then
|
||||
FileBrowser.page = FileBrowser.page + 1
|
||||
FileBrowser.selectedIdx = math.min(total, (FileBrowser.page - 1) * perPage + 1)
|
||||
end
|
||||
return true
|
||||
elseif button == "a" or button == "start" then
|
||||
local entry = FileBrowser.entries[FileBrowser.selectedIdx]
|
||||
if entry then
|
||||
if entry.isDir then
|
||||
FileBrowser.setDirectory(entry.path)
|
||||
else
|
||||
FileBrowser.close(entry.path)
|
||||
end
|
||||
end
|
||||
return true
|
||||
elseif button == "b" then
|
||||
if FileBrowser.currentDir ~= "/" and FileBrowser.currentDir ~= "." then
|
||||
FileBrowser.setDirectory(parentDir(FileBrowser.currentDir))
|
||||
else
|
||||
FileBrowser.close(nil)
|
||||
end
|
||||
return true
|
||||
elseif button == "x" then
|
||||
FileBrowser.cycleShortcut()
|
||||
return true
|
||||
elseif button == "back" or button == "select" then
|
||||
FileBrowser.close(nil)
|
||||
return true
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
function FileBrowser.keypressed(key)
|
||||
if not FileBrowser.active then return false end
|
||||
if key == "escape" then
|
||||
FileBrowser.close(nil)
|
||||
return true
|
||||
elseif key == "up" then
|
||||
return FileBrowser.gamepadpressed("dpup")
|
||||
elseif key == "down" then
|
||||
return FileBrowser.gamepadpressed("dpdown")
|
||||
elseif key == "left" then
|
||||
return FileBrowser.gamepadpressed("dpleft")
|
||||
elseif key == "right" then
|
||||
return FileBrowser.gamepadpressed("dpright")
|
||||
elseif key == "return" then
|
||||
return FileBrowser.gamepadpressed("a")
|
||||
elseif key == "backspace" then
|
||||
return FileBrowser.gamepadpressed("b")
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function FileBrowser.draw(m)
|
||||
if not FileBrowser.active then return end
|
||||
|
||||
local Kit = getKit()
|
||||
local s = m and m.s or Kit.scale or 1
|
||||
local W = (m and m.W) or (love.graphics and love.graphics.getWidth()) or 640
|
||||
local H = (m and m.H) or (love.graphics and love.graphics.getHeight()) or 480
|
||||
|
||||
Theme.fill(0, 0, W, H, PAL.bg, 0.94)
|
||||
Kit.blockClicks = true
|
||||
|
||||
local pad = math.floor(14 * s)
|
||||
local modalW = math.floor(math.min(560 * s, W - 20 * s))
|
||||
local modalH = math.floor(math.min(420 * s, H - 20 * s))
|
||||
local px = math.floor((W - modalW) / 2)
|
||||
local py = math.floor((H - modalH) / 2)
|
||||
|
||||
Kit.card(px, py, modalW, modalH, true)
|
||||
|
||||
local cy = py + pad
|
||||
-- Header
|
||||
Kit.text("button", FileBrowser.title, px + pad, cy, PAL.heading)
|
||||
Kit.tag(px + modalW - pad - math.floor(60 * s), cy, math.floor(60 * s), math.floor(18 * s),
|
||||
FileBrowser.mode:upper(), PAL.blue)
|
||||
|
||||
cy = cy + Kit.textHeight("button") + math.floor(6 * s)
|
||||
|
||||
-- Breadcrumb / Current directory bar
|
||||
local pathBarH = math.floor(24 * s)
|
||||
Theme.fillRounded(px + pad, cy, modalW - 2 * pad, pathBarH, PAL.bg, 1)
|
||||
Theme.strokeRounded(px + pad, cy, modalW - 2 * pad, pathBarH, PAL.line, Theme.A.hairline, 1)
|
||||
local pathStr = Kit.ellipsizeLeft("small", FileBrowser.currentDir, modalW - 4 * pad)
|
||||
Kit.text("small", pathStr, px + pad + math.floor(6 * s), cy + math.floor(4 * s), PAL.heading)
|
||||
|
||||
cy = cy + pathBarH + math.floor(8 * s)
|
||||
|
||||
-- File / Directory List
|
||||
local total = #FileBrowser.entries
|
||||
local perPage = FileBrowser.perPage
|
||||
local startIdx = (FileBrowser.page - 1) * perPage + 1
|
||||
local endIdx = math.min(total, startIdx + perPage - 1)
|
||||
local rowH = math.floor(26 * s)
|
||||
local rowGap = math.floor(3 * s)
|
||||
|
||||
if total == 0 then
|
||||
Theme.fillRounded(px + pad, cy, modalW - 2 * pad, rowH * 4, PAL.bg, 1)
|
||||
Kit.textCenter("small", "No files found in this folder.", px + pad, cy + rowH * 1.5, modalW - 2 * pad, PAL.muted)
|
||||
cy = cy + rowH * 4 + math.floor(8 * s)
|
||||
else
|
||||
for i = startIdx, endIdx do
|
||||
local entry = FileBrowser.entries[i]
|
||||
local isSel = (FileBrowser.selectedIdx == i)
|
||||
local rx = px + pad
|
||||
local rw = modalW - 2 * pad
|
||||
|
||||
if Kit.press(rx, cy, rw, rowH) then
|
||||
FileBrowser.selectedIdx = i
|
||||
if entry.isDir then
|
||||
FileBrowser.setDirectory(entry.path)
|
||||
else
|
||||
FileBrowser.close(entry.path)
|
||||
end
|
||||
end
|
||||
|
||||
if isSel then
|
||||
Theme.fillRounded(rx, cy, rw, rowH, PAL.ink, 1)
|
||||
local icon = entry.isDir and "[DIR] " or "[FILE] "
|
||||
Kit.text("small", icon .. entry.name, rx + math.floor(8 * s), cy + math.floor(4 * s), PAL.inverse)
|
||||
else
|
||||
Theme.fillRounded(rx, cy, rw, rowH, PAL.surface, 1)
|
||||
Theme.strokeRounded(rx, cy, rw, rowH, PAL.line, Theme.A.hairline, 1)
|
||||
local icon = entry.isDir and "[DIR] " or "[FILE] "
|
||||
local col = entry.isDir and PAL.blue or PAL.text
|
||||
Kit.text("small", icon .. entry.name, rx + math.floor(8 * s), cy + math.floor(4 * s), col)
|
||||
end
|
||||
|
||||
cy = cy + rowH + rowGap
|
||||
end
|
||||
end
|
||||
|
||||
-- Pager info & Controls guide
|
||||
local bottomY = py + modalH - pad - math.floor(18 * s)
|
||||
local pageInfo = string.format("Page %d of %d (%d items)", FileBrowser.page, math.max(1, math.ceil(total / perPage)), total)
|
||||
Kit.text("micro", pageInfo, px + pad, bottomY, PAL.muted)
|
||||
|
||||
local guide = "A: Open/Select B: Parent X: Shortcut Start: Confirm Select: Cancel"
|
||||
Kit.textRight("micro", guide, px + modalW - pad, bottomY, PAL.muted)
|
||||
|
||||
Kit.blockClicks = false
|
||||
end
|
||||
|
||||
return FileBrowser
|
||||
+178
-54
@@ -43,19 +43,22 @@
|
||||
|
||||
local Theme = require("src.ui.kit.Theme")
|
||||
local PAL = Theme.PAL
|
||||
local VirtualKeyboard = require("src.ui.kit.VirtualKeyboard")
|
||||
local FileBrowser = require("src.ui.kit.FileBrowser")
|
||||
|
||||
local Kit = {}
|
||||
Kit.Theme = Theme
|
||||
Kit.PAL = PAL
|
||||
local Kit = {
|
||||
scale = 1,
|
||||
time = 0,
|
||||
focus = nil, -- active text-input id (nil = no focused field)
|
||||
focusId = nil, -- spatial-nav ring id (nil = nothing selected by pad/arrows)
|
||||
VirtualKeyboard = VirtualKeyboard,
|
||||
FileBrowser = FileBrowser,
|
||||
}
|
||||
|
||||
Kit.mouseX, Kit.mouseY = 0, 0
|
||||
Kit.mouseClicked = false -- left button pressed this frame
|
||||
Kit.mouseDown = false -- held, polled (drag / press-and-hold)
|
||||
Kit.wheelY = 0 -- wheel notches queued since the last frame
|
||||
Kit.focus = nil -- id of the text field receiving keystrokes
|
||||
Kit.focusId = nil -- id of the keyboard/gamepad focus ring target
|
||||
Kit.time = 0
|
||||
Kit.fonts = {}
|
||||
Kit.scale = 1
|
||||
Kit.blockClicks = false
|
||||
Kit.audit = nil
|
||||
@@ -339,6 +342,32 @@ function Kit.beginFrame(mx, my, clicked, wheel)
|
||||
Kit._navN = 0
|
||||
end
|
||||
|
||||
local function getNavLayer(slot)
|
||||
if not slot then return 3 end
|
||||
local id = tostring(slot.id or "")
|
||||
local y = slot.y or 0
|
||||
|
||||
-- Layer 1: Top Bar (Settings / Gear, Close / Quit)
|
||||
if id == "gear" or id == "settings" or id == "close" or id == "quit" or (y < 45 * Kit.scale and not id:match("^tab%-")) then
|
||||
return 1
|
||||
end
|
||||
|
||||
-- Layer 2: ROM Select & Feature Tabs (Red, Blue, Yellow, Gold, Silver, Crystal, Mods, Find, Skins, Bug)
|
||||
if id:match("^tab%-") or (y >= 45 * Kit.scale and y < 105 * Kit.scale and (slot.h and slot.h < 50 * Kit.scale)) then
|
||||
return 2
|
||||
end
|
||||
|
||||
-- Layer 4: Footer (Patch notes, Updater, BCG, Export, etc.)
|
||||
local screenH = (love and love.graphics and love.graphics.getHeight and love.graphics.getHeight()) or 480
|
||||
if id == "patch-notes" or id == "updater" or id == "bcg"
|
||||
or id:match("^footer") or (y > screenH - 55 * Kit.scale) then
|
||||
return 4
|
||||
end
|
||||
|
||||
-- Layer 3: Main content panel (Import ROM / Play, Manage, Save slots, Mods list, Find mods, etc.)
|
||||
return 3
|
||||
end
|
||||
|
||||
-- Retire this frame's keystrokes, wheel notches and one-shot activations.
|
||||
-- Anything typed while no field had focus is dropped here rather than
|
||||
-- replayed into the next field that gets clicked.
|
||||
@@ -349,10 +378,26 @@ function Kit.endFrame()
|
||||
-- This frame's focusables become next frame's navigation graph.
|
||||
local n = Kit._navN or 0
|
||||
Kit._navPrevN = n
|
||||
-- If the focused id vanished (panel switch, list repaged), park the ring
|
||||
-- on the first focusable so the keyboard is never stranded.
|
||||
if Kit.focusId and not Kit._navSeen[Kit.focusId] and n > 0 then
|
||||
Kit.focusId = Kit._nav[1] and Kit._nav[1].id or nil
|
||||
-- If the focused id vanished or not set yet, park the ring on Layer 3 (Import ROM / Play)
|
||||
if (not Kit.focusId or not Kit._navSeen[Kit.focusId]) and n > 0 then
|
||||
-- 1. Prefer ROM Action (Layer 3)
|
||||
local chosen = nil
|
||||
for i = 1, n do
|
||||
local slot = Kit._nav[i]
|
||||
if slot and slot.id and tostring(slot.id):match("^rom%-") then
|
||||
chosen = slot.id; break
|
||||
end
|
||||
end
|
||||
-- 2. Prefer any Layer 3 control
|
||||
if not chosen then
|
||||
for i = 1, n do
|
||||
local slot = Kit._nav[i]
|
||||
if slot and getNavLayer(slot) == 3 then
|
||||
chosen = slot.id; break
|
||||
end
|
||||
end
|
||||
end
|
||||
Kit.focusId = chosen or (Kit._nav[1] and Kit._nav[1].id) or nil
|
||||
end
|
||||
for k in pairs(Kit._navSeen) do Kit._navSeen[k] = nil end
|
||||
end
|
||||
@@ -360,21 +405,16 @@ end
|
||||
-- ------------------------------------------------------------ focus ring
|
||||
-- Spatial navigation. Every focusable control registers its rect as it
|
||||
-- draws; a queued direction picks the nearest candidate in that direction
|
||||
-- from the previous frame's set. Spatial rather than index-order because
|
||||
-- the launcher is a multi-column layout: tab-order would zigzag between
|
||||
-- columns, while "press right, go right" is what both a keyboard and a
|
||||
-- d-pad user expects.
|
||||
-- from the previous frame's set.
|
||||
Kit._nav = {}
|
||||
Kit._navN = 0
|
||||
Kit._navPrevN = 0
|
||||
Kit._navSeen = {}
|
||||
Kit._navQueue = nil
|
||||
Kit._activateId = nil
|
||||
Kit._ringShown = false
|
||||
Kit._ringShown = true
|
||||
|
||||
-- Register a focusable. Returns true when it currently holds the ring.
|
||||
-- Shielded widgets do not register: while a modal owns the frame the ring
|
||||
-- must not wander through (or Enter-activate) the controls underneath it.
|
||||
function Kit.focusable(id, x, y, w, h)
|
||||
if Kit.blockClicks then return false end
|
||||
local n = (Kit._navN or 0) + 1
|
||||
@@ -383,9 +423,9 @@ function Kit.focusable(id, x, y, w, h)
|
||||
if not slot then slot = {}; Kit._nav[n] = slot end
|
||||
slot.id, slot.x, slot.y, slot.w, slot.h = id, x, y, w, h
|
||||
Kit._navSeen[id] = true
|
||||
-- First focusable ever drawn adopts the ring, so keyboard users start
|
||||
-- somewhere rather than nowhere.
|
||||
if Kit.focusId == nil then Kit.focusId = id end
|
||||
if Kit.focusId == nil and tostring(id):match("^rom%-") then
|
||||
Kit.focusId = id
|
||||
end
|
||||
return Kit._ringShown and Kit.focusId == id
|
||||
end
|
||||
|
||||
@@ -403,10 +443,6 @@ function Kit.setFocus(id)
|
||||
Kit._ringShown = id ~= nil
|
||||
end
|
||||
|
||||
-- Pick the nearest focusable in `dir` from the current one. Candidates must
|
||||
-- lie in the half-plane of the direction; the score prefers a small step
|
||||
-- along the axis of travel and penalises drift across it, which keeps a
|
||||
-- column walk inside its column.
|
||||
function Kit._resolveNav()
|
||||
local dir = Kit._navQueue
|
||||
Kit._navQueue = nil
|
||||
@@ -414,38 +450,75 @@ function Kit._resolveNav()
|
||||
if not dir or n == 0 then return end
|
||||
local cur
|
||||
for i = 1, n do
|
||||
if Kit._nav[i].id == Kit.focusId then cur = Kit._nav[i] break end
|
||||
if Kit._nav[i].id == Kit.focusId then cur = Kit._nav[i]; break end
|
||||
end
|
||||
if not cur then
|
||||
Kit.focusId = Kit._nav[1].id
|
||||
return
|
||||
end
|
||||
|
||||
local curLayer = getNavLayer(cur)
|
||||
local cx, cy = cur.x + cur.w / 2, cur.y + cur.h / 2
|
||||
local best, bestScore
|
||||
for i = 1, n do
|
||||
local c = Kit._nav[i]
|
||||
if c.id ~= cur.id then
|
||||
local dx = (c.x + c.w / 2) - cx
|
||||
local dy = (c.y + c.h / 2) - cy
|
||||
local along, across
|
||||
if dir == "left" then along, across = -dx, math.abs(dy)
|
||||
elseif dir == "right" then along, across = dx, math.abs(dy)
|
||||
elseif dir == "up" then along, across = -dy, math.abs(dx)
|
||||
else along, across = dy, math.abs(dx) end
|
||||
-- A control merely overlapping on the travel axis is not "in that
|
||||
-- direction"; require real separation so a tall row's neighbours do
|
||||
-- not all qualify.
|
||||
if along > 1 then
|
||||
local score = along + across * 2
|
||||
if not bestScore or score < bestScore then best, bestScore = c, score end
|
||||
|
||||
if dir == "left" or dir == "right" then
|
||||
-- STRICT SAME-LAYER HORIZONTAL NAVIGATION (Left/Right NEVER jumps between layers)
|
||||
local best, bestDx
|
||||
for i = 1, n do
|
||||
local c = Kit._nav[i]
|
||||
if c.id ~= cur.id and getNavLayer(c) == curLayer then
|
||||
local cMidX = c.x + c.w / 2
|
||||
local dx = cMidX - cx
|
||||
if dir == "left" and dx < -1 then
|
||||
local dist = -dx
|
||||
if not bestDx or dist < bestDx then
|
||||
best, bestDx = c, dist
|
||||
end
|
||||
elseif dir == "right" and dx > 1 then
|
||||
local dist = dx
|
||||
if not bestDx or dist < bestDx then
|
||||
best, bestDx = c, dist
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if best then
|
||||
Kit.focusId = best.id
|
||||
return
|
||||
end
|
||||
return
|
||||
elseif dir == "up" or dir == "down" then
|
||||
-- VERTICAL LAYER NAVIGATION (Up/Down steps between layers: 1 <-> 2 <-> 3 <-> 4)
|
||||
local targetLayer = dir == "up" and (curLayer - 1) or (curLayer + 1)
|
||||
targetLayer = math.max(1, math.min(4, targetLayer))
|
||||
|
||||
local best, bestScore
|
||||
for i = 1, n do
|
||||
local c = Kit._nav[i]
|
||||
if c.id ~= cur.id then
|
||||
local l = getNavLayer(c)
|
||||
if (dir == "up" and l < curLayer) or (dir == "down" and l > curLayer) then
|
||||
local layerDiff = math.abs(l - targetLayer)
|
||||
local cMidX = c.x + c.w / 2
|
||||
local xDist = math.abs(cMidX - cx)
|
||||
local score = layerDiff * 10000 + xDist
|
||||
if not bestScore or score < bestScore then
|
||||
best, bestScore = c, score
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if best then
|
||||
Kit.focusId = best.id
|
||||
return
|
||||
end
|
||||
end
|
||||
if best then Kit.focusId = best.id end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------ input plumbing
|
||||
function Kit.textinput(text)
|
||||
if VirtualKeyboard.active then
|
||||
return VirtualKeyboard.textinput(text)
|
||||
end
|
||||
if not Kit.focus then return false end
|
||||
edits[#edits + 1] = text
|
||||
return true
|
||||
@@ -454,6 +527,12 @@ end
|
||||
-- Returns true when the key was consumed, so the host can leave its own
|
||||
-- shortcuts alone while the user is typing or driving the ring.
|
||||
function Kit.keypressed(key)
|
||||
if VirtualKeyboard.active then
|
||||
return VirtualKeyboard.keypressed(key)
|
||||
end
|
||||
if FileBrowser.active then
|
||||
return FileBrowser.keypressed(key)
|
||||
end
|
||||
if Kit.focus then
|
||||
if key == "backspace" then edits[#edits + 1] = "\b" return true
|
||||
elseif key == "return" or key == "kpenter" or key == "escape" then
|
||||
@@ -474,11 +553,19 @@ end
|
||||
|
||||
-- Gamepad d-pad / stick, routed by the host's pad handling.
|
||||
function Kit.gamepadpressed(button)
|
||||
if button == "dpup" then Kit.navigate("up") return true
|
||||
elseif button == "dpdown" then Kit.navigate("down") return true
|
||||
elseif button == "dpleft" then Kit.navigate("left") return true
|
||||
elseif button == "dpright" then Kit.navigate("right") return true
|
||||
elseif button == "a" then Kit.activateFocused() return true end
|
||||
local GamepadMap = require("src.core.GamepadMap")
|
||||
local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
|
||||
if VirtualKeyboard.active then
|
||||
return VirtualKeyboard.gamepadpressed(action)
|
||||
end
|
||||
if FileBrowser.active then
|
||||
return FileBrowser.gamepadpressed(action)
|
||||
end
|
||||
if action == "dpup" then Kit.navigate("up") return true
|
||||
elseif action == "dpdown" then Kit.navigate("down") return true
|
||||
elseif action == "dpleft" then Kit.navigate("left") return true
|
||||
elseif action == "dpright" then Kit.navigate("right") return true
|
||||
elseif action == "a" then Kit.activateFocused() return true end
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -548,9 +635,15 @@ function Kit.row(x, y, w, h, selected, id)
|
||||
-- The focus ring is a second inset outline, so it reads on both a black
|
||||
-- row and a white selected one.
|
||||
if focused then
|
||||
Theme.strokeRounded(x + 2, y + 2, w - 4, h - 4,
|
||||
selected and PAL.inverse or PAL.lineStrong, Theme.A.focus, 1,
|
||||
Theme.radius())
|
||||
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
|
||||
-- Outer white soft aura
|
||||
Theme.strokeRounded(x - 3, y - 3, w + 6, h + 6,
|
||||
PAL.ink, 0.35 + 0.25 * glowPulse, 2.5, Theme.radius() + 3)
|
||||
-- Bright white inner stroke
|
||||
Theme.strokeRounded(x, y, w, h,
|
||||
PAL.ink, 0.95 + 0.05 * glowPulse, 2, Theme.radius())
|
||||
-- Luminous white fill overlay
|
||||
Theme.fillRounded(x, y, w, h, PAL.ink, 0.12 + 0.06 * glowPulse, Theme.radius())
|
||||
end
|
||||
local clicked = Kit.press(x, y, w, h)
|
||||
or (id ~= nil and Kit._activateId == id)
|
||||
@@ -668,9 +761,17 @@ function Kit.button(x, y, w, h, label, opts)
|
||||
Theme.strokeRounded(x, y, w, h, stroke, strokeA, 1, radius)
|
||||
end
|
||||
if doRing then
|
||||
local glowPulse = 0.5 + 0.5 * math.sin(Kit.time * 5)
|
||||
-- 1. Outer bright white soft glow aura
|
||||
Theme.strokeRounded(x - B.ringPad - 3, y - B.ringPad - 3,
|
||||
w + 2 * (B.ringPad + 3), h + 2 * (B.ringPad + 3), PAL.ink,
|
||||
0.35 + 0.25 * glowPulse, 2.5, radius + B.ringPad + 3)
|
||||
-- 2. Inner solid white focus border
|
||||
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
|
||||
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
|
||||
Theme.A.focus, B.ringWidth, radius + B.ringPad)
|
||||
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.ink,
|
||||
0.95 + 0.05 * glowPulse, 2.5, radius + B.ringPad)
|
||||
-- 3. Subtle luminous white fill overlay so the entire button body shines
|
||||
Theme.fillRounded(x, y, w, h, PAL.ink, 0.12 + 0.06 * glowPulse, radius)
|
||||
elseif glowA then
|
||||
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
|
||||
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
|
||||
@@ -829,6 +930,29 @@ function Kit.textfield(id, x, y, w, h, value, placeholder)
|
||||
audit("control", x, y, w, h, id)
|
||||
local focusRing = Kit.focusable(id, x, y, w, h)
|
||||
value = tostring(value or "")
|
||||
|
||||
local isHandheld = os.getenv("HANDHELD") == "1" or os.getenv("PORTMASTER") == "1"
|
||||
or os.getenv("POKEPORT_HANDHELD") == "1" or os.getenv("TRIMUI") == "1"
|
||||
or os.getenv("MUOS") == "1" or os.getenv("KNULLI") == "1"
|
||||
|
||||
local clickedOrActivated = (Kit._activateId == id) or (isHandheld and Kit.press(x, y, w, h))
|
||||
if clickedOrActivated and not mobile() then
|
||||
VirtualKeyboard.open({
|
||||
text = value,
|
||||
targetId = id,
|
||||
title = placeholder or "Enter Text",
|
||||
onDone = function(newText, confirmed)
|
||||
if confirmed then
|
||||
edits[#edits + 1] = "\r"
|
||||
end
|
||||
end
|
||||
})
|
||||
end
|
||||
|
||||
if VirtualKeyboard.active and VirtualKeyboard.targetId == id then
|
||||
value = VirtualKeyboard.text
|
||||
end
|
||||
|
||||
if Kit.press(x, y, w, h) or (Kit._activateId == id) then Kit.focus = id end
|
||||
local focused = (Kit.focus == id)
|
||||
if focused then
|
||||
|
||||
Executable
+265
@@ -0,0 +1,265 @@
|
||||
-- Controller-driven on-screen keyboard (OSK) for handhelds and gamepads.
|
||||
-- Allows typing URLs (mod index, repositories), mod search queries, and custom names
|
||||
-- without needing a physical keyboard.
|
||||
|
||||
local Theme = require("src.ui.kit.Theme")
|
||||
local PAL = Theme.PAL
|
||||
local Kit = nil
|
||||
local function getKit()
|
||||
if not Kit then Kit = require("src.ui.kit.Kit") end
|
||||
return Kit
|
||||
end
|
||||
|
||||
local VirtualKeyboard = {
|
||||
active = false,
|
||||
text = "",
|
||||
title = "Enter Text",
|
||||
targetId = nil,
|
||||
onDone = nil,
|
||||
row = 1,
|
||||
col = 1,
|
||||
mode = 1, -- 1: lower, 2: upper, 3: symbols
|
||||
}
|
||||
|
||||
local ROWS_LOWER = {
|
||||
{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=" },
|
||||
{ "q", "w", "e", "r", "t", "y", "u", "i", "o", "p", "/", ":" },
|
||||
{ "a", "s", "d", "f", "g", "h", "j", "k", "l", ".", "_", "@" },
|
||||
{ "z", "x", "c", "v", "b", "n", "m", "?", "!", "&", "%", "#" },
|
||||
{ { id = "shift", label = "Shift", w = 2 }, { id = "sym", label = "Sym", w = 2 }, { id = "space", label = "Space", w = 4 }, { id = "back", label = "Bksp", w = 2 }, { id = "done", label = "Done", w = 2 } },
|
||||
{ { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } },
|
||||
}
|
||||
|
||||
local ROWS_UPPER = {
|
||||
{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-", "=" },
|
||||
{ "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "/", ":" },
|
||||
{ "A", "S", "D", "F", "G", "H", "J", "K", "L", ".", "_", "@" },
|
||||
{ "Z", "X", "C", "V", "B", "N", "M", "?", "!", "&", "%", "#" },
|
||||
{ { id = "shift", label = "shift", w = 2 }, { id = "sym", label = "Sym", w = 2 }, { id = "space", label = "Space", w = 4 }, { id = "back", label = "Bksp", w = 2 }, { id = "done", label = "Done", w = 2 } },
|
||||
{ { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } },
|
||||
}
|
||||
|
||||
local ROWS_SYM = {
|
||||
{ "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "_", "+" },
|
||||
{ "~", "`", "{", "}", "[", "]", "|", "\\", ";", ":", "'", "\"" },
|
||||
{ "<", ">", ",", ".", "/", "?", "=", "-", "*", "/", "\\", "~" },
|
||||
{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", ".", "=" },
|
||||
{ { id = "shift", label = "ABC", w = 2 }, { id = "sym", label = "123", w = 2 }, { id = "space", label = "Space", w = 4 }, { id = "back", label = "Bksp", w = 2 }, { id = "done", label = "Done", w = 2 } },
|
||||
{ { id = "url_https", label = "https://", w = 3 }, { id = "url_http", label = "http://", w = 2 }, { id = "url_json", label = ".json", w = 2 }, { id = "url_zip", label = ".zip", w = 2 }, { id = "clear", label = "Clear", w = 3 } },
|
||||
}
|
||||
|
||||
function VirtualKeyboard.open(opts)
|
||||
opts = opts or {}
|
||||
VirtualKeyboard.active = true
|
||||
VirtualKeyboard.text = tostring(opts.text or "")
|
||||
VirtualKeyboard.title = opts.title or "Enter Text"
|
||||
VirtualKeyboard.targetId = opts.targetId
|
||||
VirtualKeyboard.onDone = opts.onDone
|
||||
VirtualKeyboard.row = 1
|
||||
VirtualKeyboard.col = 1
|
||||
VirtualKeyboard.mode = 1
|
||||
end
|
||||
|
||||
function VirtualKeyboard.close(confirmed)
|
||||
if not VirtualKeyboard.active then return end
|
||||
local txt = VirtualKeyboard.text
|
||||
local cb = VirtualKeyboard.onDone
|
||||
VirtualKeyboard.active = false
|
||||
VirtualKeyboard.targetId = nil
|
||||
VirtualKeyboard.onDone = nil
|
||||
if cb then
|
||||
cb(txt, confirmed == true)
|
||||
end
|
||||
end
|
||||
|
||||
local function currentRows()
|
||||
if VirtualKeyboard.mode == 2 then return ROWS_UPPER end
|
||||
if VirtualKeyboard.mode == 3 then return ROWS_SYM end
|
||||
return ROWS_LOWER
|
||||
end
|
||||
|
||||
local function triggerKey(key)
|
||||
if type(key) == "string" then
|
||||
VirtualKeyboard.text = VirtualKeyboard.text .. key
|
||||
elseif type(key) == "table" then
|
||||
if key.id == "shift" then
|
||||
VirtualKeyboard.mode = (VirtualKeyboard.mode == 2) and 1 or 2
|
||||
elseif key.id == "sym" then
|
||||
VirtualKeyboard.mode = (VirtualKeyboard.mode == 3) and 1 or 3
|
||||
elseif key.id == "space" then
|
||||
VirtualKeyboard.text = VirtualKeyboard.text .. " "
|
||||
elseif key.id == "back" then
|
||||
VirtualKeyboard.text = VirtualKeyboard.text:sub(1, -2)
|
||||
elseif key.id == "clear" then
|
||||
VirtualKeyboard.text = ""
|
||||
elseif key.id == "done" then
|
||||
VirtualKeyboard.close(true)
|
||||
elseif key.id == "url_https" then
|
||||
VirtualKeyboard.text = VirtualKeyboard.text .. "https://"
|
||||
elseif key.id == "url_http" then
|
||||
VirtualKeyboard.text = VirtualKeyboard.text .. "http://"
|
||||
elseif key.id == "url_json" then
|
||||
VirtualKeyboard.text = VirtualKeyboard.text .. ".json"
|
||||
elseif key.id == "url_zip" then
|
||||
VirtualKeyboard.text = VirtualKeyboard.text .. ".zip"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function VirtualKeyboard.gamepadpressed(button)
|
||||
if not VirtualKeyboard.active then return false end
|
||||
|
||||
local rows = currentRows()
|
||||
local r = VirtualKeyboard.row
|
||||
local c = VirtualKeyboard.col
|
||||
|
||||
if button == "dpup" then
|
||||
VirtualKeyboard.row = math.max(1, r - 1)
|
||||
local maxC = #rows[VirtualKeyboard.row]
|
||||
if VirtualKeyboard.col > maxC then VirtualKeyboard.col = maxC end
|
||||
return true
|
||||
elseif button == "dpdown" then
|
||||
VirtualKeyboard.row = math.min(#rows, r + 1)
|
||||
local maxC = #rows[VirtualKeyboard.row]
|
||||
if VirtualKeyboard.col > maxC then VirtualKeyboard.col = maxC end
|
||||
return true
|
||||
elseif button == "dpleft" then
|
||||
VirtualKeyboard.col = math.max(1, c - 1)
|
||||
return true
|
||||
elseif button == "dpright" then
|
||||
VirtualKeyboard.col = math.min(#rows[r], c + 1)
|
||||
return true
|
||||
elseif button == "a" then
|
||||
local key = rows[r] and rows[r][c]
|
||||
if key then triggerKey(key) end
|
||||
return true
|
||||
elseif button == "b" then
|
||||
VirtualKeyboard.close(false)
|
||||
return true
|
||||
elseif button == "x" then
|
||||
VirtualKeyboard.text = VirtualKeyboard.text:sub(1, -2)
|
||||
return true
|
||||
elseif button == "y" then
|
||||
VirtualKeyboard.text = VirtualKeyboard.text .. " "
|
||||
return true
|
||||
elseif button == "leftshoulder" or button == "rightshoulder" then
|
||||
VirtualKeyboard.mode = (VirtualKeyboard.mode % 3) + 1
|
||||
local maxC = #currentRows()[VirtualKeyboard.row]
|
||||
if VirtualKeyboard.col > maxC then VirtualKeyboard.col = maxC end
|
||||
return true
|
||||
elseif button == "start" then
|
||||
VirtualKeyboard.close(true)
|
||||
return true
|
||||
end
|
||||
|
||||
return true -- consume all input while keyboard modal is up
|
||||
end
|
||||
|
||||
function VirtualKeyboard.keypressed(key)
|
||||
if not VirtualKeyboard.active then return false end
|
||||
if key == "escape" then
|
||||
VirtualKeyboard.close(false)
|
||||
return true
|
||||
elseif key == "return" then
|
||||
VirtualKeyboard.close(true)
|
||||
return true
|
||||
elseif key == "backspace" then
|
||||
VirtualKeyboard.text = VirtualKeyboard.text:sub(1, -2)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function VirtualKeyboard.textinput(text)
|
||||
if not VirtualKeyboard.active then return false end
|
||||
VirtualKeyboard.text = VirtualKeyboard.text .. text
|
||||
return true
|
||||
end
|
||||
|
||||
function VirtualKeyboard.draw(m)
|
||||
if not VirtualKeyboard.active then return end
|
||||
|
||||
local Kit = getKit()
|
||||
local s = m and m.s or Kit.scale or 1
|
||||
local W = (m and m.W) or (love.graphics and love.graphics.getWidth()) or 640
|
||||
local H = (m and m.H) or (love.graphics and love.graphics.getHeight()) or 480
|
||||
|
||||
-- Scrim background
|
||||
Theme.fill(0, 0, W, H, PAL.bg, 0.92)
|
||||
Kit.blockClicks = true
|
||||
|
||||
local pad = math.floor(12 * s)
|
||||
local modalW = math.floor(math.min(540 * s, W - 20 * s))
|
||||
local modalH = math.floor(math.min(360 * s, H - 20 * s))
|
||||
local px = math.floor((W - modalW) / 2)
|
||||
local py = math.floor((H - modalH) / 2)
|
||||
|
||||
Kit.card(px, py, modalW, modalH, true)
|
||||
|
||||
local cy = py + pad
|
||||
-- Title & hint
|
||||
Kit.text("button", VirtualKeyboard.title, px + pad, cy, PAL.heading)
|
||||
Kit.textRight("micro", "L1/R1: Mode X: Bksp Y: Space Start: Done B: Cancel",
|
||||
px + modalW - pad, cy + 2 * s, PAL.muted)
|
||||
|
||||
cy = cy + Kit.textHeight("button") + math.floor(8 * s)
|
||||
|
||||
-- Preview textfield box
|
||||
local fieldH = math.floor(32 * s)
|
||||
Theme.fillRounded(px + pad, cy, modalW - 2 * pad, fieldH, PAL.bg, 1)
|
||||
Theme.strokeRounded(px + pad, cy, modalW - 2 * pad, fieldH, PAL.lineStrong, 1, 2)
|
||||
|
||||
local shown = Kit.ellipsizeLeft("mono", VirtualKeyboard.text .. "_", modalW - 4 * pad)
|
||||
Kit.text("mono", shown, px + pad + math.floor(8 * s), cy + math.floor(7 * s), PAL.heading)
|
||||
|
||||
cy = cy + fieldH + math.floor(10 * s)
|
||||
|
||||
-- Keyboard Rows
|
||||
local rows = currentRows()
|
||||
local rowH = math.floor(30 * s)
|
||||
local gap = math.floor(4 * s)
|
||||
local availableW = modalW - 2 * pad
|
||||
|
||||
for rIdx, rData in ipairs(rows) do
|
||||
local totalUnits = 0
|
||||
for _, key in ipairs(rData) do
|
||||
totalUnits = totalUnits + (type(key) == "table" and (key.w or 1) or 1)
|
||||
end
|
||||
|
||||
local unitW = (availableW - (gap * (#rData - 1))) / totalUnits
|
||||
local kx = px + pad
|
||||
|
||||
for cIdx, key in ipairs(rData) do
|
||||
local units = type(key) == "table" and (key.w or 1) or 1
|
||||
local kw = math.floor(unitW * units)
|
||||
local isSelected = (VirtualKeyboard.row == rIdx and VirtualKeyboard.col == cIdx)
|
||||
|
||||
local label = type(key) == "table" and key.label or key
|
||||
local isSpecial = type(key) == "table"
|
||||
|
||||
-- Click / touch detection
|
||||
if Kit.press(kx, cy, kw, rowH) then
|
||||
VirtualKeyboard.row = rIdx
|
||||
VirtualKeyboard.col = cIdx
|
||||
triggerKey(key)
|
||||
end
|
||||
|
||||
if isSelected then
|
||||
Theme.fillRounded(kx, cy, kw, rowH, PAL.ink, 1)
|
||||
Kit.textCenterBold("small", label, kx, cy + math.floor(6 * s), kw, PAL.inverse)
|
||||
else
|
||||
Theme.fillRounded(kx, cy, kw, rowH, isSpecial and PAL.raised or PAL.surface, 1)
|
||||
Theme.strokeRounded(kx, cy, kw, rowH, PAL.line, Theme.A.hairline, 1)
|
||||
Kit.textCenter("small", label, kx, cy + math.floor(6 * s), kw, isSpecial and PAL.heading or PAL.text)
|
||||
end
|
||||
|
||||
kx = kx + kw + gap
|
||||
end
|
||||
|
||||
cy = cy + rowH + gap
|
||||
end
|
||||
|
||||
Kit.blockClicks = false
|
||||
end
|
||||
|
||||
return VirtualKeyboard
|
||||
+11
-1
@@ -43,10 +43,13 @@ local function fullAssetName(version, osName, arch, port)
|
||||
return "gen1recomp-" .. version .. "-xbox-uwp.zip"
|
||||
elseif osName == "NX" then
|
||||
return "gen1recomp-" .. version .. "-switch.zip"
|
||||
elseif osName == "Linux" and (port == "flatpak"
|
||||
or (type(os.getenv) == "function" and os.getenv("FLATPAK_ID"))) then
|
||||
return "gen1recomp-" .. version .. "-linux.flatpak"
|
||||
elseif osName == "Linux" and (arch == "arm64" or arch == "aarch64") then
|
||||
return "gen1recomp-" .. version .. "-linux-arm64.AppImage"
|
||||
elseif osName == "Linux" then
|
||||
return "gen1recomp-" .. version .. "-linux.zip"
|
||||
return "gen1recomp-" .. version .. "-linux-x86_64.AppImage"
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -288,6 +291,13 @@ function Check.fullUpdateAction()
|
||||
"https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json" }
|
||||
elseif osName == "UWP" then
|
||||
return { label = "Open Xbox install guide", url = Check.releaseUrl() }
|
||||
elseif osName == "Linux" and type(st.full) == "table"
|
||||
and type(st.full.url) == "string" then
|
||||
local name = type(st.full.name) == "string" and st.full.name or ""
|
||||
if name:find("%.flatpak$") or os.getenv("FLATPAK_ID") then
|
||||
return { label = "Download Flatpak update", url = st.full.url }
|
||||
end
|
||||
return { label = "Download AppImage update", url = st.full.url }
|
||||
elseif type(st.full) == "table" and type(st.full.url) == "string" then
|
||||
return { label = "Download full update", url = st.full.url }
|
||||
end
|
||||
|
||||
@@ -310,8 +310,13 @@ local function launchDownload(url, partAbs, doneAbs)
|
||||
.. "type nul > \"" .. doneAbs .. "\"\r\n")
|
||||
os.execute('start "" /b ' .. shq(saveDir .. "/" .. batRel))
|
||||
else
|
||||
-- ( ... ) & backgrounds the whole group so os.execute returns at once
|
||||
os.execute("( " .. HostShell.envPrefix() .. "curl -fsSL --connect-timeout 15 --max-time 900 -o "
|
||||
-- ( ... ) & backgrounds the whole group so os.execute returns at once.
|
||||
-- Use the same dual-env curl resolution as HostShell.http*: bundled
|
||||
-- AppDir/Flatpak curl keeps APPDIR libs; host curl scrubs them.
|
||||
local curlPath, curlKind = HostShell.resolveCurl()
|
||||
os.execute("( " .. HostShell.curlEnvPrefix(curlKind)
|
||||
.. HostShell.quote(curlPath)
|
||||
.. " -fsSL --connect-timeout 15 --max-time 900 -o "
|
||||
.. shq(partAbs) .. " " .. shq(url)
|
||||
.. " ; touch " .. shq(doneAbs) .. " ) >/dev/null 2>&1 &")
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
-- SpecialKabutoChamber, the four routines that open the chambers' walls.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Font = require("src.render.Font")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
@@ -189,7 +190,7 @@ function UnownWords:draw()
|
||||
local wall = self.wall
|
||||
if not wall then return end
|
||||
local bx, by, bw, bh = UnownWords.boxRect(wall)
|
||||
Font.drawBox(bx, by, bw, bh)
|
||||
Chrome.paletteBox(bx, by, bw, bh)
|
||||
local tileset, atlas = self:tileset()
|
||||
if not tileset then return end
|
||||
local perRow = tileset.tilesPerRow or 16
|
||||
|
||||
@@ -8217,7 +8217,8 @@ function World:mapCacheKey(mapId)
|
||||
local daytime = self.daytime
|
||||
local flicker = (daytime == "DARK") and self.flickerPhase or 1
|
||||
return mapId .. "|" .. tostring(daytime)
|
||||
.. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker)
|
||||
.. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(GbcPalette.customRamp)
|
||||
.. "|" .. tostring(flicker)
|
||||
end
|
||||
|
||||
function World:imageFor(mapId)
|
||||
@@ -8440,7 +8441,8 @@ function World:borderImageFor(mapId)
|
||||
-- stale bake (#1418).
|
||||
local waterFrame = self:borderWaterFrame(def, tileset)
|
||||
local cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime)
|
||||
.. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker)
|
||||
.. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(GbcPalette.customRamp)
|
||||
.. "|" .. tostring(flicker)
|
||||
.. "|" .. tostring(BorderFill.voidFill or "fade")
|
||||
.. "|" .. tostring(blockId)
|
||||
.. "|" .. tostring(waterFrame and waterFrame.row or 0))
|
||||
@@ -10500,12 +10502,12 @@ end
|
||||
-- The COLOR option can change under a standing world (the hotkey, or the
|
||||
-- OPTION screen closing), and the map is a baked canvas rather than a live
|
||||
-- draw -- so the cached references have to be re-fetched when it does. The
|
||||
-- bakes themselves are keyed by mode in imageFor, so this is a pointer swap
|
||||
-- after the first time each mode is seen, not a re-bake.
|
||||
function World:refreshColorMode()
|
||||
local mode = GbcPalette.mode
|
||||
if self.colorMode == mode then return end
|
||||
local ramp = GbcPalette.customRamp
|
||||
if self.colorMode == mode and self.colorRamp == ramp then return end
|
||||
self.colorMode = mode
|
||||
self.colorRamp = ramp
|
||||
if not self.map then return end
|
||||
self.mapImage = self:imageFor(self.map.id)
|
||||
self:rebuildNeighbors()
|
||||
|
||||
@@ -20,8 +20,8 @@ return function(game)
|
||||
end
|
||||
assert(layoutRow and hudRow, "battle layout/HUD rows are present")
|
||||
|
||||
menu.index = 6
|
||||
menu.scroll = 3
|
||||
-- BATTLE HUD lives on the BATTLE OPTIONS page now, so focus it there.
|
||||
assert(menu:focusRow("battleHud"), "BATTLE HUD row is reachable")
|
||||
assert(hudRow.value(game) == "EXTENDED", "WIDE displays EXTENDED")
|
||||
assert(U.shot(game, BEFORE), "WIDE/EXTENDED screenshot was written")
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user