Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83463a5a59 | |||
| d66a72ac95 | |||
| 1b659dab01 | |||
| 9e01fe2c2c | |||
| 5fa5005786 | |||
| 1ac5b867bb | |||
| 69ef1bfc77 | |||
| 9ed7e05dc1 | |||
| 25166ff3a1 | |||
| c777e85641 | |||
| 06299328f5 | |||
| 5b19259928 | |||
| c2b6a7b937 | |||
| 7c9c2380d2 | |||
| 2468d5042d | |||
| 51c4766ead | |||
| ec9dc29646 | |||
| 934a4c55ca | |||
| 72592665d7 | |||
| 8f88d01cf2 | |||
| 2279617b29 | |||
| 17fbf6cec4 | |||
| 34c4481f96 | |||
| bff40a5d90 | |||
| 354a8b476d | |||
| f0d3c014a7 | |||
| 0dd889b35b | |||
| 032f894f7f | |||
| 9922e235c6 | |||
| 667267d9bb | |||
| ecdea61cfd | |||
| 872d6b4516 | |||
| 518d61e039 | |||
| 4349a1142f | |||
| b36d38815f | |||
| e24f812475 | |||
| f8ba51636b | |||
| fb97318e87 | |||
| fc83ecd52f | |||
| 5ba49bdf3f | |||
| c01bda3570 | |||
| 74e04cb086 | |||
| 6588901e9a | |||
| 0ea224d5db | |||
| c2e1db0f89 | |||
| 4c13770e70 | |||
| cf45cbbf92 | |||
| 7d1ddf9b7c | |||
| faf82c2cec | |||
| 142d1358dd | |||
| e0e030003b | |||
| ce2afb83f1 | |||
| 28f741f72f | |||
| ea28f886f3 | |||
| 6cd8f0ddea | |||
| fb4eaeda10 | |||
| 69100301a1 | |||
| 114352b75f | |||
| 0e40a7a1f4 |
@@ -12,10 +12,11 @@ name: ci
|
|||||||
#
|
#
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
# Integration branch + release branch. PRs already run via pull_request
|
|
||||||
# (any base); this list is only for post-merge push runs.
|
|
||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
|
# PRs into dev only: a dev -> main ship PR reuses the required checks the
|
||||||
|
# dev push already put on the same head SHA, so it needs no second run.
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches: [dev]
|
||||||
|
|
||||||
# a force-push while CI is mid-run should cancel the stale run, not queue
|
# a force-push while CI is mid-run should cancel the stale run, not queue
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -318,52 +319,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
scripts/build_linux_arm64.sh --version 0.0.0
|
scripts/build_linux_arm64.sh --version 0.0.0
|
||||||
|
# Shared with the release workflow so shipped images get the same
|
||||||
|
# self-contained / glibc-floor checks as PR builds.
|
||||||
- name: Verify the AppImage is self-contained and bullseye-compatible
|
- name: Verify the AppImage is self-contained and bullseye-compatible
|
||||||
run: |
|
run: bash scripts/linux-arm64/verify_appimage.sh dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage
|
||||||
set -euo pipefail
|
|
||||||
image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage"
|
|
||||||
|
|
||||||
# --appimage-extract needs no FUSE, so this works on a runner
|
|
||||||
# without /dev/fuse and still exercises the real payload.
|
|
||||||
"$image" --appimage-extract >/dev/null
|
|
||||||
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|
|
||||||
[ -e "squashfs-root/$required" ] \
|
|
||||||
|| { echo "::error::AppImage is missing $required"; exit 1; }
|
|
||||||
done
|
|
||||||
|
|
||||||
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
|
|
||||||
# applied; an unresolved soname here is a user-visible launch crash.
|
|
||||||
#
|
|
||||||
# This runs on a HEADLESS runner on purpose, and that is the point.
|
|
||||||
# The first version of this build bundled Debian's SDL2, which
|
|
||||||
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
|
|
||||||
# started on a full desktop -- a bare runner is what exposed it.
|
|
||||||
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
|
|
||||||
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
|
||||||
| grep 'not found' || true)"
|
|
||||||
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
|
|
||||||
|
|
||||||
# Nothing may hard-link a driver, session or audio-stack library:
|
|
||||||
# those must be reached through dlopen so the AppImage runs on a box
|
|
||||||
# with only ALSA, only Wayland, or only KMSDRM.
|
|
||||||
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
|
|
||||||
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
|
|
||||||
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
|
|
||||||
[ -z "$linked" ] \
|
|
||||||
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
|
|
||||||
|
|
||||||
# The whole point of compiling on bullseye. If a future change moves
|
|
||||||
# the builder to a newer base, the glibc floor silently rises and
|
|
||||||
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
|
|
||||||
# it here instead of in a release.
|
|
||||||
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
|
||||||
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
|
|
||||||
echo "highest required glibc symbol version: $floor"
|
|
||||||
[ -n "$floor" ] \
|
|
||||||
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
|
|
||||||
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
|
|
||||||
[ "$highest" = "GLIBC_2.31" ] \
|
|
||||||
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
|
|
||||||
- name: Upload the AppImage
|
- name: Upload the AppImage
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
@@ -400,7 +359,6 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- run: sudo apt-get update && sudo apt-get install -y luajit
|
|
||||||
- run: python3 -m pip install --upgrade pillow
|
- run: python3 -m pip install --upgrade pillow
|
||||||
|
|
||||||
# the fixture PNGs are committed (they are 8x8 placeholders, not
|
# the fixture PNGs are committed (they are 8x8 placeholders, not
|
||||||
@@ -421,13 +379,8 @@ jobs:
|
|||||||
print(f"\n{len(paths)} fixture assets valid")
|
print(f"\n{len(paths)} fixture assets valid")
|
||||||
PY
|
PY
|
||||||
|
|
||||||
# the fingerprint golden is the parity tripwire; prove it still
|
# the fingerprint parity gates (gate_fingerprint / gate_meta_coverage)
|
||||||
# matches the dataset on a clean checkout
|
# run in the headless job via run_engine; this job only guards the PNGs
|
||||||
- name: fingerprint gate
|
|
||||||
run: luajit tests/engine/gate_fingerprint.lua
|
|
||||||
|
|
||||||
- name: parity-guarantee meta-test
|
|
||||||
run: luajit tests/engine/gate_meta_coverage.lua
|
|
||||||
|
|
||||||
# Only the differ is under test here, and the job is named for that. The
|
# Only the differ is under test here, and the job is named for that. The
|
||||||
# capture half of the golden pipeline does not exist: a POKEPORT_DRIVER
|
# capture half of the golden pipeline does not exist: a POKEPORT_DRIVER
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ jobs:
|
|||||||
scripts/build_linux_arm64.sh \
|
scripts/build_linux_arm64.sh \
|
||||||
--version "${{ needs.version.outputs.version }}" \
|
--version "${{ needs.version.outputs.version }}" \
|
||||||
--game-love .bazinga/work/game.love
|
--game-love .bazinga/work/game.love
|
||||||
|
- name: Verify the AppImage is self-contained and bullseye-compatible
|
||||||
|
run: bash scripts/linux-arm64/verify_appimage.sh "dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage"
|
||||||
- name: Upload Linux arm64 release
|
- name: Upload Linux arm64 release
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
@@ -285,7 +287,7 @@ jobs:
|
|||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [version, xbox-uwp, linux-arm64, native-tls-win]
|
needs: [version, love-payload, xbox-uwp, linux-arm64, native-tls-win]
|
||||||
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -307,6 +309,15 @@ jobs:
|
|||||||
name: gen1tls-win-x64
|
name: gen1tls-win-x64
|
||||||
path: dist/native/win-x64
|
path: dist/native/win-x64
|
||||||
|
|
||||||
|
# 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).
|
||||||
|
- name: Download shared payload
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: gen1recomp-release-love
|
||||||
|
path: dist/payload
|
||||||
|
|
||||||
- name: Import signing certificate into a temporary keychain
|
- name: Import signing certificate into a temporary keychain
|
||||||
if: github.repository == 'bryanthaboi/gen1recomp'
|
if: github.repository == 'bryanthaboi/gen1recomp'
|
||||||
run: |
|
run: |
|
||||||
@@ -357,7 +368,8 @@ jobs:
|
|||||||
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
|
echo "::error::gen1tls.dll missing at $GEN1TLS_DLL (native-tls-win job)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize
|
scripts/build.sh all --version "${{ needs.version.outputs.version }}" --no-notarize \
|
||||||
|
--game-love dist/payload/game.love
|
||||||
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll \
|
||||||
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
|| { echo "::error::Windows zip is missing gen1tls.dll"; exit 1; }
|
||||||
|
|
||||||
|
|||||||
@@ -140,17 +140,17 @@ ship text.
|
|||||||
|
|
||||||
### 4. `games` (and the legacy `gen2compat`)
|
### 4. `games` (and the legacy `gen2compat`)
|
||||||
|
|
||||||
Pokemon Gold is Gen 2, and it runs its own battle engine, overworld, script
|
Pokemon Gold and Silver are Gen 2, and they run their own battle engine,
|
||||||
VM and save format. The mod API is shared across both generations (same hook
|
overworld, script VM and save format. The mod API is shared across both
|
||||||
names, same event names, same registry names) but Gold cannot serve all of it
|
generations (same hook names, same event names, same registry names) but Gen 2
|
||||||
yet, so Gen 2 is opt-in. Say which games the mod is for:
|
cannot serve all of it yet, so it is opt-in. Say which games the mod is for:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"games": ["gen1", "gen2"]
|
"games": ["gen1", "gen2"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`), a
|
Each entry is a version id (`"red"`, `"blue"`, `"yellow"`, `"gold"`,
|
||||||
generation (`"gen1"`, `"gen2"`) or `"all"`;
|
`"silver"`), a generation (`"gen1"`, `"gen2"`) or `"all"`;
|
||||||
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
|
`src/mods/ModTargets.lua` resolves them off `GameVersion.ORDER` so nothing
|
||||||
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
|
restates the game list. `python3 tools/modkit.py scaffold my_mod --games
|
||||||
gen1,gen2` writes the key for you. The mod still installs to one directory,
|
gen1,gen2` writes the key for you. The mod still installs to one directory,
|
||||||
@@ -159,10 +159,10 @@ gen1,gen2` writes the key for you. The mod still installs to one directory,
|
|||||||
Absent means Gen 1 only, which is what every mod written before the key existed
|
Absent means Gen 1 only, which is what every mod written before the key existed
|
||||||
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
|
was tested as. `"gen2compat": true` is the legacy spelling, still accepted and
|
||||||
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
|
purely additive (it *adds* the Gen 2 games), so no manifest can lose a game it
|
||||||
already ran on. On a Gold boot a mod claiming no Gen 2 game is not loaded at
|
already ran on. On a Gold or Silver boot a mod claiming no Gen 2 game is not
|
||||||
all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why, because a
|
loaded at all: the manager lists it as `ENABLED (NOT THIS GAME)` and says why,
|
||||||
mod that half-applies reads as a broken mod. Claim Gen 2 once you have actually
|
because a mod that half-applies reads as a broken mod. Claim Gen 2 once you
|
||||||
run your mod on Gold.
|
have actually run your mod on Gold or Silver.
|
||||||
|
|
||||||
Every token is enforced, per game: the loader gates on the same
|
Every token is enforced, per game: the loader gates on the same
|
||||||
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
|
`ModTargets.supports` answer both mod surfaces draw, so `"games": ["blue"]`
|
||||||
@@ -173,7 +173,7 @@ before the key existed changes behavior; list both generations or say `"all"`
|
|||||||
when you mean everywhere.
|
when you mean everywhere.
|
||||||
|
|
||||||
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
|
`docs/mod-api-gen2-compat.md` is the compatibility matrix: what works on Gold
|
||||||
today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
|
and Silver today (40 of the 46 registries, 40 event and 43 hook names shared with Gen 1,
|
||||||
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
and 24 Gen 2-only ones), which registries have no Gen 2 home and drop their
|
||||||
writes with a report, and which hooks and events are still to come.
|
writes with a report, and which hooks and events are still to come.
|
||||||
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
`docs/preparing-your-mod-for-gen2.md` is the step-by-step migration guide for a
|
||||||
|
|||||||
@@ -55,16 +55,15 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
|
|||||||
|
|
||||||
[](https://youtu.be/yi7LkWQPKKM)
|
[](https://youtu.be/yi7LkWQPKKM)
|
||||||
|
|
||||||
|
|
||||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, or Gold ROM
|
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold, or
|
||||||
is the only game content input.
|
Silver ROM is the only game content input.
|
||||||
|
|
||||||
The ROM is verified, used during import, and then released from memory. It is
|
The ROM is verified, used during import, and then released from memory. It is
|
||||||
not copied into the cache. Later launches load the private generated cache and
|
not copied into the cache. Later launches load the private generated cache and
|
||||||
do not ask for the ROM again. Red, Blue, Yellow, and Gold can all be imported
|
do not ask for the ROM again. Red, Blue, Yellow, Gold, and Silver can all be
|
||||||
side by side. Gold is Gen 2 Phase 1 (import + launcher; see
|
imported side by side. Gold and Silver are Gen 2 Phase 1 (import + launcher;
|
||||||
`docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
see `docs/gold-phase1.md`): the Gen 2 engine is still under construction.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -72,13 +71,14 @@ Open the desktop app. On first boot, choose your legally obtained `.gb` /
|
|||||||
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
`.gbc` file or drop it onto the window. Import takes a few seconds and the
|
||||||
game starts automatically.
|
game starts automatically.
|
||||||
|
|
||||||
Only the canonical US Red, Blue, Yellow (1 MiB), and Gold (2 MiB) ROMs are
|
Only the canonical US Red, Blue, Yellow (1 MiB), Gold, and Silver (2 MiB)
|
||||||
accepted. The importer verifies SHA-1 before creating any game data:
|
ROMs are accepted. The importer verifies SHA-1 before creating any game data:
|
||||||
|
|
||||||
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
|
||||||
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
|
||||||
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
|
||||||
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
- Gold: `d8b8a3600a465308c9953dfa04f0081c05bdcb94`
|
||||||
|
- Silver: `49b163f7e57702bc939d642a18f591de55d92dae`
|
||||||
|
|
||||||
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
The packaged app contains neither a ROM nor pre-extracted game data. Music,
|
||||||
sound effects, and cries are synthesized while the game runs from compact
|
sound effects, and cries are synthesized while the game runs from compact
|
||||||
@@ -219,7 +219,7 @@ entry: a desktop shortcut per game, a Steam entry, or a handheld frontend.
|
|||||||
|
|
||||||
| Option | Effect |
|
| Option | Effect |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `--game=red` | boot Red, skipping the launcher (`blue` and `yellow` too, or just `r` / `b` / `y`) |
|
| `--game=red` | boot Red, skipping the launcher (`blue`, `yellow`, `gold` and `silver` too, or just `r` / `b` / `y` / `g` / `s`) |
|
||||||
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
| `--slot=2` | load that save slot; takes a slot number or a slot id |
|
||||||
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made |
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -133,6 +133,8 @@ mkdir -p "$GAME_SRC"
|
|||||||
(cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \
|
(cd "$SOURCE_DIR" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||||
main.lua conf.lua src libs data assets tools/save-editor \
|
main.lua conf.lua src libs data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
|
tools/rom_manifest_silver.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ mkdir -p "$GAME_SRC"
|
|||||||
main.lua conf.lua src libs data assets tools/save-editor \
|
main.lua conf.lua src libs data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
|
tools/rom_manifest_silver.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
||||||
printf '%s\n' "$payload_list" \
|
printf '%s\n' "$payload_list" \
|
||||||
@@ -99,6 +100,8 @@ printf '%s\n' "$payload_list" \
|
|||||||
&& fail "payload unexpectedly contains generated ROM data"
|
&& fail "payload unexpectedly contains generated ROM data"
|
||||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|
||||||
|| fail "payload is missing tools/rom_manifest_gold.json"
|
|| fail "payload is missing tools/rom_manifest_gold.json"
|
||||||
|
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_silver.json" \
|
||||||
|
|| fail "payload is missing tools/rom_manifest_silver.json"
|
||||||
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||||
rm -f "$WORK/game-payload.zip"
|
rm -f "$WORK/game-payload.zip"
|
||||||
|
|
||||||
@@ -193,6 +196,26 @@ get_controls
|
|||||||
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
|
[ -f "${controlfolder}/mod_${CFW_NAME}.txt" ] && source "${controlfolder}/mod_${CFW_NAME}.txt"
|
||||||
|
|
||||||
GAMEDIR="$SHDIR/gen1recomp"
|
GAMEDIR="$SHDIR/gen1recomp"
|
||||||
|
# Anbernic stock keeps the launcher and the game folder side by side, so the
|
||||||
|
# SHDIR-relative path above is correct there and is tried first.
|
||||||
|
#
|
||||||
|
# Other firmwares (muOS, and PortMaster's layout on several devices) keep
|
||||||
|
# launcher scripts and port data in SEPARATE trees -- scripts under roms/ports,
|
||||||
|
# data under ports -- so the sibling folder holds no game.
|
||||||
|
#
|
||||||
|
# Probe for the BINARY, not the directory: on a split layout this script has
|
||||||
|
# usually already created "$SHDIR/gen1recomp/conf" and log.txt on an earlier
|
||||||
|
# failed run (see mkdir/tee below), so an existence test matches a decoy of our
|
||||||
|
# own making. Stock is unaffected -- its sibling holds the real binary and wins
|
||||||
|
# on the first test.
|
||||||
|
if [ ! -f "$GAMEDIR/bin/love.aarch64" ]; then
|
||||||
|
for candidate in "/$directory/ports/gen1recomp" \
|
||||||
|
"/mnt/sdcard/ports/gen1recomp" \
|
||||||
|
"/mnt/mmc/ports/gen1recomp" \
|
||||||
|
"/roms/ports/gen1recomp"; do
|
||||||
|
if [ -f "$candidate/bin/love.aarch64" ]; then GAMEDIR="$candidate"; break; fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
CONFDIR="$GAMEDIR/conf"
|
CONFDIR="$GAMEDIR/conf"
|
||||||
mkdir -p "$CONFDIR"
|
mkdir -p "$CONFDIR"
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,10 @@
|
|||||||
local Menu = require("src.ui.Menu")
|
local Menu = require("src.ui.Menu")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the
|
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, but the
|
||||||
-- extractor never collects it and the pamphlet's text is inlined.
|
-- extractor now collects any top-level label in a dedicated text file
|
||||||
|
-- regardless (tools/extract/text.py), so this is the real ROM label --
|
||||||
|
-- the literal below is only the fallback for a catalog without it.
|
||||||
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
|
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
|
||||||
.. "There are 50 TMs\nin all.\f"
|
.. "There are 50 TMs\nin all.\f"
|
||||||
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
|
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
|
||||||
@@ -70,7 +72,8 @@ return {
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
if fx == 3 and fy == 4 then
|
if fx == 3 and fy == 4 then
|
||||||
game.stack:push(TextBox.new(game, TM_NOTEBOOK_TEXT))
|
local text = game.data.text or {}
|
||||||
|
game.stack:push(TextBox.new(game, text.TMNotebookText or TM_NOTEBOOK_TEXT))
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ return {
|
|||||||
-- pick the dish: bit 7 set (~50%) -> Salmon du Salad, else bit 4
|
-- pick the dish: bit 7 set (~50%) -> Salmon du Salad, else bit 4
|
||||||
-- set (~25%) -> Eels au Barbecue, else (~25%) -> Prime Beef Steak.
|
-- set (~25%) -> Eels au Barbecue, else (~25%) -> Prime Beef Steak.
|
||||||
-- The three dish texts (SSAnneKitchenCook7SalmonDuSaladText /
|
-- The three dish texts (SSAnneKitchenCook7SalmonDuSaladText /
|
||||||
-- ...EelsAuBarbecueText / ...PrimeBeefSteakText) aren't extracted
|
-- ...EelsAuBarbecueText / ...PrimeBeefSteakText) have no leading
|
||||||
-- into data/generated/text.lua (no leading underscore in
|
-- underscore in pokered/text/SSAnneKitchen.asm, but the extractor
|
||||||
-- pokered/text/SSAnneKitchen.asm), so their literal strings are
|
-- collects them regardless (tools/extract/text.py); the literals
|
||||||
-- ported here verbatim.
|
-- below are only the fallback for a catalog without them.
|
||||||
TEXT_SSANNEKITCHEN_COOK7 = function(game, ow, npc, done)
|
TEXT_SSANNEKITCHEN_COOK7 = function(game, ow, npc, done)
|
||||||
local t = game.data.text
|
local t = game.data.text
|
||||||
push(game, t._SSAnneKitchenCook7MainCourseIsText
|
push(game, t._SSAnneKitchenCook7MainCourseIsText
|
||||||
@@ -27,13 +27,16 @@ return {
|
|||||||
local dish
|
local dish
|
||||||
if roll <= 2 then
|
if roll <= 2 then
|
||||||
-- bit 7 of hRandomAdd set (~50%)
|
-- bit 7 of hRandomAdd set (~50%)
|
||||||
dish = "Salmon du Salad!\fLes guests may\ngripe it's fish\vagain, however!"
|
dish = t.SSAnneKitchenCook7SalmonDuSaladText
|
||||||
|
or "Salmon du Salad!\fLes guests may\ngripe it's fish\vagain, however!"
|
||||||
elseif roll == 3 then
|
elseif roll == 3 then
|
||||||
-- bit 4 set, bit 7 clear (~25%)
|
-- bit 4 set, bit 7 clear (~25%)
|
||||||
dish = "Eels au Barbecue!\fLes guests will\nmutiny, I fear."
|
dish = t.SSAnneKitchenCook7EelsAuBarbecueText
|
||||||
|
or "Eels au Barbecue!\fLes guests will\nmutiny, I fear."
|
||||||
else
|
else
|
||||||
-- neither bit set (~25%)
|
-- neither bit set (~25%)
|
||||||
dish = "Prime Beef Steak!\fBut, have I enough\nfillets du beef?"
|
dish = t.SSAnneKitchenCook7PrimeBeefSteakText
|
||||||
|
or "Prime Beef Steak!\fBut, have I enough\nfillets du beef?"
|
||||||
end
|
end
|
||||||
push(game, dish, done)
|
push(game, dish, done)
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -60,22 +60,23 @@ M.VIRIDIAN_CITY = {
|
|||||||
-- you want to know about the two kinds of caterpillar Pokemon;
|
-- you want to know about the two kinds of caterpillar Pokemon;
|
||||||
-- YES -> CATERPIE/WEEDLE description, NO -> "Oh, OK then!".
|
-- YES -> CATERPIE/WEEDLE description, NO -> "Oh, OK then!".
|
||||||
-- ViridianCityYoungster2OkThenText and
|
-- ViridianCityYoungster2OkThenText and
|
||||||
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are
|
-- ViridianCityYoungster2CaterpieAndWeedleDescriptionText are defined
|
||||||
-- defined without a leading underscore in pokered/text/ViridianCity.asm
|
-- without a leading underscore in pokered/text/ViridianCity.asm, but
|
||||||
-- and aren't present in data/generated/text.lua, so we fall back to
|
-- tools/extract/text.py now collects them regardless -- the literal
|
||||||
-- the literal strings from pokered. Those fallbacks have to carry the
|
-- strings below are only the fallback for a catalog without them.
|
||||||
-- extractor's markers, not plain newlines: line -> \n, cont -> \v,
|
-- Those fallbacks have to carry the extractor's markers, not plain
|
||||||
-- para -> \f. Spelling cont/para as \n and \n\n put all six lines on
|
-- newlines: line -> \n, cont -> \v, para -> \f. Spelling cont/para as
|
||||||
-- one page with nothing to wait on, so the whole speech scrolled past
|
-- \n and \n\n put all six lines on one page with nothing to wait on,
|
||||||
-- without a button press (#250).
|
-- so the whole speech scrolled past without a button press (#250).
|
||||||
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
TEXT_VIRIDIANCITY_YOUNGSTER2 = function(game, ow, npc, done)
|
||||||
local t = text(game)
|
local t = text(game)
|
||||||
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
ask(game, t._ViridianCityYoungster2YouWantToKnowAboutText
|
||||||
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
|
or "You want to know\nabout the 2 kinds\vof caterpillar\vPOKéMON?", function(yes)
|
||||||
if yes then
|
if yes then
|
||||||
push(game, "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
|
push(game, t.ViridianCityYoungster2CaterpieAndWeedleDescriptionText
|
||||||
|
or "CATERPIE has no\npoison, but\vWEEDLE does.\fWatch out for its\nPOISON STING!", done)
|
||||||
else
|
else
|
||||||
push(game, "Oh, OK then!", done)
|
push(game, t.ViridianCityYoungster2OkThenText or "Oh, OK then!", done)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
|
|||||||
@@ -122,9 +122,8 @@ M.CINNABAR_LAB_METRONOME_ROOM = {
|
|||||||
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
|
-- TM42 Dream Eater (scripts/ViridianCity.asm, the fisher). The fisher's
|
||||||
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
|
-- YouCanHaveThisText prints before GiveItem, so this gift needs a pre
|
||||||
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
|
-- text (#775). Like the SilphCo2F worker (#393) that label carries no
|
||||||
-- leading underscore, and on Red it sits outside the extractor's symbol
|
-- leading underscore; tools/extract/text.py now collects it regardless,
|
||||||
-- set, so the literal from text/ViridianCity.asm rides along as the
|
-- so preFallback below is just the safety net for a catalog without it.
|
||||||
-- fallback; Yellow resolves the ROM string instead.
|
|
||||||
M.VIRIDIAN_CITY = {
|
M.VIRIDIAN_CITY = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_VIRIDIANCITY_FISHER = gift({
|
TEXT_VIRIDIANCITY_FISHER = gift({
|
||||||
@@ -146,9 +145,11 @@ M.SILPH_CO_2F = {
|
|||||||
talk = {
|
talk = {
|
||||||
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
|
||||||
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
|
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
|
||||||
-- the label carries no leading underscore: pokered keeps this one in
|
-- the label carries no leading underscore (#393); collected like any
|
||||||
-- the script bank, not the far-text bank (#393)
|
-- other text/*.asm label now, preFallback is just the safety net
|
||||||
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
|
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
|
||||||
|
preFallback = "Eeek!\nNo! Stop! Help!\fOh, you're not\nwith TEAM ROCKET."
|
||||||
|
.. "\vI thought...\vI'm sorry. Here,\vplease take this!",
|
||||||
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
|
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
|
||||||
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
|
||||||
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
|
||||||
|
|||||||
@@ -105,8 +105,9 @@ trixie.
|
|||||||
This is a statement about the *compile environment*, not about where the
|
This is a statement about the *compile environment*, not about where the
|
||||||
artifact runs — building on your own newer distro would silently raise that
|
artifact runs — building on your own newer distro would silently raise that
|
||||||
floor and strand every user on an older one, with no symptom until they
|
floor and strand every user on an older one, with no symptom until they
|
||||||
download it. CI enforces the floor: `linux-arm64-build` fails if the highest
|
download it. `scripts/linux-arm64/verify_appimage.sh` enforces the floor in
|
||||||
required glibc symbol version climbs above 2.31.
|
both CI (`linux-arm64-build`) and the release workflow: the build fails if
|
||||||
|
the highest required glibc symbol version climbs above 2.31.
|
||||||
|
|
||||||
### Why five libraries are built from source
|
### Why five libraries are built from source
|
||||||
|
|
||||||
@@ -172,13 +173,15 @@ Three jobs, path-gated on `scripts/build_linux_arm64.sh`,
|
|||||||
exclude list still classifies known sonames correctly, that AppRun still
|
exclude list still classifies known sonames correctly, that AppRun still
|
||||||
launches `game.love` with `--fused`, and that the host-arch guard actually
|
launches `game.love` with `--fused`, and that the host-arch guard actually
|
||||||
fires. Needs no container and no arm64 machine.
|
fires. Needs no container and no arm64 machine.
|
||||||
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts
|
- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then
|
||||||
the artifact and asserts the layout, that every bundled object resolves
|
`scripts/linux-arm64/verify_appimage.sh` extracts the artifact and asserts
|
||||||
under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31.
|
the layout, that every bundled object resolves under AppRun's
|
||||||
Uploads the AppImage for 7 days.
|
`LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31. Uploads the
|
||||||
|
AppImage for 7 days.
|
||||||
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
|
- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared
|
||||||
`game.love` from the `love-payload` job, and the AppImage is staged and
|
`game.love` from the `love-payload` job, runs the same
|
||||||
published like every other release asset.
|
`verify_appimage.sh` checks on the shipped image, and the AppImage is
|
||||||
|
staged and published like every other release asset.
|
||||||
|
|
||||||
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
|
Unlike the Switch job, none of this needs secrets or self-hosted hardware, so
|
||||||
it runs on fork PRs too.
|
it runs on fork PRs too.
|
||||||
|
|||||||
@@ -55,9 +55,10 @@ The short version, for an author deciding what to write:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
|
`games` is an optional array of version ids (`"red"`, `"blue"`, `"yellow"`,
|
||||||
`"gold"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or `"all"`.
|
`"gold"`, `"silver"`), generations (`"gen1"`, `"gen2"`, case-insensitive) or
|
||||||
`src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER` and
|
`"all"`. `src/mods/ModTargets.lua` resolves the tokens off `GameVersion.ORDER`
|
||||||
`GameVersion.generation`, so nothing anywhere restates the game list.
|
and `GameVersion.generation`, so nothing anywhere restates the game list.
|
||||||
|
`"gen2"` now expands to both Gold and Silver.
|
||||||
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
|
`Manifest.validate` stores the resolved, ORDER-sorted ids on `manifest.games`
|
||||||
and **derives** `manifest.gen2compat` from them, which is the one field the
|
and **derives** `manifest.gen2compat` from them, which is the one field the
|
||||||
loader's gate reads.
|
loader's gate reads.
|
||||||
@@ -541,8 +542,9 @@ gains a field instead of the name gaining a prefix.
|
|||||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||||
`battle.catch_exp`, `battle.bottom_ui_visible` and
|
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
||||||
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla
|
`battle.status_hud_visible` and `battle.move_grid_navigation`. One payload
|
||||||
|
difference: Gen 1's vanilla
|
||||||
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
|
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
|
||||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
|
|||||||
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
|
| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). |
|
||||||
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
|
| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. |
|
||||||
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
|
| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). |
|
||||||
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. |
|
| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, `["silver"]`, or `["all"]`. |
|
||||||
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
|
| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). |
|
||||||
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
|
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). |
|
||||||
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
|
| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. |
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
|||||||
* **Persistent custom options** stored separately from game saves
|
* **Persistent custom options** stored separately from game saves
|
||||||
* **Optional widescreen battle layout**
|
* **Optional widescreen battle layout**
|
||||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
||||||
|
* **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen
|
||||||
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
||||||
* **Pokédex diploma and printer image exports**
|
* **Pokédex diploma and printer image exports**
|
||||||
|
|
||||||
## Gen 2 Specifics
|
## Gen 2 Specifics
|
||||||
|
|
||||||
|
* **Pokémon Silver** as an importable, launcher-selectable version alongside Gold
|
||||||
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
||||||
* **Followers** for mods, plus Gen 2-only registries and hooks
|
* **Followers** for mods, plus Gen 2-only registries and hooks
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ something the filesystem encodes.
|
|||||||
|
|
||||||
| token | means |
|
| token | means |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `"red"`, `"blue"`, `"yellow"`, `"gold"` | that one game (a version id from `GameVersion.ORDER`) |
|
| `"red"`, `"blue"`, `"yellow"`, `"gold"`, `"silver"` | that one game (a version id from `GameVersion.ORDER`) |
|
||||||
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
|
| `"gen1"`, `"gen2"` | every game of that generation (case-insensitive; `"gen 2"` also parses) |
|
||||||
| `"all"` | every game this engine has |
|
| `"all"` | every game this engine has |
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# What This Port Requires
|
# What This Port Requires
|
||||||
|
|
||||||
The packaged desktop app requires one user-supplied input on first boot: a
|
The packaged desktop app requires one user-supplied input on first boot: a
|
||||||
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM.
|
canonical 1 MiB US Pokemon Red, Blue, or Yellow ROM, or a canonical 2 MiB US
|
||||||
|
Pokemon Gold or Silver ROM.
|
||||||
|
|
||||||
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
|
The importer verifies the SHA-1 for the game (see `src/core/GameVersion.lua`
|
||||||
for specific hashes). Other revisions and Virtual Console releases are rejected
|
for specific hashes). Other revisions and Virtual Console releases are rejected
|
||||||
@@ -15,7 +16,8 @@ Python and Pillow are not required by the packaged app.
|
|||||||
|
|
||||||
Assembly removes high-level names and some relationships that the Lua port
|
Assembly removes high-level names and some relationships that the Lua port
|
||||||
needs. The version-specific files `tools/rom_manifest.json`,
|
needs. The version-specific files `tools/rom_manifest.json`,
|
||||||
`tools/rom_manifest_blue.json`, and `tools/rom_manifest_yellow.json` therefore
|
`tools/rom_manifest_blue.json`, `tools/rom_manifest_yellow.json`,
|
||||||
|
`tools/rom_manifest_gold.json`, and `tools/rom_manifest_silver.json` therefore
|
||||||
contain:
|
contain:
|
||||||
|
|
||||||
- the ROM symbol addresses actually read by the extractor
|
- the ROM symbol addresses actually read by the extractor
|
||||||
|
|||||||
@@ -91,14 +91,14 @@ Do **not** launch from the Album applet path for normal play.
|
|||||||
|
|
||||||
This project ships **no** game data. On first launch:
|
This project ships **no** game data. On first launch:
|
||||||
|
|
||||||
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, or
|
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, Gold, or
|
||||||
Gold (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
Silver (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||||
launcher also shows the live save-dir path). All four can sit in the
|
launcher also shows the live save-dir path). All five can sit in the
|
||||||
same folder.
|
same folder.
|
||||||
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold).
|
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold /
|
||||||
Rescan matches by ROM SHA-1 for the open tab only. A Red dump never
|
Silver). Rescan matches by ROM SHA-1 for the open tab only. A Red dump
|
||||||
imports from the Yellow tab (and vice versa). Gold is Beta in the
|
never imports from the Yellow tab (and vice versa). Gold and Silver are
|
||||||
launcher; a clean US Gold dump is enough to Play.
|
Beta in the launcher; a clean US dump of either is enough to Play.
|
||||||
|
|
||||||
## 5. Import / Export a raw `.sav`
|
## 5. Import / Export a raw `.sav`
|
||||||
|
|
||||||
@@ -111,10 +111,12 @@ SD / FTP, same transfer methods as ROMs. Paths are **per game**:
|
|||||||
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
||||||
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
||||||
| Gold | `imports/saves/gold/` | `exports/gold/` |
|
| Gold | `imports/saves/gold/` | `exports/gold/` |
|
||||||
|
| Silver | `imports/saves/silver/` | `exports/silver/` |
|
||||||
|
|
||||||
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.
|
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.
|
||||||
Gold cart `.sav` import/export is not supported yet -- the folders exist so
|
Gold and Silver cart `.sav` import/export is not supported yet -- the folders
|
||||||
MTP browsing matches the other games. Gold progress still saves in-engine.)
|
exist so MTP browsing matches the other games. Gold and Silver progress still
|
||||||
|
saves in-engine.)
|
||||||
|
|
||||||
1. Copy a Gen 1 `.sav` (32 KB) into that game's inbox under the save dir
|
1. Copy a Gen 1 `.sav` (32 KB) into that game's inbox under the save dir
|
||||||
([switch-transfer.md](switch-transfer.md)).
|
([switch-transfer.md](switch-transfer.md)).
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ Player install (what to download, title override) stays in
|
|||||||
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
||||||
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
||||||
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
||||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold/` then that game's SAVE FILES → **Import save** (Gold cart `.sav` not supported yet) |
|
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold\|silver/` then that game's SAVE FILES → **Import save** (Gold / Silver cart `.sav` not supported yet) |
|
||||||
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold/` (pull after **Export save**; Gold cart `.sav` not supported yet) |
|
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold\|silver/` (pull after **Export save**; Gold / Silver cart `.sav` not supported yet) |
|
||||||
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||||
| Lua error log | `lua-error.log` in the save dir |
|
| Lua error log | `lua-error.log` in the save dir |
|
||||||
|
|
||||||
@@ -54,7 +54,8 @@ macOS, not a Mac-only requirement.
|
|||||||
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
||||||
(or copy NRO / `game.love` for loose).
|
(or copy NRO / `game.love` for loose).
|
||||||
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
||||||
`imports/saves/<red|blue|yellow|gold>/`, or `exports/<red|blue|yellow|gold>/`
|
`imports/saves/<red|blue|yellow|gold|silver>/`, or
|
||||||
|
`exports/<red|blue|yellow|gold|silver>/`
|
||||||
path the launcher prints.
|
path the launcher prints.
|
||||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||||
|
|
||||||
|
|||||||
@@ -327,6 +327,7 @@ local function returnToLauncher()
|
|||||||
if love.audio and love.audio.stop then
|
if love.audio and love.audio.stop then
|
||||||
pcall(love.audio.stop)
|
pcall(love.audio.stop)
|
||||||
end
|
end
|
||||||
|
pcall(function() require("src.render.SecondScreen").setEnabled(false) end)
|
||||||
|
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local currentVersion = GameVersion.get()
|
local currentVersion = GameVersion.get()
|
||||||
@@ -386,11 +387,12 @@ function bootGame(version)
|
|||||||
love.window.setTitle(Version.title(
|
love.window.setTitle(Version.title(
|
||||||
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
|
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
|
||||||
end
|
end
|
||||||
-- Gold: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
|
-- Gen 2: Gen 1 Game:load cannot consume a Gen 2 cache -- different generated
|
||||||
-- tables, save shape and screen registry -- so Gold boots its own service
|
-- tables, save shape and screen registry -- so Gold and Silver boot their
|
||||||
-- owner, which mounts src/world/gen2 (walk / warps / connections) and the
|
-- own service owner, which mounts src/world/gen2 (walk / warps /
|
||||||
-- Gen 2 screens instead of src/core/Game.lua's Gen 1 wiring.
|
-- connections) and the Gen 2 screens instead of src/core/Game.lua's Gen 1
|
||||||
if GameVersion.isGold() then
|
-- wiring.
|
||||||
|
if GameVersion.generation() == 2 then
|
||||||
Game = require("src.core.Game2").new()
|
Game = require("src.core.Game2").new()
|
||||||
Game:load()
|
Game:load()
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -109,10 +109,10 @@ The APK lands under `app/build/outputs/apk/embedNoRecord/debug/`.
|
|||||||
|
|
||||||
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
|
`app/src/embed/assets/game.love` - zip of `main.lua`, `conf.lua`, `src/`,
|
||||||
`libs/` (the vendored FlexLove toolkit the launcher UI needs), `data/`,
|
`libs/` (the vendored FlexLove toolkit the launcher UI needs), `data/`,
|
||||||
`assets/`, and the Red, Blue, and Yellow ROM manifests. The Android
|
`assets/`, and the Red, Blue, Yellow, Gold, and Silver ROM manifests. The
|
||||||
packer verifies the Yellow manifest before it packages; if a partial source
|
Android packer verifies the Yellow, Gold, and Silver manifests before it
|
||||||
export omitted it, it restores the file from this checkout's Git data and then
|
packages; if a partial source export omitted one, it restores the file from
|
||||||
falls back to the project's GitHub copy. Generated game data,
|
this checkout's Git data and then falls back to the project's GitHub copy. Generated game data,
|
||||||
scripts, tests, and mobile build sources are excluded.
|
scripts, tests, and mobile build sources are excluded.
|
||||||
|
|
||||||
## Branding (applied by the build script)
|
## Branding (applied by the build script)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -8,4 +8,5 @@
|
|||||||
<color name="shortcut_blue">#1E88E5</color>
|
<color name="shortcut_blue">#1E88E5</color>
|
||||||
<color name="shortcut_yellow">#FDD835</color>
|
<color name="shortcut_yellow">#FDD835</color>
|
||||||
<color name="shortcut_gold">#D4AF37</color>
|
<color name="shortcut_gold">#D4AF37</color>
|
||||||
|
<color name="shortcut_silver">#BEC6D2</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -398,11 +398,15 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onDestroy() {
|
protected void onDestroy() {
|
||||||
|
secondaryHostResumed = false;
|
||||||
if (vibrator != null) {
|
if (vibrator != null) {
|
||||||
Log.d("GameActivity", "Cancelling vibration");
|
Log.d("GameActivity", "Cancelling vibration");
|
||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
}
|
}
|
||||||
unregisterSecondaryDisplayListener();
|
unregisterSecondaryDisplayListener();
|
||||||
|
teardownSecondaryDisplay();
|
||||||
|
secondaryEnabled = false;
|
||||||
|
synchronized (secondaryFrameLock) { secondaryFrame = null; }
|
||||||
unregisterAudioDeviceCallback();
|
unregisterAudioDeviceCallback();
|
||||||
abandonAudioFocus();
|
abandonAudioFocus();
|
||||||
onHostDestroy();
|
onHostDestroy();
|
||||||
@@ -411,6 +415,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onPause() {
|
protected void onPause() {
|
||||||
|
secondaryHostResumed = false;
|
||||||
if (vibrator != null) {
|
if (vibrator != null) {
|
||||||
Log.d("GameActivity", "Cancelling vibration");
|
Log.d("GameActivity", "Cancelling vibration");
|
||||||
vibrator.cancel();
|
vibrator.cancel();
|
||||||
@@ -426,6 +431,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
@Override
|
@Override
|
||||||
public void onResume() {
|
public void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
|
secondaryHostResumed = true;
|
||||||
onHostResume();
|
onHostResume();
|
||||||
requestGameAudioFocus();
|
requestGameAudioFocus();
|
||||||
registerAudioDeviceCallback();
|
registerAudioDeviceCallback();
|
||||||
@@ -1933,6 +1939,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||||
private static volatile long secondaryRetryAfter;
|
private static volatile long secondaryRetryAfter;
|
||||||
private static volatile boolean secondaryEnabled = false;
|
private static volatile boolean secondaryEnabled = false;
|
||||||
|
private static volatile boolean secondaryHostResumed = false;
|
||||||
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
||||||
private static volatile int dualScreenDisplayMode = -1;
|
private static volatile int dualScreenDisplayMode = -1;
|
||||||
private static volatile byte[] secondaryFrame;
|
private static volatile byte[] secondaryFrame;
|
||||||
@@ -1963,7 +1970,7 @@ public class GameActivity extends SDLActivity {
|
|||||||
if (self == null) return;
|
if (self == null) return;
|
||||||
self.runOnUiThread(new Runnable() {
|
self.runOnUiThread(new Runnable() {
|
||||||
@Override public void run() {
|
@Override public void run() {
|
||||||
if (on) {
|
if (on && secondaryHostResumed) {
|
||||||
self.refreshDualScreenDisplayMode();
|
self.refreshDualScreenDisplayMode();
|
||||||
self.registerSecondaryDisplayListener();
|
self.registerSecondaryDisplayListener();
|
||||||
rebindSecondaryDisplay();
|
rebindSecondaryDisplay();
|
||||||
@@ -2035,9 +2042,11 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
private static void rebindSecondaryDisplay() {
|
private static void rebindSecondaryDisplay() {
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
if (self == null || !secondaryHostResumed || !secondaryEnabled
|
||||||
|
|| secondaryOutputIsPreferred(self)) return;
|
||||||
self.runOnUiThread(() -> {
|
self.runOnUiThread(() -> {
|
||||||
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
if (!secondaryHostResumed || !secondaryEnabled
|
||||||
|
|| secondaryOutputIsPreferred(self)) return;
|
||||||
teardownSecondaryDisplay();
|
teardownSecondaryDisplay();
|
||||||
setupSecondaryDisplay();
|
setupSecondaryDisplay();
|
||||||
});
|
});
|
||||||
@@ -2045,7 +2054,8 @@ public class GameActivity extends SDLActivity {
|
|||||||
|
|
||||||
private static void setupSecondaryDisplay() {
|
private static void setupSecondaryDisplay() {
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null || !secondaryEnabled || secondaryPresentation != null
|
if (self == null || !secondaryHostResumed || !secondaryEnabled
|
||||||
|
|| secondaryPresentation != null
|
||||||
|| secondaryActivity != null || secondaryActivityPending
|
|| secondaryActivity != null || secondaryActivityPending
|
||||||
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,6 +12,104 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.2.11",
|
||||||
|
"date": "2026-08-20",
|
||||||
|
"size": 13735190,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.11/gen1recomp++-0.2.11-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #393 silph co. npc missing some dialogue\n- #1600 allow my uncle's neighbor to sit at the big kids table\n- #1603 pocket taco - type option \"screen position\"\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @dburton95\n- @mleo2003\n- @thibautbus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.10",
|
||||||
|
"date": "2026-08-19",
|
||||||
|
"size": 13662645,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.10/gen1recomp++-0.2.10-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #998 Jingles not finishing before game proceeds\n- #1472 Message for sending out Pokemon not closing automatically\n- #1526 No screen shake when getting poisoned\n- #1529 Messages missing when interacting with PC\n- #1530 No message for interacting with bikes in the bike shop\n- #1532 Thrash animation incomplete\n- #1534 Dialogue missing when switching out Pokemon\n- #1547 Save states can be used to bypass certain NPCs\n- #1549 Menu Cartridge 3D model has visual issues\n- #1550 Nugget Bridge Rocket repeating dialogue\n- #1551 No scripted dialogue after beating Nugget Bridge Rocket\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.9",
|
||||||
|
"date": "2026-08-19",
|
||||||
|
"size": 13656293,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.9/gen1recomp++-0.2.9-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.8",
|
||||||
|
"date": "2026-08-19",
|
||||||
|
"size": 13653911,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.8/gen1recomp++-0.2.8-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1502 Gold doesn't show trainer balls\n- #1533 Retroarch Skin Problem 2 (#1503)\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @castdrian\n- @thibautbus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.7",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"size": 13597177,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.7/gen1recomp++-0.2.7-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1005 (Android) Screen record mutes the game\n- #1291 Audio Crash\n- #1310 Incoming call crashes G1R\n- #1471 [Gold] #1117 still not fixed\n- #1528 Surfing Minigame doesn't play as intended\n- #1537 Shellder and Corsola missing from Rod encounter tables\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi\n- @castdrian"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.6",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"size": 13589036,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.6/gen1recomp++-0.2.6-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1393 [Launcher -> Mods] Only the pages that you manually clicked to are used for sorting\n- #1418 (Pokémon Gold) Framerate and void fill options missing\n- #1430 [Gold] Shop ui off because of a border\n- #1519 Poison damage after battle inconsistent\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.5",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"size": 13586158,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.5/gen1recomp++-0.2.5-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1390 switch and gold\n- #1503 Retroarch Skin Problem\n- #1508 Please check #1412 & #1414 again, we had a misunderstanding\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.4",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"size": 13582747,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.4/gen1recomp++-0.2.4-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1496 Investigate Security according to https://hdbreaker.github.io/blog/pokemon-gen1recomp-hate-cheat/\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.3",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"size": 13579254,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.3/gen1recomp++-0.2.3-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1497 skin studio needs a import file picker babyyyyyy\n\n## Contributors\n\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.2",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"size": 13575387,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.2/gen1recomp++-0.2.2-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.1",
|
||||||
|
"date": "2026-08-17",
|
||||||
|
"size": 13575320,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.1/gen1recomp++-0.2.1-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"date": "2026-08-17",
|
||||||
|
"size": 13575299,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.0/gen1recomp++-0.2.0-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1396 Nurse dialogue & options\n- #1398 Alignment of options for changing Pokemon\n- #1400 Flying Bug\n- #1401 [Gold] battlergfx $d9/$da load the wrong row count (jumptable crossed vs macro names)\n- #1406 Magikarp salesman dialogue issues\n- #1407 Not able to nickname Magikarp\n- #1411 No indication for stone evolutions\n- #1413 Using a stone closes menu\n- #1415 Super Nerd dialogue issues\n- #1416 (Pokémon Gold) Pokédex doesn't register other trainers' pokémon as seen\n- #1417 (Pokémon Gold) Pokémon you get in trade aren't being registered as caught\n- #1419 (Pokémon Gold) Deposited pokémon don't get healed\n- #1421 (Pokémon Gold) Bad status and catch state appears on the HUD before they should\n- #1422 (Pokémon Gold) Impossible to have the pokédex register Ditto as caught after it transforms\n- #1423 (Pokémon Gold) No save prompt before changing boxes in the PC\n- #1424 (Pokémon Gold) Quantity for owned TMs not being displayed\n- #1425 (Pokémon Gold) Items quantity in your bag should be alligned to the right\n- #1427 (Pokémon Gold) Can't switch items' position in your bag\n- #1428 (Pokémon Gold) Game doesn't show how many pokémon other trainers have\n- #1429 Pikachu not sliding in before its cry. Stuck on standard pokeball release animation.\n- #1431 Shiny sparkle does not play on your sent out shiny pokemon\n- #1432 Experimental marked mods don't install Android\n- #1433 (Pokémon Gold) Missing prompt for depositing pokémon\n- #1435 When npcs stop you to talk or when you walk up to npcs to talk to them sometimes the player has the wrong sprite\n- #1437 Issues with player sprite on map\n- #1440 hold a direction during cutscene and face the wrong way\n- #1441 Magnet Train missing animation\n- #1442 Radio dial is missing in PokeGear radio\n- #1443 Skipping production logo also skips battle scene\n- #1444 Pokemon lack type immunity to status moves\n- #1447 Soft-lock on Cinnabar Island\n- #1449 Visual error on Route 28\n- #1456 Activating all mods doesn't work properly\n- #1461 #1265 didnt got fixed.\n- #1464 Experiance shared in battle\n- #1465 Changing Touch Layout crashes launcher\n- #1466 #1403 Still Happens\n- #1467 A clearer definition of the use of AI for this reconstruction\n- #1468 [Gold] BICYCLE is broken and some pokegear bug\n- #1469 [Gold] status effects aren't shown in the party overlay or the summary screen of the pokemon\n- #1470 Mod updater doesn't work properly when AppImage is running through Steam or Game Mode (Steam Deck)\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.1.99",
|
||||||
|
"date": "2026-08-17",
|
||||||
|
"size": 11391467,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.99/gen1recomp++-0.1.99-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #597 Pulling mod index fails on Android\n- #1403 Save editor not allowing moves to go past ZAP_CANNON\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @emre155\n- @sanjinpepic\n- @ShaneMcGovernIE\n- @syybott\n- @thibautbus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.1.98",
|
||||||
|
"date": "2026-08-16",
|
||||||
|
"size": 11380117,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.98/gen1recomp++-0.1.98-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1181 Poison seems to trigger twice during the poisoned pokemon's turn\n- #1211 S.S. Anne Visual bug when it's sailing away\n- #1212 the move payday does not grant money in gen 2\n- #1214 Title Screen with OG Red is the wrong color\n- #1224 Windowed and borderless toggle in Gold\n- #1228 No option to nickname starter\n- #1229 Encounter rate grace period not working\n- #1230 Couple of sound effects missing\n- #1231 Using Tackle partially distorts battle sprites\n- #1232 Wild pokemon's sprite disappears early when using a pokeball\n- #1249 Cant use stat items IE HP UP PP UP PROTIEN\n- #1251 You don't have a COIN CASE\n- #1265 Major: Regression from #984 (probably?)\n- #1267 [GOLD] POKEDEX didn't show pokemon appear area\n- #1269 [Gold] shadow ball should be invert the screen\n- #1271 [Gold] substitute image broken/not shown\n- #1272 [Gold] swift still checks accuracy and/or evasion\n- #1273 S.S. Anne Issues\n- #1276 Nurse back to not bowing (and turning)\n- #1279 Rival still not looking at player when initiating first fight\n- #1282 PKMN league PC option missing\n- #1293 Dig animation is bugged in-battle\n- #1296 Opponent's moves failing\n- #1298 Gen1 sound tracks have a fade in period, if you enter a route and immediately exit it while this transition is going on it will land on the wrong music\n- #1301 Pixels aren't square\n- #1303 Animation speed of walking NPCs too slow\n- #1305 Wrong Pikachu cry when getting defeated\n- #1307 Rival theme broken after initial fight in Yellow\n- #1318 Thunder Wave works on Ground-types\n- #1328 Message for turning on the PC missing\n- #1329 Name Select Background\n- #1330 Message before looking at map missing\n- #1331 Messages in Oak's lab missing\n- #1333 E-mail in Oak's lab missing\n- #1334 Missing message after picking starter\n- #1335 No Money Box\n- #1338 Rival's sister missing dialogue and roaming\n- #1340 Color palett doesn't affect attack animations\n- #1341 Pokedex entries look wrong\n- #1343 No dashes in empty attack slots during fights\n- #1344 Town Map not showing player sprite\n- #1345 Wrong health color on OG palett\n- #1346 Health still black when viewing stats\n- #1360 No Surfing Music\n- #1362 Poison damage does not flash the screen\n- #1368 Fishing Rods behaving irregularly\n- #1385 Team Rocket Hideouts missing music\n- #1388 Safeguard targets opponent, not user\n- #1389 Gastly unobtainable\n- #1391 NPC not escorting player to museum\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.1.97",
|
"version": "0.1.97",
|
||||||
"date": "2026-08-16",
|
"date": "2026-08-16",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#
|
#
|
||||||
# Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
|
# Usage: scripts/build.sh [mac|win|linux|android|ios|all] [--version X.Y.Z] [--identity "Developer ID Application: ..."]
|
||||||
# [--notary-profile NAME] [--no-notarize]
|
# [--notary-profile NAME] [--no-notarize]
|
||||||
|
# [--game-love PATH] # fuse a prebuilt payload (scripts/pack_love.sh) instead of packing one
|
||||||
# [--release] # ios only: release config instead of debug
|
# [--release] # ios only: release config instead of debug
|
||||||
#
|
#
|
||||||
# Output: dist/mac/gen1recomp-macos.zip
|
# Output: dist/mac/gen1recomp-macos.zip
|
||||||
@@ -36,6 +37,7 @@ NOTARY_PROFILE="notary-profile"
|
|||||||
NOTARIZE=true
|
NOTARIZE=true
|
||||||
IOS_RELEASE=false
|
IOS_RELEASE=false
|
||||||
IOS_IPA=false
|
IOS_IPA=false
|
||||||
|
GAME_LOVE_IN=""
|
||||||
|
|
||||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||||
@@ -48,6 +50,7 @@ while [ $# -gt 0 ]; do
|
|||||||
--identity) IDENTITY="$2"; shift ;;
|
--identity) IDENTITY="$2"; shift ;;
|
||||||
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
||||||
--no-notarize) NOTARIZE=false ;;
|
--no-notarize) NOTARIZE=false ;;
|
||||||
|
--game-love) GAME_LOVE_IN="${2:?--game-love needs a path}"; shift ;;
|
||||||
--release) IOS_RELEASE=true ;;
|
--release) IOS_RELEASE=true ;;
|
||||||
--ipa) IOS_IPA=true ;;
|
--ipa) IOS_IPA=true ;;
|
||||||
*) fail "unknown argument: $1" ;;
|
*) fail "unknown argument: $1" ;;
|
||||||
@@ -62,16 +65,23 @@ mkdir -p "$CACHE" "$WORK" "$DIST/mac" "$DIST/win" "$DIST/linux"
|
|||||||
# launcher's Edit button on a save row opens it in-process (main.lua), and
|
# launcher's Edit button on a save row opens it in-process (main.lua), and
|
||||||
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
|
# `--editor` / POKEPORT_EDITOR=1 opens it standalone. It is required through
|
||||||
# love.filesystem's require path, so it has to live inside the archive.
|
# love.filesystem's require path, so it has to live inside the archive.
|
||||||
say "packing game.love"
|
|
||||||
LOVE_FILE="$WORK/game.love"
|
LOVE_FILE="$WORK/game.love"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
# The launcher UI kit lives at src/ui/kit (inside src/, packed wholesale);
|
if [ -n "$GAME_LOVE_IN" ]; then
|
||||||
# the vendored libs/flexlove tree it replaced is gone.
|
[ -f "$GAME_LOVE_IN" ] || fail "--game-love: no such file: $GAME_LOVE_IN"
|
||||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
say "using prebuilt payload: $GAME_LOVE_IN"
|
||||||
|
cp "$GAME_LOVE_IN" "$LOVE_FILE"
|
||||||
|
else
|
||||||
|
say "packing game.love"
|
||||||
|
# The launcher UI kit lives at src/ui/kit (inside src/, packed wholesale);
|
||||||
|
# the vendored libs/flexlove tree it replaced is gone.
|
||||||
|
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||||
main.lua conf.lua src data assets tools/save-editor \
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
|
tools/rom_manifest_silver.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
|
fi
|
||||||
# Materialize the listing once and grep the file: piping unzip straight into
|
# Materialize the listing once and grep the file: piping unzip straight into
|
||||||
# grep -q under `set -o pipefail` SIGPIPEs unzip when grep exits early on a
|
# grep -q under `set -o pipefail` SIGPIPEs unzip when grep exits early on a
|
||||||
# match, and the pipeline's failure reads as "missing <file>" for whichever
|
# match, and the pipeline's failure reads as "missing <file>" for whichever
|
||||||
@@ -90,7 +100,8 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
|||||||
tools/save-editor/panels/Party.lua \
|
tools/save-editor/panels/Party.lua \
|
||||||
src/ui/kit/Kit.lua \
|
src/ui/kit/Kit.lua \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json; do
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
|
tools/rom_manifest_silver.json; do
|
||||||
grep -qxF "$required" "$LOVE_LISTING" \
|
grep -qxF "$required" "$LOVE_LISTING" \
|
||||||
|| fail "game.love is missing $required"
|
|| fail "game.love is missing $required"
|
||||||
done
|
done
|
||||||
@@ -105,6 +116,13 @@ say "game.love: $(du -h "$LOVE_FILE" | cut -f1)"
|
|||||||
# mistaken for a release. The stamp is then read back out of the archive and the
|
# mistaken for a release. The stamp is then read back out of the archive and the
|
||||||
# build fails if it did not take.
|
# build fails if it did not take.
|
||||||
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
if [ -n "$GAME_LOVE_IN" ]; then
|
||||||
|
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
|
||||||
|
unzip -p "$LOVE_FILE" src/core/Version.lua \
|
||||||
|
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||||
|
|| fail "prebuilt payload does not report engine $VERSION (pack it with pack_love.sh --version $VERSION)"
|
||||||
|
say "prebuilt payload already stamped: $VERSION"
|
||||||
|
else
|
||||||
say "stamping engine version $VERSION into game.love"
|
say "stamping engine version $VERSION into game.love"
|
||||||
stamp_dir="$WORK/stamp"
|
stamp_dir="$WORK/stamp"
|
||||||
rm -rf "$stamp_dir"
|
rm -rf "$stamp_dir"
|
||||||
@@ -117,6 +135,7 @@ if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
|||||||
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|
||||||
|| fail "version stamp failed: game.love does not report engine $VERSION"
|
|| fail "version stamp failed: game.love does not report engine $VERSION"
|
||||||
say "stamped engine version: $VERSION"
|
say "stamped engine version: $VERSION"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
|
say "version '$VERSION' is not X.Y.Z, shipping default engine (no stamp)"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
|||||||
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
||||||
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
||||||
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
|
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
|
||||||
|
SILVER_MANIFEST_RELATIVE="tools/rom_manifest_silver.json"
|
||||||
|
SILVER_MANIFEST_URL="${SILVER_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_silver.json}"
|
||||||
|
|
||||||
VERSION=""
|
VERSION=""
|
||||||
PACKAGE_ONLY=false
|
PACKAGE_ONLY=false
|
||||||
@@ -177,6 +179,54 @@ ensure_gold_manifest() {
|
|||||||
fail "Gold import manifest is unavailable. Git recovery failed and could not download $GOLD_MANIFEST_URL"
|
fail "Gold import manifest is unavailable. Git recovery failed and could not download $GOLD_MANIFEST_URL"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
silver_manifest_is_valid() {
|
||||||
|
local path="$1"
|
||||||
|
python3 - "$path" <<'PY'
|
||||||
|
import json, pathlib, sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
raise SystemExit(0 if manifest.get("romSha1") ==
|
||||||
|
"49b163f7e57702bc939d642a18f591de55d92dae" else 1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_silver_manifest() {
|
||||||
|
local manifest="$ROOT/$SILVER_MANIFEST_RELATIVE"
|
||||||
|
local staged
|
||||||
|
staged="$(mktemp)"
|
||||||
|
|
||||||
|
if silver_manifest_is_valid "$manifest"; then
|
||||||
|
rm -f "$staged"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "Silver import manifest is missing or invalid; recovering it before packaging"
|
||||||
|
if git -C "$ROOT" show "HEAD:$SILVER_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
|
||||||
|
&& silver_manifest_is_valid "$staged"; then
|
||||||
|
mkdir -p "$(dirname "$manifest")"
|
||||||
|
mv "$staged" "$manifest"
|
||||||
|
say "restored Silver import manifest from this checkout's Git data"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v curl >/dev/null 2>&1 \
|
||||||
|
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||||
|
--output "$staged" "$SILVER_MANIFEST_URL" \
|
||||||
|
&& silver_manifest_is_valid "$staged"; then
|
||||||
|
mkdir -p "$(dirname "$manifest")"
|
||||||
|
mv "$staged" "$manifest"
|
||||||
|
say "downloaded Silver import manifest from the project repository"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$staged"
|
||||||
|
fail "Silver import manifest is unavailable. Git recovery failed and could not download $SILVER_MANIFEST_URL"
|
||||||
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- branding
|
# --------------------------------------------------------------- branding
|
||||||
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
||||||
# Manifest still gets permission trims. Re-applied every build so refreshing
|
# Manifest still gets permission trims. Re-applied every build so refreshing
|
||||||
@@ -242,6 +292,7 @@ pack_game_love() {
|
|||||||
say "packing game.love for love-android embed flavor"
|
say "packing game.love for love-android embed flavor"
|
||||||
ensure_yellow_manifest
|
ensure_yellow_manifest
|
||||||
ensure_gold_manifest
|
ensure_gold_manifest
|
||||||
|
ensure_silver_manifest
|
||||||
mkdir -p "$EMBED_ASSETS"
|
mkdir -p "$EMBED_ASSETS"
|
||||||
rm -f "$LOVE_FILE"
|
rm -f "$LOVE_FILE"
|
||||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||||
@@ -256,6 +307,7 @@ pack_game_love() {
|
|||||||
main.lua conf.lua src data assets tools/save-editor \
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
|
tools/rom_manifest_silver.json \
|
||||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||||
-x 'data/generated/*' -x 'assets/generated/*')
|
-x 'data/generated/*' -x 'assets/generated/*')
|
||||||
# List once and match against the captured text: piping unzip straight into
|
# List once and match against the captured text: piping unzip straight into
|
||||||
@@ -277,6 +329,8 @@ pack_game_love() {
|
|||||||
|| fail "game.love is missing the Yellow ROM import manifest"
|
|| fail "game.love is missing the Yellow ROM import manifest"
|
||||||
grep -qx 'tools/rom_manifest_gold.json' <<< "$archive_entries" \
|
grep -qx 'tools/rom_manifest_gold.json' <<< "$archive_entries" \
|
||||||
|| fail "game.love is missing the Gold ROM import manifest"
|
|| fail "game.love is missing the Gold ROM import manifest"
|
||||||
|
grep -qx 'tools/rom_manifest_silver.json' <<< "$archive_entries" \
|
||||||
|
|| fail "game.love is missing the Silver ROM import manifest"
|
||||||
# This gate exists because the launcher's UI toolkit once lived outside
|
# This gate exists because the launcher's UI toolkit once lived outside
|
||||||
# src/ (libs/flexlove) and was added to scripts/build.sh's payload and to
|
# src/ (libs/flexlove) and was added to scripts/build.sh's payload and to
|
||||||
# no other packager, so Android and iOS built an APK/IPA whose launcher
|
# no other packager, so Android and iOS built an APK/IPA whose launcher
|
||||||
|
|||||||
@@ -366,6 +366,8 @@ cp "$IN/game.love" "$APPDIR/game.love"
|
|||||||
unzip -Z1 "$APPDIR/game.love" > "$WORK/love-listing.txt"
|
unzip -Z1 "$APPDIR/game.love" > "$WORK/love-listing.txt"
|
||||||
grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|
grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|
||||||
|| fail "game.love is missing tools/rom_manifest_gold.json"
|
|| fail "game.love is missing tools/rom_manifest_gold.json"
|
||||||
|
grep -qxF "tools/rom_manifest_silver.json" "$WORK/love-listing.txt" \
|
||||||
|
|| fail "game.love is missing tools/rom_manifest_silver.json"
|
||||||
# The .desktop's Icon= resolves against the AppDir root by basename, and
|
# The .desktop's Icon= resolves against the AppDir root by basename, and
|
||||||
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
||||||
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
|
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
|
||||||
|
|||||||
@@ -169,5 +169,7 @@ unzip -p "$temp_dir/game.love" src/core/Version.lua \
|
|||||||
|| fail "shared payload version was not stamped"
|
|| fail "shared payload version was not stamped"
|
||||||
grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|
grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|
||||||
|| fail "shared payload is missing tools/rom_manifest_gold.json"
|
|| fail "shared payload is missing tools/rom_manifest_gold.json"
|
||||||
|
grep -qxF "tools/rom_manifest_silver.json" "$temp_dir/love-listing.txt" \
|
||||||
|
|| fail "shared payload is missing tools/rom_manifest_silver.json"
|
||||||
|
|
||||||
say "Linux arm64 self-test passed"
|
say "Linux arm64 self-test passed"
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verifies a built arm64 AppImage is self-contained and bullseye-compatible.
|
||||||
|
# Usage: scripts/linux-arm64/verify_appimage.sh <AppImage>
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
image="${1:?usage: verify_appimage.sh <AppImage>}"
|
||||||
|
[ -f "$image" ] || { echo "::error::no such AppImage: $image"; exit 1; }
|
||||||
|
image="$(cd "$(dirname "$image")" && pwd)/$(basename "$image")"
|
||||||
|
|
||||||
|
workdir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$workdir"' EXIT
|
||||||
|
cd "$workdir"
|
||||||
|
|
||||||
|
# --appimage-extract needs no FUSE, so this works on a runner
|
||||||
|
# without /dev/fuse and still exercises the real payload.
|
||||||
|
"$image" --appimage-extract >/dev/null
|
||||||
|
for required in AppRun bin/love game.love lib/liblove-11.5.so; do
|
||||||
|
[ -e "squashfs-root/$required" ] \
|
||||||
|
|| { echo "::error::AppImage is missing $required"; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
# Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is
|
||||||
|
# applied; an unresolved soname here is a user-visible launch crash.
|
||||||
|
#
|
||||||
|
# This runs on a HEADLESS runner on purpose, and that is the point.
|
||||||
|
# The first version of this build bundled Debian's SDL2, which
|
||||||
|
# hard-links libpulse/libasound/libX11/libwayland, so it only ever
|
||||||
|
# started on a full desktop -- a bare runner is what exposed it.
|
||||||
|
missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \
|
||||||
|
ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||||
|
| grep 'not found' || true)"
|
||||||
|
[ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; }
|
||||||
|
|
||||||
|
# Nothing may hard-link a driver, session or audio-stack library:
|
||||||
|
# those must be reached through dlopen so the AppImage runs on a box
|
||||||
|
# with only ALSA, only Wayland, or only KMSDRM.
|
||||||
|
linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do
|
||||||
|
objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}'
|
||||||
|
done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)"
|
||||||
|
[ -z "$linked" ] \
|
||||||
|
|| { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; }
|
||||||
|
|
||||||
|
# The whole point of compiling on bullseye. If a future change moves
|
||||||
|
# the builder to a newer base, the glibc floor silently rises and
|
||||||
|
# every user on an older distro gets "GLIBC_2.xx not found" -- catch
|
||||||
|
# it here instead of in a release.
|
||||||
|
floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \
|
||||||
|
| grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)"
|
||||||
|
echo "highest required glibc symbol version: $floor"
|
||||||
|
[ -n "$floor" ] \
|
||||||
|
|| { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; }
|
||||||
|
highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)"
|
||||||
|
[ "$highest" = "GLIBC_2.31" ] \
|
||||||
|
|| { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; }
|
||||||
|
|
||||||
|
echo "AppImage verified: $image"
|
||||||
@@ -50,6 +50,7 @@ rm -f "$OUTPUT"
|
|||||||
main.lua conf.lua src data assets tools/save-editor \
|
main.lua conf.lua src data assets tools/save-editor \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
|
tools/rom_manifest_silver.json \
|
||||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||||
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
if [ -f "$ROOT/PATCH_NOTES.md" ]; then
|
||||||
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
|
(cd "$ROOT" && zip -q "$OUTPUT" PATCH_NOTES.md)
|
||||||
@@ -94,6 +95,7 @@ for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
|||||||
tools/save-editor/panels/Party.lua \
|
tools/save-editor/panels/Party.lua \
|
||||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||||
|
tools/rom_manifest_silver.json \
|
||||||
src/ui/kit/Kit.lua \
|
src/ui/kit/Kit.lua \
|
||||||
src/import/LauncherView.lua; do
|
src/import/LauncherView.lua; do
|
||||||
grep -qxF "$required" "$LISTING" \
|
grep -qxF "$required" "$LISTING" \
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; }
|
|||||||
|
|
||||||
if [ ! -f "$ROOT/data/generated/maps.lua" ] \
|
if [ ! -f "$ROOT/data/generated/maps.lua" ] \
|
||||||
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
|
&& [ ! -f "$ROOT/blue/data/generated/maps.lua" ] \
|
||||||
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ]; then
|
&& [ ! -f "$ROOT/yellow/data/generated/maps.lua" ] \
|
||||||
|
&& [ ! -f "$ROOT/gold/data/generated/maps.lua" ] \
|
||||||
|
&& [ ! -f "$ROOT/silver/data/generated/maps.lua" ]; then
|
||||||
fail "generated data missing, run scripts/setup.sh first"
|
fail "generated data missing, run scripts/setup.sh first"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -71,15 +71,15 @@ First install or update (same steps):
|
|||||||
your saves, imported ROMs, mods, and options. Re-extracting only
|
your saves, imported ROMs, mods, and options. Re-extracting only
|
||||||
replaces the NRO(s) and these help files.
|
replaces the NRO(s) and these help files.
|
||||||
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
||||||
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc into:
|
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc into:
|
||||||
switch/gen1recomp/pokemon-love2d/imports/
|
switch/gen1recomp/pokemon-love2d/imports/
|
||||||
then use Scan again in the launcher if needed.
|
then use Scan again in the launcher if needed.
|
||||||
|
|
||||||
Inboxes (drop files here via MTP / SD / FTP):
|
Inboxes (drop files here via MTP / SD / FTP):
|
||||||
imports/ — ROM .gb / .gbc
|
imports/ — ROM .gb / .gbc
|
||||||
imports/mods/ — community mod .zip
|
imports/mods/ — community mod .zip
|
||||||
imports/saves/red|blue|yellow|gold/ — raw .sav import (Gold cart .sav not yet)
|
imports/saves/red|blue|yellow|gold|silver/ — raw .sav import (Gold/Silver cart .sav not yet)
|
||||||
exports/red|blue|yellow|gold/ — pull after Export save (Gold not yet)
|
exports/red|blue|yellow|gold|silver/ — pull after Export save (Gold/Silver not yet)
|
||||||
|
|
||||||
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
||||||
EOF
|
EOF
|
||||||
@@ -92,7 +92,7 @@ write_readme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
write_readme "$SAVE_ROOT/imports/README.txt" \
|
write_readme "$SAVE_ROOT/imports/README.txt" \
|
||||||
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc here, then Scan again in the launcher."
|
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold/Silver .gbc here, then Scan again in the launcher."
|
||||||
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
||||||
"Put community mod .zip files here, then MODS → Scan again."
|
"Put community mod .zip files here, then MODS → Scan again."
|
||||||
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
||||||
@@ -103,6 +103,8 @@ write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \
|
|||||||
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
|
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
|
||||||
write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
|
write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
|
||||||
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
|
write_readme "$SAVE_ROOT/imports/saves/silver/README.txt" \
|
||||||
|
"Silver cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
||||||
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
||||||
@@ -111,6 +113,8 @@ write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
|
|||||||
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
write_readme "$SAVE_ROOT/exports/gold/README.txt" \
|
write_readme "$SAVE_ROOT/exports/gold/README.txt" \
|
||||||
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
|
write_readme "$SAVE_ROOT/exports/silver/README.txt" \
|
||||||
|
"Silver cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
|
|
||||||
rm -f "$OUT_ZIP"
|
rm -f "$OUT_ZIP"
|
||||||
(
|
(
|
||||||
@@ -140,10 +144,12 @@ REQUIRED=(
|
|||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
|
||||||
|
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
||||||
|
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
|
||||||
)
|
)
|
||||||
for rel in "${REQUIRED[@]}"; do
|
for rel in "${REQUIRED[@]}"; do
|
||||||
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
||||||
|
|||||||
@@ -271,10 +271,12 @@ for rel in \
|
|||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
|
||||||
|
"switch/gen1recomp/pokemon-love2d/imports/saves/silver/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt" \
|
||||||
|
"switch/gen1recomp/pokemon-love2d/exports/silver/README.txt"
|
||||||
do
|
do
|
||||||
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ run_tier() {
|
|||||||
# ------- ROM-free tiers: these are what CI runs
|
# ------- ROM-free tiers: these are what CI runs
|
||||||
|
|
||||||
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
||||||
|
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
|
||||||
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_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 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
||||||
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
# NX Blue/Yellow asset overlay: ROM-free, must run on every checkout so a
|
||||||
|
|||||||
@@ -1718,9 +1718,14 @@ function BattleState:enter()
|
|||||||
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
-- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs
|
||||||
-- playerMonFainted queues on the battle screen; there is no battle
|
-- playerMonFainted queues on the battle screen; there is no battle
|
||||||
-- screen to queue them on here, so they print over the map.
|
-- screen to queue them on here, so they print over the map.
|
||||||
|
-- _PlayerBlackedOutText (no "2") extracts to the identical wording from
|
||||||
|
-- a different ROM address and is unused anywhere in this engine -- not
|
||||||
|
-- a fallback for this one, just pokered printing the same paragraph
|
||||||
|
-- from a second call site elsewhere.
|
||||||
self.game.stack:push(require("src.render.TextBox").new(self.game,
|
self.game.stack:push(require("src.render.TextBox").new(self.game,
|
||||||
Strings("%s is out of\nuseable POKéMON!", name) .. "\f"
|
self:romText("_PlayerBlackedOutText2",
|
||||||
.. Strings("%s blacked\nout!", name), blackedOut))
|
"%s is out of\nuseable POKéMON!\f%s blacked\nout!", name, name),
|
||||||
|
blackedOut))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self.musicKind = self:computeMusicKind()
|
self.musicKind = self:computeMusicKind()
|
||||||
@@ -1857,7 +1862,8 @@ function BattleState:enter()
|
|||||||
self:slidePic("foe")
|
self:slidePic("foe")
|
||||||
end)
|
end)
|
||||||
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
|
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
|
||||||
self:sayAuto(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
self:sayAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
|
||||||
|
foeName, self.enemy.name))
|
||||||
self:act(function()
|
self:act(function()
|
||||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||||
@@ -3638,12 +3644,13 @@ function BattleState:executeAction(user, target, action)
|
|||||||
})
|
})
|
||||||
self.aiUses = self:aiUsesFor()
|
self.aiUses = self:aiUsesFor()
|
||||||
markSeen(self.game, self.enemy.mon.species)
|
markSeen(self.game, self.enemy.mon.species)
|
||||||
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
self:sayNext(self:romText("_AIBattleWithdrawText", "%s with-\ndrew %s!",
|
||||||
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
|
self.trainer.name, oldName))
|
||||||
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
|
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
|
||||||
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
|
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
|
||||||
self.enemySendingOut = true
|
self.enemySendingOut = true
|
||||||
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
|
||||||
|
self.trainer.name, self.enemy.name))
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self.enemySendingOut = false
|
self.enemySendingOut = false
|
||||||
self:startGrowIn(self.enemy)
|
self:startGrowIn(self.enemy)
|
||||||
@@ -4142,8 +4149,12 @@ function BattleState:onFaint(battler)
|
|||||||
-- acknowledged core.asm:797-798 bug.)
|
-- acknowledged core.asm:797-798 bug.)
|
||||||
self:actNext(function() self:playVictoryMusic() end)
|
self:actNext(function() self:playVictoryMusic() end)
|
||||||
end
|
end
|
||||||
-- _EnemyMonFaintedText "Enemy X fainted!" / _PlayerMonFaintedText
|
-- _EnemyMonFaintedText already carries its own "Enemy" wording, so this
|
||||||
self:sayNext(Strings("%s\nfainted!", displayName(battler)))
|
-- passes the raw name -- displayName's separate Strings("Enemy %s", ...)
|
||||||
|
-- would double it up
|
||||||
|
self:sayNext(battler.isPlayer
|
||||||
|
and self:romText("_PlayerMonFaintedText", "%s\nfainted!", battler.name)
|
||||||
|
or self:romText("_EnemyMonFaintedText", "Enemy %s\nfainted!", battler.name))
|
||||||
if battler.isPlayer then
|
if battler.isPlayer then
|
||||||
self:act(function() self:playerMonFainted() end)
|
self:act(function() self:playerMonFainted() end)
|
||||||
else
|
else
|
||||||
@@ -4319,6 +4330,13 @@ function BattleState:enemyMonFainted()
|
|||||||
-- "X is" off so "about to use" stays above the name, instead of the
|
-- "X is" off so "about to use" stays above the name, instead of the
|
||||||
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
|
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
|
||||||
-- "change POKéMON?" with YES/NO.
|
-- "change POKéMON?" with YES/NO.
|
||||||
|
--
|
||||||
|
-- _TrainerAboutToUseText combines both \f-paged, but unlike
|
||||||
|
-- _ItemUseBallText00's say()+say() merge above, this is say()+
|
||||||
|
-- sayChoice(): tried merging into one romText/sayChoice call and
|
||||||
|
-- confirmed via tests/engine/trainer_shift_prompt_bug565.lua that
|
||||||
|
-- the battle queue's own \f handling (not TextBox.lua's) does not
|
||||||
|
-- page a sayChoice string the same way -- left as two calls.
|
||||||
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
||||||
self:sayChoice(
|
self:sayChoice(
|
||||||
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
||||||
@@ -4362,7 +4380,8 @@ function BattleState:enemyMonFainted()
|
|||||||
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
||||||
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
||||||
self.enemySendingOut = true
|
self.enemySendingOut = true
|
||||||
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
self:sayNextAuto(self:romText("_TrainerSentOutText", "%s sent\nout %s!",
|
||||||
|
self.trainer.name, self.enemy.name))
|
||||||
self:actNext(function()
|
self:actNext(function()
|
||||||
self.enemySendingOut = false
|
self.enemySendingOut = false
|
||||||
self:startGrowIn(self.enemy)
|
self:startGrowIn(self.enemy)
|
||||||
@@ -4878,7 +4897,8 @@ function BattleState:storeCaughtMon()
|
|||||||
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
-- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
|
||||||
-- the box rather than firing when the dex bit is set
|
-- the box rather than firing when the dex bit is set
|
||||||
self:sayNextWaitSfx(
|
self:sayNextWaitSfx(
|
||||||
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
|
self:romText("_ItemUseBallText06",
|
||||||
|
"New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
|
||||||
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
|
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
|
||||||
self:uiNext(function()
|
self:uiNext(function()
|
||||||
return self:buildScreen("DexEntryMenu", species)
|
return self:buildScreen("DexEntryMenu", species)
|
||||||
@@ -4899,9 +4919,12 @@ function BattleState:storeCaughtMon()
|
|||||||
if boxNum then
|
if boxNum then
|
||||||
askCaughtNickname()
|
askCaughtNickname()
|
||||||
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
-- _ItemUseBallText07/08 keyed on EVENT_MET_BILL
|
||||||
local pc = (game.save.flags and game.save.flags.EVENT_MET_BILL)
|
local metBill = game.save.flags and game.save.flags.EVENT_MET_BILL
|
||||||
and "BILL's PC" or Strings("someone's PC")
|
self:sayNext(self:romText(
|
||||||
self:sayNext(Strings("%s was\ntransferred to\n%s!", self.enemy.name, pc))
|
metBill and "_ItemUseBallText07" or "_ItemUseBallText08",
|
||||||
|
metBill and "%s was\ntransferred to\nBILL's PC!"
|
||||||
|
or "%s was\ntransferred to\nsomeone's PC!",
|
||||||
|
self.enemy.name))
|
||||||
else
|
else
|
||||||
self:sayNext(Strings("But every BOX\nis full!"))
|
self:sayNext(Strings("But every BOX\nis full!"))
|
||||||
end
|
end
|
||||||
@@ -5007,8 +5030,18 @@ function BattleState:throwBall(ball)
|
|||||||
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
-- RESTLESS SOUL dodges balls even once the scope has revealed it,
|
||||||
-- so it is not a ghost battle any more (#444)
|
-- so it is not a ghost battle any more (#444)
|
||||||
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
self:animNext(self:tossAnimFor(ball), true, nil, ball)
|
||||||
self:sayNext(Strings("It dodged the\nthrown BALL!"))
|
-- _ItemUseBallText00 is one label for both lines, \f-paged. Unlike
|
||||||
self:sayNext(Strings("This POKéMON\ncan't be caught!"))
|
-- TextBox.new() (which splits \f itself), the battle queue's own
|
||||||
|
-- startMessage() only splits on \n/\v -- confirmed live: the \f
|
||||||
|
-- landed mid-line and the second sentence overflowed off the box
|
||||||
|
-- instead of starting a fresh page. Resolve the label once, then
|
||||||
|
-- split it the same way TextBox.lua does and queue one sayNext per
|
||||||
|
-- page, so the two ROM sentences still render as two pages.
|
||||||
|
local dodgeText = self:romText("_ItemUseBallText00",
|
||||||
|
"It dodged the\nthrown BALL!\fThis POKéMON\ncan't be caught!")
|
||||||
|
for page in (dodgeText .. "\f"):gmatch("(.-)\f") do
|
||||||
|
self:sayNext(page)
|
||||||
|
end
|
||||||
self:act(function()
|
self:act(function()
|
||||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -326,6 +326,9 @@ function Game:logicSpeed()
|
|||||||
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
|
||||||
return 1
|
return 1
|
||||||
end
|
end
|
||||||
|
if Game.isFixedSpeedInStack and Game.isFixedSpeedInStack(self.stack) then
|
||||||
|
return 1
|
||||||
|
end
|
||||||
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
|
||||||
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's
|
-- Clamp here too, not just in _resolveLogicSpeed's vanilla path: a mod's
|
||||||
-- core.logic_speed hook can return anything (0, negative, nil, NaN) and
|
-- core.logic_speed hook can return anything (0, negative, nil, NaN) and
|
||||||
@@ -475,6 +478,15 @@ function Game.speedCategoryInStack(stack)
|
|||||||
return "menu"
|
return "menu"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Game.isFixedSpeedInStack(stack)
|
||||||
|
local states = stack and stack.states
|
||||||
|
for i = #(states or {}), 1, -1 do
|
||||||
|
local state = states[i]
|
||||||
|
if state and (state.isFixedSpeed or state.isMinigame) then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
-- Whether a state on the stack composes its own screen and so wants the
|
-- Whether a state on the stack composes its own screen and so wants the
|
||||||
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
|
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
|
||||||
-- everything else here: the text box and YES/NO a battle puts up are states
|
-- everything else here: the text box and YES/NO a battle puts up are states
|
||||||
@@ -970,7 +982,11 @@ end
|
|||||||
-- parked the player until every direction was re-pressed (#799).
|
-- parked the player until every direction was re-pressed (#799).
|
||||||
function Game:focus(f)
|
function Game:focus(f)
|
||||||
Input:reset()
|
Input:reset()
|
||||||
if f then Input:reconcile() end
|
if f then
|
||||||
|
Input:reconcile()
|
||||||
|
local eng = self:syncEngine()
|
||||||
|
if eng then pcall(eng.noteResumed, eng) end
|
||||||
|
end
|
||||||
TouchControls:reset()
|
TouchControls:reset()
|
||||||
self:cancelPointers()
|
self:cancelPointers()
|
||||||
end
|
end
|
||||||
@@ -990,6 +1006,8 @@ function Game:onResume()
|
|||||||
Input:reconcile()
|
Input:reconcile()
|
||||||
TouchControls:reset()
|
TouchControls:reset()
|
||||||
self:cancelPointers()
|
self:cancelPointers()
|
||||||
|
local eng = self:syncEngine()
|
||||||
|
if eng then pcall(eng.noteResumed, eng) end
|
||||||
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
|
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
|
||||||
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
||||||
-- Desktop/mobile window-visible flips must not kill overworld music.
|
-- Desktop/mobile window-visible flips must not kill overworld music.
|
||||||
@@ -1197,18 +1215,26 @@ end
|
|||||||
|
|
||||||
function Game:syncEngine()
|
function Game:syncEngine()
|
||||||
if self._syncOff then return nil end
|
if self._syncOff then return nil end
|
||||||
if self._syncEngineRef then return self._syncEngineRef end
|
local eng = self._syncEngineRef
|
||||||
|
if not eng then
|
||||||
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
|
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
|
||||||
if not ok or type(SyncEngine) ~= "table" then
|
if not ok or type(SyncEngine) ~= "table" then
|
||||||
self._syncOff = true
|
self._syncOff = true
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local eng = SyncEngine.shared()
|
eng = SyncEngine.shared()
|
||||||
if not eng then
|
if not eng then
|
||||||
self._syncOff = true
|
self._syncOff = true
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
self._syncEngineRef = eng
|
self._syncEngineRef = eng
|
||||||
|
end
|
||||||
|
if type(eng.protectPlaythrough) == "function" then
|
||||||
|
local meta = self.save and self.save.meta
|
||||||
|
eng:protectPlaythrough(
|
||||||
|
(self.save and self.save.version) or require("src.core.GameVersion").get(),
|
||||||
|
type(meta) == "table" and meta.playthroughId or nil)
|
||||||
|
end
|
||||||
return eng
|
return eng
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1248,6 +1274,7 @@ function Game:applyOptions(opts)
|
|||||||
-- after VideoMode: a faithful-resolution lock is an exact window size, so
|
-- after VideoMode: a faithful-resolution lock is an exact window size, so
|
||||||
-- it has to be the last word on the window (it drops fullscreen to hold)
|
-- it has to be the last word on the window (it drops fullscreen to hold)
|
||||||
require("src.core.FaithfulRes").applyOptions(opts)
|
require("src.core.FaithfulRes").applyOptions(opts)
|
||||||
|
require("src.core.ScreenPosition").applyOptions(opts)
|
||||||
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||||
-- fpsCap key pace at the standard rate (issue #88)
|
-- fpsCap key pace at the standard rate (issue #88)
|
||||||
require("src.core.FrameCap").applyOptions(opts)
|
require("src.core.FrameCap").applyOptions(opts)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
-- everything under src/*/gen2 reaches shared services through here. Gen 1
|
-- everything under src/*/gen2 reaches shared services through here. Gen 1
|
||||||
-- Game:load cannot consume a Gen 2 cache -- different generated tables, save
|
-- Game:load cannot consume a Gen 2 cache -- different generated tables, save
|
||||||
-- shape and screen registry -- so main.lua's bootGame picks this owner when
|
-- shape and screen registry -- so main.lua's bootGame picks this owner when
|
||||||
-- GameVersion.isGold(), and the two never branch into each other.
|
-- GameVersion.generation() == 2, and the two never branch into each other.
|
||||||
--
|
--
|
||||||
-- Boot: copyright → GameFreak Presents → GS intro stub → title
|
-- Boot: copyright → GameFreak Presents → GS intro stub → title
|
||||||
-- (tilemap + Ho-Oh flap / clouds / trails) → Oak speech (Marill + shrink)
|
-- (tilemap + Ho-Oh flap / clouds / trails) → Oak speech (Marill + shrink)
|
||||||
@@ -1660,8 +1660,9 @@ function Game2:drawScene(w, h)
|
|||||||
-- row to opt into the step-down half, so CENTERED is the whole rule
|
-- row to opt into the step-down half, so CENTERED is the whole rule
|
||||||
-- here.
|
-- here.
|
||||||
local s = self.world:fitScale()
|
local s = self.world:fitScale()
|
||||||
|
local ox, oy = Chrome.fitOrigin(w, h, s)
|
||||||
G.push()
|
G.push()
|
||||||
G.translate(math.floor((w - 160 * s) / 2), math.floor((h - 144 * s) / 2))
|
G.translate(ox, oy)
|
||||||
G.scale(s, s)
|
G.scale(s, s)
|
||||||
self.stack:draw()
|
self.stack:draw()
|
||||||
G.pop()
|
G.pop()
|
||||||
@@ -1702,7 +1703,7 @@ function Game2:hotkey(key)
|
|||||||
self:writeSave()
|
self:writeSave()
|
||||||
return true
|
return true
|
||||||
elseif key == "f2" then
|
elseif key == "f2" then
|
||||||
local loaded = Save.load("gold")
|
local loaded = Save.load()
|
||||||
if loaded then self:continueGame(loaded) end
|
if loaded then self:continueGame(loaded) end
|
||||||
return true
|
return true
|
||||||
elseif key == "1" then
|
elseif key == "1" then
|
||||||
@@ -1982,6 +1983,7 @@ function Game2:applyOptions()
|
|||||||
haptics = options.haptics,
|
haptics = options.haptics,
|
||||||
})
|
})
|
||||||
require("src.core.VideoMode").applyOptions(options)
|
require("src.core.VideoMode").applyOptions(options)
|
||||||
|
require("src.core.ScreenPosition").applyOptions(options)
|
||||||
require("src.core.FrameCap").applyOptions(options)
|
require("src.core.FrameCap").applyOptions(options)
|
||||||
require("src.world.gen2.BorderFill").applyOptions(options)
|
require("src.world.gen2.BorderFill").applyOptions(options)
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
-- Which game this process is running: Red (the historical default), Blue,
|
-- Which game this process is running: Red (the historical default), Blue,
|
||||||
-- Yellow, or Gold. One source of truth for everything that differs by
|
-- Yellow, Gold, or Silver. One source of truth for everything that differs by
|
||||||
-- version -- the accepted ROM hash, the import manifest, where the
|
-- version -- the accepted ROM hash, the import manifest, where the
|
||||||
-- extracted cache lives, and the save-file suffix -- so the importer,
|
-- extracted cache lives, and the save-file suffix -- so the importer,
|
||||||
-- cache mount, SaveData, title screen and palette all agree.
|
-- cache mount, SaveData, title screen and palette all agree.
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
-- saves are untouched, but its extracted cache lives under red/ like Blue,
|
-- saves are untouched, but its extracted cache lives under red/ like Blue,
|
||||||
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
|
-- Yellow, and Gold (issue #899); a legacy root cache is moved into red/ once
|
||||||
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
|
-- by CacheFs.migrateLegacyRedCache. All supported versions can be imported
|
||||||
-- and selected side by side. Gold is Gen 2 (see docs/gold-phase1.md).
|
-- and selected side by side. Gold and Silver are Gen 2 (see
|
||||||
|
-- docs/gold-phase1.md).
|
||||||
--
|
--
|
||||||
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
-- Zero requires, so it loads during love.conf and under plain Lua for tools
|
||||||
-- and tests. The active version is a process-global set once at boot from
|
-- and tests. The active version is a process-global set once at boot from
|
||||||
@@ -61,13 +62,26 @@ GameVersion.VERSIONS = {
|
|||||||
manifest = "tools/rom_manifest_gold.json",
|
manifest = "tools/rom_manifest_gold.json",
|
||||||
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
|
cachePrefix = "gold/", -- gold/data/generated, gold/assets/generated
|
||||||
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
|
saveSuffix = "_gold", -- save_gold.lua / .bak / .tmp
|
||||||
-- The only row that carries one; absent reads as 1 (GameVersion.generation)
|
-- Absent reads as 1 (GameVersion.generation)
|
||||||
|
generation = 2,
|
||||||
|
},
|
||||||
|
-- Gold's engine with edition-selected data; the manifest is derived from
|
||||||
|
-- Gold's by tools/make_silver_manifest.py.
|
||||||
|
silver = {
|
||||||
|
id = "silver",
|
||||||
|
label = "Silver",
|
||||||
|
displayName = "Pokemon Silver",
|
||||||
|
launcherName = "Silver (Beta)",
|
||||||
|
sha1 = "49b163f7e57702bc939d642a18f591de55d92dae",
|
||||||
|
manifest = "tools/rom_manifest_silver.json",
|
||||||
|
cachePrefix = "silver/", -- silver/data/generated, silver/assets/generated
|
||||||
|
saveSuffix = "_silver", -- save_silver.lua / .bak / .tmp
|
||||||
generation = 2,
|
generation = 2,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
-- Launcher column order.
|
-- Launcher column order.
|
||||||
GameVersion.ORDER = { "red", "blue", "yellow", "gold" }
|
GameVersion.ORDER = { "red", "blue", "yellow", "gold", "silver" }
|
||||||
|
|
||||||
GameVersion.current = "red"
|
GameVersion.current = "red"
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ local function normalizeVersion(v)
|
|||||||
b = "blue", blue = "blue",
|
b = "blue", blue = "blue",
|
||||||
y = "yellow", yellow = "yellow",
|
y = "yellow", yellow = "yellow",
|
||||||
g = "gold", gold = "gold",
|
g = "gold", gold = "gold",
|
||||||
|
s = "silver", silver = "silver",
|
||||||
}
|
}
|
||||||
v = alias[v] or v
|
v = alias[v] or v
|
||||||
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
|
if GameVersion.VERSIONS and not GameVersion.VERSIONS[v] then return nil end
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ end
|
|||||||
|
|
||||||
function Music.play(data, song, loop, ctx)
|
function Music.play(data, song, loop, ctx)
|
||||||
if not song then return end
|
if not song then return end
|
||||||
if not love.audio then return end -- headless test stub
|
if not (love and love.audio) then return end -- headless test stub
|
||||||
ctx = ctx or {}
|
ctx = ctx or {}
|
||||||
song = selectSong(song, ctx)
|
song = selectSong(song, ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ function SaveData.defaultOptions()
|
|||||||
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
|
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
|
||||||
-- src/core/FaithfulRes.lua. Ignored on mobile.
|
-- src/core/FaithfulRes.lua. Ignored on mobile.
|
||||||
faithfulRes = 0,
|
faithfulRes = 0,
|
||||||
|
screenPos = "center",
|
||||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||||
fpsCap = 60,
|
fpsCap = 60,
|
||||||
-- graphics performance tier: auto | high | balanced | low. "auto"
|
-- graphics performance tier: auto | high | balanced | low. "auto"
|
||||||
@@ -1279,14 +1280,27 @@ function SaveData.ensurePlaythroughId(save, injectedFs)
|
|||||||
local isFresh = save == freshPlaythrough
|
local isFresh = save == freshPlaythrough
|
||||||
if isFresh then freshPlaythrough = nil end
|
if isFresh then freshPlaythrough = nil end
|
||||||
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
||||||
id = not isFresh and byVersion and byVersion[scope] or nil
|
local existing = byVersion and byVersion[scope]
|
||||||
|
id = not isFresh and existing or nil
|
||||||
if type(id) ~= "string" or id == "" then
|
if type(id) ~= "string" or id == "" then
|
||||||
id = SaveData.newPlaythroughId()
|
id = SaveData.newPlaythroughId()
|
||||||
|
-- A fresh skeleton still gets its own id (two unsaved New Games sharing a
|
||||||
|
-- slot must stay distinct), and it is still persisted when the slot has no
|
||||||
|
-- binding yet -- that is the contract a tool relies on to resolve
|
||||||
|
-- `selected` at the title after a restart, before any normal SAVE.
|
||||||
|
--
|
||||||
|
-- What it must NOT do is OVERWRITE a binding that already exists. newGame()
|
||||||
|
-- marks a skeleton on the boot frame, before any save is loaded, and mods
|
||||||
|
-- initialise inside that window -- so a mod touching storage at init
|
||||||
|
-- replaced the real save's id with a throwaway, stranding that save's mod
|
||||||
|
-- storage and repeating on every launch.
|
||||||
|
if not (isFresh and type(existing) == "string" and existing ~= "") then
|
||||||
opts.playthroughIds = opts.playthroughIds or {}
|
opts.playthroughIds = opts.playthroughIds or {}
|
||||||
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||||
opts.playthroughIds[version][scope] = id
|
opts.playthroughIds[version][scope] = id
|
||||||
SaveData.saveOptions(opts, injectedFs)
|
SaveData.saveOptions(opts, injectedFs)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
save.meta.playthroughId = id
|
save.meta.playthroughId = id
|
||||||
return id
|
return id
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
local ScreenPosition = {}
|
||||||
|
|
||||||
|
ScreenPosition.MODES = { "center", "upper", "top" }
|
||||||
|
ScreenPosition.DEFAULT = "center"
|
||||||
|
ScreenPosition.mode = ScreenPosition.DEFAULT
|
||||||
|
|
||||||
|
local LABELS = { center = "CENTER", upper = "UPPER", top = "TOP" }
|
||||||
|
|
||||||
|
function ScreenPosition.normalize(v)
|
||||||
|
if LABELS[v] then return v end
|
||||||
|
return ScreenPosition.DEFAULT
|
||||||
|
end
|
||||||
|
|
||||||
|
function ScreenPosition.label(v)
|
||||||
|
return LABELS[ScreenPosition.normalize(v)]
|
||||||
|
end
|
||||||
|
|
||||||
|
function ScreenPosition.cycle(v, dir)
|
||||||
|
v = ScreenPosition.normalize(v)
|
||||||
|
local modes = ScreenPosition.MODES
|
||||||
|
local cur = 1
|
||||||
|
for i, mode in ipairs(modes) do
|
||||||
|
if mode == v then cur = i break end
|
||||||
|
end
|
||||||
|
return modes[(cur - 1 + (dir or 1)) % #modes + 1]
|
||||||
|
end
|
||||||
|
|
||||||
|
function ScreenPosition.setMode(v)
|
||||||
|
ScreenPosition.mode = ScreenPosition.normalize(v)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ScreenPosition.applyOptions(opts)
|
||||||
|
ScreenPosition.setMode(opts and opts.screenPos)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ScreenPosition.safeTop()
|
||||||
|
local ok, SafeArea = pcall(require, "src.core.SafeArea")
|
||||||
|
if not ok then return 0 end
|
||||||
|
local okr, _, y = pcall(SafeArea.rect)
|
||||||
|
if not okr then return 0 end
|
||||||
|
return math.max(0, tonumber(y) or 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ScreenPosition.skinActive(w, h)
|
||||||
|
local ok, TouchSkin = pcall(require, "src.core.TouchSkin")
|
||||||
|
if not ok or type(TouchSkin.viewport) ~= "function" then return false end
|
||||||
|
local okv, x = pcall(TouchSkin.viewport, w, h)
|
||||||
|
return okv and x ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function ScreenPosition.lift(viewH, contentH, safeTop)
|
||||||
|
if ScreenPosition.mode == "center" then return 0 end
|
||||||
|
viewH = tonumber(viewH) or 0
|
||||||
|
contentH = tonumber(contentH) or 0
|
||||||
|
local slack = viewH - contentH
|
||||||
|
if slack <= 0 then return 0 end
|
||||||
|
local centered = math.floor(slack / 2)
|
||||||
|
local target = ScreenPosition.mode == "top" and 0 or math.floor(slack / 4)
|
||||||
|
safeTop = math.floor(tonumber(safeTop) or 0)
|
||||||
|
if safeTop > 0 and target < safeTop then
|
||||||
|
target = math.min(safeTop, centered)
|
||||||
|
end
|
||||||
|
return centered - target
|
||||||
|
end
|
||||||
|
|
||||||
|
return ScreenPosition
|
||||||
@@ -218,7 +218,7 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
|
|||||||
local ssW = dpadW * 0.30
|
local ssW = dpadW * 0.30
|
||||||
local margin = dpadW * 0.12
|
local margin = dpadW * 0.12
|
||||||
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
||||||
if ok and GameVersion.isGold and GameVersion.isGold() then
|
if ok and GameVersion.generation and GameVersion.generation() == 2 then
|
||||||
margin = math.max(margin, math.min(ww * 0.10, 72))
|
margin = math.max(margin, math.min(ww * 0.10, 72))
|
||||||
end
|
end
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ Save.PLAYER_STATES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
local function saveNames(version)
|
local function saveNames(version)
|
||||||
version = version or "gold"
|
version = version or GameVersion.get()
|
||||||
-- Resolve the ACTIVE SLOT the same way SaveData does, and only fall back to
|
-- Resolve the ACTIVE SLOT the same way SaveData does, and only fall back to
|
||||||
-- the flat save_<version>.lua when no slot is registered.
|
-- the flat save_<version>.lua when no slot is registered.
|
||||||
--
|
--
|
||||||
@@ -133,16 +133,22 @@ local function fs()
|
|||||||
return love.filesystem
|
return love.filesystem
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A fresh Gold save. `opts` carries what the intro collected: player name,
|
-- The blank-name fallback is the first PlayerNameArray row, which differs
|
||||||
|
-- per edition -- data/player_names.asm:12-23.
|
||||||
|
function Save.defaultPlayerName(version)
|
||||||
|
return (version or GameVersion.get()) == "silver" and "SILVER" or "GOLD"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A fresh Gen 2 save. `opts` carries what the intro collected: player name,
|
||||||
-- rival name, and the options the OPTION screen was left on.
|
-- rival name, and the options the OPTION screen was left on.
|
||||||
function Save.newGame(opts)
|
function Save.newGame(opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local save = {
|
local save = {
|
||||||
format = Save.FORMAT,
|
format = Save.FORMAT,
|
||||||
version = "gold",
|
version = GameVersion.get(),
|
||||||
generation = 2,
|
generation = 2,
|
||||||
player = {
|
player = {
|
||||||
name = opts.playerName or "GOLD",
|
name = opts.playerName or Save.defaultPlayerName(),
|
||||||
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
|
-- _ResetWRAM rolls wPlayerID out of hRandomSub/hRandomAdd
|
||||||
-- (engine/menus/intro_menu.asm:41-49).
|
-- (engine/menus/intro_menu.asm:41-49).
|
||||||
id = opts.trainerId or rand(0, 65535),
|
id = opts.trainerId or rand(0, 65535),
|
||||||
@@ -282,6 +288,7 @@ Save.DEFAULT_OPTIONS = {
|
|||||||
musicFilter = 0, -- low-pass steps, 0 = off
|
musicFilter = 0, -- low-pass steps, 0 = off
|
||||||
haptics = "light",
|
haptics = "light",
|
||||||
touchControls = { enabled = true },
|
touchControls = { enabled = true },
|
||||||
|
screenPos = "center",
|
||||||
}
|
}
|
||||||
|
|
||||||
function Save.defaultOptions()
|
function Save.defaultOptions()
|
||||||
@@ -302,7 +309,7 @@ end
|
|||||||
Save.OPTIONS_KEY = "gold"
|
Save.OPTIONS_KEY = "gold"
|
||||||
|
|
||||||
local SHARED_KEYS = {
|
local SHARED_KEYS = {
|
||||||
touchControls = true, haptics = true,
|
touchControls = true, haptics = true, screenPos = true,
|
||||||
mods = true, modsByVersion = true, modsGen2 = true,
|
mods = true, modsByVersion = true, modsGen2 = true,
|
||||||
modOptions = true, modProfiles = true, modProfilesSeeded = true,
|
modOptions = true, modProfiles = true, modProfilesSeeded = true,
|
||||||
activeProfile = true,
|
activeProfile = true,
|
||||||
@@ -374,10 +381,13 @@ end
|
|||||||
function Save.normalize(save)
|
function Save.normalize(save)
|
||||||
if type(save) ~= "table" then return nil end
|
if type(save) ~= "table" then return nil end
|
||||||
save.format = save.format or Save.FORMAT
|
save.format = save.format or Save.FORMAT
|
||||||
save.version = "gold"
|
if not (GameVersion.VERSIONS[save.version]
|
||||||
|
and GameVersion.generation(save.version) == 2) then
|
||||||
|
save.version = GameVersion.get()
|
||||||
|
end
|
||||||
save.generation = 2
|
save.generation = 2
|
||||||
save.player = save.player or {}
|
save.player = save.player or {}
|
||||||
save.player.name = save.player.name or "GOLD"
|
save.player.name = save.player.name or Save.defaultPlayerName(save.version)
|
||||||
save.player.id = save.player.id or rand(0, 65535)
|
save.player.id = save.player.id or rand(0, 65535)
|
||||||
save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY))
|
save.player.money = math.max(0, math.min(save.player.money or 0, Save.MAX_MONEY))
|
||||||
save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS))
|
save.player.coins = math.max(0, math.min(save.player.coins or 0, Save.MAX_COINS))
|
||||||
@@ -753,21 +763,24 @@ end
|
|||||||
-- copy is the witness that survives a crash mid-replace.
|
-- copy is the witness that survives a crash mid-replace.
|
||||||
function Save.save(save)
|
function Save.save(save)
|
||||||
if type(save) ~= "table" then return false, "no save" end
|
if type(save) ~= "table" then return false, "no save" end
|
||||||
if (save.version or "gold") == "gold" then
|
Save.normalize(save)
|
||||||
|
local version = save.version
|
||||||
|
do
|
||||||
local ok, SaveData = pcall(require, "src.core.SaveData")
|
local ok, SaveData = pcall(require, "src.core.SaveData")
|
||||||
if ok and SaveData.activeSlot and not SaveData.activeSlot("gold") then
|
if ok and SaveData.activeSlot and not SaveData.activeSlot(version) then
|
||||||
local id = SaveData.createSlot and SaveData.createSlot("gold")
|
local id = SaveData.createSlot and SaveData.createSlot(version)
|
||||||
if id and SaveData.setActiveSlot then SaveData.setActiveSlot("gold", id) end
|
if id and SaveData.setActiveSlot then
|
||||||
|
SaveData.setActiveSlot(version, id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local main, backup, tmp = saveNames(save.version)
|
end
|
||||||
|
local main, backup, tmp = saveNames(version)
|
||||||
local f = fs()
|
local f = fs()
|
||||||
if not f then return false, "no filesystem" end
|
if not f then return false, "no filesystem" end
|
||||||
-- saveNames may now return a saves/<version>/<slot>.lua path, and
|
-- saveNames may now return a saves/<version>/<slot>.lua path, and
|
||||||
-- love.filesystem.write does not create missing parent directories.
|
-- love.filesystem.write does not create missing parent directories.
|
||||||
local dir = main:match("^(.*)/[^/]+$")
|
local dir = main:match("^(.*)/[^/]+$")
|
||||||
if dir and f.createDirectory then f.createDirectory(dir) end
|
if dir and f.createDirectory then f.createDirectory(dir) end
|
||||||
Save.normalize(save)
|
|
||||||
save.savedAt = os.time()
|
save.savedAt = os.time()
|
||||||
local encoded = SaveSerializer.encode(save)
|
local encoded = SaveSerializer.encode(save)
|
||||||
if f.getInfo(main) then
|
if f.getInfo(main) then
|
||||||
|
|||||||
@@ -249,10 +249,12 @@ function SwitchDiagnostics.probeAssets(version)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Shallow listing so we can see if the extract tree exists at all.
|
-- Shallow listing so we can see if the extract tree exists at all.
|
||||||
local roots = { "yellow", "blue", "gold", "assets", "yellow/assets/generated",
|
local roots = { "yellow", "blue", "gold", "silver", "assets",
|
||||||
|
"yellow/assets/generated",
|
||||||
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
|
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
|
||||||
"gold/assets/generated", "gold/assets/generated/sprites",
|
"gold/assets/generated", "gold/assets/generated/sprites",
|
||||||
"gold/data/generated" }
|
"gold/data/generated", "silver/assets/generated",
|
||||||
|
"silver/data/generated" }
|
||||||
for _, dir in ipairs(roots) do
|
for _, dir in ipairs(roots) do
|
||||||
local info = filesystem.getInfo(dir)
|
local info = filesystem.getInfo(dir)
|
||||||
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ local ok, err = pcall(function()
|
|||||||
CacheFs.prefix = prefix
|
CacheFs.prefix = prefix
|
||||||
|
|
||||||
local manifest = require("src.import.RomManifest").decode(version)
|
local manifest = require("src.import.RomManifest").decode(version)
|
||||||
local RomExtractor = version == "gold"
|
local RomExtractor =
|
||||||
|
require("src.core.GameVersion").generation(version) == 2
|
||||||
and require("src.import.RomExtractorGen2")
|
and require("src.import.RomExtractorGen2")
|
||||||
or require("src.import.RomExtractor")
|
or require("src.import.RomExtractor")
|
||||||
|
|
||||||
|
|||||||
@@ -332,6 +332,17 @@ local function coreRows(opts, hooks)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local okSp, ScreenPos = pcall(require, "src.core.ScreenPosition")
|
||||||
|
if okSp then
|
||||||
|
add(Strings("SCREEN POS"),
|
||||||
|
function() return Strings(ScreenPos.label(opts.screenPos)) end,
|
||||||
|
function(dir)
|
||||||
|
opts.screenPos = ScreenPos.cycle(opts.screenPos, dir)
|
||||||
|
ScreenPos.setMode(opts.screenPos)
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
local okCap, FrameCap = pcall(require, "src.core.FrameCap")
|
local okCap, FrameCap = pcall(require, "src.core.FrameCap")
|
||||||
if okCap then
|
if okCap then
|
||||||
add(Strings("MAX FPS"),
|
add(Strings("MAX FPS"),
|
||||||
@@ -550,9 +561,10 @@ end
|
|||||||
-- opened on the Gold tab used to offer a dozen controls that did nothing
|
-- opened on the Gold tab used to offer a dozen controls that did nothing
|
||||||
-- and hide the seven that the cart itself has.
|
-- and hide the seven that the cart itself has.
|
||||||
--
|
--
|
||||||
-- The block lives in options.lua under `gold`, which is exactly where
|
-- The block lives in options.lua under `gold` (the historical key; Gold and
|
||||||
-- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the
|
-- Silver share it the way the Gen 1 games share the flat namespace), which is
|
||||||
-- next boot the same way a Gen 1 edit is. Ladders mirror
|
-- exactly where src/core/gen2/Save.lua loadOptions reads it, so an edit here
|
||||||
|
-- is live on the next boot the same way a Gen 1 edit is. Ladders mirror
|
||||||
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
|
-- src/ui/gen2/OptionsMenu.lua's ROWS; when editing one, keep the two in sync.
|
||||||
local GEN2_KEY = "gold"
|
local GEN2_KEY = "gold"
|
||||||
|
|
||||||
@@ -699,7 +711,9 @@ end
|
|||||||
function LauncherSettings.open(hooks, version)
|
function LauncherSettings.open(hooks, version)
|
||||||
local opts = SaveData.loadOptions()
|
local opts = SaveData.loadOptions()
|
||||||
local sections
|
local sections
|
||||||
if version == "gold" then
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
if GameVersion.VERSIONS[version]
|
||||||
|
and GameVersion.generation(version) == 2 then
|
||||||
local block = opts[GEN2_KEY]
|
local block = opts[GEN2_KEY]
|
||||||
if type(block) ~= "table" then
|
if type(block) ~= "table" then
|
||||||
block = {}
|
block = {}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
-- height, and flex-shrink compressing text until it overlapped.
|
-- height, and flex-shrink compressing text until it overlapped.
|
||||||
--
|
--
|
||||||
-- THE RULES THIS FILE FOLLOWS:
|
-- THE RULES THIS FILE FOLLOWS:
|
||||||
-- * Lists paginate (Kit.pager). The installed-mod list also scrolls inside
|
-- * Short lists paginate (Kit.pager, perPage from Kit.rowsThatFit); the
|
||||||
-- its viewport, so each of its pages can hold at least ten entries without
|
-- installed-mod list is one continuous scroll instead, drawing only the
|
||||||
-- requiring a tall window. Pages still bound how many mod rows we visit.
|
-- rows inside the region viewport so the window bounds the frame cost.
|
||||||
-- * Every click handler only QUEUES work (imp._uiActions); update() drains
|
-- * Every click handler only QUEUES work (imp._uiActions); update() drains
|
||||||
-- the queue, so an action that tears the view down (Play, Edit save)
|
-- the queue, so an action that tears the view down (Play, Edit save)
|
||||||
-- never runs inside the frame that dispatched it.
|
-- never runs inside the frame that dispatched it.
|
||||||
@@ -45,9 +45,6 @@ local COMMUNITY_URL = "https://bois.icu"
|
|||||||
local ACT_DEDUP = 0.35
|
local ACT_DEDUP = 0.35
|
||||||
-- Finger travel past this (px) is a drag, not a tap.
|
-- Finger travel past this (px) is a drag, not a tap.
|
||||||
local TAP_SLOP2 = 16 * 16
|
local TAP_SLOP2 = 16 * 16
|
||||||
-- Installed mods should not turn into a one- or two-item pager on a compact
|
|
||||||
-- display. Keep a useful page size, then let the list viewport scroll.
|
|
||||||
local MIN_MODS_PER_PAGE = 10
|
|
||||||
local MIN_SKIN_ROWS = 4
|
local MIN_SKIN_ROWS = 4
|
||||||
local SKIN_FORMAT_LABEL = {
|
local SKIN_FORMAT_LABEL = {
|
||||||
native = "GEN1",
|
native = "GEN1",
|
||||||
@@ -81,15 +78,6 @@ local function setTabScroll(imp, value)
|
|||||||
imp._tabScroll[tabKeyOf(imp)] = clamp(value, 0, tabScrollMax(imp))
|
imp._tabScroll[tabKeyOf(imp)] = clamp(value, 0, tabScrollMax(imp))
|
||||||
end
|
end
|
||||||
|
|
||||||
local function modListWantsWheel(imp, wheel)
|
|
||||||
if imp.tab ~= "mods" or (imp._modScrollMax or 0) <= 0 then return false end
|
|
||||||
if not inRect(imp._modListRect, Kit.mouseX, Kit.mouseY) then return false end
|
|
||||||
if not inRect(imp._tabRegionRect, Kit.mouseX, Kit.mouseY) then return false end
|
|
||||||
local at = clamp(imp.modScroll or 0, 0, imp._modScrollMax)
|
|
||||||
if wheel < 0 then return at < imp._modScrollMax end
|
|
||||||
return at > 0
|
|
||||||
end
|
|
||||||
|
|
||||||
-- ------------------------------------------------------------- lifecycle
|
-- ------------------------------------------------------------- lifecycle
|
||||||
|
|
||||||
local function ensureState(imp)
|
local function ensureState(imp)
|
||||||
@@ -136,12 +124,48 @@ function LauncherView.detach(imp)
|
|||||||
Kit.clearCaches()
|
Kit.clearCaches()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function markNoDrag(imp, x, y, w, h)
|
||||||
|
if Kit.blockClicks then return end
|
||||||
|
local t = imp._noDragRects
|
||||||
|
if not t then t = {}; imp._noDragRects = t end
|
||||||
|
local n = (imp._noDragN or 0) + 1
|
||||||
|
imp._noDragN = n
|
||||||
|
local r = t[n]
|
||||||
|
if not r then r = {}; t[n] = r end
|
||||||
|
r.x, r.y, r.w, r.h = x, y, w, h
|
||||||
|
end
|
||||||
|
|
||||||
|
local function noDragAt(imp, x, y)
|
||||||
|
local rects = imp._noDragRects
|
||||||
|
for i = 1, imp._noDragN or 0 do
|
||||||
|
if inRect(rects[i], x, y) then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function armMouse(imp, x, y)
|
||||||
|
if noDragAt(imp, x, y) then
|
||||||
|
imp._clickPt = { x = x, y = y }
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local shielded = imp._modalUpNow
|
||||||
|
imp._mouseAt = {
|
||||||
|
x = x, y = y,
|
||||||
|
region = not shielded and tabScrollMax(imp) > 0
|
||||||
|
and inRect(imp._tabRegionRect, x, y) or false,
|
||||||
|
page = not shielded and (imp._pageScrollMax or 0) > 0 or false,
|
||||||
|
}
|
||||||
|
Kit.dragBegin(x, y)
|
||||||
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------------- input
|
-- ---------------------------------------------------------------- input
|
||||||
-- The kit is polled, not evented: update() samples the mouse and turns a
|
-- The kit is polled, not evented: update() samples the mouse. A press arms
|
||||||
-- rising edge into a click point that the next draw consumes. Host-forwarded
|
-- a drag that scrolls like a finger, and the click dispatches on RELEASE so
|
||||||
-- mousepressed stays unused, exactly as before, so Android's synthesized
|
-- the drag can disqualify it, exactly like the touch path below; only the
|
||||||
-- mouse path cannot double-fire a tap (#553) -- the dedup window below is the
|
-- cartridge (which owns its own spin-drag) keeps the press-down click.
|
||||||
-- other half of that guarantee.
|
-- Host-forwarded mousepressed stays unused, exactly as before, so Android's
|
||||||
|
-- synthesized mouse path cannot double-fire a tap (#553) -- the dedup window
|
||||||
|
-- below is the other half of that guarantee.
|
||||||
function LauncherView.update(imp, dt)
|
function LauncherView.update(imp, dt)
|
||||||
if not imp._flex then return end
|
if not imp._flex then return end
|
||||||
if imp._launchFade then return end
|
if imp._launchFade then return end
|
||||||
@@ -166,7 +190,39 @@ function LauncherView.update(imp, dt)
|
|||||||
if not touching and now >= (imp._suppressMouseUntil or 0)
|
if not touching and now >= (imp._suppressMouseUntil or 0)
|
||||||
and now >= (imp._suppressClickUntil or 0) then
|
and now >= (imp._suppressClickUntil or 0) then
|
||||||
local mx, my = love.mouse.getPosition()
|
local mx, my = love.mouse.getPosition()
|
||||||
imp._clickPt = { x = mx, y = my }
|
armMouse(imp, mx, my)
|
||||||
|
end
|
||||||
|
elseif down and imp._mouseAt then
|
||||||
|
local start = imp._mouseAt
|
||||||
|
local mx, my = love.mouse.getPosition()
|
||||||
|
local ddx, ddy = mx - start.x, my - start.y
|
||||||
|
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
|
||||||
|
start.dragged = true
|
||||||
|
end
|
||||||
|
if start.dragged then
|
||||||
|
local last = start.lastY or start.y
|
||||||
|
local move = -(my - last)
|
||||||
|
if move ~= 0 and start.region then
|
||||||
|
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
|
||||||
|
tabScrollMax(imp), move)
|
||||||
|
setTabScroll(imp, at)
|
||||||
|
move = leftover
|
||||||
|
end
|
||||||
|
if move ~= 0 and start.page and (imp._pageScrollMax or 0) > 0 then
|
||||||
|
local at, leftover = Kit.scrollHandoff(imp._pageScroll or 0,
|
||||||
|
imp._pageScrollMax, move)
|
||||||
|
imp._pageScroll = at
|
||||||
|
move = leftover
|
||||||
|
end
|
||||||
|
if move ~= 0 then Kit.dragAdd(move) end
|
||||||
|
end
|
||||||
|
start.lastY = my
|
||||||
|
elseif not down and imp._mouseAt then
|
||||||
|
local start = imp._mouseAt
|
||||||
|
imp._mouseAt = nil
|
||||||
|
Kit.dragEnd()
|
||||||
|
if not start.dragged then
|
||||||
|
imp._clickPt = { x = start.x, y = start.y }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
imp._prevMouseDown = down
|
imp._prevMouseDown = down
|
||||||
@@ -192,9 +248,6 @@ function LauncherView.touchpressed(imp, id, x, y)
|
|||||||
imp._touchAt = imp._touchAt or {}
|
imp._touchAt = imp._touchAt or {}
|
||||||
imp._touchAt[tostring(id)] = {
|
imp._touchAt[tostring(id)] = {
|
||||||
x = x, y = y,
|
x = x, y = y,
|
||||||
modsList = imp.tab == "mods" and (imp._modScrollMax or 0) > 0
|
|
||||||
and inRect(imp._modListRect, x, y)
|
|
||||||
and inRect(imp._tabRegionRect, x, y),
|
|
||||||
region = tabScrollMax(imp) > 0 and inRect(imp._tabRegionRect, x, y),
|
region = tabScrollMax(imp) > 0 and inRect(imp._tabRegionRect, x, y),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -207,19 +260,9 @@ function LauncherView.touchmoved(imp, id, x, y)
|
|||||||
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
|
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
|
||||||
start.dragged = true
|
start.dragged = true
|
||||||
end
|
end
|
||||||
-- A drag that began in the installed-mod viewport scrolls that page's
|
|
||||||
-- rows. Its pager remains available for moving to the next ten-plus
|
|
||||||
-- entries; a drag elsewhere keeps the normal short-window page scroll.
|
|
||||||
if start.dragged then
|
if start.dragged then
|
||||||
local last = start.lastY or start.y
|
local last = start.lastY or start.y
|
||||||
local move = -(y - last)
|
local move = -(y - last)
|
||||||
if start.modsList then
|
|
||||||
local listMax = imp._modScrollMax or 0
|
|
||||||
local at, leftover = Kit.scrollHandoff(
|
|
||||||
clamp(imp.modScroll or 0, 0, listMax), listMax, move)
|
|
||||||
imp.modScroll = at
|
|
||||||
move = leftover
|
|
||||||
end
|
|
||||||
if move ~= 0 and start.region then
|
if move ~= 0 and start.region then
|
||||||
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
|
local at, leftover = Kit.scrollHandoff(tabScrollAt(imp),
|
||||||
tabScrollMax(imp), move)
|
tabScrollMax(imp), move)
|
||||||
@@ -258,6 +301,24 @@ function LauncherView.clickAt(imp, x, y)
|
|||||||
imp._clickPt = { x = x, y = y }
|
imp._clickPt = { x = x, y = y }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Event-driven press: a macOS trackpad tap delivers press+release inside one
|
||||||
|
-- frame, so update()'s love.mouse.isDown poll never sees it. Arm the drag
|
||||||
|
-- from the press event under the poll's own suppression rules -- the poll's
|
||||||
|
-- release branch then mints the tap, still within the same frame for a
|
||||||
|
-- one-frame tap -- and mark the press seen so the poll cannot arm a second
|
||||||
|
-- one when isDown does catch it.
|
||||||
|
function LauncherView.mousepressed(imp, x, y)
|
||||||
|
if not imp._flex then return end
|
||||||
|
local now = love.timer.getTime()
|
||||||
|
local touching = imp._touchAt ~= nil and next(imp._touchAt) ~= nil
|
||||||
|
if touching or now < (imp._suppressMouseUntil or 0)
|
||||||
|
or now < (imp._suppressClickUntil or 0) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not imp._mouseAt then armMouse(imp, x, y) end
|
||||||
|
imp._prevMouseDown = true
|
||||||
|
end
|
||||||
|
|
||||||
-- Keyboard focus ring. Returns true when the key was consumed. Arrows arm
|
-- Keyboard focus ring. Returns true when the key was consumed. Arrows arm
|
||||||
-- the ring; Enter only activates a focused control once the user has actually
|
-- the ring; Enter only activates a focused control once the user has actually
|
||||||
-- used the arrows this session, so the long-standing "Enter plays the visible
|
-- used the arrows this session, so the long-standing "Enter plays the visible
|
||||||
@@ -343,7 +404,7 @@ end
|
|||||||
|
|
||||||
local CART_COLOR = {
|
local CART_COLOR = {
|
||||||
red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold,
|
red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold,
|
||||||
gold = PAL.railAmber,
|
gold = PAL.railAmber, silver = PAL.railSilver,
|
||||||
}
|
}
|
||||||
local function cartColor(version)
|
local function cartColor(version)
|
||||||
return CART_COLOR[version] or PAL.green
|
return CART_COLOR[version] or PAL.green
|
||||||
@@ -418,13 +479,13 @@ end
|
|||||||
local function cartPill(project, x, y, w, h, z, color, alpha)
|
local function cartPill(project, x, y, w, h, z, color, alpha)
|
||||||
local points, radius = {}, h / 2
|
local points, radius = {}, h / 2
|
||||||
for i = 0, 10 do
|
for i = 0, 10 do
|
||||||
local a = math.pi + math.pi * i / 10
|
local a = -math.pi / 2 + math.pi * i / 10
|
||||||
points[#points + 1] = { project(x + radius + math.cos(a) * radius,
|
points[#points + 1] = { project(x + w - radius + math.cos(a) * radius,
|
||||||
y + radius + math.sin(a) * radius, z) }
|
y + radius + math.sin(a) * radius, z) }
|
||||||
end
|
end
|
||||||
for i = 0, 10 do
|
for i = 0, 10 do
|
||||||
local a = math.pi * i / 10
|
local a = math.pi / 2 + math.pi * i / 10
|
||||||
points[#points + 1] = { project(x + w - radius + math.cos(a) * radius,
|
points[#points + 1] = { project(x + radius + math.cos(a) * radius,
|
||||||
y + radius + math.sin(a) * radius, z) }
|
y + radius + math.sin(a) * radius, z) }
|
||||||
end
|
end
|
||||||
cartPolygon(points, color, alpha)
|
cartPolygon(points, color, alpha)
|
||||||
@@ -504,6 +565,7 @@ end
|
|||||||
|
|
||||||
local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||||
local state = cartridgeState(imp, version)
|
local state = cartridgeState(imp, version)
|
||||||
|
markNoDrag(imp, x, y, w, h)
|
||||||
local focused = Kit.focusable(key, x, y, w, h)
|
local focused = Kit.focusable(key, x, y, w, h)
|
||||||
local hot = Kit.hover(x, y, w, h)
|
local hot = Kit.hover(x, y, w, h)
|
||||||
local active = state.active
|
local active = state.active
|
||||||
@@ -605,7 +667,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local halfW, halfH = w / 2, h / 2
|
local halfW, halfH = w / 2, h / 2
|
||||||
local depth = math.max(8, w * 0.14)
|
local depth = math.max(6, w * 0.10)
|
||||||
local project = function(px, py, pz)
|
local project = function(px, py, pz)
|
||||||
return cartProject(cx + pressX, cy + pressY, yaw, pitch,
|
return cartProject(cx + pressX, cy + pressY, yaw, pitch,
|
||||||
px * pressedScale, py * pressedScale, pz * pressedScale)
|
px * pressedScale, py * pressedScale, pz * pressedScale)
|
||||||
@@ -648,6 +710,7 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
|||||||
cartPolygon({ mainFront[2], mainFront[3], mainBack[3], mainBack[2] }, side, 1)
|
cartPolygon({ mainFront[2], mainFront[3], mainBack[3], mainBack[2] }, side, 1)
|
||||||
cartPolygon({ mainFront[3], mainFront[4], mainBack[4], mainBack[3] }, side, 1)
|
cartPolygon({ mainFront[3], mainFront[4], mainBack[4], mainBack[3] }, side, 1)
|
||||||
cartPolygon({ mainFront[1], mainFront[2], mainBack[2], mainBack[1] }, side, 1)
|
cartPolygon({ mainFront[1], mainFront[2], mainBack[2], mainBack[1] }, side, 1)
|
||||||
|
cartPolygon({ mainFront[4], mainFront[1], mainBack[1], mainBack[4] }, side, 1)
|
||||||
cartPolygon({ capFront[2], capFront[3], capBack[3], capBack[2] }, side, 1)
|
cartPolygon({ capFront[2], capFront[3], capBack[3], capBack[2] }, side, 1)
|
||||||
cartPolygon({ capFront[1], capFront[2], capBack[2], capBack[1] }, side, 1)
|
cartPolygon({ capFront[1], capFront[2], capBack[2], capBack[1] }, side, 1)
|
||||||
cartPolygon({ capFront[4], capFront[1], capBack[1], capBack[4] }, side, 1)
|
cartPolygon({ capFront[4], capFront[1], capBack[1], capBack[4] }, side, 1)
|
||||||
@@ -657,22 +720,72 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
|||||||
else
|
else
|
||||||
cartPolygon(mainBack, side, 1)
|
cartPolygon(mainBack, side, 1)
|
||||||
cartPolygon(capBack, side, 1)
|
cartPolygon(capBack, side, 1)
|
||||||
|
-- The tri-wing security screw: a domed brass head with three teardrop
|
||||||
|
-- recesses pinwheeled at 120 degrees.
|
||||||
|
local backZ = -(depth + 0.8)
|
||||||
|
local sd = math.min(w, h) * 0.11
|
||||||
|
cartPill(project, -sd * 0.62, -sd * 0.62, sd * 1.24, sd * 1.24, backZ,
|
||||||
|
{ math.floor(shell[1] * 0.4), math.floor(shell[2] * 0.4),
|
||||||
|
math.floor(shell[3] * 0.4) }, 0.9)
|
||||||
|
cartPill(project, -sd / 2, -sd / 2, sd, sd, backZ - 0.4,
|
||||||
|
{ 196, 186, 148 }, 1)
|
||||||
|
cartPill(project, -sd * 0.32, -sd * 0.32, sd * 0.64, sd * 0.64,
|
||||||
|
backZ - 0.6, { 220, 212, 178 }, 0.8)
|
||||||
|
local r = sd / 2
|
||||||
|
for k = 0, 2 do
|
||||||
|
local a = -math.pi / 2 + k * (2 * math.pi / 3)
|
||||||
|
local ux, uy = math.cos(a), math.sin(a)
|
||||||
|
local vx, vy = -uy, ux
|
||||||
|
local r0, r1 = r * 0.16, r * 0.82
|
||||||
|
local w0, w1 = r * 0.13, r * 0.3
|
||||||
|
cartPolygon({
|
||||||
|
{ project(ux * r0 + vx * w0, uy * r0 + vy * w0, backZ - 0.8) },
|
||||||
|
{ project(ux * r0 - vx * w0, uy * r0 - vy * w0, backZ - 0.8) },
|
||||||
|
{ project(ux * r1 - vx * w1, uy * r1 - vy * w1, backZ - 0.8) },
|
||||||
|
{ project(ux * r1 + vx * w1, uy * r1 + vy * w1, backZ - 0.8) },
|
||||||
|
}, { 112, 104, 76 }, 1)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if frontFacing then
|
if frontFacing then
|
||||||
local faceZ = depth + 0.8
|
local faceZ = depth + 0.8
|
||||||
for i = 0, 4 do
|
-- The shell's grip grooves: a stack beside the label recess on the left,
|
||||||
local ry = mainTop + 7 + i * h * 0.025
|
-- and one below the top-right corner notch, like the DMG cart.
|
||||||
cartPolygon(cartQuad(project, -halfW + 2, ry, w * 0.13, 2, faceZ), side, 0.7)
|
local grooveW = w * 0.115
|
||||||
cartPolygon(cartQuad(project, halfW - w * 0.13 - 2, ry, w * 0.13, 2, faceZ), side, 0.7)
|
local grooveH = math.max(1, h * 0.009)
|
||||||
|
for i = 0, 5 do
|
||||||
|
local ry = mainTop + h * 0.014 + i * h * 0.021
|
||||||
|
cartPolygon(cartQuad(project, -halfW + w * 0.02, ry,
|
||||||
|
grooveW, grooveH, faceZ), side, 0.7)
|
||||||
|
cartPolygon(cartQuad(project, halfW - grooveW - w * 0.02, ry,
|
||||||
|
grooveW, grooveH, faceZ), side, 0.7)
|
||||||
end
|
end
|
||||||
local recessX, recessY = -w * 0.32, mainTop + h * 0.023
|
-- The thin diagonal mold ridge cut into each long side a little below
|
||||||
local recessW, recessH = w * 0.64, h * 0.24
|
-- the grip grooves, mirrored left/right.
|
||||||
cartPolygon(cartQuad(project, recessX, recessY, recessW, recessH, faceZ), shell, 0.88)
|
local function diagonal(x0, y0, x1, y1)
|
||||||
cartPill(project, recessX + w * 0.025, recessY + h * 0.025,
|
local dx, dy = x1 - x0, y1 - y0
|
||||||
recessW - w * 0.05, h * 0.12, faceZ + 0.5, shell, 0.7)
|
local len = math.sqrt(dx * dx + dy * dy)
|
||||||
cartPill(project, recessX + w * 0.045, recessY + h * 0.043,
|
local nx, ny = -dy / len, dx / len
|
||||||
recessW - w * 0.09, h * 0.083, faceZ + 0.8, side, 0.42)
|
local t = math.max(0.6, h * 0.004)
|
||||||
|
cartPolygon({
|
||||||
|
{ project(x0 + nx * t, y0 + ny * t, faceZ) },
|
||||||
|
{ project(x0 - nx * t, y0 - ny * t, faceZ) },
|
||||||
|
{ project(x1 - nx * t, y1 - ny * t, faceZ) },
|
||||||
|
{ project(x1 + nx * t, y1 + ny * t, faceZ) },
|
||||||
|
}, side, 0.7)
|
||||||
|
end
|
||||||
|
local dgY = mainTop + h * 0.20
|
||||||
|
diagonal(-halfW + w * 0.006, dgY, -halfW + w * 0.085, dgY + h * 0.038)
|
||||||
|
diagonal(halfW - w * 0.006, dgY, halfW - w * 0.085, dgY + h * 0.038)
|
||||||
|
-- The Nintendo GAME BOY recess: one stadium pill sunk into the shell.
|
||||||
|
local pillX, pillW = -halfW + w * 0.17, w * 0.62
|
||||||
|
local pillY, pillH = mainTop + h * 0.024, h * 0.115
|
||||||
|
cartPill(project, pillX, pillY, pillW, pillH, faceZ + 0.5, side, 0.55)
|
||||||
|
local inX, inY = w * 0.008, h * 0.008
|
||||||
|
cartPill(project, pillX + inX, pillY + inY,
|
||||||
|
pillW - 2 * inX, pillH - 2 * inY, faceZ + 0.8,
|
||||||
|
{ math.floor(shell[1] * 0.92), math.floor(shell[2] * 0.92),
|
||||||
|
math.floor(shell[3] * 0.92) }, 1)
|
||||||
|
|
||||||
local labelX, labelY = -w * 0.33, -h * 0.20
|
local labelX, labelY = -w * 0.33, -h * 0.20
|
||||||
local labelW, labelH = w * 0.66, h * 0.55
|
local labelW, labelH = w * 0.66, h * 0.55
|
||||||
@@ -942,6 +1055,8 @@ local GAME_TABS = {
|
|||||||
label = "Yellow" },
|
label = "Yellow" },
|
||||||
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber,
|
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber,
|
||||||
label = "Gold" },
|
label = "Gold" },
|
||||||
|
{ id = "silver", key = "tab-silver", letter = "S", color = PAL.railSilver,
|
||||||
|
label = "Silver" },
|
||||||
}
|
}
|
||||||
|
|
||||||
local HEADER_TABS = {
|
local HEADER_TABS = {
|
||||||
@@ -1114,16 +1229,11 @@ local function buildHeader(imp, m)
|
|||||||
local tabGap = math.floor(6 * m.s)
|
local tabGap = math.floor(6 * m.s)
|
||||||
local tabRowGap = math.floor(4 * m.s)
|
local tabRowGap = math.floor(4 * m.s)
|
||||||
|
|
||||||
-- the cartridge dropdown, sized to its longest label so switching games
|
-- the cartridge dropdown: just the game's initial and the caret; the
|
||||||
-- never reflows the row
|
-- popup list carries the full names
|
||||||
local chrome0 = headerChrome(imp)
|
local chrome0 = headerChrome(imp)
|
||||||
local game = currentGame(imp)
|
local game = currentGame(imp)
|
||||||
local labelW = 0
|
local dropW = math.min(tabRight - tabLeft, tabH + math.floor(24 * m.s))
|
||||||
for _, g in ipairs(GAME_TABS) do
|
|
||||||
labelW = math.max(labelW, Kit.textWidth("tab", Strings(g.label)))
|
|
||||||
end
|
|
||||||
local dropW = math.min(tabRight - tabLeft,
|
|
||||||
tabH + labelW + math.floor(34 * m.s))
|
|
||||||
chrome0.game.color = game.color
|
chrome0.game.color = game.color
|
||||||
chrome0.game.letter = game.letter
|
chrome0.game.letter = game.letter
|
||||||
chrome0.game.active = imp.tab == game.id
|
chrome0.game.active = imp.tab == game.id
|
||||||
@@ -1133,7 +1243,7 @@ local function buildHeader(imp, m)
|
|||||||
-- flip with it or it vanishes into the cartridge colour
|
-- flip with it or it vanishes into the cartridge colour
|
||||||
local gameInvert = chrome0.game.active or gameHot
|
local gameInvert = chrome0.game.active or gameHot
|
||||||
chrome0.game.ring = gameHot and not chrome0.game.active or nil
|
chrome0.game.ring = gameHot and not chrome0.game.active or nil
|
||||||
btn(imp, tx, ty, dropW, tabH, "tab-game", Strings(game.label), chrome0.game)
|
btn(imp, tx, ty, dropW, tabH, "tab-game", "", chrome0.game)
|
||||||
do
|
do
|
||||||
local cw = math.floor(7 * m.s)
|
local cw = math.floor(7 * m.s)
|
||||||
local ccx = tx + dropW - math.floor(14 * m.s)
|
local ccx = tx + dropW - math.floor(14 * m.s)
|
||||||
@@ -1147,7 +1257,7 @@ local function buildHeader(imp, m)
|
|||||||
end
|
end
|
||||||
tx = tx + dropW + tabGap
|
tx = tx + dropW + tabGap
|
||||||
|
|
||||||
for _, t in ipairs(tabs) do
|
local function headerTab(t)
|
||||||
local w = tabH
|
local w = tabH
|
||||||
if tx > tabLeft and tx + w > tabRight then
|
if tx > tabLeft and tx + w > tabRight then
|
||||||
tx = tabLeft
|
tx = tabLeft
|
||||||
@@ -1160,6 +1270,11 @@ local function buildHeader(imp, m)
|
|||||||
btn(imp, tx, ty, w, tabH, t.key, "", o)
|
btn(imp, tx, ty, w, tabH, t.key, "", o)
|
||||||
tx = tx + w + tabGap
|
tx = tx + w + tabGap
|
||||||
end
|
end
|
||||||
|
-- The bug-report chip sits LAST, past the sync chip.
|
||||||
|
local bugTab
|
||||||
|
for _, t in ipairs(tabs) do
|
||||||
|
if t.id == "bug" then bugTab = t else headerTab(t) end
|
||||||
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local w = tabH
|
local w = tabH
|
||||||
@@ -1181,6 +1296,7 @@ local function buildHeader(imp, m)
|
|||||||
end
|
end
|
||||||
tx = tx + w + tabGap
|
tx = tx + w + tabGap
|
||||||
end
|
end
|
||||||
|
if bugTab then headerTab(bugTab) end
|
||||||
|
|
||||||
-- `ty` has walked down with the wraps, so this stays correct at one row too.
|
-- `ty` has walked down with the wraps, so this stays correct at one row too.
|
||||||
y = ty + tabH + math.floor(8 * m.s)
|
y = ty + tabH + math.floor(8 * m.s)
|
||||||
@@ -1964,7 +2080,6 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
|||||||
cy = cy + buildModScopeRow(imp, x, cy, w, m)
|
cy = cy + buildModScopeRow(imp, x, cy, w, m)
|
||||||
|
|
||||||
if #mods == 0 then
|
if #mods == 0 then
|
||||||
imp.modScroll, imp._modScrollMax, imp._modListRect = 0, 0, nil
|
|
||||||
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
|
Kit.emptyBox(x, cy, w, math.floor(110 * m.s), imp:_modsEmptyHint())
|
||||||
return (cy - y) + math.floor(110 * m.s)
|
return (cy - y) + math.floor(110 * m.s)
|
||||||
end
|
end
|
||||||
@@ -2005,43 +2120,26 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- A mod row is a fixed height: its details first, then a dedicated second
|
-- A mod row is a fixed height: its details first, then a dedicated second
|
||||||
-- line of per-game checkboxes. Fixed because a page of uniform rows is
|
-- line of per-game checkboxes. Fixed row heights are what make the
|
||||||
-- what lets perPage come from the viewport.
|
-- cull below plain arithmetic.
|
||||||
local togH = math.floor(26 * m.s)
|
local togH = math.floor(26 * m.s)
|
||||||
local gamesLabel = Strings("Enable for:")
|
local gamesLabel = Strings("Enable for:")
|
||||||
local textH = Kit.textHeight("button") + math.floor(4 * m.s)
|
local textH = Kit.textHeight("button") + math.floor(4 * m.s)
|
||||||
+ Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small")
|
+ Kit.textHeight("small") + math.floor(2 * m.s) + Kit.textHeight("small")
|
||||||
local rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + togH
|
local rowH = math.floor(8 * m.s) + textH + math.floor(8 * m.s) + togH
|
||||||
+ math.floor(8 * m.s)
|
+ math.floor(8 * m.s)
|
||||||
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
|
||||||
local listH = availH - (cy - y) - pagerH - gap
|
|
||||||
local perPage = Kit.rowsThatFit(listH, rowH, gap, MIN_MODS_PER_PAGE, 20)
|
|
||||||
local first, last, cur, pages = Kit.pageBounds(page(imp, "mods"), #mods, perPage)
|
|
||||||
setPage(imp, "mods", cur)
|
|
||||||
local listTop = cy
|
local listTop = cy
|
||||||
local shown = math.max(0, last - first + 1)
|
|
||||||
local contentH = shown * rowH + math.max(0, shown - 1) * gap
|
|
||||||
local scrollMax = math.max(0, contentH - listH)
|
|
||||||
local scroll = clamp(imp.modScroll or 0, 0, scrollMax)
|
|
||||||
local lr = imp._modListRect
|
|
||||||
if not lr then lr = {}; imp._modListRect = lr end
|
|
||||||
lr.x, lr.y, lr.w, lr.h = x, listTop, w, listH
|
|
||||||
imp._modScrollMax = scrollMax
|
|
||||||
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks
|
|
||||||
and Kit.hit(x, listTop, w, listH) then
|
|
||||||
scroll = clamp(scroll - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
|
|
||||||
Kit.wheelY = 0
|
|
||||||
elseif scrollMax == 0 then
|
|
||||||
local wheelPage = Kit.wheelPage(x, listTop, w, listH, cur, #mods, perPage)
|
|
||||||
if wheelPage ~= cur then imp.modScroll = 0 end
|
|
||||||
setPage(imp, "mods", wheelPage)
|
|
||||||
end
|
|
||||||
imp.modScroll = scroll
|
|
||||||
|
|
||||||
Kit.pushClip(x, listTop, w, listH)
|
-- One continuous list: every row is laid out, the region scroll moves
|
||||||
for i = first, last do
|
-- through all of it, and only rows inside the region's viewport draw --
|
||||||
|
-- so the per-frame cost stays bounded by the window, not the list.
|
||||||
|
local view = imp._tabRegionRect
|
||||||
|
local viewTop = view and view.y or listTop
|
||||||
|
local viewBot = view and (view.y + view.h) or (listTop + availH)
|
||||||
|
for i = 1, #mods do
|
||||||
local mod = mods[i]
|
local mod = mods[i]
|
||||||
local ry = listTop + (i - first) * (rowH + gap) - scroll
|
local ry = listTop + (i - 1) * (rowH + gap)
|
||||||
|
if ry + rowH >= viewTop and ry <= viewBot then
|
||||||
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
|
local rowKey = rowKeyFor(imp, "mod-row-", mod.id)
|
||||||
local isFullyDisabled = true
|
local isFullyDisabled = true
|
||||||
if mod.enabledByVersion then
|
if mod.enabledByVersion then
|
||||||
@@ -2151,13 +2249,10 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
|||||||
px, ly, PAL.detail)
|
px, ly, PAL.detail)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Kit.popClip()
|
end
|
||||||
|
|
||||||
local pagerY = listTop + listH + gap
|
local contentH = #mods * rowH + (#mods - 1) * gap
|
||||||
local newPage, newPagerH = Kit.pager(x, pagerY, w, cur, #mods, perPage, "mods")
|
return (listTop + contentH + gap) - y
|
||||||
if newPage ~= cur then imp.modScroll = 0 end
|
|
||||||
setPage(imp, "mods", newPage)
|
|
||||||
return pagerY + newPagerH - y
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------------------- find mods panel
|
-- ---------------------------------------------------------- find mods panel
|
||||||
@@ -2889,6 +2984,8 @@ local function buildConfirmModal(imp, m)
|
|||||||
imp:_setAllMods(true, true)
|
imp:_setAllMods(true, true)
|
||||||
elseif c.kind == "importOversize" then
|
elseif c.kind == "importOversize" then
|
||||||
imp:_importSave(c.version, c.source, true)
|
imp:_importSave(c.version, c.source, true)
|
||||||
|
elseif c.kind == "largeImport" then
|
||||||
|
imp:_importRequiredSource(c.modId, c.importId, c.source, true)
|
||||||
else
|
else
|
||||||
imp:_toggleMod(c.id, true, c.version)
|
imp:_toggleMod(c.id, true, c.version)
|
||||||
end
|
end
|
||||||
@@ -4457,7 +4554,7 @@ local function buildSyncHome(imp, m, eng)
|
|||||||
local linked = eng:linked()
|
local linked = eng:linked()
|
||||||
local codes = eng.codes
|
local codes = eng.codes
|
||||||
local body = linked
|
local body = linked
|
||||||
and Strings("This device is linked. Saves and the mod list sync when the launcher opens and a few seconds after each save.")
|
and Strings("This device is linked. Saves sync when the launcher opens, a few seconds after each save, and every few minutes while the app is running.")
|
||||||
or Strings(SYNC_HINT)
|
or Strings(SYNC_HINT)
|
||||||
local innerW = w - 2 * pad
|
local innerW = w - 2 * pad
|
||||||
local hintH = Kit.wrapHeight("small", body, innerW, 5)
|
local hintH = Kit.wrapHeight("small", body, innerW, 5)
|
||||||
@@ -4842,17 +4939,18 @@ function LauncherView.draw(imp)
|
|||||||
Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
|
Kit.beginFrame(mx, my, click ~= nil, imp._wheelY or 0)
|
||||||
imp._clickPt = nil
|
imp._clickPt = nil
|
||||||
imp._wheelY = 0
|
imp._wheelY = 0
|
||||||
|
imp._noDragN = 0
|
||||||
|
|
||||||
Theme.field()
|
Theme.field()
|
||||||
|
|
||||||
-- Everything from here to buildModals sits UNDER any open modal, so the
|
-- Everything from here to buildModals sits UNDER any open modal, so the
|
||||||
-- whole stage draws shielded (no clicks, no hover, no focus ring) while
|
-- whole stage draws shielded (no clicks, no hover, no focus ring) while
|
||||||
-- one is up; buildModals lowers the shield for the modal's own controls.
|
-- one is up; buildModals lowers the shield for the modal's own controls.
|
||||||
Kit.blockClicks = modalUp(imp)
|
imp._modalUpNow = modalUp(imp)
|
||||||
|
Kit.blockClicks = imp._modalUpNow
|
||||||
|
|
||||||
local step = Kit.scrollStep(m.s)
|
local step = Kit.scrollStep(m.s)
|
||||||
local nested = modListWantsWheel(imp, Kit.wheelY or 0)
|
do
|
||||||
if not nested then
|
|
||||||
local rect = imp._tabRegionRect
|
local rect = imp._tabRegionRect
|
||||||
if rect then
|
if rect then
|
||||||
setTabScroll(imp, (Kit.scrollWheel(tabScrollAt(imp), tabScrollMax(imp),
|
setTabScroll(imp, (Kit.scrollWheel(tabScrollAt(imp), tabScrollMax(imp),
|
||||||
@@ -4860,8 +4958,7 @@ function LauncherView.draw(imp)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax))
|
local scroll = math.max(0, math.min(imp._pageScroll or 0, scrollMax))
|
||||||
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not nested
|
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks then
|
||||||
and not Kit.blockClicks then
|
|
||||||
local moved = math.max(0, math.min(scroll - Kit.wheelY * step, scrollMax))
|
local moved = math.max(0, math.min(scroll - Kit.wheelY * step, scrollMax))
|
||||||
if moved ~= scroll then
|
if moved ~= scroll then
|
||||||
scroll = moved
|
scroll = moved
|
||||||
@@ -4869,7 +4966,7 @@ function LauncherView.draw(imp)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
imp._pageScroll, imp._pageScrollMax = scroll, scrollMax
|
imp._pageScroll, imp._pageScrollMax = scroll, scrollMax
|
||||||
if (Kit.wheelY or 0) ~= 0 and not nested and not Kit.blockClicks
|
if (Kit.wheelY or 0) ~= 0 and not Kit.blockClicks
|
||||||
and tabScrollMax(imp) > 0 then
|
and tabScrollMax(imp) > 0 then
|
||||||
local was = tabScrollAt(imp)
|
local was = tabScrollAt(imp)
|
||||||
local to = Kit.scrollClamp(was - Kit.wheelY * step, tabScrollMax(imp))
|
local to = Kit.scrollClamp(was - Kit.wheelY * step, tabScrollMax(imp))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
-- (species order IS dex order, pics/tilesets are lz3-compressed rather than
|
-- (species order IS dex order, pics/tilesets are lz3-compressed rather than
|
||||||
-- pkmncompress'd, maps are grouped instead of flat). See docs/gold-phase1.md.
|
-- pkmncompress'd, maps are grouped instead of flat). See docs/gold-phase1.md.
|
||||||
local bit = require("bit")
|
local bit = require("bit")
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local ImageWriter = require("src.import.ImageWriter")
|
local ImageWriter = require("src.import.ImageWriter")
|
||||||
local LuaWriter = require("src.import.LuaWriter")
|
local LuaWriter = require("src.import.LuaWriter")
|
||||||
local Rom = require("src.import.Rom")
|
local Rom = require("src.import.Rom")
|
||||||
@@ -168,6 +169,10 @@ function RomExtractorGen2.new(romData, manifest, progress)
|
|||||||
symbols = manifest.symbols,
|
symbols = manifest.symbols,
|
||||||
progress = progress,
|
progress = progress,
|
||||||
stage = 0,
|
stage = 0,
|
||||||
|
-- _GOLD / _SILVER: the labels are shared, the data behind a handful of
|
||||||
|
-- them is not (gfx/misc.asm:9-20 vs :46-57).
|
||||||
|
edition = GameVersion.forSha1(manifest.romSha1) == "silver"
|
||||||
|
and "silver" or "gold",
|
||||||
}, RomExtractorGen2)
|
}, RomExtractorGen2)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1641,22 +1646,40 @@ function RomExtractorGen2:extractTitle()
|
|||||||
return 3
|
return 3
|
||||||
end
|
end
|
||||||
|
|
||||||
-- pret gfx/title/title_bg_gold.pal / title_fg.pal (5 BG pals, 2 OBJ pals).
|
local silver = self.edition == "silver"
|
||||||
local BG_PALS = {
|
|
||||||
|
-- pret gfx/title/title_bg_gold.pal / title_bg_silver.pal (5 BG pals);
|
||||||
|
-- GSTitleBGPals is the edition-selected include (engine/gfx/color.asm:1234).
|
||||||
|
local BG_PALS = silver and {
|
||||||
|
{ { 31, 31, 31 }, { 0, 12, 15 }, { 4, 8, 21 }, { 0, 0, 0 } },
|
||||||
|
{ { 31, 21, 0 }, { 15, 17, 15 }, { 4, 8, 21 }, { 0, 0, 17 } },
|
||||||
|
{ { 31, 31, 31 }, { 31, 0, 0 }, { 4, 8, 21 }, { 0, 0, 0 } },
|
||||||
|
{ { 31, 31, 31 }, { 24, 23, 25 }, { 4, 8, 21 }, { 8, 8, 9 } },
|
||||||
|
{ { 31, 31, 31 }, { 5, 10, 11 }, { 0, 12, 15 }, { 0, 0, 0 } },
|
||||||
|
} or {
|
||||||
{ { 31, 31, 31 }, { 18, 23, 31 }, { 15, 20, 31 }, { 0, 0, 0 } },
|
{ { 31, 31, 31 }, { 18, 23, 31 }, { 15, 20, 31 }, { 0, 0, 0 } },
|
||||||
{ { 31, 21, 0 }, { 12, 14, 12 }, { 15, 20, 31 }, { 0, 0, 17 } },
|
{ { 31, 21, 0 }, { 12, 14, 12 }, { 15, 20, 31 }, { 0, 0, 17 } },
|
||||||
{ { 31, 31, 31 }, { 31, 0, 0 }, { 15, 20, 31 }, { 0, 0, 0 } },
|
{ { 31, 31, 31 }, { 31, 0, 0 }, { 15, 20, 31 }, { 0, 0, 0 } },
|
||||||
{ { 31, 31, 31 }, { 29, 25, 0 }, { 15, 20, 31 }, { 17, 10, 1 } },
|
{ { 31, 31, 31 }, { 29, 25, 0 }, { 15, 20, 31 }, { 17, 10, 1 } },
|
||||||
{ { 31, 31, 31 }, { 23, 26, 31 }, { 18, 23, 31 }, { 0, 0, 0 } },
|
{ { 31, 31, 31 }, { 23, 26, 31 }, { 18, 23, 31 }, { 0, 0, 0 } },
|
||||||
}
|
}
|
||||||
-- title_fg.pal: pal 0 = Ho-Oh silhouette (shades 1-3 are the same brown);
|
-- title_fg.pal, shared (GSTitleOBPals, engine/gfx/color.asm:1241): pal 0 =
|
||||||
-- pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail).
|
-- Ho-Oh silhouette; pal 1 = gold trail sparks (OAM_PAL1 on GSTitleTrail).
|
||||||
local OBJ_HOOH = {
|
local OBJ_HOOH = {
|
||||||
{ 31, 31, 31 }, { 7, 6, 3 }, { 7, 6, 3 }, { 7, 6, 3 },
|
{ 31, 31, 31 }, { 7, 6, 3 }, { 7, 6, 3 }, { 7, 6, 3 },
|
||||||
}
|
}
|
||||||
local OBJ_TRAIL = {
|
local OBJ_TRAIL = {
|
||||||
{ 31, 31, 31 }, { 31, 31, 0 }, { 26, 22, 0 }, { 0, 0, 0 },
|
{ 31, 31, 31 }, { 31, 31, 0 }, { 26, 22, 0 }, { 0, 0, 0 },
|
||||||
}
|
}
|
||||||
|
-- engine/movie/title.asm:134-141 + CopyPals (home/palettes.asm:190):
|
||||||
|
-- DmgToCgbObjPal0 %11100000 makes Silver's OBJ pal 0 {c0, c0, c2, c3}.
|
||||||
|
if silver then
|
||||||
|
OBJ_HOOH = {
|
||||||
|
OBJ_HOOH[1], OBJ_HOOH[1], OBJ_HOOH[3], OBJ_HOOH[4],
|
||||||
|
}
|
||||||
|
-- .OAMData_GSTitleTrail is attribute 0, not OAM_PAL1 (oam.asm:834-837).
|
||||||
|
OBJ_TRAIL = OBJ_HOOH
|
||||||
|
end
|
||||||
|
|
||||||
local function palColor(pal, shade)
|
local function palColor(pal, shade)
|
||||||
local c = pal[shade + 1] or pal[4]
|
local c = pal[shade + 1] or pal[4]
|
||||||
@@ -1671,8 +1694,9 @@ function RomExtractorGen2:extractTitle()
|
|||||||
-- solid BLACK silhouette on a monochrome screen rather than the shaded pose
|
-- solid BLACK silhouette on a monochrome screen rather than the shaded pose
|
||||||
-- a straight decode gives. rOBP1 (%11111000) carries the gold trail.
|
-- a straight decode gives. rOBP1 (%11111000) carries the gold trail.
|
||||||
local DMG_BGP = { 0, 2, 1, 3 }
|
local DMG_BGP = { 0, 2, 1, 3 }
|
||||||
local DMG_OBP0 = { 3, 3, 3, 3 }
|
-- engine/movie/title.asm:105-115: Silver writes %11110000 to both OBPs.
|
||||||
local DMG_OBP1 = { 0, 2, 3, 3 }
|
local DMG_OBP0 = silver and { 0, 0, 3, 3 } or { 3, 3, 3, 3 }
|
||||||
|
local DMG_OBP1 = silver and { 0, 0, 3, 3 } or { 0, 2, 3, 3 }
|
||||||
-- ImageWriter's four hardware shades, by shade number.
|
-- ImageWriter's four hardware shades, by shade number.
|
||||||
local DMG_SHADE = { 1, 2 / 3, 1 / 3, 0 }
|
local DMG_SHADE = { 1, 2 / 3, 1 / 3, 0 }
|
||||||
|
|
||||||
@@ -1776,6 +1800,28 @@ function RomExtractorGen2:extractTitle()
|
|||||||
|
|
||||||
-- Ho-Oh frames from OAMData_GSIntroHoOh1..5 (data/sprite_anims/oam.asm).
|
-- Ho-Oh frames from OAMData_GSIntroHoOh1..5 (data/sprite_anims/oam.asm).
|
||||||
local hoohTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleScreenGFX4"), true)
|
local hoohTiles = tilesFrom2bpp(self:decompressLz3Symbol("TitleScreenGFX4"), true)
|
||||||
|
-- .OAMData_GSIntroLugia1 / 2 (data/sprite_anims/oam.asm:736-773); the
|
||||||
|
-- spriteanimoam vtile offset is added per frame (core.asm:224-227).
|
||||||
|
local LUGIA_1 = {
|
||||||
|
{ -5, -2, 0, 0, 0x00 }, { -5, 0, 0, 0, 0x02 },
|
||||||
|
{ -4, -2, 0, 0, 0x04 }, { -4, 0, 0, 0, 0x06 },
|
||||||
|
{ -3, -1, 0, 0, 0x08 }, { -2, -1, 0, 0, 0x0a },
|
||||||
|
{ -1, -2, 0, 0, 0x0c }, { -1, 0, 0, 0, 0x0e },
|
||||||
|
{ 0, -2, 0, 0, 0x10 }, { 0, 0, 0, 0, 0x12 },
|
||||||
|
{ 1, -2, 0, 0, 0x14 }, { 1, 0, 0, 0, 0x16 },
|
||||||
|
{ 2, -2, 0, 0, 0x18 }, { 2, 0, 0, 0, 0x1a },
|
||||||
|
{ 3, -1, 0, 0, 0x1c }, { 4, -1, 0, 0, 0x1e },
|
||||||
|
}
|
||||||
|
local LUGIA_2 = {
|
||||||
|
{ -5, -2, 0, 0, 0x00 }, { -5, 0, 0, 0, 0x02 },
|
||||||
|
{ -4, -2, 0, 0, 0x04 }, { -4, 0, 0, 0, 0x06 },
|
||||||
|
{ -3, -1, 0, 0, 0x08 }, { -2, -1, 0, 0, 0x0a },
|
||||||
|
{ -1, -2, 0, 0, 0x0c }, { -1, 0, 0, 0, 0x0e },
|
||||||
|
{ 0, -2, 0, 0, 0x10 }, { 0, 0, 0, 0, 0x12 },
|
||||||
|
{ 1, -2, 0, 0, 0x14 }, { 1, 0, 0, 0, 0x16 },
|
||||||
|
{ 2, -2, 0, 0, 0x18 }, { 2, 0, 0, 0, 0x1a },
|
||||||
|
{ 3, -2, 0, 0, 0x1c }, { 4, -2, 0, 0, 0x1e },
|
||||||
|
}
|
||||||
local HOOH_FRAMES = {
|
local HOOH_FRAMES = {
|
||||||
{ -- 1
|
{ -- 1
|
||||||
{ -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 },
|
{ -4, -1, 0, 0, 0x00 }, { -3, -2, 0, 0, 0x02 }, { -3, 0, 0, 0, 0x04 },
|
||||||
@@ -1823,22 +1869,36 @@ function RomExtractorGen2:extractTitle()
|
|||||||
{ 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 },
|
{ 3, -2, 0, 0, 0x22 }, { 3, 0, 0, 0, 0x24 },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
-- Frameset_GSIntroHoOhLugia (Gold): 1,2,3,4,3,5 with these durations.
|
-- Silver's five oamsets, as {layout, vtile base} (oam.asm:103-107).
|
||||||
local HOOH_SEQUENCE = {
|
local LUGIA_FRAMES = {
|
||||||
|
{ LUGIA_1, 0x00 }, { LUGIA_1, 0x20 }, { LUGIA_2, 0x40 },
|
||||||
|
{ LUGIA_2, 0x60 }, { LUGIA_1, 0x00 },
|
||||||
|
}
|
||||||
|
-- Frameset_GSIntroHoOhLugia (data/sprite_anims/framesets.asm:376-396):
|
||||||
|
-- Gold 1,2,3,4,3,5; Silver 2,1,2,3,3,4,4,3,2 on a faster clock.
|
||||||
|
local HOOH_SEQUENCE = silver and {
|
||||||
|
{ 2, 3 }, { 1, 7 }, { 2, 7 }, { 3, 7 }, { 3, 7 },
|
||||||
|
{ 4, 7 }, { 4, 7 }, { 3, 7 }, { 2, 3 },
|
||||||
|
} or {
|
||||||
{ 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 },
|
{ 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 },
|
||||||
}
|
}
|
||||||
local hoohPaths, hoohGrayPaths = {}, {}
|
local hoohPaths, hoohGrayPaths = {}, {}
|
||||||
local originX, originY = 32, 24
|
-- Lugia1/2 span x tiles -5..4, four tiles wider than Ho-Oh's -4..3.
|
||||||
for fi, oam in ipairs(HOOH_FRAMES) do
|
local originX, originY = silver and 40 or 32, 24
|
||||||
|
local poseW = silver and 80 or 64
|
||||||
|
local frames = silver and LUGIA_FRAMES or HOOH_FRAMES
|
||||||
|
for fi, entry in ipairs(frames) do
|
||||||
|
local oam = silver and entry[1] or entry
|
||||||
|
local base = silver and entry[2] or 0
|
||||||
-- The pose starts EMPTY, not white: an OBJ's colour 0 is transparent
|
-- The pose starts EMPTY, not white: an OBJ's colour 0 is transparent
|
||||||
-- wherever it falls, so a gap enclosed by the bird shows the sky through
|
-- wherever it falls, so a gap enclosed by the bird shows the sky through
|
||||||
-- exactly like one outside it, and there is no matte to flood-fill.
|
-- exactly like one outside it, and there is no matte to flood-fill.
|
||||||
local pose = ImageWriter.blank(64, 64, 0, 0, 0, 0)
|
local pose = ImageWriter.blank(poseW, 64, 0, 0, 0, 0)
|
||||||
for _, spr in ipairs(oam) do
|
for _, spr in ipairs(oam) do
|
||||||
local px = originX + spr[1] * 8 + spr[3]
|
local px = originX + spr[1] * 8 + spr[3]
|
||||||
local py = originY + spr[2] * 8 + spr[4]
|
local py = originY + spr[2] * 8 + spr[4]
|
||||||
blitSprite(pose, hoohTiles[spr[5] + 1], px, py)
|
blitSprite(pose, hoohTiles[base + spr[5] + 1], px, py)
|
||||||
blitSprite(pose, hoohTiles[spr[5] + 2], px, py + 8)
|
blitSprite(pose, hoohTiles[base + spr[5] + 2], px, py + 8)
|
||||||
end
|
end
|
||||||
local tinted = colorize(pose, function() return OBJ_HOOH end)
|
local tinted = colorize(pose, function() return OBJ_HOOH end)
|
||||||
local rel = ("title/hooh_%d.png"):format(fi)
|
local rel = ("title/hooh_%d.png"):format(fi)
|
||||||
@@ -1855,14 +1915,20 @@ function RomExtractorGen2:extractTitle()
|
|||||||
end
|
end
|
||||||
self:tick("Title screen", 3, 5)
|
self:tick("Title screen", 3, 5)
|
||||||
|
|
||||||
-- Trail: TitleScreenGFX3 is raw 2bpp (8 tiles); Gold OAM uses one 8x16
|
-- Trail: TitleScreenGFX3 is raw 2bpp; Gold's OAM is one 8x16, Silver's two
|
||||||
-- on OAM_PAL1 (gold), not the Ho-Oh silhouette pal.
|
-- side by side, and only 4 of Silver's 8 copied tiles exist (title.asm:43).
|
||||||
local trailSym = self:symbol("TitleScreenGFX3")
|
local trailSym = self:symbol("TitleScreenGFX3")
|
||||||
local trailRaw = self.rom:bytes(trailSym.bank, trailSym.address, 8 * 16)
|
local trailTileCount = silver and 4 or 8
|
||||||
|
local trailRaw =
|
||||||
|
self.rom:bytes(trailSym.bank, trailSym.address, trailTileCount * 16)
|
||||||
local trailTiles = tilesFrom2bpp(trailRaw, true)
|
local trailTiles = tilesFrom2bpp(trailRaw, true)
|
||||||
local trail = ImageWriter.blank(8, 16, 0, 0, 0, 0)
|
local trail = ImageWriter.blank(silver and 16 or 8, 16, 0, 0, 0, 0)
|
||||||
blitSprite(trail, trailTiles[1], 0, 0)
|
blitSprite(trail, trailTiles[1], 0, 0)
|
||||||
blitSprite(trail, trailTiles[2], 0, 8)
|
blitSprite(trail, trailTiles[2], 0, 8)
|
||||||
|
if silver then
|
||||||
|
blitSprite(trail, trailTiles[3], 8, 0)
|
||||||
|
blitSprite(trail, trailTiles[4], 8, 8)
|
||||||
|
end
|
||||||
local trailTint = colorize(trail, function() return OBJ_TRAIL end)
|
local trailTint = colorize(trail, function() return OBJ_TRAIL end)
|
||||||
self:save(trailTint, "title/trail.png")
|
self:save(trailTint, "title/trail.png")
|
||||||
self:save(throughRegister(trail, DMG_OBP1), "title/trail_gray.png")
|
self:save(throughRegister(trail, DMG_OBP1), "title/trail_gray.png")
|
||||||
@@ -1908,8 +1974,11 @@ function RomExtractorGen2:extractTitle()
|
|||||||
cloudsGray = "assets/generated/title/clouds_gray.png",
|
cloudsGray = "assets/generated/title/clouds_gray.png",
|
||||||
hoohFramesGray = hoohGrayPaths,
|
hoohFramesGray = hoohGrayPaths,
|
||||||
trailGray = "assets/generated/title/trail_gray.png",
|
trailGray = "assets/generated/title/trail_gray.png",
|
||||||
-- Frameset_GSIntroHoOhLugia (Gold), frame index 1-based + duration frames.
|
-- Frameset_GSIntroHoOhLugia, frame index 1-based + duration frames.
|
||||||
hoohSequence = HOOH_SEQUENCE,
|
hoohSequence = HOOH_SEQUENCE,
|
||||||
|
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
|
||||||
|
hoohBobAmplitude = silver and 8 or 2,
|
||||||
|
hoohBobStep = silver and -1 or 1,
|
||||||
-- `depixel 12, 11` (engine/movie/title.asm). Two traps, and the port had
|
-- `depixel 12, 11` (engine/movie/title.asm). Two traps, and the port had
|
||||||
-- fallen into both, which is what put Ho-Oh off-centre:
|
-- fallen into both, which is what put Ho-Oh off-centre:
|
||||||
-- * ldpixel's own comment calls its first tile argument the X one and is
|
-- * ldpixel's own comment calls its first tile argument the X one and is
|
||||||
@@ -1919,19 +1988,45 @@ function RomExtractorGen2:extractTitle()
|
|||||||
-- the cursor two rows up on the box screen. So this is x 88, y 96.
|
-- the cursor two rows up on the box screen. So this is x 88, y 96.
|
||||||
-- * those are OAM coordinates, which are biased; a drawn object sits at
|
-- * those are OAM coordinates, which are biased; a drawn object sits at
|
||||||
-- (x - 8, y - 16) on screen.
|
-- (x - 8, y - 16) on screen.
|
||||||
-- The pose canvas holds its own origin at (32, 24), so the sheet's corner
|
-- The pose canvas holds its own origin, so the sheet's corner is
|
||||||
-- is (88 - 8 - 32, 96 - 16 - 24) -- and the bird's 64px width then lands
|
-- (88 - 8 - originX, 96 - 16 - originY) -- and the pose's width then lands
|
||||||
-- centred on the screen, 48 to 112.
|
-- centred on the screen (Ho-Oh 48..112, Lugia 40..120).
|
||||||
hoohX = 48,
|
hoohX = 80 - originX,
|
||||||
hoohY = 56,
|
hoohY = 80 - originY,
|
||||||
trail = "assets/generated/title/trail.png",
|
trail = "assets/generated/title/trail.png",
|
||||||
copyright = "assets/generated/title/copyright.png",
|
copyright = "assets/generated/title/copyright.png",
|
||||||
copyrightSplash = "assets/generated/title/copyright_splash.png",
|
copyrightSplash = "assets/generated/title/copyright_splash.png",
|
||||||
-- ScrollTitleScreenClouds: Gold decrements the cloud-band SCX every
|
-- ScrollTitleScreenClouds (engine/menus/intro_menu.asm:917-928): Gold
|
||||||
-- 8 vblanks, so the strip slides 1px right. Silver does the same
|
-- decrements the cloud-band SCX every 8 vblanks, so the strip slides 1px
|
||||||
-- decrement every frame.
|
-- right. Silver does the same decrement every frame.
|
||||||
cloudScrollEvery = 8,
|
cloudScrollEvery = silver and 1 or 8,
|
||||||
cloudY = 88,
|
cloudY = 88,
|
||||||
|
-- BG pal 0 colour 2: the sky the widescreen bands have to match.
|
||||||
|
sky = {
|
||||||
|
BG_PALS[1][3][1] / 31, BG_PALS[1][3][2] / 31, BG_PALS[1][3][3] / 31,
|
||||||
|
},
|
||||||
|
-- The fill under the cloud/wave band: Gold's cloud field is BG pal 0
|
||||||
|
-- colour 0 (white), Silver's sea floor is colour 3 (black).
|
||||||
|
below = silver and {
|
||||||
|
BG_PALS[1][4][1] / 31, BG_PALS[1][4][2] / 31, BG_PALS[1][4][3] / 31,
|
||||||
|
} or {
|
||||||
|
BG_PALS[1][1][1] / 31, BG_PALS[1][1][2] / 31, BG_PALS[1][1][3] / 31,
|
||||||
|
},
|
||||||
|
-- UpdateTitleTrailSprite (engine/menus/intro_menu.asm:1069-1124). Silver's
|
||||||
|
-- `depixel 15, 11, 4, 0` is OAM (88, 124), less the bias and the (-16, -8)
|
||||||
|
-- corner .OAMData_GSTitleTrail draws from.
|
||||||
|
trailMode = silver and "silver" or "gold",
|
||||||
|
trailSpawns = silver and { { 72, 100 } } or {
|
||||||
|
{ 80, 88 }, { 104, 88 }, { 104, 88 }, { 120, 88 },
|
||||||
|
{ 120, 88 }, { 88, 88 },
|
||||||
|
},
|
||||||
|
trailSpawnEvery = 4,
|
||||||
|
trailStepX = 4,
|
||||||
|
trailStepY = silver and 0 or 1,
|
||||||
|
-- AnimSeq_GSTitleTrail (functions.asm:784-813) with wIntroSceneTimer 0.
|
||||||
|
trailBobAmplitude = silver and 3 or 2,
|
||||||
|
trailPhaseStep = silver and 7 or 3,
|
||||||
|
trailPhase = silver and 0 or nil,
|
||||||
}
|
}
|
||||||
self:write("title", data)
|
self:write("title", data)
|
||||||
return data
|
return data
|
||||||
@@ -5136,6 +5231,82 @@ function RomExtractorGen2:extractMenuGfx()
|
|||||||
end
|
end
|
||||||
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end
|
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end
|
||||||
|
|
||||||
|
-- Goldenrod Game Corner: Slot Machine graphics assets
|
||||||
|
if self.symbols["Slots1LZ"] then
|
||||||
|
local raw1 = self:decompressLz3Symbol("Slots1LZ")
|
||||||
|
self:write2bpp(raw1, 16, #raw1 / 4, "slots/gold_slots_1.png")
|
||||||
|
end
|
||||||
|
if self.symbols["Slots2LZ"] then
|
||||||
|
local raw2 = self:decompressLz3Symbol("Slots2LZ")
|
||||||
|
-- In Pokemon Gold ROM, Seven symbol (first 4 tiles = 64 bytes) has inverted bit polarity
|
||||||
|
for i = 1, math.min(64, #raw2) do
|
||||||
|
raw2[i] = bit.band(bit.bnot(raw2[i]), 0xFF)
|
||||||
|
end
|
||||||
|
self:write2bpp(raw2, 16, #raw2 / 4, "slots/gold_slots_2.png")
|
||||||
|
end
|
||||||
|
if self.symbols["Slots3LZ"] then
|
||||||
|
local raw3 = self:decompressLz3Symbol("Slots3LZ")
|
||||||
|
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_3.png", true)
|
||||||
|
-- Slots3LZ is a 24px-wide (3 tiles), 240px-tall (30 tiles) sprite sheet containing:
|
||||||
|
-- Y=0: Golem 1 (Standing, 24x32)
|
||||||
|
-- Y=32: Golem 2 (Ball, 24x32)
|
||||||
|
-- Y=64: Chansey 1 (Standing / Step 1, 24x32)
|
||||||
|
-- Y=96: Chansey 2 (Step 2, 24x32)
|
||||||
|
-- Y=128: Chansey 3 (Step 3, 24x32)
|
||||||
|
-- Y=160: Chansey 4 (Arm raised / Step 4, 24x32)
|
||||||
|
-- Y=192: Chansey 5 (Egg Drop pose, 24x32)
|
||||||
|
-- Y=224: Egg (8x16 at X=0)
|
||||||
|
self:write2bpp(raw3, 24, #raw3 / 6, "slots/gold_slots_actors.png", true)
|
||||||
|
end
|
||||||
|
if self.symbols["SlotsTilemap"] then
|
||||||
|
local symbol = self:symbol("SlotsTilemap")
|
||||||
|
local tm = self.rom:bytes(symbol.bank, symbol.address, 20 * 12)
|
||||||
|
self:save(tm, "slots/gold_slots.tilemap")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Goldenrod Game Corner: Card Flip graphics assets
|
||||||
|
if self.symbols["CardFlipLZ01"] then
|
||||||
|
local raw1 = self:decompressLz3Symbol("CardFlipLZ01")
|
||||||
|
self:write2bpp(raw1, 128, #raw1 / 32, "card_flip/card_flip_1.png")
|
||||||
|
end
|
||||||
|
if self.symbols["CardFlipLZ02"] then
|
||||||
|
local raw2 = self:decompressLz3Symbol("CardFlipLZ02")
|
||||||
|
self:write2bpp(raw2, 24, #raw2 / 6, "card_flip/card_flip_2.png")
|
||||||
|
end
|
||||||
|
if self.symbols["CardFlipLZ03"] then
|
||||||
|
local raw3 = self:decompressLz3Symbol("CardFlipLZ03")
|
||||||
|
self:write2bpp(raw3, 8, #raw3 / 2, "card_flip/card_flip_3.png")
|
||||||
|
end
|
||||||
|
if self.symbols["CardFlipOnButtonGFX"] then
|
||||||
|
local symbol = self:symbol("CardFlipOnButtonGFX")
|
||||||
|
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/on.png")
|
||||||
|
end
|
||||||
|
if self.symbols["CardFlipOffButtonGFX"] then
|
||||||
|
local symbol = self:symbol("CardFlipOffButtonGFX")
|
||||||
|
self:write2bpp(self.rom:bytes(symbol.bank, symbol.address, 16), 8, 8, "card_flip/off.png")
|
||||||
|
end
|
||||||
|
if self.symbols["CardFlipTilemap"] then
|
||||||
|
local symbol = self:symbol("CardFlipTilemap")
|
||||||
|
local tm = self.rom:bytes(symbol.bank, symbol.address, 11 * 12)
|
||||||
|
self:save(tm, "card_flip/card_flip.tilemap")
|
||||||
|
end
|
||||||
|
|
||||||
|
out.slots = {
|
||||||
|
sheet1 = "assets/generated/slots/gold_slots_1.png",
|
||||||
|
sheet2 = "assets/generated/slots/gold_slots_2.png",
|
||||||
|
sheet3 = "assets/generated/slots/gold_slots_3.png",
|
||||||
|
tilemap = "assets/generated/slots/gold_slots.tilemap",
|
||||||
|
}
|
||||||
|
|
||||||
|
out.cardFlip = {
|
||||||
|
sheet1 = "assets/generated/card_flip/card_flip_1.png",
|
||||||
|
sheet2 = "assets/generated/card_flip/card_flip_2.png",
|
||||||
|
sheet3 = "assets/generated/card_flip/card_flip_3.png",
|
||||||
|
on = "assets/generated/card_flip/on.png",
|
||||||
|
off = "assets/generated/card_flip/off.png",
|
||||||
|
tilemap = "assets/generated/card_flip/card_flip.tilemap",
|
||||||
|
}
|
||||||
|
|
||||||
self:write("menu_gfx", out)
|
self:write("menu_gfx", out)
|
||||||
self:tick("Menu graphics", 1, 1)
|
self:tick("Menu graphics", 1, 1)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
|
|||||||
"assets/generated/audio/programs.bin",
|
"assets/generated/audio/programs.bin",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
|
||||||
|
VERSION_REQUIRED_FILES_OVERRIDE.silver = VERSION_REQUIRED_FILES_OVERRIDE.gold
|
||||||
|
|
||||||
-- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
|
-- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
|
||||||
-- a dark neon arcade panel, one column per game.
|
-- a dark neon arcade panel, one column per game.
|
||||||
@@ -335,7 +337,7 @@ function RomImporter.syncAndroidShortcuts(activeVersion)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
local allVersions = { "red", "blue", "yellow", "gold" }
|
local allVersions = GameVersion.ORDER
|
||||||
local ready = {}
|
local ready = {}
|
||||||
local seen = {}
|
local seen = {}
|
||||||
|
|
||||||
@@ -1361,11 +1363,10 @@ function RomImporter.new(onComplete, opts)
|
|||||||
saveNotice = {},
|
saveNotice = {},
|
||||||
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
|
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
|
||||||
-- (refreshed lazily on first draw and after any toggle/install/delete);
|
-- (refreshed lazily on first draw and after any toggle/install/delete);
|
||||||
-- modScroll is the current paged list's inner scroll offset (px, clamped
|
-- modNotice is the last install/delete result { ok, text }.
|
||||||
-- in draw); modNotice is the last install/delete result { ok, text }.
|
|
||||||
-- requiredImportNotice stays inside the imported-files modal so validation
|
-- requiredImportNotice stays inside the imported-files modal so validation
|
||||||
-- failures are visible beside the file picker that caused them.
|
-- failures are visible beside the file picker that caused them.
|
||||||
mods = nil, modScroll = 0, modNotice = nil, issueNotice = nil,
|
mods = nil, modNotice = nil, issueNotice = nil,
|
||||||
requiredImportNotice = nil,
|
requiredImportNotice = nil,
|
||||||
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
||||||
-- every game). Rows resolve their enable-state and their "runs here"
|
-- every game). Rows resolve their enable-state and their "runs here"
|
||||||
@@ -1422,7 +1423,8 @@ function RomImporter.new(onComplete, opts)
|
|||||||
self.returning[version] =
|
self.returning[version] =
|
||||||
(not ready) and marker ~= nil and marker ~= markerFor(version)
|
(not ready) and marker ~= nil and marker ~= markerFor(version)
|
||||||
self.romName[version] = "pokemon_" .. info.id
|
self.romName[version] = "pokemon_" .. info.id
|
||||||
.. ((info.id == "yellow" or info.id == "gold") and ".gbc" or ".gb")
|
.. ((info.id == "yellow" or GameVersion.generation(version) == 2)
|
||||||
|
and ".gbc" or ".gb")
|
||||||
end
|
end
|
||||||
RomImporter.syncAndroidShortcuts()
|
RomImporter.syncAndroidShortcuts()
|
||||||
self:_applyLastVersionTab()
|
self:_applyLastVersionTab()
|
||||||
@@ -1535,6 +1537,9 @@ function RomImporter:focus(f)
|
|||||||
self._modPress = nil
|
self._modPress = nil
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if type(self._sync) == "table" then
|
||||||
|
pcall(self._sync.noteResumed, self._sync)
|
||||||
|
end
|
||||||
if not (f and self.android and self.workState ~= "working") then return end
|
if not (f and self.android and self.workState ~= "working") then return end
|
||||||
-- SAF create-document finished: GameActivity wrote export_done.flag.
|
-- SAF create-document finished: GameActivity wrote export_done.flag.
|
||||||
if love.filesystem.getInfo("export_done.flag", "file") then
|
if love.filesystem.getInfo("export_done.flag", "file") then
|
||||||
@@ -1687,7 +1692,7 @@ function RomImporter:startData(data, displayName)
|
|||||||
end
|
end
|
||||||
if not isAcceptedRomSize(#data) then
|
if not isAcceptedRomSize(#data) then
|
||||||
self:setError(("Expected a 1 MiB Game Boy ROM (Red/Blue/Yellow) or a "
|
self:setError(("Expected a 1 MiB Game Boy ROM (Red/Blue/Yellow) or a "
|
||||||
.. "2 MiB Game Boy Color ROM (Gold); this file is %.2f MiB.")
|
.. "2 MiB Game Boy Color ROM (Gold/Silver); this file is %.2f MiB.")
|
||||||
:format(#data / 1024 / 1024))
|
:format(#data / 1024 / 1024))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -1695,7 +1700,8 @@ function RomImporter:startData(data, displayName)
|
|||||||
local version = GameVersion.forSha1(actualHash)
|
local version = GameVersion.forSha1(actualHash)
|
||||||
if not version then
|
if not version then
|
||||||
self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon "
|
self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon "
|
||||||
.. "Red, Blue, Yellow, or Gold dump; patched, trimmed or \"fixed\" dumps "
|
.. "Red, Blue, Yellow, Gold, or Silver dump; patched, trimmed or "
|
||||||
|
.. "\"fixed\" dumps "
|
||||||
.. "(tagged [b] or [BF]) never verify."):format(actualHash))
|
.. "(tagged [b] or [BF]) never verify."):format(actualHash))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -1772,7 +1778,7 @@ function RomImporter:_startExtractCoroutine(version, info, prefix, displayName)
|
|||||||
local CacheFs = require("src.import.CacheFs")
|
local CacheFs = require("src.import.CacheFs")
|
||||||
CacheFs.prefix = prefix
|
CacheFs.prefix = prefix
|
||||||
local manifest = require("src.import.RomManifest").decode(version)
|
local manifest = require("src.import.RomManifest").decode(version)
|
||||||
local RomExtractor = version == "gold"
|
local RomExtractor = GameVersion.generation(version) == 2
|
||||||
and require("src.import.RomExtractorGen2")
|
and require("src.import.RomExtractorGen2")
|
||||||
or require("src.import.RomExtractor")
|
or require("src.import.RomExtractor")
|
||||||
local extractor = RomExtractor.new(self.romData, manifest,
|
local extractor = RomExtractor.new(self.romData, manifest,
|
||||||
@@ -2057,7 +2063,7 @@ function RomImporter:_importRequiredData(modId, importId, data)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:_importRequiredSource(modId, importId, source)
|
function RomImporter:_importRequiredSource(modId, importId, source, confirmed)
|
||||||
local manifest = requiredManifest(self, modId)
|
local manifest = requiredManifest(self, modId)
|
||||||
local spec = manifest and requiredSpec(manifest, importId)
|
local spec = manifest and requiredSpec(manifest, importId)
|
||||||
if not spec then
|
if not spec then
|
||||||
@@ -2065,14 +2071,31 @@ function RomImporter:_importRequiredSource(modId, importId, source)
|
|||||||
self.modNotice = nil
|
self.modNotice = nil
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
local RequiredImports = require("src.mods.RequiredImports")
|
||||||
local info = love.filesystem.getInfo(source, "file")
|
local info = love.filesystem.getInfo(source, "file")
|
||||||
local size = info and info.size or externalFileSize(source)
|
local size = info and info.size or externalFileSize(source)
|
||||||
local sizeErr = require("src.mods.RequiredImports").sizeError(spec, size, false)
|
local sizeErr = RequiredImports.sizeError(spec, size, false)
|
||||||
if sizeErr then
|
if sizeErr then
|
||||||
requiredImportNotice(self, modId, importId, sizeErr)
|
requiredImportNotice(self, modId, importId, sizeErr)
|
||||||
self.modNotice = nil
|
self.modNotice = nil
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
if not confirmed and type(size) == "number"
|
||||||
|
and size > RequiredImports.LARGE_WARN_BYTES then
|
||||||
|
self._modConfirm = {
|
||||||
|
kind = "largeImport",
|
||||||
|
modId = modId, importId = importId, source = source,
|
||||||
|
title = Strings("Large import"),
|
||||||
|
lines = {
|
||||||
|
Strings("This is a large import (%s).",
|
||||||
|
RequiredImports.sizeLabel(size)),
|
||||||
|
Strings("Please ensure you have enough space on your"),
|
||||||
|
Strings("device before doing this."),
|
||||||
|
},
|
||||||
|
yesLabel = Strings("I understand"),
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
end
|
||||||
local data = love.filesystem.read(source)
|
local data = love.filesystem.read(source)
|
||||||
if not data then data = readExternalPath(source) end
|
if not data then data = readExternalPath(source) end
|
||||||
if not data then
|
if not data then
|
||||||
@@ -2115,8 +2138,13 @@ function RomImporter:chooseRequiredImport(modId, importId)
|
|||||||
if name:sub(1, 1) ~= "." then
|
if name:sub(1, 1) ~= "." then
|
||||||
local path = inbox .. "/" .. name
|
local path = inbox .. "/" .. name
|
||||||
local info = love.filesystem.getInfo(path, "file")
|
local info = love.filesystem.getInfo(path, "file")
|
||||||
local sizeErr = info and require("src.mods.RequiredImports")
|
local RequiredImports = require("src.mods.RequiredImports")
|
||||||
.sizeError(spec, info.size, false)
|
local sizeErr = info
|
||||||
|
and RequiredImports.sizeError(spec, info.size, false)
|
||||||
|
if info and not sizeErr
|
||||||
|
and info.size > RequiredImports.LARGE_WARN_BYTES then
|
||||||
|
return self:_importRequiredSource(modId, importId, path)
|
||||||
|
end
|
||||||
local data = not sizeErr and love.filesystem.read(path) or nil
|
local data = not sizeErr and love.filesystem.read(path) or nil
|
||||||
if data and self:_importRequiredData(modId, importId, data) then return end
|
if data and self:_importRequiredData(modId, importId, data) then return end
|
||||||
if sizeErr then lastError = sizeErr
|
if sizeErr then lastError = sizeErr
|
||||||
@@ -2757,7 +2785,8 @@ function RomImporter:resumeAfterOverlay()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:_cycleTab(delta)
|
function RomImporter:_cycleTab(delta)
|
||||||
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins", "bug" }
|
local order = { "red", "blue", "yellow", "gold", "silver",
|
||||||
|
"mods", "find", "skins", "bug" }
|
||||||
local idx = 1
|
local idx = 1
|
||||||
for i, id in ipairs(order) do
|
for i, id in ipairs(order) do
|
||||||
if id == self.tab then idx = i; break end
|
if id == self.tab then idx = i; break end
|
||||||
@@ -3094,8 +3123,10 @@ end
|
|||||||
-- multi-monitor coords). Same contract as PadCursor.yieldToPointer for
|
-- multi-monitor coords). Same contract as PadCursor.yieldToPointer for
|
||||||
-- the overlay hosts. Touch move/press/release must still reach
|
-- the overlay hosts. Touch move/press/release must still reach
|
||||||
-- FlexLove.touch* or scroll containers never drag on phones.
|
-- FlexLove.touch* or scroll containers never drag on phones.
|
||||||
function RomImporter:mousepressed()
|
function RomImporter:mousepressed(x, y, button)
|
||||||
self._padCursorActive = false
|
self._padCursorActive = false
|
||||||
|
if button ~= 1 or not self._flex then return end
|
||||||
|
require("src.import.LauncherView").mousepressed(self, x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:touchpressed(id, x, y, dx, dy, pressure)
|
function RomImporter:touchpressed(id, x, y, dx, dy, pressure)
|
||||||
@@ -3123,7 +3154,6 @@ function RomImporter:_switchTab(id)
|
|||||||
self.tab = id
|
self.tab = id
|
||||||
self._findSearchFocus = false
|
self._findSearchFocus = false
|
||||||
self._skinUrlFocus = false
|
self._skinUrlFocus = false
|
||||||
self._modScrollMax, self._modListRect = 0, nil
|
|
||||||
self:_disarmTextInput()
|
self:_disarmTextInput()
|
||||||
-- the skins list is cheap and can change behind the launcher's back
|
-- the skins list is cheap and can change behind the launcher's back
|
||||||
-- (an export, a hand-dropped folder), so re-read it on every visit
|
-- (an export, a hand-dropped folder), so re-read it on every visit
|
||||||
|
|||||||
@@ -262,7 +262,8 @@ function LinkBattle.new(game, net, opts)
|
|||||||
self.opponentName = theirName
|
self.opponentName = theirName
|
||||||
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
|
-- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
|
||||||
-- takes PrintBeginningBattleText's .trainerBattle arm, link included
|
-- takes PrintBeginningBattleText's .trainerBattle arm, link included
|
||||||
self.introText = Strings("%s wants\nto fight!", theirName)
|
self.introText = self:romText("_TrainerWantsToFightText",
|
||||||
|
"%s wants\nto fight!", theirName)
|
||||||
self.remoteHashes = {}
|
self.remoteHashes = {}
|
||||||
self.localHashes = {}
|
self.localHashes = {}
|
||||||
self.remoteParts = {}
|
self.remoteParts = {}
|
||||||
|
|||||||
@@ -195,8 +195,8 @@ local function parseImports(value, field, required)
|
|||||||
if value == nil then return end
|
if value == nil then return end
|
||||||
assert(type(value) == "number" and value > 0 and value % 1 == 0,
|
assert(type(value) == "number" and value > 0 and value % 1 == 0,
|
||||||
field .. " " .. label .. " must be a positive integer")
|
field .. " " .. label .. " must be a positive integer")
|
||||||
assert(value <= 128 * 1024 * 1024,
|
assert(value <= 2 * 1024 * 1024 * 1024,
|
||||||
field .. " " .. label .. " exceeds the 128 MiB hard limit")
|
field .. " " .. label .. " exceeds the 2 GiB hard limit")
|
||||||
end
|
end
|
||||||
validateSize(size, "size")
|
validateSize(size, "size")
|
||||||
validateSize(maxSize, "max_size")
|
validateSize(maxSize, "max_size")
|
||||||
|
|||||||
@@ -23,11 +23,17 @@ local function isRequired(spec)
|
|||||||
end
|
end
|
||||||
|
|
||||||
RequiredImports.specs = allSpecs
|
RequiredImports.specs = allSpecs
|
||||||
RequiredImports.MAX_BYTES = 128 * 1024 * 1024
|
RequiredImports.MAX_BYTES = 2 * 1024 * 1024 * 1024
|
||||||
|
-- Past this, the launcher interposes a free-space warning before importing.
|
||||||
|
RequiredImports.LARGE_WARN_BYTES = 128 * 1024 * 1024
|
||||||
|
|
||||||
local function sizeLabel(bytes)
|
local function sizeLabel(bytes)
|
||||||
|
if bytes >= 1024 * 1024 * 1024 then
|
||||||
|
return ("%.1f GiB"):format(bytes / (1024 * 1024 * 1024))
|
||||||
|
end
|
||||||
return ("%.1f MiB"):format(bytes / (1024 * 1024))
|
return ("%.1f MiB"):format(bytes / (1024 * 1024))
|
||||||
end
|
end
|
||||||
|
RequiredImports.sizeLabel = sizeLabel
|
||||||
|
|
||||||
-- Check size before a caller reads an external or stored file into one large
|
-- Check size before a caller reads an external or stored file into one large
|
||||||
-- Lua string. N64 sources may carry a 512-byte copier header, while stored
|
-- Lua string. N64 sources may carry a 512-byte copier header, while stored
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ local Runtime = require("src.mods.Runtime")
|
|||||||
local GameViewport = require("src.render.GameViewport")
|
local GameViewport = require("src.render.GameViewport")
|
||||||
-- leaf module (no renderer dependency), so requiring it here cannot cycle
|
-- leaf module (no renderer dependency), so requiring it here cannot cycle
|
||||||
local FaithfulRes = require("src.core.FaithfulRes")
|
local FaithfulRes = require("src.core.FaithfulRes")
|
||||||
|
local ScreenPosition = require("src.core.ScreenPosition")
|
||||||
local Playfield = require("src.render.Playfield")
|
local Playfield = require("src.render.Playfield")
|
||||||
|
|
||||||
local Renderer = {}
|
local Renderer = {}
|
||||||
@@ -94,6 +95,11 @@ local function displayMetrics()
|
|||||||
return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow
|
return ww, wh, pw, ph, dpiX, dpiY, vx, vy, cut, grow
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function positionLift(ph, contentPx, dpiY, cut)
|
||||||
|
if cut then return 0 end
|
||||||
|
return ScreenPosition.lift(ph, contentPx, ScreenPosition.safeTop() * dpiY)
|
||||||
|
end
|
||||||
|
|
||||||
function Renderer:init()
|
function Renderer:init()
|
||||||
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
|
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
|
||||||
-- (#208). Every canvas below is sized in framebuffer pixels for the same
|
-- (#208). Every canvas below is sized in framebuffer pixels for the same
|
||||||
@@ -269,7 +275,7 @@ end
|
|||||||
-- corners; flat mode returns exactly today's size (growth factor is 1 when
|
-- corners; flat mode returns exactly today's size (growth factor is 1 when
|
||||||
-- tilt is inactive).
|
-- tilt is inactive).
|
||||||
function Renderer:worldViewSize()
|
function Renderer:worldViewSize()
|
||||||
local _, _, pw, ph, _, _, _, _, cut, grow = displayMetrics()
|
local _, _, pw, ph, _, dpiY, _, _, cut, grow = displayMetrics()
|
||||||
-- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
|
-- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
|
||||||
-- WHOLE display, so letterbox voids become more map instead of black bars.
|
-- WHOLE display, so letterbox voids become more map instead of black bars.
|
||||||
-- That is why the lock appeared to do nothing in the overworld: it shrank
|
-- That is why the lock appeared to do nothing in the overworld: it shrank
|
||||||
@@ -293,6 +299,9 @@ function Renderer:worldViewSize()
|
|||||||
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
|
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
|
||||||
if vw % 2 ~= 0 then vw = vw + 1 end
|
if vw % 2 ~= 0 then vw = vw + 1 end
|
||||||
if vh % 2 ~= 0 then vh = vh + 1 end
|
if vh % 2 ~= 0 then vh = vh + 1 end
|
||||||
|
local _, uih = self:uiSize()
|
||||||
|
local lift = positionLift(ph, uih * self:fitScale(), dpiY, cut)
|
||||||
|
if lift > 0 then vh = vh + 2 * math.ceil(lift / sp) end
|
||||||
if Tilt.active() then
|
if Tilt.active() then
|
||||||
local g = Tilt.viewGrowth()
|
local g = Tilt.viewGrowth()
|
||||||
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
|
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
|
||||||
@@ -774,8 +783,9 @@ function Renderer:frameRects()
|
|||||||
r.uiw, r.uih = uiw, uih
|
r.uiw, r.uih = uiw, uih
|
||||||
r.vpw, r.vph = uiw * r.Sx, uih * r.Sy
|
r.vpw, r.vph = uiw * r.Sx, uih * r.Sy
|
||||||
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||||
|
r.lift = positionLift(ph, uih * Sp, dpiY, cut)
|
||||||
r.ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
|
r.ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
|
||||||
r.oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY
|
r.oy = (vy + math.floor((ph - uih * Sp) / 2) - r.lift) / dpiY
|
||||||
-- The UI has its own scale: it steps down as the survey zoom goes out (see
|
-- The UI has its own scale: it steps down as the survey zoom goes out (see
|
||||||
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
|
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
|
||||||
-- are identical to Sp/ox/oy and every rect below is what it always was.
|
-- are identical to Sp/ox/oy and every rect below is what it always was.
|
||||||
@@ -795,7 +805,7 @@ function Renderer:frameRects()
|
|||||||
r.Up, r.Ux, r.Uy = Up, Up / dpiX, Up / dpiY
|
r.Up, r.Ux, r.Uy = Up, Up / dpiX, Up / dpiY
|
||||||
r.uvpw, r.uvph = uiw * r.Ux, uih * r.Uy
|
r.uvpw, r.uvph = uiw * r.Ux, uih * r.Uy
|
||||||
r.uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
|
r.uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
|
||||||
r.uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY
|
r.uoy = (vy + math.max(0, math.floor((ph - uih * Up) / 2) - r.lift)) / dpiY
|
||||||
return r
|
return r
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1002,7 +1012,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
local wvw = self.worldCanvas:getWidth()
|
local wvw = self.worldCanvas:getWidth()
|
||||||
local wvh = self.worldCanvas:getHeight()
|
local wvh = self.worldCanvas:getHeight()
|
||||||
local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX
|
local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX
|
||||||
local woy = (vy + math.floor((ph - wvh * sp) / 2)) / dpiY
|
local woy = (vy + math.floor((ph - wvh * sp) / 2) - R.lift) / dpiY
|
||||||
-- Tilt mode projects the ground world pass through the perspective mesh
|
-- Tilt mode projects the ground world pass through the perspective mesh
|
||||||
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
||||||
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
||||||
|
|||||||
@@ -228,7 +228,10 @@ SaveConvert.mergeDefaults = mergeDefaults
|
|||||||
-- codec exists yet. Both directions answer with a plain message the
|
-- codec exists yet. Both directions answer with a plain message the
|
||||||
-- launcher's save card renders as-is, instead of pushing a Gen 2 save
|
-- launcher's save card renders as-is, instead of pushing a Gen 2 save
|
||||||
-- table through Gen 1 offsets and surfacing a codec traceback.
|
-- table through Gen 1 offsets and surfacing a codec traceback.
|
||||||
local GEN2_SAV_UNSUPPORTED = { gold = "Pokemon Gold" }
|
local GEN2_SAV_UNSUPPORTED = {
|
||||||
|
gold = "Pokemon Gold",
|
||||||
|
silver = "Pokemon Silver",
|
||||||
|
}
|
||||||
|
|
||||||
-- importSav(bytes, version, gameVersion) -> saveTable, err
|
-- importSav(bytes, version, gameVersion) -> saveTable, err
|
||||||
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
|
-- bytes: the raw 32768-byte SRAM string. Validates size and the main-data
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ SyncEngine.__index = SyncEngine
|
|||||||
|
|
||||||
SyncEngine.UPLOAD_DEBOUNCE = 5
|
SyncEngine.UPLOAD_DEBOUNCE = 5
|
||||||
SyncEngine.AUTO_INTERVAL = 300
|
SyncEngine.AUTO_INTERVAL = 300
|
||||||
|
SyncEngine.RESUME_MIN_GAP = 60
|
||||||
SyncEngine.MAX_STEPS_PER_UPDATE = 8
|
SyncEngine.MAX_STEPS_PER_UPDATE = 8
|
||||||
|
|
||||||
local IDLE_STATUS = "Ready"
|
local IDLE_STATUS = "Ready"
|
||||||
@@ -133,6 +134,7 @@ function SyncEngine.new(opts)
|
|||||||
eng.modPlan = nil
|
eng.modPlan = nil
|
||||||
eng.shareCode = nil
|
eng.shareCode = nil
|
||||||
eng.clock = 0
|
eng.clock = 0
|
||||||
|
eng.autoAt = SyncEngine.AUTO_INTERVAL
|
||||||
eng.queue = {}
|
eng.queue = {}
|
||||||
eng.pending = nil
|
eng.pending = nil
|
||||||
eng.uploadAt = nil
|
eng.uploadAt = nil
|
||||||
@@ -258,6 +260,11 @@ function SyncEngine:update(dt)
|
|||||||
self.uploadAt = nil
|
self.uploadAt = nil
|
||||||
if self.state.enabled and self:linked() then self:syncNow() end
|
if self.state.enabled and self:linked() then self:syncNow() end
|
||||||
end
|
end
|
||||||
|
if self.clock >= self.autoAt and not self:busy()
|
||||||
|
and (self.phase == "idle" or self.phase == "error")
|
||||||
|
and self.state.enabled and self:linked() then
|
||||||
|
self:syncNow()
|
||||||
|
end
|
||||||
local steps = 0
|
local steps = 0
|
||||||
while not self.pending and #self.queue > 0
|
while not self.pending and #self.queue > 0
|
||||||
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
|
and steps < SyncEngine.MAX_STEPS_PER_UPDATE do
|
||||||
@@ -296,6 +303,7 @@ function SyncEngine:createAccount(label)
|
|||||||
eng.phase = "idle"
|
eng.phase = "idle"
|
||||||
eng.status = "Sync account created"
|
eng.status = "Sync account created"
|
||||||
eng:_persist()
|
eng:_persist()
|
||||||
|
eng:syncNow()
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -385,9 +393,24 @@ function SyncEngine:setEnabled(enabled)
|
|||||||
return self.state.enabled
|
return self.state.enabled
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function SyncEngine:protectPlaythrough(version, playthroughId)
|
||||||
|
self.protectedKey = SyncState.key(version, playthroughId)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SyncEngine:noteResumed()
|
||||||
|
if not (self.state.enabled and self:linked()) then return end
|
||||||
|
if self:busy() or self.phase == "conflict" then return end
|
||||||
|
if self.now() - (tonumber(self.state.lastSyncAt) or 0)
|
||||||
|
< SyncEngine.RESUME_MIN_GAP then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self:syncNow()
|
||||||
|
end
|
||||||
|
|
||||||
function SyncEngine:syncNow()
|
function SyncEngine:syncNow()
|
||||||
if not self:linked() then return false, "this device is not linked" end
|
if not self:linked() then return false, "this device is not linked" end
|
||||||
if self.pending then return false, "sync is busy" end
|
if self.pending then return false, "sync is busy" end
|
||||||
|
self.autoAt = self.clock + SyncEngine.AUTO_INTERVAL
|
||||||
self.queue = {}
|
self.queue = {}
|
||||||
self.conflicts = {}
|
self.conflicts = {}
|
||||||
self.state.pendingConflicts = {}
|
self.state.pendingConflicts = {}
|
||||||
@@ -437,13 +460,13 @@ function SyncEngine:_planFrom(remoteState)
|
|||||||
self:_addConflict(entry, key, row)
|
self:_addConflict(entry, key, row)
|
||||||
elseif localChanged then
|
elseif localChanged then
|
||||||
self:_queueUpload(entry, key, false)
|
self:_queueUpload(entry, key, false)
|
||||||
elseif remoteChanged then
|
elseif remoteChanged and key ~= self.protectedKey then
|
||||||
self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
|
self:_queueDownload(key, entry.version, entry.playthroughId, "replace")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
for key, row in pairs(remote) do
|
for key, row in pairs(remote) do
|
||||||
if not seen[key] then
|
if not seen[key] and key ~= self.protectedKey then
|
||||||
local version, id = SyncState.splitKey(key)
|
local version, id = SyncState.splitKey(key)
|
||||||
if version and id then
|
if version and id then
|
||||||
self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
|
self:_queueDownload(key, version, id, "replace", tonumber(row.rev))
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SyncMods.REV = 1
|
|||||||
local function versions()
|
local function versions()
|
||||||
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
||||||
if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end
|
if ok and GameVersion and GameVersion.ORDER then return GameVersion.ORDER end
|
||||||
return { "red", "blue", "yellow", "gold" }
|
return { "red", "blue", "yellow", "gold", "silver" }
|
||||||
end
|
end
|
||||||
|
|
||||||
local function defaultDeps()
|
local function defaultDeps()
|
||||||
|
|||||||
@@ -185,14 +185,16 @@ local function release(game)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
|
(t._OnceReleasedText or Strings("Once released,\n%s is\ngone forever. OK?", name))
|
||||||
|
:gsub("{RAM:wStringBuffer}", name), nil, {
|
||||||
defaultNo = true, noSound = true,
|
defaultNo = true, noSound = true,
|
||||||
choice = function(yes)
|
choice = function(yes)
|
||||||
if not yes then return end
|
if not yes then return end
|
||||||
table.remove(box, list.index)
|
table.remove(box, list.index)
|
||||||
require("src.core.Sound").playCry(game.data, mon.species)
|
require("src.core.Sound").playCry(game.data, mon.species)
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
Strings("%s was\nreleased outside.\fBye %s!", name, name)))
|
((t._MonWasReleasedText or Strings("%s was\nreleased outside.\fBye %s!", name, name))
|
||||||
|
:gsub("{RAM:wStringBuffer}", name))))
|
||||||
list:removeCurrent()
|
list:removeCurrent()
|
||||||
end,
|
end,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ local GameVersion = require("src.core.GameVersion")
|
|||||||
local VideoMode = require("src.core.VideoMode")
|
local VideoMode = require("src.core.VideoMode")
|
||||||
local Orientation = require("src.core.Orientation")
|
local Orientation = require("src.core.Orientation")
|
||||||
local FaithfulRes = require("src.core.FaithfulRes")
|
local FaithfulRes = require("src.core.FaithfulRes")
|
||||||
|
local ScreenPosition = require("src.core.ScreenPosition")
|
||||||
local FrameCap = require("src.core.FrameCap")
|
local FrameCap = require("src.core.FrameCap")
|
||||||
local Performance = require("src.core.Performance")
|
local Performance = require("src.core.Performance")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
@@ -443,6 +444,16 @@ local function buildRows(game)
|
|||||||
FaithfulRes.apply(o.faithfulRes)
|
FaithfulRes.apply(o.faithfulRes)
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
|
{ id = "screenPos", label = Strings("SCREEN POS"),
|
||||||
|
value = function(g)
|
||||||
|
return Strings(ScreenPosition.label(g.save.options.screenPos))
|
||||||
|
end,
|
||||||
|
step = function(g, dir)
|
||||||
|
local o = g.save.options
|
||||||
|
o.screenPos = ScreenPosition.cycle(o.screenPos, dir)
|
||||||
|
ScreenPosition.setMode(o.screenPos)
|
||||||
|
return true
|
||||||
|
end },
|
||||||
-- hard render cap (issue #88): bounds the present rate so a
|
-- hard render cap (issue #88): bounds the present rate so a
|
||||||
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
|
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
|
||||||
-- is fixed-step off dt, so this touches presentation only.
|
-- is fixed-step off dt, so this touches presentation only.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ local ListMenu = require("src.ui.ListMenu")
|
|||||||
local Menu = require("src.ui.Menu")
|
local Menu = require("src.ui.Menu")
|
||||||
local QuantityBox = require("src.ui.QuantityBox")
|
local QuantityBox = require("src.ui.QuantityBox")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local romText = require("src.core.RomText")
|
||||||
|
|
||||||
local ShopMenu = {}
|
local ShopMenu = {}
|
||||||
|
|
||||||
@@ -57,7 +58,8 @@ local function buy(game, stock)
|
|||||||
end
|
end
|
||||||
local cost = qty * def.price
|
local cost = qty * def.price
|
||||||
-- _PokemartTellBuyPriceText + yes/no confirm
|
-- _PokemartTellBuyPriceText + yes/no confirm
|
||||||
list.footer = Strings("%s?\nThat will be\n¥%d. OK?", def.name, cost)
|
list.footer = romText(game.data, "_PokemartTellBuyPriceText",
|
||||||
|
"%s?\nThat will be\n¥%d. OK?", def.name, cost)
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
if not yes then
|
if not yes then
|
||||||
list.footer = greet
|
list.footer = greet
|
||||||
@@ -147,7 +149,8 @@ local function sell(game)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- _PokemartTellSellPriceText + yes/no confirm
|
-- _PokemartTellSellPriceText + yes/no confirm
|
||||||
list.footer = Strings("I can pay you\n¥%d for that.", unit * qty)
|
list.footer = romText(game.data, "_PokemartTellSellPriceText",
|
||||||
|
"I can pay you\n¥%d for that.", unit * qty)
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
if not yes then
|
if not yes then
|
||||||
list.footer = greet
|
list.footer = greet
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local romText = require("src.core.RomText")
|
||||||
|
|
||||||
local SlotMachine = {}
|
local SlotMachine = {}
|
||||||
SlotMachine.__index = SlotMachine
|
SlotMachine.__index = SlotMachine
|
||||||
@@ -357,7 +358,8 @@ function SlotMachine:resolveWin(win)
|
|||||||
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
|
-- SlotReward300Func prints "Yeah!" (text_pause) before the flash; the port
|
||||||
-- shows it in the box while the screen flashes. LinedUpText follows.
|
-- shows it in the box while the screen flashes. LinedUpText follows.
|
||||||
self.yeah = (sym == "7")
|
self.yeah = (sym == "7")
|
||||||
self.message = Strings("%s lined up!\nScored %d coins!", sym, pay)
|
self.message = sym .. romText(self.game.data, "_LinedUpText",
|
||||||
|
" lined up!\nScored %d coins!", pay)
|
||||||
-- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not
|
-- .flashScreenLoop: flip rBGP, wait 5 frames, b times. The coins are not
|
||||||
-- credited until the player dismisses the "lined up" text (see startPayout).
|
-- credited until the player dismisses the "lined up" text (see startPayout).
|
||||||
self.stage = "flash"
|
self.stage = "flash"
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ local TOTAL_SECTIONS = 24 -- 24 sections ($18) of 128px = 3072px course
|
|||||||
local BG_HEIGHT = 128 -- Rows the BG shows; HP window covers the rest (y=128..144)
|
local BG_HEIGHT = 128 -- Rows the BG shows; HP window covers the rest (y=128..144)
|
||||||
|
|
||||||
-- Routine numbers (wSurfingMinigameRoutineNumber)
|
-- Routine numbers (wSurfingMinigameRoutineNumber)
|
||||||
|
local ROUTINE_TITLE = -1
|
||||||
local ROUTINE_START_GAME = 0
|
local ROUTINE_START_GAME = 0
|
||||||
local ROUTINE_RUN_GAME = 1
|
local ROUTINE_RUN_GAME = 1
|
||||||
local ROUTINE_WAIT_RESULTS = 2
|
local ROUTINE_WAIT_RESULTS = 2
|
||||||
@@ -216,7 +217,9 @@ local WAVE_STEPS = {
|
|||||||
[0x78] = { "beach", 116, 116, ADV }, [0x79] = { "beach", 116, 116, ADV },
|
[0x78] = { "beach", 116, 116, ADV }, [0x79] = { "beach", 116, 116, ADV },
|
||||||
[0x7a] = { "beach", 116, 116, ADV }, [0x7b] = { "beach", 116, 116, RESET },
|
[0x7a] = { "beach", 116, 116, ADV }, [0x7b] = { "beach", 116, 116, RESET },
|
||||||
}
|
}
|
||||||
local SEQ_STARTS = { 0x01, 0x0e, 0x1a, 0x29, 0x32, 0x40, 0x4d, 0x5c }
|
local SEQ_STARTS = {
|
||||||
|
0x01, 0x0e, 0x1a, 0x29, 0x32, 0x40, 0x4d, 0x5c
|
||||||
|
}
|
||||||
|
|
||||||
SurfingMinigame.BG_METATILES = BG_METATILES
|
SurfingMinigame.BG_METATILES = BG_METATILES
|
||||||
SurfingMinigame.WAVE_PATTERNS = WAVE_PATTERNS
|
SurfingMinigame.WAVE_PATTERNS = WAVE_PATTERNS
|
||||||
@@ -233,6 +236,40 @@ local ANGLE_BASES = {
|
|||||||
[7] = { 0x33, 0x69 }, -- Angle 06 (nose down steep / frontflip apex)
|
[7] = { 0x33, 0x69 }, -- Angle 06 (nose down steep / frontflip apex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- OAM Definitions from surfing_pikachu_oam.asm
|
||||||
|
-- .WaterSpray (3 tiles, relative to PIKA_X = 68, y = self.pikaY)
|
||||||
|
local OAM_WATER_SPRAY = {
|
||||||
|
{ dy = -4, dx = 11, tile = 0xa7, xflip = false },
|
||||||
|
{ dy = 4, dx = 3, tile = 0xb6, xflip = false },
|
||||||
|
{ dy = 4, dx = 11, tile = 0xb7, xflip = false },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- .SmallSplash (6 tiles, relative to cx = 80, cy = self.pikaY + 4)
|
||||||
|
local OAM_SMALL_SPLASH = {
|
||||||
|
{ dy = -4, dx = -16, tile = 0xa7, xflip = true },
|
||||||
|
{ dy = -4, dx = 8, tile = 0xa7, xflip = false },
|
||||||
|
{ dy = 4, dx = -16, tile = 0xb7, xflip = true },
|
||||||
|
{ dy = 4, dx = -8, tile = 0xb6, xflip = true },
|
||||||
|
{ dy = 4, dx = 0, tile = 0xb6, xflip = false },
|
||||||
|
{ dy = 4, dx = 8, tile = 0xb7, xflip = false },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- .LargeSplash (12 tiles, relative to cx = 80, cy = self.pikaY + 4)
|
||||||
|
local OAM_LARGE_SPLASH = {
|
||||||
|
{ dy = -12, dx = -16, tile = 0xa8, xflip = false },
|
||||||
|
{ dy = -12, dx = -8, tile = 0xa9, xflip = false },
|
||||||
|
{ dy = -12, dx = 0, tile = 0xa9, xflip = true },
|
||||||
|
{ dy = -12, dx = 8, tile = 0xa8, xflip = true },
|
||||||
|
{ dy = -4, dx = -16, tile = 0xb8, xflip = false },
|
||||||
|
{ dy = -4, dx = -8, tile = 0xb9, xflip = false },
|
||||||
|
{ dy = -4, dx = 0, tile = 0xb9, xflip = true },
|
||||||
|
{ dy = -4, dx = 8, tile = 0xb8, xflip = true },
|
||||||
|
{ dy = 4, dx = -16, tile = 0xc8, xflip = false },
|
||||||
|
{ dy = 4, dx = -8, tile = 0xc9, xflip = false },
|
||||||
|
{ dy = 4, dx = 0, tile = 0xc9, xflip = true },
|
||||||
|
{ dy = 4, dx = 8, tile = 0xc8, xflip = true },
|
||||||
|
}
|
||||||
|
|
||||||
-- Beach outro tilemap (gfx/surfing_pikachu/beach_outro.tilemap, 20x10)
|
-- Beach outro tilemap (gfx/surfing_pikachu/beach_outro.tilemap, 20x10)
|
||||||
local BEACH_OUTRO = {
|
local BEACH_OUTRO = {
|
||||||
{ 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b },
|
{ 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0c, 0x0e, 0x0f, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b, 0x0b },
|
||||||
@@ -258,9 +295,9 @@ local PIKACHUS_BEACH_PAL = {
|
|||||||
-- 5 Tempo tiers (117, 109, 101, 93, 85 from surfing_pikachu.asm)
|
-- 5 Tempo tiers (117, 109, 101, 93, 85 from surfing_pikachu.asm)
|
||||||
local TEMPO_TIERS = { 1.0, 117 / 109, 117 / 101, 117 / 93, 117 / 85 }
|
local TEMPO_TIERS = { 1.0, 117 / 109, 117 / 101, 117 / 93, 117 / 85 }
|
||||||
|
|
||||||
function SurfingMinigame.new(game, onDone)
|
function SurfingMinigame.new(game, onDone, skipTitle)
|
||||||
local self = setmetatable({ game = game, onDone = onDone }, SurfingMinigame)
|
local self = setmetatable({ game = game, onDone = onDone }, SurfingMinigame)
|
||||||
self.routine = ROUTINE_START_GAME
|
self.routine = skipTitle and ROUTINE_START_GAME or ROUTINE_TITLE
|
||||||
self.pikaState = PIKA_STATE_RIDING
|
self.pikaState = PIKA_STATE_RIDING
|
||||||
self.t = 0
|
self.t = 0
|
||||||
self.routineTimer = 0
|
self.routineTimer = 0
|
||||||
@@ -269,9 +306,11 @@ function SurfingMinigame.new(game, onDone)
|
|||||||
self.hp = 6000 -- starts at 6000 (60.00 seconds)
|
self.hp = 6000 -- starts at 6000 (60.00 seconds)
|
||||||
self.radness = 0 -- accumulated trick stunt points
|
self.radness = 0 -- accumulated trick stunt points
|
||||||
self.totalScore = 0 -- tallied total score
|
self.totalScore = 0 -- tallied total score
|
||||||
self.hiScore = game.save.surfingHighScore or 0
|
self.hiScore = (game and game.save and game.save.surfingHighScore) or 0
|
||||||
self.newRecord = false
|
self.newRecord = false
|
||||||
self.currentPitch = 1.0
|
self.currentPitch = 1.0
|
||||||
|
self.isMinigame = true
|
||||||
|
self.isFixedSpeed = true
|
||||||
|
|
||||||
-- Hardware RNG simulation registers (hRandomAdd, hRandomSub, rDIV)
|
-- Hardware RNG simulation registers (hRandomAdd, hRandomSub, rDIV)
|
||||||
self.rDiv = 0
|
self.rDiv = 0
|
||||||
@@ -297,6 +336,7 @@ function SurfingMinigame.new(game, onDone)
|
|||||||
self.boardAngleDecreasing = false
|
self.boardAngleDecreasing = false
|
||||||
self.boardAngleTimer = 0
|
self.boardAngleTimer = 0
|
||||||
self.crashTimer = 0
|
self.crashTimer = 0
|
||||||
|
self.landingTimer = 0
|
||||||
|
|
||||||
-- 3-frame buffered D-Pad rotation & input accumulator
|
-- 3-frame buffered D-Pad rotation & input accumulator
|
||||||
self.joyCounter = 0
|
self.joyCounter = 0
|
||||||
@@ -318,14 +358,28 @@ function SurfingMinigame.new(game, onDone)
|
|||||||
self.tallyStep = 0
|
self.tallyStep = 0
|
||||||
self.tallyTimer = 0
|
self.tallyTimer = 0
|
||||||
|
|
||||||
-- Load sheets safely
|
-- Load sheets safely across all GameVersion prefix paths
|
||||||
local function sheet(path)
|
local function sheet(path)
|
||||||
if not (love and love.graphics and love.graphics.newImage) then return nil end
|
if not (love and love.graphics and love.graphics.newImage) then return nil end
|
||||||
local ok, img = pcall(love.graphics.newImage, path)
|
local GameVersion = require("src.core.GameVersion")
|
||||||
return ok and img or nil
|
local prefix = (GameVersion and GameVersion.cachePrefix and GameVersion.cachePrefix(game and game.version)) or "yellow/"
|
||||||
|
local filename = path:match("([^/]+)$") or path
|
||||||
|
local paths = {
|
||||||
|
prefix .. path,
|
||||||
|
path,
|
||||||
|
"yellow/assets/generated/minigame/" .. filename,
|
||||||
|
"assets/generated/minigame/" .. filename,
|
||||||
|
}
|
||||||
|
for _, p in ipairs(paths) do
|
||||||
|
local ok, img = pcall(love.graphics.newImage, p)
|
||||||
|
if ok and img then return img end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
end
|
end
|
||||||
self.bg = sheet("assets/generated/minigame/surf_1a.png")
|
self.bg = sheet("assets/generated/minigame/surf_1a.png")
|
||||||
self.ob = sheet("assets/generated/minigame/surf_1b.png")
|
self.ob = sheet("assets/generated/minigame/surf_1b.png")
|
||||||
|
self.intro = sheet("assets/generated/minigame/surf_1c.png")
|
||||||
|
self.titleBg = sheet("assets/generated/minigame/title_bg.png")
|
||||||
|
|
||||||
if self.bg then
|
if self.bg then
|
||||||
self.tq = {}
|
self.tq = {}
|
||||||
@@ -343,32 +397,97 @@ function SurfingMinigame.new(game, onDone)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Music.play(game.data, "Music_SurfingPikachu")
|
if self.intro then
|
||||||
|
self.iq = {}
|
||||||
|
local iW, iH = self.intro:getDimensions() -- 96x96 tile sheet (12x12 tiles)
|
||||||
|
for n = 0, 143 do
|
||||||
|
self.iq[n] = love.graphics.newQuad((n % 12) * 8, math.floor(n / 12) * 8, 8, 8, iW, iH)
|
||||||
|
end
|
||||||
|
-- Pika Intro Poses (24x32 px each in top 32px: X=0, 24, 48)
|
||||||
|
self.introPikaQuad1 = love.graphics.newQuad(0, 0, 24, 32, iW, iH)
|
||||||
|
self.introPikaQuad2 = love.graphics.newQuad(24, 0, 24, 32, iW, iH)
|
||||||
|
self.introPikaQuad3 = love.graphics.newQuad(48, 0, 24, 32, iW, iH)
|
||||||
|
-- Title Banner Logo ("PIKACHU'S BEACH") (96x32 px at Y=32..64)
|
||||||
|
self.introLogoQuad = love.graphics.newQuad(0, 32, 96, 32, iW, iH)
|
||||||
|
-- Instruction Text ("Use Control Pad to Surf") (96x32 px at Y=64..96)
|
||||||
|
self.introTextQuad = love.graphics.newQuad(0, 64, 96, 32, iW, iH)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- GPU Shader & Canvas for authentic HBlank wave distortion
|
||||||
|
if love and love.graphics and love.graphics.newShader then
|
||||||
|
local shaderCode = [[
|
||||||
|
extern float u_time;
|
||||||
|
extern float u_water_line;
|
||||||
|
|
||||||
|
vec4 effect(vec4 color, Image texture, vec2 texture_coords, vec2 screen_coords) {
|
||||||
|
vec2 uv = texture_coords;
|
||||||
|
if (uv.y >= u_water_line) {
|
||||||
|
float pixel_y = uv.y * 144.0;
|
||||||
|
float wave_px = sin((pixel_y * 0.39269908) + (u_time * 6.0)) * (1.5 / 160.0);
|
||||||
|
uv.x = fract(uv.x + wave_px);
|
||||||
|
}
|
||||||
|
return Texel(texture, uv) * color;
|
||||||
|
}
|
||||||
|
]]
|
||||||
|
local ok, shader = pcall(love.graphics.newShader, shaderCode)
|
||||||
|
if ok then self.waveShader = shader end
|
||||||
|
end
|
||||||
|
|
||||||
|
if love and love.graphics and love.graphics.newCanvas then
|
||||||
|
local ok, canvas = pcall(love.graphics.newCanvas, 160, 144)
|
||||||
|
if ok then self.bgCanvas = canvas end
|
||||||
|
end
|
||||||
|
|
||||||
|
Music.play(game and game.data, "Music_SurfingPikachu")
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Authentic Game Boy VBlank LFSR Random Number Generator
|
-- Transition cleanly from Title Screen to active run, flushing input bleed-through
|
||||||
|
function SurfingMinigame:startFromTitle()
|
||||||
|
self.routine = ROUTINE_START_GAME
|
||||||
|
self.inputAccum = 0
|
||||||
|
self.joyCounter = 0
|
||||||
|
self.rotCountLeft = 0
|
||||||
|
self.rotCountRight = 0
|
||||||
|
self:resetTempo()
|
||||||
|
if self.game and self.game.input and self.game.input.clearPressed then
|
||||||
|
self.game.input:clearPressed()
|
||||||
|
end
|
||||||
|
Sound.play(self.game and self.game.data, "Press_AB")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Authentic Game Boy LFSR Random Number Generator
|
||||||
function SurfingMinigame:getGBRandom()
|
function SurfingMinigame:getGBRandom()
|
||||||
self.rDiv = (self.rDiv + 7 + (self.inputAccum or 0)) % 256
|
self.rDiv = (self.rDiv + 13) % 256
|
||||||
self.rAdd = (self.rAdd + self.rDiv) % 256
|
local tempAdd = self.rAdd + self.rDiv
|
||||||
self.rSub = (self.rSub - self.rDiv + 256) % 256
|
self.rAdd = tempAdd % 256
|
||||||
return (self.rAdd + self.rSub) % 256
|
local tempSub = self.rSub - self.rDiv
|
||||||
|
self.rSub = (tempSub + 256) % 256
|
||||||
|
return self.rAdd
|
||||||
end
|
end
|
||||||
|
|
||||||
function SurfingMinigame:chooseSequence()
|
function SurfingMinigame:chooseSequence()
|
||||||
local distPx = math.floor(self.distanceFixed / 256)
|
local distPx = math.floor(self.distanceFixed / 256)
|
||||||
if math.floor(distPx / 128) >= 0x16 then
|
local section = math.floor(distPx / 128)
|
||||||
|
if section == 0x16 then
|
||||||
self.waveFn = 0x6a
|
self.waveFn = 0x6a
|
||||||
else
|
elseif section < 0x16 then
|
||||||
local r = self:getGBRandom()
|
local r = self:getGBRandom()
|
||||||
if r ~= 0 then self.waveFn = SEQ_STARTS[((r - 1) % 8) + 1] end
|
if r ~= 0 then
|
||||||
|
self.waveFn = SEQ_STARTS[bit.band(r - 1, 0x07) + 1]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
return WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y
|
return WAVE_PATTERNS[0x00], FLAT_WATER_Y, FLAT_WATER_Y
|
||||||
end
|
end
|
||||||
|
|
||||||
function SurfingMinigame:pushColumn()
|
function SurfingMinigame:pushColumn()
|
||||||
local pat, hl, hr
|
local pat, hl, hr
|
||||||
if self.waveFn == 0 then
|
local distPx = math.floor(self.distanceFixed / 256)
|
||||||
|
if distPx >= (TOTAL_SECTIONS * 128) or self.waveFn >= 0x74 then
|
||||||
|
-- Lock finish line to solid beach sand without cycling back to ocean waves
|
||||||
|
pat, hl, hr = WAVE_PATTERNS["beach"], FLAT_WATER_Y, FLAT_WATER_Y
|
||||||
|
self.waveFn = 0x74
|
||||||
|
elseif self.waveFn == 0 then
|
||||||
pat, hl, hr = self:chooseSequence()
|
pat, hl, hr = self:chooseSequence()
|
||||||
else
|
else
|
||||||
local step = WAVE_STEPS[self.waveFn]
|
local step = WAVE_STEPS[self.waveFn]
|
||||||
@@ -396,30 +515,40 @@ function SurfingMinigame:seaY(x)
|
|||||||
local distPx = math.floor(self.distanceFixed / 256)
|
local distPx = math.floor(self.distanceFixed / 256)
|
||||||
local tile = math.floor((distPx + x) / 8)
|
local tile = math.floor((distPx + x) / 8)
|
||||||
local col = self.cols[math.floor(tile / 2)]
|
local col = self.cols[math.floor(tile / 2)]
|
||||||
if not col then return FLAT_WATER_Y - 16 end
|
if not col then return FLAT_WATER_Y end
|
||||||
return (tile % 2 == 0 and col.hl or col.hr) - 16
|
return tile % 2 == 0 and col.hl or col.hr
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Get the tile ID of the wave under Pikachu (sample 9-10 tiles into viewport)
|
-- Get the tile ID of the wave under Pikachu (sample 9 tiles / 72 pixels into viewport)
|
||||||
function SurfingMinigame:getWaveTileUnderPika()
|
function SurfingMinigame:getWaveTileUnderPika()
|
||||||
local distPx = math.floor(self.distanceFixed / 256)
|
local distPx = math.floor(self.distanceFixed / 256)
|
||||||
local tile = math.floor((distPx + 80) / 8)
|
local tile_col = math.floor((distPx + 72) / 8)
|
||||||
local col = self.cols[math.floor(tile / 2)]
|
local waterY = math.floor(self:seaY(80))
|
||||||
|
local tile_row = math.floor(waterY / 8)
|
||||||
|
|
||||||
|
local c = math.floor(tile_col / 2)
|
||||||
|
local col = self.cols[c]
|
||||||
if not col or not col.pat then return 0x01 end
|
if not col or not col.pat then return 0x01 end
|
||||||
local pat = col.pat
|
|
||||||
for i = 4, 8 do
|
local i = math.floor(tile_row / 2) + 1
|
||||||
local mt = pat[i]
|
if i < 1 or i > 8 then return 0x01 end
|
||||||
if mt then
|
local mt = BG_METATILES[col.pat[i]]
|
||||||
if mt == 0x02 or mt == 0x06 or mt == 0x0a or mt == 0x11 then
|
if not mt then return 0x01 end
|
||||||
|
|
||||||
|
local sub_x = (tile_col % 2 == 0) and 0 or 1
|
||||||
|
local sub_y = (tile_row % 2 == 0) and 0 or 1
|
||||||
|
|
||||||
|
local sub_idx = 1 + sub_x + sub_y * 2
|
||||||
|
local mt_tile = mt[sub_idx] or 0x01
|
||||||
|
|
||||||
|
if mt_tile == 0x02 or mt_tile == 0x04 or mt_tile == 0x06 or mt_tile == 0x0a or mt_tile == 0x11 then
|
||||||
return 0x06 -- rising slope
|
return 0x06 -- rising slope
|
||||||
elseif mt == 0x03 or mt == 0x07 or mt == 0x0b or mt == 0x0d then
|
elseif mt_tile == 0x03 or mt_tile == 0x05 or mt_tile == 0x07 or mt_tile == 0x0d or mt_tile == 0x13 then
|
||||||
return 0x07 -- falling slope
|
return 0x07 -- falling slope
|
||||||
elseif mt == 0x08 or mt == 0x09 or mt == 0x0f or mt == 0x10 or mt == 0x14 or mt == 0x15 then
|
elseif mt_tile == 0x08 or mt_tile == 0x09 or mt_tile == 0x0f or mt_tile == 0x10 or mt_tile == 0x12 or mt_tile == 0x14 or mt_tile == 0x15 then
|
||||||
return 0x14 -- wave crest / face
|
return 0x14 -- wave crest / face
|
||||||
end
|
end
|
||||||
end
|
return 0x01 -- open water (0x0b, 0x00, 0x0e, etc.)
|
||||||
end
|
|
||||||
return 0x01 -- flat open water
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function SurfingMinigame:spawnTrickPopup(text)
|
function SurfingMinigame:spawnTrickPopup(text)
|
||||||
@@ -497,21 +626,51 @@ function SurfingMinigame:evaluateLanding()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function SurfingMinigame:updateTempo()
|
||||||
|
local tier = 1
|
||||||
|
if self.speedFixed >= 416 then
|
||||||
|
tier = 5
|
||||||
|
elseif self.speedFixed >= 320 then
|
||||||
|
tier = 4
|
||||||
|
elseif self.speedFixed >= 224 then
|
||||||
|
tier = 3
|
||||||
|
elseif self.speedFixed >= 128 then
|
||||||
|
tier = 2
|
||||||
|
else
|
||||||
|
tier = 1
|
||||||
|
end
|
||||||
|
local targetPitch = TEMPO_TIERS[tier] or 1.0
|
||||||
|
if self.currentPitch ~= targetPitch then
|
||||||
|
self.currentPitch = targetPitch
|
||||||
|
if Music and Music.setPitch then
|
||||||
|
Music.setPitch(self.currentPitch)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function SurfingMinigame:resetTempo()
|
||||||
|
self.currentPitch = 1.0
|
||||||
|
if Music and Music.setPitch then
|
||||||
|
Music.setPitch(1.0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function SurfingMinigame:updateRiding()
|
function SurfingMinigame:updateRiding()
|
||||||
-- Automatic speed up (+2/256 = +1/128 per frame up to max 512 = 2.0)
|
-- Automatic speed up (+2/256 = +1/128 per frame up to max 512 = 2.0)
|
||||||
if self.speedFixed < SPEED_MAX then
|
if self.speedFixed < SPEED_MAX then
|
||||||
self.speedFixed = math.min(SPEED_MAX, self.speedFixed + SPEED_ACCEL)
|
self.speedFixed = math.min(SPEED_MAX, self.speedFixed + SPEED_ACCEL)
|
||||||
end
|
end
|
||||||
|
self:updateTempo()
|
||||||
|
|
||||||
-- Follow wave surface height
|
-- Follow wave surface height
|
||||||
local targetY = self:seaY(80)
|
local targetY = self:seaY(80)
|
||||||
self.pikaY = math.floor(targetY)
|
self.pikaY = math.floor(targetY) - 16
|
||||||
self.pikaSubY = 0
|
self.pikaSubY = 0
|
||||||
|
|
||||||
-- Water spray every 4 frames
|
-- Water spray every 4 frames
|
||||||
self.sprayTimer = self.sprayTimer + 1
|
self.sprayTimer = self.sprayTimer + 1
|
||||||
if self.sprayTimer % 4 == 0 then
|
if self.sprayTimer % 4 == 0 then
|
||||||
table.insert(self.waterSprays, { x = PIKA_X + 16, y = self.pikaY + 12, timer = 12 })
|
table.insert(self.waterSprays, { x = PIKA_X, y = self.pikaY, timer = 4 })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Board angle wobbling every 8 frames
|
-- Board angle wobbling every 8 frames
|
||||||
@@ -532,14 +691,14 @@ function SurfingMinigame:updateRiding()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Select frame based on slope
|
-- Select frame based on slope (lock flat open water to frame 4 without wobble)
|
||||||
local tile = self:getWaveTileUnderPika()
|
local tile = self:getWaveTileUnderPika()
|
||||||
if tile == 0x06 or tile == 0x14 then
|
if tile == 0x06 or tile == 0x14 then
|
||||||
self.frameSet = 6 + (self.boardAngleOffset - 1)
|
self.frameSet = 6 + (self.boardAngleOffset - 1)
|
||||||
elseif tile == 0x07 then
|
elseif tile == 0x07 then
|
||||||
self.frameSet = 2 + (self.boardAngleOffset - 1)
|
self.frameSet = 2 + (self.boardAngleOffset - 1)
|
||||||
else
|
else
|
||||||
self.frameSet = 4 + (self.boardAngleOffset - 1)
|
self.frameSet = 4 -- flat open water: steady horizontal ride
|
||||||
end
|
end
|
||||||
self.frameSet = math.max(1, math.min(14, self.frameSet))
|
self.frameSet = math.max(1, math.min(14, self.frameSet))
|
||||||
|
|
||||||
@@ -560,43 +719,71 @@ function SurfingMinigame:updateRiding()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function SurfingMinigame:handleLanding()
|
||||||
|
local result = self:evaluateLanding()
|
||||||
|
if result == "wipeout" then
|
||||||
|
self.pikaState = PIKA_STATE_CRASHED
|
||||||
|
self.crashTimer = 96
|
||||||
|
self.speedFixed = SPEED_INITIAL
|
||||||
|
self.frameSet = 4
|
||||||
|
Sound.play(self.game.data, "Faint_Fall")
|
||||||
|
else
|
||||||
|
if result == "rough" then
|
||||||
|
self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_ROUGH_PENALTY)
|
||||||
|
elseif result == "hard" then
|
||||||
|
self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_HARD_PENALTY)
|
||||||
|
end
|
||||||
|
if self.routine == ROUTINE_RUN_GAME then
|
||||||
|
self:calculateStuntPoints()
|
||||||
|
end
|
||||||
|
self.pikaState = PIKA_STATE_LANDING
|
||||||
|
self.landingTimer = 32
|
||||||
|
self.frameSet = 4
|
||||||
|
Sound.play(self.game.data, "Cut")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function SurfingMinigame:updateJumping()
|
function SurfingMinigame:updateJumping()
|
||||||
-- Process accumulated input on the 3-frame buffer boundary
|
-- Process accumulated input on the 3-frame buffer boundary (only during active run)
|
||||||
|
if self.routine == ROUTINE_RUN_GAME then
|
||||||
self.joyCounter = (self.joyCounter + 1) % 3
|
self.joyCounter = (self.joyCounter + 1) % 3
|
||||||
if self.joyCounter == 0 then
|
if self.joyCounter == 0 then
|
||||||
local rightHeld = bit.band(self.inputAccum, 1) ~= 0
|
local rightHeld = bit.band(self.inputAccum, 1) ~= 0
|
||||||
local leftHeld = bit.band(self.inputAccum, 2) ~= 0
|
local leftHeld = bit.band(self.inputAccum, 2) ~= 0
|
||||||
self.inputAccum = 0
|
self.inputAccum = 0
|
||||||
|
|
||||||
if rightHeld then
|
-- Game Boy priority: Left D-pad checked first, then Right D-pad
|
||||||
self.rotCountLeft = 0
|
if leftHeld then
|
||||||
self.rotCountRight = self.rotCountRight + 1
|
|
||||||
if self.rotCountRight >= 11 then
|
|
||||||
self.rotCountRight = 0
|
self.rotCountRight = 0
|
||||||
|
self.rotCountLeft = self.rotCountLeft + 1
|
||||||
|
if self.rotCountLeft >= 11 then
|
||||||
|
self.rotCountLeft = 0
|
||||||
self.radnessMeter = math.min(3, self.radnessMeter + 1)
|
self.radnessMeter = math.min(3, self.radnessMeter + 1)
|
||||||
self.trickFlags = bit.bor(self.trickFlags, 1)
|
self.trickFlags = bit.bor(self.trickFlags, 1)
|
||||||
Sound.play(self.game.data, "Tink")
|
Sound.play(self.game.data, "Tink")
|
||||||
end
|
end
|
||||||
-- Increment frame set (frontflip forward)
|
|
||||||
self.frameSet = (self.frameSet % 14) + 1
|
self.frameSet = (self.frameSet % 14) + 1
|
||||||
elseif leftHeld then
|
elseif rightHeld then
|
||||||
self.rotCountRight = 0
|
|
||||||
self.rotCountLeft = self.rotCountLeft + 1
|
|
||||||
if self.rotCountLeft >= 13 then
|
|
||||||
self.rotCountLeft = 0
|
self.rotCountLeft = 0
|
||||||
|
self.rotCountRight = self.rotCountRight + 1
|
||||||
|
if self.rotCountRight >= 13 then
|
||||||
|
self.rotCountRight = 0
|
||||||
self.radnessMeter = math.min(3, self.radnessMeter + 1)
|
self.radnessMeter = math.min(3, self.radnessMeter + 1)
|
||||||
self.trickFlags = bit.bor(self.trickFlags, 2)
|
self.trickFlags = bit.bor(self.trickFlags, 2)
|
||||||
Sound.play(self.game.data, "Tink")
|
Sound.play(self.game.data, "Tink")
|
||||||
end
|
end
|
||||||
-- Decrement frame set (backflip backward)
|
|
||||||
self.frameSet = self.frameSet - 1
|
self.frameSet = self.frameSet - 1
|
||||||
if self.frameSet < 1 then self.frameSet = 14 end
|
if self.frameSet < 1 then self.frameSet = 14 end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
self.inputAccum = 0
|
||||||
|
end
|
||||||
|
|
||||||
-- Authentic Game Boy collision boundary & integer fixed-point jump physics
|
-- Authentic Game Boy collision boundary & integer fixed-point jump physics
|
||||||
if not self.jumpDescending then
|
if not self.jumpDescending then
|
||||||
self.pikaSubY = (self.pikaSubY or 0) + (self.jumpArcMagnitude ^ 2) * 4
|
local a = math.floor(self.jumpArcMagnitude)
|
||||||
|
self.pikaSubY = (self.pikaSubY or 0) + (a * a) * 4
|
||||||
local intDelta = math.floor(self.pikaSubY / 256)
|
local intDelta = math.floor(self.pikaSubY / 256)
|
||||||
self.pikaSubY = self.pikaSubY % 256
|
self.pikaSubY = self.pikaSubY % 256
|
||||||
self.pikaY = self.pikaY - intDelta
|
self.pikaY = self.pikaY - intDelta
|
||||||
@@ -608,34 +795,16 @@ function SurfingMinigame:updateJumping()
|
|||||||
end
|
end
|
||||||
else
|
else
|
||||||
-- Hardware execution order: evaluate boundary before adding velocity
|
-- Hardware execution order: evaluate boundary before adding velocity
|
||||||
local waveY = math.floor(self:seaY(80))
|
local waveY = math.floor(self:seaY(80)) - 16
|
||||||
if self.pikaY >= waveY then
|
if self.pikaY >= waveY then
|
||||||
self.pikaY = waveY
|
self.pikaY = waveY
|
||||||
self.pikaSubY = 0
|
self.pikaSubY = 0
|
||||||
-- Evaluate landing angle vs wave slope
|
self:handleLanding()
|
||||||
local result = self:evaluateLanding()
|
|
||||||
if result == "wipeout" then
|
|
||||||
self.pikaState = PIKA_STATE_CRASHED
|
|
||||||
self.crashTimer = 96
|
|
||||||
self.speedFixed = SPEED_INITIAL
|
|
||||||
self.frameSet = 4
|
|
||||||
Sound.play(self.game.data, "Faint_Fall")
|
|
||||||
else
|
|
||||||
if result == "rough" then
|
|
||||||
self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_ROUGH_PENALTY)
|
|
||||||
elseif result == "hard" then
|
|
||||||
self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_HARD_PENALTY)
|
|
||||||
end
|
|
||||||
self:calculateStuntPoints()
|
|
||||||
self.pikaState = PIKA_STATE_LANDING
|
|
||||||
self.routineTimer = 32
|
|
||||||
self.frameSet = 4
|
|
||||||
Sound.play(self.game.data, "Cut")
|
|
||||||
end
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
self.pikaSubY = (self.pikaSubY or 0) + (self.jumpArcMagnitude ^ 2) * 4
|
local a = math.floor(self.jumpArcMagnitude)
|
||||||
|
self.pikaSubY = (self.pikaSubY or 0) + (a * a) * 4
|
||||||
local intDelta = math.floor(self.pikaSubY / 256)
|
local intDelta = math.floor(self.pikaSubY / 256)
|
||||||
self.pikaSubY = self.pikaSubY % 256
|
self.pikaSubY = self.pikaSubY % 256
|
||||||
self.pikaY = self.pikaY + intDelta
|
self.pikaY = self.pikaY + intDelta
|
||||||
@@ -644,57 +813,66 @@ function SurfingMinigame:updateJumping()
|
|||||||
if self.pikaY >= waveY then
|
if self.pikaY >= waveY then
|
||||||
self.pikaY = waveY
|
self.pikaY = waveY
|
||||||
self.pikaSubY = 0
|
self.pikaSubY = 0
|
||||||
local result = self:evaluateLanding()
|
self:handleLanding()
|
||||||
if result == "wipeout" then
|
|
||||||
self.pikaState = PIKA_STATE_CRASHED
|
|
||||||
self.crashTimer = 96
|
|
||||||
self.speedFixed = SPEED_INITIAL
|
|
||||||
self.frameSet = 4
|
|
||||||
Sound.play(self.game.data, "Faint_Fall")
|
|
||||||
else
|
|
||||||
if result == "rough" then
|
|
||||||
self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_ROUGH_PENALTY)
|
|
||||||
elseif result == "hard" then
|
|
||||||
self.speedFixed = math.max(SPEED_INITIAL, self.speedFixed - SPEED_HARD_PENALTY)
|
|
||||||
end
|
|
||||||
self:calculateStuntPoints()
|
|
||||||
self.pikaState = PIKA_STATE_LANDING
|
|
||||||
self.routineTimer = 32
|
|
||||||
self.frameSet = 4
|
|
||||||
Sound.play(self.game.data, "Cut")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function SurfingMinigame:updateLanding()
|
function SurfingMinigame:updateLanding()
|
||||||
self.routineTimer = self.routineTimer - 1
|
self.landingTimer = (self.landingTimer or 0) - 1
|
||||||
|
|
||||||
|
-- Follow wave surface height continuously while landing so slopes don't cause position jumps!
|
||||||
|
local targetY = self:seaY(80)
|
||||||
|
self.pikaY = math.floor(targetY) - 16
|
||||||
|
self.pikaSubY = 0
|
||||||
|
|
||||||
-- Sine wave splash offset
|
-- Sine wave splash offset
|
||||||
self.pikaYOffset = math.floor(math.sin((32 - self.routineTimer) / 32 * math.pi * 2) * 4)
|
self.pikaYOffset = math.floor(math.sin((32 - math.max(0, self.landingTimer)) / 32 * math.pi * 2) * 4)
|
||||||
if self.routineTimer % 4 == 0 then
|
if self.landingTimer % 4 == 0 then
|
||||||
table.insert(self.waterSprays, { x = PIKA_X + 16, y = self.pikaY + 12, timer = 12 })
|
table.insert(self.waterSprays, { x = PIKA_X, y = self.pikaY, timer = 4 })
|
||||||
end
|
end
|
||||||
if self.routineTimer <= 0 then
|
|
||||||
|
if self.landingTimer <= 0 then
|
||||||
self.pikaYOffset = 0
|
self.pikaYOffset = 0
|
||||||
self.pikaState = PIKA_STATE_RIDING
|
self.pikaState = PIKA_STATE_RIDING
|
||||||
|
self.frameSet = 4
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function SurfingMinigame:updateCrashed()
|
function SurfingMinigame:updateCrashed()
|
||||||
self.crashTimer = self.crashTimer - 1
|
self.crashTimer = self.crashTimer - 1
|
||||||
|
self:resetTempo()
|
||||||
|
-- Follow water surface while wiped out
|
||||||
|
local targetY = self:seaY(80)
|
||||||
|
self.pikaY = math.floor(targetY) - 16
|
||||||
|
self.pikaSubY = 0
|
||||||
if self.crashTimer <= 0 then
|
if self.crashTimer <= 0 then
|
||||||
self.pikaState = PIKA_STATE_RIDING
|
self.pikaState = PIKA_STATE_RIDING
|
||||||
self.frameSet = 4
|
self.frameSet = 4
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function SurfingMinigame:update()
|
-- Single Game Boy hardware VBlank cycle tick (59.7275 Hz)
|
||||||
local input = self.game.input
|
function SurfingMinigame:tick()
|
||||||
|
local input = self.game and self.game.input
|
||||||
self.t = self.t + 1
|
self.t = self.t + 1
|
||||||
|
self.rDiv = (self.rDiv + 1) % 256
|
||||||
|
|
||||||
-- Accumulate physical button presses on every frame (eliminates polling blind spot)
|
-- Title Screen State
|
||||||
if input:isDown("right") then self.inputAccum = bit.bor(self.inputAccum, 1) end
|
if self.routine == ROUTINE_TITLE then
|
||||||
if input:isDown("left") then self.inputAccum = bit.bor(self.inputAccum, 2) end
|
if input and (input:wasPressed("start") or input:wasPressed("a")) then
|
||||||
|
self:startFromTitle()
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Accumulate physical button presses on every frame (only during active run)
|
||||||
|
if self.routine == ROUTINE_RUN_GAME then
|
||||||
|
if input and input:isDown("right") then self.inputAccum = bit.bor(self.inputAccum, 1) end
|
||||||
|
if input and input:isDown("left") then self.inputAccum = bit.bor(self.inputAccum, 2) end
|
||||||
|
else
|
||||||
|
self.inputAccum = 0
|
||||||
|
end
|
||||||
|
|
||||||
-- Update trick popups
|
-- Update trick popups
|
||||||
for i = #self.trickPopups, 1, -1 do
|
for i = #self.trickPopups, 1, -1 do
|
||||||
@@ -728,7 +906,7 @@ function SurfingMinigame:update()
|
|||||||
self.routineTimer = 128
|
self.routineTimer = 128
|
||||||
self.speedFixed = 0
|
self.speedFixed = 0
|
||||||
self.ohNoBanner = true
|
self.ohNoBanner = true
|
||||||
Sound.play(self.game.data, "Faint_Fall")
|
Sound.play(self.game and self.game.data, "Faint_Fall")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -758,20 +936,44 @@ function SurfingMinigame:update()
|
|||||||
end
|
end
|
||||||
|
|
||||||
elseif self.routine == ROUTINE_WAIT_RESULTS then
|
elseif self.routine == ROUTINE_WAIT_RESULTS then
|
||||||
-- 192 frames coasting past the goal line
|
-- Coasting past the goal line
|
||||||
self.distanceFixed = self.distanceFixed + (2 * 256)
|
self.distanceFixed = self.distanceFixed + (2 * 256)
|
||||||
|
self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor((2 * 256) * 0.25)
|
||||||
self:generateAhead()
|
self:generateAhead()
|
||||||
self.pikaY = math.floor(self:seaY(80))
|
|
||||||
|
-- Run Pikachu state machine so mid-air jumps and wipeout crashes complete
|
||||||
|
if self.pikaState == PIKA_STATE_JUMPING then
|
||||||
|
self:updateJumping()
|
||||||
|
elseif self.pikaState == PIKA_STATE_LANDING then
|
||||||
|
self:updateLanding()
|
||||||
|
elseif self.pikaState == PIKA_STATE_CRASHED then
|
||||||
|
self:updateCrashed()
|
||||||
|
else
|
||||||
|
-- Riding: follow water surface
|
||||||
|
local targetY = self:seaY(80)
|
||||||
|
self.pikaY = math.floor(targetY) - 16
|
||||||
|
self.pikaSubY = 0
|
||||||
|
self.frameSet = 4
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.routineTimer > 0 then
|
||||||
self.routineTimer = self.routineTimer - 1
|
self.routineTimer = self.routineTimer - 1
|
||||||
if self.routineTimer <= 0 then
|
end
|
||||||
|
|
||||||
|
-- Only advance to scroll results when delay has finished AND Pikachu is upright
|
||||||
|
if self.routineTimer <= 0 and self.pikaState == PIKA_STATE_RIDING then
|
||||||
self.routine = ROUTINE_SCROLL_RESULTS
|
self.routine = ROUTINE_SCROLL_RESULTS
|
||||||
self.routineTimer = 36
|
self.routineTimer = 36
|
||||||
|
self.pikaState = PIKA_STATE_GAME_END
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif self.routine == ROUTINE_SCROLL_RESULTS then
|
elseif self.routine == ROUTINE_SCROLL_RESULTS then
|
||||||
self.distanceFixed = self.distanceFixed + (1 * 256)
|
self.distanceFixed = self.distanceFixed + (1 * 256)
|
||||||
|
self.cloudOffsetFixed = self.cloudOffsetFixed + math.floor((1 * 256) * 0.25)
|
||||||
self:generateAhead()
|
self:generateAhead()
|
||||||
self.pikaY = math.floor(self:seaY(80))
|
self.pikaY = math.floor(self:seaY(80)) - 16
|
||||||
|
self.pikaSubY = 0
|
||||||
|
self.frameSet = 4
|
||||||
self.routineTimer = self.routineTimer - 1
|
self.routineTimer = self.routineTimer - 1
|
||||||
if self.routineTimer <= 0 then
|
if self.routineTimer <= 0 then
|
||||||
self.routine = ROUTINE_DRAW_RESULTS
|
self.routine = ROUTINE_DRAW_RESULTS
|
||||||
@@ -813,7 +1015,7 @@ function SurfingMinigame:update()
|
|||||||
local step = math.min(self.hp, 99)
|
local step = math.min(self.hp, 99)
|
||||||
self.hp = self.hp - step
|
self.hp = self.hp - step
|
||||||
self.totalScore = self.totalScore + step
|
self.totalScore = self.totalScore + step
|
||||||
Sound.play(self.game.data, "Press_AB")
|
Sound.play(self.game and self.game.data, "Press_AB")
|
||||||
else
|
else
|
||||||
self.routine = ROUTINE_ADD_RAD_TOTAL
|
self.routine = ROUTINE_ADD_RAD_TOTAL
|
||||||
end
|
end
|
||||||
@@ -824,18 +1026,18 @@ function SurfingMinigame:update()
|
|||||||
local step = math.min(self.radness, 99)
|
local step = math.min(self.radness, 99)
|
||||||
self.radness = self.radness - step
|
self.radness = self.radness - step
|
||||||
self.totalScore = self.totalScore + step
|
self.totalScore = self.totalScore + step
|
||||||
Sound.play(self.game.data, "Press_AB")
|
Sound.play(self.game and self.game.data, "Press_AB")
|
||||||
else
|
else
|
||||||
self.routine = ROUTINE_WAIT_LAST
|
self.routine = ROUTINE_WAIT_LAST
|
||||||
self.routineTimer = 64
|
self.routineTimer = 64
|
||||||
-- High score check
|
-- High score check
|
||||||
self.newRecord = self.totalScore > (self.game.save.surfingHighScore or 0)
|
self.newRecord = self.totalScore > ((self.game and self.game.save and self.game.save.surfingHighScore) or 0)
|
||||||
if self.newRecord then
|
if self.newRecord then
|
||||||
self.game.save.surfingHighScore = self.totalScore
|
if self.game and self.game.save then self.game.save.surfingHighScore = self.totalScore end
|
||||||
Sound.play(self.game.data, "Get_Item1")
|
Sound.play(self.game and self.game.data, "Get_Item1")
|
||||||
Sound.playPikaCry(self.game.data, 34)
|
Sound.playPikaCry(self.game and self.game.data, 34)
|
||||||
else
|
else
|
||||||
Sound.playPikaCry(self.game.data, 28)
|
Sound.playPikaCry(self.game and self.game.data, 28)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -846,24 +1048,48 @@ function SurfingMinigame:update()
|
|||||||
end
|
end
|
||||||
|
|
||||||
elseif self.routine == ROUTINE_EXIT_ON_PRESS_A then
|
elseif self.routine == ROUTINE_EXIT_ON_PRESS_A then
|
||||||
if input:wasPressed("a") or input:wasPressed("b") then
|
if input and input:wasPressed("a") then
|
||||||
self.game.stack:pop()
|
if self.game and self.game.stack then self.game.stack:pop() end
|
||||||
if self.onDone then self.onDone(self.totalScore) end
|
if self.onDone then self.onDone(self.totalScore) end
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif self.routine == ROUTINE_GAME_OVER then
|
elseif self.routine == ROUTINE_GAME_OVER then
|
||||||
self.routineTimer = self.routineTimer - 1
|
self.routineTimer = self.routineTimer - 1
|
||||||
if self.routineTimer <= 0 and (input:wasPressed("a") or input:wasPressed("b")) then
|
if self.routineTimer <= 0 and input and input:wasPressed("a") then
|
||||||
self.game.stack:pop()
|
if self.game and self.game.stack then self.game.stack:pop() end
|
||||||
if self.onDone then self.onDone(0) end
|
if self.onDone then self.onDone(0) end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Draw scrolling wave background
|
-- Decoupled timestep accumulator for modern multi-refresh-rate displays
|
||||||
|
function SurfingMinigame:update(dt)
|
||||||
|
if not dt then
|
||||||
|
self:tick()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Accumulate real-world time
|
||||||
|
self.tickAccumulator = (self.tickAccumulator or 0) + dt
|
||||||
|
|
||||||
|
-- Exact Game Boy Color framerate: 59.7275 Hz (approx 0.0167427 seconds per frame)
|
||||||
|
local gbTickRate = 1 / 59.7275
|
||||||
|
|
||||||
|
-- Process as many physical hardware frames as necessary
|
||||||
|
while self.tickAccumulator >= gbTickRate do
|
||||||
|
self:tick()
|
||||||
|
self.tickAccumulator = self.tickAccumulator - gbTickRate
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Draw scrolling wave background with authentic GPU shader HBlank wave distortion
|
||||||
function SurfingMinigame:drawBackground()
|
function SurfingMinigame:drawBackground()
|
||||||
local scx = math.floor(self.distanceFixed / 256)
|
local scx = math.floor(self.distanceFixed / 256)
|
||||||
local first = math.floor(scx / 16)
|
local first = math.floor(scx / 16)
|
||||||
|
|
||||||
|
local function renderTiles()
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
if not self.bg then return end
|
||||||
for c = first, first + 10 do
|
for c = first, first + 10 do
|
||||||
local col = self.cols[c]
|
local col = self.cols[c]
|
||||||
if col then
|
if col then
|
||||||
@@ -880,30 +1106,59 @@ function SurfingMinigame:drawBackground()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.bgCanvas and self.waveShader and love.graphics.setCanvas and love.graphics.getCanvas then
|
||||||
|
-- 1. Capture the framework's active canvas
|
||||||
|
local prevCanvas = love.graphics.getCanvas()
|
||||||
|
|
||||||
|
love.graphics.setCanvas(self.bgCanvas)
|
||||||
|
-- 2. Clear with transparency (0 alpha) so the sky remains empty
|
||||||
|
love.graphics.clear(0, 0, 0, 0)
|
||||||
|
renderTiles()
|
||||||
|
|
||||||
|
-- 3. Restore the framework's canvas before drawing!
|
||||||
|
love.graphics.setCanvas(prevCanvas)
|
||||||
|
|
||||||
|
-- 4. Safely capture and restore the shader
|
||||||
|
local prevShader = love.graphics.getShader and love.graphics.getShader()
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.setShader(self.waveShader)
|
||||||
|
|
||||||
|
if self.waveShader.hasUniform and self.waveShader:hasUniform("u_time") then
|
||||||
|
self.waveShader:send("u_time", self.t / 60.0)
|
||||||
|
end
|
||||||
|
if self.waveShader.hasUniform and self.waveShader:hasUniform("u_water_line") then
|
||||||
|
self.waveShader:send("u_water_line", 48.0 / 144.0)
|
||||||
|
end
|
||||||
|
|
||||||
|
love.graphics.draw(self.bgCanvas, 0, 0)
|
||||||
|
love.graphics.setShader(prevShader)
|
||||||
|
else
|
||||||
|
renderTiles()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Draw 3x3 Pikachu sprite (24x24 px centered at cx, cy)
|
-- Draw 3x3 Pikachu sprite (24x24 px centered at cx, cy)
|
||||||
function SurfingMinigame:draw3x3(baseTile, cx, cy, flipX, flipY)
|
function SurfingMinigame:draw3x3(baseTile, cx, cy, flipX, flipY)
|
||||||
|
local sx = flipX and -1 or 1
|
||||||
|
local sy = flipY and -1 or 1
|
||||||
for r = 0, 2 do
|
for r = 0, 2 do
|
||||||
for c = 0, 2 do
|
for c = 0, 2 do
|
||||||
local tileId = baseTile + r * 16 + c
|
local tileId = baseTile + r * 16 + c
|
||||||
local q = self.oq[tileId]
|
local q = self.oq[tileId]
|
||||||
if q then
|
if q then
|
||||||
if not flipX and not flipY then
|
local colIdx = flipX and (2 - c) or c
|
||||||
local dx = (cx - 12) + c * 8
|
local rowIdx = flipY and (2 - r) or r
|
||||||
local dy = (cy - 12) + r * 8
|
local dx = (cx - 12) + colIdx * 8 + (flipX and 8 or 0)
|
||||||
love.graphics.draw(self.ob, q, dx, dy)
|
local dy = (cy - 12) + rowIdx * 8 + (flipY and 8 or 0)
|
||||||
else
|
love.graphics.draw(self.ob, q, dx, dy, 0, sx, sy)
|
||||||
local dx = (cx - 12) + (2 - c) * 8 + 8
|
|
||||||
local dy = (cy - 12) + (2 - r) * 8 + 8
|
|
||||||
love.graphics.draw(self.ob, q, dx, dy, 0, -1, -1)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Draw HUD status bar
|
-- Draw HUD status bar with inverted progress marker (moving right-to-left towards beach)
|
||||||
function SurfingMinigame:drawHUD()
|
function SurfingMinigame:drawHUD()
|
||||||
-- White background in bottom 16 rows
|
-- White background in bottom 16 rows
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
@@ -931,9 +1186,14 @@ function SurfingMinigame:drawHUD()
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Mini-Pikachu progress marker (tile $fe)
|
-- Mini-Pikachu progress marker (tile $fe)
|
||||||
|
-- Game Boy: initial OAM X = $50 (80) = screen X 72, decrements by 2 per section.
|
||||||
|
-- 24 sections × 2 = 48 px total travel, ending at screen X 24.
|
||||||
|
-- We interpolate continuously across those same 48 pixels.
|
||||||
local distPx = math.floor(self.distanceFixed / 256)
|
local distPx = math.floor(self.distanceFixed / 256)
|
||||||
local progressRatio = math.min(1.0, math.max(0, distPx / (TOTAL_SECTIONS * 128)))
|
local progressRatio = math.min(1.0, math.max(0, distPx / (TOTAL_SECTIONS * 128)))
|
||||||
local markerX = 16 + math.floor(progressRatio * 80)
|
local markerStartX = 72 -- OAM X $50 (80) minus 8-pixel OAM offset = screen X 72
|
||||||
|
local markerTrack = 48 -- 24 sections × 2 px per section
|
||||||
|
local markerX = markerStartX - math.floor(progressRatio * markerTrack)
|
||||||
if self.ob and self.oq and self.oq[0xfe] then
|
if self.ob and self.oq and self.oq[0xfe] then
|
||||||
love.graphics.draw(self.ob, self.oq[0xfe], markerX, BG_HEIGHT + 6)
|
love.graphics.draw(self.ob, self.oq[0xfe], markerX, BG_HEIGHT + 6)
|
||||||
end
|
end
|
||||||
@@ -956,46 +1216,100 @@ function SurfingMinigame:drawResultsOutro()
|
|||||||
for r = 1, 10 do
|
for r = 1, 10 do
|
||||||
for c = 1, 20 do
|
for c = 1, 20 do
|
||||||
local tId = BEACH_OUTRO[r][c]
|
local tId = BEACH_OUTRO[r][c]
|
||||||
if tId and self.tq[tId] then
|
if tId and self.tq and self.tq[tId] then
|
||||||
love.graphics.draw(self.bg, self.tq[tId], (c - 1) * 8, (r + 5) * 8)
|
love.graphics.draw(self.bg, self.tq[tId], (c - 1) * 8, (r + 5) * 8)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Textbox frame on rows 1..9 (X=8..152, Y=8..72)
|
-- Fill interior with white
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 8, 8, 144, 64)
|
love.graphics.rectangle("fill", 16, 16, 128, 56)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
|
||||||
love.graphics.rectangle("line", 8.5, 8.5, 143, 63)
|
|
||||||
|
|
||||||
-- Text lines
|
-- Draw the textbox frame using original Game Boy border tiles (rows 1..9, Y=8..72)
|
||||||
|
local function drawBoxRow(rowIdx, leftTile, interiorTile, rightTile)
|
||||||
|
local y = rowIdx * 8
|
||||||
|
if self.tq[leftTile] then
|
||||||
|
love.graphics.draw(self.bg, self.tq[leftTile], 8, y)
|
||||||
|
end
|
||||||
|
if interiorTile and self.tq[interiorTile] then
|
||||||
|
for colIdx = 2, 17 do
|
||||||
|
love.graphics.draw(self.bg, self.tq[interiorTile], colIdx * 8, y)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if self.tq[rightTile] then
|
||||||
|
love.graphics.draw(self.bg, self.tq[rightTile], 144, y)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
drawBoxRow(1, 0x3b, 0x40, 0x3c)
|
||||||
|
for r = 2, 8 do
|
||||||
|
drawBoxRow(r, 0x3f, nil, 0x3f)
|
||||||
|
end
|
||||||
|
drawBoxRow(9, 0x3d, 0x40, 0x3e)
|
||||||
|
|
||||||
|
-- Text lines matching Game Boy screen memory coordinates (cols 2, 10, 15; rows 2, 4, 6, 8)
|
||||||
Font.draw(Strings("HP Left"), 16, 16)
|
Font.draw(Strings("HP Left"), 16, 16)
|
||||||
if self.routine >= ROUTINE_WRITE_HP_LEFT then
|
if self.routine >= ROUTINE_WRITE_HP_LEFT then
|
||||||
Font.draw(string.format("%4d Pts", self.hp), 88, 16)
|
Font.draw(string.format("%04d", self.hp), 80, 16)
|
||||||
|
Font.draw(Strings("Pts"), 120, 16)
|
||||||
end
|
end
|
||||||
|
|
||||||
if self.routine >= ROUTINE_WRITE_RADNESS then
|
if self.routine >= ROUTINE_WRITE_RADNESS then
|
||||||
Font.draw(Strings("Radness"), 16, 32)
|
Font.draw(Strings("Radness"), 16, 32)
|
||||||
Font.draw(string.format("%4d Pts", self.radness), 88, 32)
|
Font.draw(string.format("%04d", self.radness), 80, 32)
|
||||||
|
Font.draw(Strings("Pts"), 120, 32)
|
||||||
end
|
end
|
||||||
|
|
||||||
if self.routine >= ROUTINE_WRITE_TOTAL then
|
if self.routine >= ROUTINE_WRITE_TOTAL then
|
||||||
Font.draw(Strings("Total"), 16, 48)
|
Font.draw(Strings("Total"), 16, 48)
|
||||||
Font.draw(string.format("%4d Pts", self.totalScore), 88, 48)
|
Font.draw(string.format("%04d", self.totalScore), 80, 48)
|
||||||
|
Font.draw(Strings("Pts"), 120, 48)
|
||||||
end
|
end
|
||||||
|
|
||||||
if self.routine >= ROUTINE_WAIT_LAST then
|
if self.routine >= ROUTINE_WAIT_LAST then
|
||||||
if self.newRecord then
|
if self.newRecord then
|
||||||
Font.draw(Strings("Hi-Score!!"), 48, 60)
|
Font.draw(Strings("Hi-Score!!"), 48, 64)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Draw minigame Title Screen ("Pikachu's Beach")
|
||||||
|
function SurfingMinigame:drawTitleScreen()
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
|
|
||||||
|
-- 1. Draw authentic 160x144 composite title background from ROM
|
||||||
|
if self.titleBg then
|
||||||
|
love.graphics.draw(self.titleBg, 0, 0)
|
||||||
|
else
|
||||||
|
Font.draw("PIKACHU'S BEACH", 20, 32)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 2. Draw 3x3 Pikachu intro sprite on the water using authentic OAM tile indices
|
||||||
|
if self.ob and self.oq then
|
||||||
|
local animBase = (math.floor(self.t / 32) % 2 == 0) and 0x0c or 0x09
|
||||||
|
self:draw3x3(animBase, 80, 100, false, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 3. High score display at bottom
|
||||||
|
Font.draw(string.format("Hi-Score %4d Pt", self.hiScore), 16, 120)
|
||||||
|
|
||||||
|
if math.floor(self.t / 30) % 2 == 0 then
|
||||||
|
Font.draw("PRESS START", 36, 132)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function SurfingMinigame:draw()
|
function SurfingMinigame:draw()
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
|
|
||||||
|
if self.routine == ROUTINE_TITLE then
|
||||||
|
self:drawTitleScreen()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
if self.routine >= ROUTINE_DRAW_RESULTS and self.routine <= ROUTINE_EXIT_ON_PRESS_A then
|
if self.routine >= ROUTINE_DRAW_RESULTS and self.routine <= ROUTINE_EXIT_ON_PRESS_A then
|
||||||
self:drawResultsOutro()
|
self:drawResultsOutro()
|
||||||
return
|
return
|
||||||
@@ -1004,10 +1318,11 @@ function SurfingMinigame:draw()
|
|||||||
-- Draw scrolling BG waves
|
-- Draw scrolling BG waves
|
||||||
self:drawBackground()
|
self:drawBackground()
|
||||||
|
|
||||||
-- Parallax clouds in sky
|
-- Parallax clouds in sky (scrolling left at uniform 0.25x camera speed)
|
||||||
local cloudOffsetPx = math.floor(self.cloudOffsetFixed / 256)
|
local cloudOffsetPx = math.floor(self.cloudOffsetFixed / 256)
|
||||||
local c1x = (32 - cloudOffsetPx * 2) % 200 - 40
|
local c1x = (160 - (cloudOffsetPx % 200)) - 40
|
||||||
local c2x = (128 - cloudOffsetPx * 3) % 200 - 40
|
local c2x = (240 - (cloudOffsetPx % 200)) - 40
|
||||||
|
if self.ob and self.oq then
|
||||||
-- Wide cloud (5 tiles: $ec, $ed, $ed, $ee, $ef)
|
-- Wide cloud (5 tiles: $ec, $ed, $ed, $ee, $ef)
|
||||||
for i, tid in ipairs({ 0xec, 0xed, 0xed, 0xee, 0xef }) do
|
for i, tid in ipairs({ 0xec, 0xed, 0xed, 0xee, 0xef }) do
|
||||||
love.graphics.draw(self.ob, self.oq[tid], c1x + (i - 1) * 8, 12)
|
love.graphics.draw(self.ob, self.oq[tid], c1x + (i - 1) * 8, 12)
|
||||||
@@ -1016,10 +1331,29 @@ function SurfingMinigame:draw()
|
|||||||
for i, tid in ipairs({ 0xec, 0xed, 0xee, 0xef }) do
|
for i, tid in ipairs({ 0xec, 0xed, 0xee, 0xef }) do
|
||||||
love.graphics.draw(self.ob, self.oq[tid], c2x + (i - 1) * 8, 20)
|
love.graphics.draw(self.ob, self.oq[tid], c2x + (i - 1) * 8, 20)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
-- Helper function to draw OAM multi-sprite composite objects with palette and X-flipping
|
||||||
|
function self:drawOAMSprites(sprites, ox, oy)
|
||||||
|
if not (self.ob and self.oq) then return end
|
||||||
|
love.graphics.setColor(0.5, 0.5, 0.5, 1) -- OAM_PAL1 maps shade 1 to shade 2 (Sea Blue)
|
||||||
|
for _, sp in ipairs(sprites) do
|
||||||
|
local q = self.oq[sp.tile]
|
||||||
|
if q then
|
||||||
|
local x = ox + sp.dx
|
||||||
|
local y = oy + sp.dy
|
||||||
|
if sp.xflip then
|
||||||
|
love.graphics.draw(self.ob, q, x + 8, y, 0, -1, 1)
|
||||||
|
else
|
||||||
|
love.graphics.draw(self.ob, q, x, y)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
-- Draw water spray sprites
|
-- Draw water spray sprites on trailing edge of surfboard
|
||||||
for _, s in ipairs(self.waterSprays) do
|
for _, s in ipairs(self.waterSprays) do
|
||||||
love.graphics.draw(self.ob, self.oq[0xa7], s.x, s.y)
|
self:drawOAMSprites(OAM_WATER_SPRAY, s.x, s.y)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Draw Pikachu
|
-- Draw Pikachu
|
||||||
@@ -1028,11 +1362,19 @@ function SurfingMinigame:draw()
|
|||||||
self.pikaScreenY = cy
|
self.pikaScreenY = cy
|
||||||
|
|
||||||
if self.pikaState == PIKA_STATE_CRASHED then
|
if self.pikaState == PIKA_STATE_CRASHED then
|
||||||
-- Empty surfboard + splash animation
|
-- Empty surfboard (3 tiles at bottom)
|
||||||
|
if self.ob and self.oq then
|
||||||
love.graphics.draw(self.ob, self.oq[0x98], cx - 12, cy + 4)
|
love.graphics.draw(self.ob, self.oq[0x98], cx - 12, cy + 4)
|
||||||
love.graphics.draw(self.ob, self.oq[0x99], cx - 4, cy + 4)
|
love.graphics.draw(self.ob, self.oq[0x99], cx - 4, cy + 4)
|
||||||
love.graphics.draw(self.ob, self.oq[0x9a], cx + 4, cy + 4)
|
love.graphics.draw(self.ob, self.oq[0x9a], cx + 4, cy + 4)
|
||||||
love.graphics.draw(self.ob, self.oq[0xa8], cx - 12, cy - 8)
|
end
|
||||||
|
-- Multi-tile splash animation (.SmallSplash then .LargeSplash)
|
||||||
|
local elapsed = 60 - (self.crashTimer or 0)
|
||||||
|
if elapsed < 16 then
|
||||||
|
self:drawOAMSprites(OAM_SMALL_SPLASH, cx, cy + 4)
|
||||||
|
else
|
||||||
|
self:drawOAMSprites(OAM_LARGE_SPLASH, cx, cy + 4)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
local angleIdx = ((self.frameSet - 1) % 7) + 1
|
local angleIdx = ((self.frameSet - 1) % 7) + 1
|
||||||
local isFlipped = self.frameSet > 7
|
local isFlipped = self.frameSet > 7
|
||||||
@@ -1048,6 +1390,7 @@ function SurfingMinigame:draw()
|
|||||||
|
|
||||||
-- "START" banner
|
-- "START" banner
|
||||||
if self.routine == ROUTINE_START_GAME then
|
if self.routine == ROUTINE_START_GAME then
|
||||||
|
if self.ob and self.oq then
|
||||||
for r = 0, 1 do
|
for r = 0, 1 do
|
||||||
for c = 0, 5 do
|
for c = 0, 5 do
|
||||||
local tid = 0xe0 + r * 16 + c
|
local tid = 0xe0 + r * 16 + c
|
||||||
@@ -1055,9 +1398,11 @@ function SurfingMinigame:draw()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- "Oh no.." banner on Game Over
|
-- "Oh no.." banner on Game Over
|
||||||
if self.ohNoBanner then
|
if self.ohNoBanner then
|
||||||
|
if self.ob and self.oq then
|
||||||
for r = 0, 1 do
|
for r = 0, 1 do
|
||||||
for c = 0, 5 do
|
for c = 0, 5 do
|
||||||
local tid = 0xca + r * 16 + c
|
local tid = 0xca + r * 16 + c
|
||||||
@@ -1065,6 +1410,7 @@ function SurfingMinigame:draw()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Draw HUD (Progress track, HP digits)
|
-- Draw HUD (Progress track, HP digits)
|
||||||
self:drawHUD()
|
self:drawHUD()
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ function Editor.load(opts)
|
|||||||
}
|
}
|
||||||
local optsTbl = SaveData.loadOptions()
|
local optsTbl = SaveData.loadOptions()
|
||||||
local applied = optsTbl
|
local applied = optsTbl
|
||||||
if opts.version == "gold" then
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
local gen2 = GameVersion.VERSIONS[opts.version]
|
||||||
|
and GameVersion.generation(opts.version) == 2
|
||||||
|
if gen2 then
|
||||||
local gold = type(optsTbl.gold) == "table" and optsTbl.gold or {}
|
local gold = type(optsTbl.gold) == "table" and optsTbl.gold or {}
|
||||||
applied = {
|
applied = {
|
||||||
touchControls = gold.touchControls,
|
touchControls = gold.touchControls,
|
||||||
@@ -151,7 +154,9 @@ local function persist()
|
|||||||
skin = cfg.skin,
|
skin = cfg.skin,
|
||||||
layouts = cfg.layouts,
|
layouts = cfg.layouts,
|
||||||
}
|
}
|
||||||
if Editor.version == "gold" then
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
if GameVersion.VERSIONS[Editor.version]
|
||||||
|
and GameVersion.generation(Editor.version) == 2 then
|
||||||
opts.gold = type(opts.gold) == "table" and opts.gold or {}
|
opts.gold = type(opts.gold) == "table" and opts.gold or {}
|
||||||
opts.gold.touchControls = block
|
opts.gold.touchControls = block
|
||||||
else
|
else
|
||||||
@@ -569,7 +574,7 @@ function Editor.new(game)
|
|||||||
local state = { game = game, isOpaque = true }
|
local state = { game = game, isOpaque = true }
|
||||||
Editor.hostPoll = true
|
Editor.hostPoll = true
|
||||||
Editor.load({
|
Editor.load({
|
||||||
version = "gold",
|
version = require("src.core.GameVersion").get(),
|
||||||
hostPoll = true,
|
hostPoll = true,
|
||||||
onClose = function()
|
onClose = function()
|
||||||
Editor.hostPoll = false
|
Editor.hostPoll = false
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ local BattleState = {}
|
|||||||
BattleState.__index = BattleState
|
BattleState.__index = BattleState
|
||||||
BattleState.isOpaque = true
|
BattleState.isOpaque = true
|
||||||
|
|
||||||
|
function BattleState:moveGridNavigation()
|
||||||
|
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
|
||||||
|
return Runtime.call("battle.move_grid_navigation", function() return false end,
|
||||||
|
self) == true
|
||||||
|
end
|
||||||
|
|
||||||
-- Armed while a battle line waits for PromptButton (home/text.asm). Any
|
-- Armed while a battle line waits for PromptButton (home/text.asm). Any
|
||||||
-- positive value means "hold until A/B"; the cart never times these out, so
|
-- positive value means "hold until A/B"; the cart never times these out, so
|
||||||
-- the victory jingle can keep looping through the post-win prompts.
|
-- the victory jingle can keep looping through the post-win prompts.
|
||||||
@@ -2044,7 +2050,22 @@ function BattleState:update(_dt)
|
|||||||
|
|
||||||
if self.phase == "moves" then
|
if self.phase == "moves" then
|
||||||
local moves = self:playerMoves()
|
local moves = self:playerMoves()
|
||||||
if input:wasPressed("up") then
|
local grid
|
||||||
|
if self:moveGridNavigation() then
|
||||||
|
local index, count = self.moveIndex, #moves
|
||||||
|
if input:wasPressed("left") or input:wasPressed("right") then
|
||||||
|
local other = math.floor((index - 1) / 2) * 2
|
||||||
|
+ (1 - (index - 1) % 2) + 1
|
||||||
|
grid = other <= count and other or index
|
||||||
|
elseif input:wasPressed("up") or input:wasPressed("down") then
|
||||||
|
local other = (1 - math.floor((index - 1) / 2)) * 2
|
||||||
|
+ (index - 1) % 2 + 1
|
||||||
|
grid = other <= count and other or index
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if grid then
|
||||||
|
self.moveIndex = grid
|
||||||
|
elseif input:wasPressed("up") then
|
||||||
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
|
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
|
||||||
elseif input:wasPressed("down") then
|
elseif input:wasPressed("down") then
|
||||||
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
|
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
|
||||||
|
|||||||
@@ -619,8 +619,8 @@ function BattleTransition:grid(w, h)
|
|||||||
scale = math.max(1, math.floor(math.min(w / 160, h / 144)))
|
scale = math.max(1, math.floor(math.min(w / 160, h / 144)))
|
||||||
end
|
end
|
||||||
local size = 8 * scale
|
local size = 8 * scale
|
||||||
local ox = math.floor((w - 160 * scale) / 2)
|
local Chrome = require("src.ui.gen2.Chrome")
|
||||||
local oy = math.floor((h - 144 * scale) / 2)
|
local ox, oy = Chrome.fitOrigin(w, h, scale)
|
||||||
return size, ox, oy
|
return size, ox, oy
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -303,12 +303,12 @@ local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2
|
|||||||
-- data/text/common_3.asm; none of these are in the cache's text.lua, because no
|
-- data/text/common_3.asm; none of these are in the cache's text.lua, because no
|
||||||
-- script bytecode the extractor walks points at them.
|
-- script bytecode the extractor walks points at them.
|
||||||
CardFlip.TEXTS = {
|
CardFlip.TEXTS = {
|
||||||
playWithThree = { "Play with three", "coins?" },
|
playWithThree = { "Play with", "3 coins?" },
|
||||||
notEnough = { "Not enough coins…" },
|
notEnough = { "Not enough", "coins." },
|
||||||
chooseACard = { "Choose a card." },
|
chooseACard = { "Choose a", "card." },
|
||||||
placeYourBet = { "Place your bet." },
|
placeYourBet = { "Place", "your bet" },
|
||||||
playAgain = { "Want to play", "again?" },
|
playAgain = { "Play", "again?" },
|
||||||
shuffled = { "The cards have", "been shuffled." },
|
shuffled = { "The cards", "shuffled." },
|
||||||
yeah = { "Yeah!" },
|
yeah = { "Yeah!" },
|
||||||
darn = { "Darn…" },
|
darn = { "Darn…" },
|
||||||
}
|
}
|
||||||
@@ -401,14 +401,34 @@ function CardFlip:enterBet()
|
|||||||
self.lines = CardFlip.TEXTS.placeYourBet
|
self.lines = CardFlip.TEXTS.placeYourBet
|
||||||
end
|
end
|
||||||
|
|
||||||
-- .CheckTheCard: the dealt card is turned face up and marked on the discard
|
-- .CheckTheCard: trigger hardware-accurate discrete tile flip sequence
|
||||||
-- pile, which is what blanks its cell on the odds board.
|
|
||||||
function CardFlip:flip()
|
function CardFlip:flip()
|
||||||
local card = CardFlip.dealt(self.deck, self.played, self.which)
|
local card = CardFlip.dealt(self.deck, self.played, self.which)
|
||||||
self.faceUp = card
|
self.faceUp = card
|
||||||
self.discarded[card] = true
|
self.discarded[card] = true
|
||||||
|
local won = CardFlip.payout(self.cursorX, self.cursorY, card)
|
||||||
|
self.payoutLeft = won
|
||||||
|
self.payoutTick = 0
|
||||||
|
self.phase = "flipping"
|
||||||
|
self.flipTimer = 0
|
||||||
|
self.targetCard = card
|
||||||
|
end
|
||||||
|
|
||||||
|
function CardFlip:updateFlipping()
|
||||||
|
self.flipTimer = (self.flipTimer or 0) + 1
|
||||||
|
if self.flipTimer == 4 then
|
||||||
self:sfx(SFX_CHOOSE)
|
self:sfx(SFX_CHOOSE)
|
||||||
self:tabulate()
|
elseif self.flipTimer >= 12 then
|
||||||
|
if (self.payoutLeft or 0) > 0 then
|
||||||
|
self.phase = "payout"
|
||||||
|
self.lines = CardFlip.TEXTS.yeah
|
||||||
|
self:sfx(SFX_WIN)
|
||||||
|
else
|
||||||
|
self.phase = "result"
|
||||||
|
self.lines = CardFlip.TEXTS.darn
|
||||||
|
self:sfx(SFX_WRONG)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function CardFlip:tabulate()
|
function CardFlip:tabulate()
|
||||||
@@ -472,7 +492,21 @@ function CardFlip:quit()
|
|||||||
if self.onClose then self.onClose() end
|
if self.onClose then self.onClose() end
|
||||||
end
|
end
|
||||||
|
|
||||||
function CardFlip:update(_dt)
|
function CardFlip:update(dt)
|
||||||
|
-- Support both fixed-tick 60Hz loop and variable dt accumulator
|
||||||
|
if dt and dt > 0 then
|
||||||
|
self.dtAccum = (self.dtAccum or 0) + dt
|
||||||
|
local TICK = 1 / 60
|
||||||
|
while self.dtAccum >= TICK do
|
||||||
|
self.dtAccum = self.dtAccum - TICK
|
||||||
|
self:tick()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
self:tick()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function CardFlip:tick()
|
||||||
local input = self.game and self.game.input
|
local input = self.game and self.game.input
|
||||||
if not input then return end
|
if not input then return end
|
||||||
local phase = self.phase
|
local phase = self.phase
|
||||||
@@ -539,6 +573,11 @@ function CardFlip:update(_dt)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if phase == "flipping" then
|
||||||
|
self:updateFlipping()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
if phase == "payout" then
|
if phase == "payout" then
|
||||||
self:updatePayout()
|
self:updatePayout()
|
||||||
return
|
return
|
||||||
@@ -552,79 +591,370 @@ function CardFlip:update(_dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- ------------------------------------------------------------------- draw
|
-- ------------------------------------------------------------------- draw
|
||||||
|
--
|
||||||
|
-- Authentic Color Game Boy palettes and tile graphics matching pret/pokegold
|
||||||
|
local TileSheet = require("src.ui.gen2.TileSheet")
|
||||||
|
local GbcPalette = require("src.render.GbcPalette")
|
||||||
|
|
||||||
|
local CARDFLIP_PALS = {
|
||||||
|
bg = {
|
||||||
|
[0] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 0: Base / Table green
|
||||||
|
[1] = { { 255, 255, 255 }, { 239, 206, 0 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 1: Pikachu (Yellow)
|
||||||
|
[2] = { { 255, 255, 255 }, { 255, 107, 247 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 2: Jigglypuff (Pink)
|
||||||
|
[3] = { { 255, 255, 255 }, { 66, 140, 247 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 3: Poliwag (Blue)
|
||||||
|
[4] = { { 255, 255, 255 }, { 66, 255, 66 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 4: Oddish (Green)
|
||||||
|
[5] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 5: Level header
|
||||||
|
[6] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 6: Border
|
||||||
|
[7] = { { 255, 255, 255 }, { 140, 57, 255 }, { 49, 156, 66 }, { 0, 0, 0 } }, -- 7: Textbox
|
||||||
|
},
|
||||||
|
obj = {
|
||||||
|
[0] = { { 255, 255, 255 }, { 248, 56, 40 }, { 248, 56, 40 }, { 248, 56, 40 } }, -- Authentic GBC Red OAM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
local TILEMAP = nil
|
||||||
|
local function getCardFlipTilemap()
|
||||||
|
if TILEMAP == nil then
|
||||||
|
local path = "assets/generated/card_flip/card_flip.tilemap"
|
||||||
|
local f = io.open(path, "rb")
|
||||||
|
if f then
|
||||||
|
local data = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
TILEMAP = {}
|
||||||
|
for i = 1, #data do
|
||||||
|
TILEMAP[i] = string.byte(data, i)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
TILEMAP = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return TILEMAP or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function CardFlip:sheets()
|
||||||
|
if self.sheet1 == nil then
|
||||||
|
self.sheet1 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_1.png", wide = 16, firstTile = 0 })
|
||||||
|
self.sheet2 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_2.png", wide = 3, firstTile = 0 })
|
||||||
|
self.sheet3 = TileSheet.new({ path = "assets/generated/card_flip/card_flip_3.png", wide = 1, firstTile = 0 })
|
||||||
|
self.sheetOn = TileSheet.new({ path = "assets/generated/card_flip/on.png", wide = 1, firstTile = 0 })
|
||||||
|
self.sheetOff = TileSheet.new({ path = "assets/generated/card_flip/off.png", wide = 1, firstTile = 0 })
|
||||||
|
end
|
||||||
|
return self.sheet1, self.sheet2, self.sheet3, self.sheetOn, self.sheetOff
|
||||||
|
end
|
||||||
|
|
||||||
|
function CardFlip:cursorQuads()
|
||||||
|
if self.s3Image == nil then
|
||||||
|
local _, _, s3 = self:sheets()
|
||||||
|
self.s3Image = s3:image()
|
||||||
|
if self.s3Image and love and love.graphics then
|
||||||
|
local G = love.graphics
|
||||||
|
self.quadCorner = G.newQuad(0, 0, 8, 8, 8, 56) -- Tile 0: 1px corner
|
||||||
|
self.quadVEdge = G.newQuad(0, 8, 8, 8, 8, 56) -- Tile 1: 1px vertical edge
|
||||||
|
self.quadHEdge = G.newQuad(0, 16, 8, 8, 8, 56) -- Tile 2: 1px horizontal edge
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return self.s3Image, self.quadCorner, self.quadVEdge, self.quadHEdge
|
||||||
|
end
|
||||||
|
|
||||||
|
local HEADER_TILE_MAP = {
|
||||||
|
[0x3E] = 0, [0x3F] = 1,
|
||||||
|
[0x40] = 3, [0x41] = 4,
|
||||||
|
[0x42] = 6, [0x43] = 7,
|
||||||
|
[0x44] = 9, [0x45] = 10,
|
||||||
|
[0x46] = 12, [0x47] = 13,
|
||||||
|
[0x48] = 15, [0x49] = 16,
|
||||||
|
[0x4A] = 18, [0x4B] = 19,
|
||||||
|
[0x4C] = 21, [0x4D] = 22,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Draw an authentic GBC red cursor bounding frame using OAM sprite tiles.
|
||||||
|
-- On real GBC hardware OAM color 0 is always transparent, so only the 1px
|
||||||
|
-- dark edges of tiles 0-2 are visible; the board shows through the middle.
|
||||||
|
-- We replicate this by drawing in "multiply" blend mode: white (255,255,255)
|
||||||
|
-- pixels multiply to the board colour unchanged, black (0,0,0) pixels tinted
|
||||||
|
-- to GBC red draw the border, and nothing fills the interior.
|
||||||
|
function CardFlip:drawOamBox(px, py, w, h)
|
||||||
|
local img, qCorner, qVEdge, qHEdge = self:cursorQuads()
|
||||||
|
local G = love.graphics
|
||||||
|
|
||||||
|
if not (img and qCorner and qVEdge and qHEdge) then
|
||||||
|
-- Fallback: plain 1px red outline
|
||||||
|
G.setColor(248 / 255, 56 / 255, 40 / 255, 1)
|
||||||
|
G.rectangle("fill", px, py, w, 1)
|
||||||
|
G.rectangle("fill", px, py + h - 1, w, 1)
|
||||||
|
G.rectangle("fill", px, py, 1, h)
|
||||||
|
G.rectangle("fill", px + w - 1, py, 1, h)
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local prevBlend, prevAlpha = G.getBlendMode()
|
||||||
|
G.setBlendMode("multiply", "premultiplied")
|
||||||
|
-- Tint: black (0) → GBC red, white (255) → white (passthrough = transparent)
|
||||||
|
G.setColor(248 / 255, 56 / 255, 40 / 255, 1)
|
||||||
|
|
||||||
|
-- 4 Corners
|
||||||
|
G.draw(img, qCorner, px, py, 0, 1, 1)
|
||||||
|
G.draw(img, qCorner, px + w, py, 0, -1, 1)
|
||||||
|
G.draw(img, qCorner, px, py + h, 0, 1, -1)
|
||||||
|
G.draw(img, qCorner, px + w, py + h, 0, -1, -1)
|
||||||
|
|
||||||
|
-- Top & Bottom horizontal edges
|
||||||
|
if w > 16 then
|
||||||
|
for x = px + 8, px + w - 16, 8 do
|
||||||
|
G.draw(img, qHEdge, x, py, 0, 1, 1)
|
||||||
|
G.draw(img, qHEdge, x, py + h, 0, 1, -1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Left & Right vertical edges
|
||||||
|
if h > 16 then
|
||||||
|
for y = py + 8, py + h - 16, 8 do
|
||||||
|
G.draw(img, qVEdge, px, y, 0, 1, 1)
|
||||||
|
G.draw(img, qVEdge, px + w, y, 0, -1, 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
G.setBlendMode(prevBlend, prevAlpha)
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
function CardFlip:drawBoard()
|
function CardFlip:drawBoard()
|
||||||
-- The twelve hand lights down column 9; CARDFLIP_LIGHT_ON marks the hand
|
local s1, s2, s3, sOn, sOff = self:sheets()
|
||||||
-- being played and every one before it stays off.
|
local tm = getCardFlipTilemap()
|
||||||
|
local G = love.graphics
|
||||||
|
|
||||||
|
-- Green background fill
|
||||||
|
G.setColor(49 / 255, 156 / 255, 66 / 255, 1)
|
||||||
|
G.rectangle("fill", 0, 0, 160, 144)
|
||||||
|
|
||||||
|
if not tm or not s2:available() then
|
||||||
|
-- Fallback simple board
|
||||||
for row = 0, CardFlip.HANDS_PER_DECK - 1 do
|
for row = 0, CardFlip.HANDS_PER_DECK - 1 do
|
||||||
Chrome.print(row == self.played and "o" or ".", LIGHT_X, row)
|
Chrome.print(row == self.played and "o" or ".", LIGHT_X, row)
|
||||||
end
|
end
|
||||||
for x = 2, 5 do
|
for x = 2, 5 do
|
||||||
Chrome.print(CardFlip.MON_LABELS[x - 2], MON_COL[x], MON_ROW)
|
Chrome.print(CardFlip.MON_LABELS[x - 2], MON_COL[x], MON_ROW)
|
||||||
-- The pair headers sit above the two Pokemon they cover.
|
|
||||||
if x % 2 == 0 then Chrome.print("6", MON_COL[x] + 1, MON_PAIR_ROW) end
|
if x % 2 == 0 then Chrome.print("6", MON_COL[x] + 1, MON_PAIR_ROW) end
|
||||||
end
|
end
|
||||||
for y = 2, 7 do
|
for y = 2, 7 do
|
||||||
local row = LEVEL_ROW[y]
|
local pair = math.floor((y - 2) / 2)
|
||||||
|
local isBottom = ((y - 2) % 2 == 1)
|
||||||
|
local row = 3 + pair * 3 + (isBottom and 1 or 0)
|
||||||
Chrome.print(tostring(y - 1), LEVEL_COL, row)
|
Chrome.print(tostring(y - 1), LEVEL_COL, row)
|
||||||
if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end
|
if y % 2 == 0 then Chrome.print("9", LEVEL_PAIR_COL, row) end
|
||||||
for x = 2, 5 do
|
for x = 2, 5 do
|
||||||
-- A still-in-the-deck cell stands in for the card back until the art
|
|
||||||
-- lands. It cannot be '#': that is charmap.asm $54, the text command
|
|
||||||
-- that places "POKé", not a one-tile glyph.
|
|
||||||
local card = CardFlip.card(y - 2, x - 2)
|
local card = CardFlip.card(y - 2, x - 2)
|
||||||
Chrome.print(self.discarded[card] and " " or "?", CARD_COL[x], row)
|
Chrome.print(self.discarded[card] and " " or "?", MON_COL[x], row)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Draw the 11x12 board tilemap at (9, 0)
|
||||||
|
for ty = 0, 11 do
|
||||||
|
for tx = 0, 10 do
|
||||||
|
local idx = ty * 11 + tx + 1
|
||||||
|
local tileId = tm[idx]
|
||||||
|
local screenX = 9 + tx
|
||||||
|
local screenY = ty
|
||||||
|
|
||||||
|
-- Attribute palette
|
||||||
|
local pal = 0
|
||||||
|
if screenY >= 1 and screenY <= 2 then
|
||||||
|
if screenX == 12 or screenX == 13 then pal = 1 -- Pikachu
|
||||||
|
elseif screenX == 14 or screenX == 15 then pal = 2 -- Jigglypuff
|
||||||
|
elseif screenX == 16 or screenX == 17 then pal = 3 -- Poliwag
|
||||||
|
elseif screenX == 18 or screenX == 19 then pal = 4 -- Oddish
|
||||||
|
end
|
||||||
|
elseif screenX == 9 then
|
||||||
|
pal = 1 -- Lights
|
||||||
|
end
|
||||||
|
|
||||||
|
local colors = CARDFLIP_PALS.bg[pal]
|
||||||
|
s1.palette = colors
|
||||||
|
s2.palette = colors
|
||||||
|
s3.palette = colors
|
||||||
|
|
||||||
|
if screenX == 9 then
|
||||||
|
-- Column 9: Light buttons
|
||||||
|
if screenY == self.played then
|
||||||
|
sOn.palette = colors
|
||||||
|
sOn:draw(0, screenX, screenY)
|
||||||
|
else
|
||||||
|
sOff.palette = colors
|
||||||
|
sOff:draw(0, screenX, screenY)
|
||||||
|
end
|
||||||
|
elseif tileId >= 0x3e then
|
||||||
|
-- Board graphics from card_flip_2 (using accurate 2x2 header tile mapping)
|
||||||
|
local mappedId = HEADER_TILE_MAP[tileId] or (tileId - 0x3e)
|
||||||
|
s2:draw(mappedId, screenX, screenY)
|
||||||
|
elseif tileId < 0x3e then
|
||||||
|
-- Graphics from card_flip_1
|
||||||
|
s1:draw(tileId, screenX, screenY)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Draw discarded card blanking covers (each 2-tile wide stacked card cell is 16x12 px)
|
||||||
|
for y = 2, 7 do
|
||||||
|
local level = y - 2
|
||||||
|
local pair = math.floor(level / 2)
|
||||||
|
local isBottom = (level % 2 == 1)
|
||||||
|
local py = 24 + pair * 24 + (isBottom and 12 or 0)
|
||||||
|
for x = 2, 5 do
|
||||||
|
local mon = x - 2
|
||||||
|
local card = CardFlip.card(level, mon)
|
||||||
|
if self.discarded[card] then
|
||||||
|
-- Discarded cover over full 16x12 stacked card cell
|
||||||
|
G.setColor(49 / 255, 156 / 255, 66 / 255, 1)
|
||||||
|
G.rectangle("fill", MON_COL[x] * 8, py, 16, 12)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function CardFlip:cursorCell()
|
function CardFlip:cursorBounds()
|
||||||
local x, y = self.cursorX, self.cursorY
|
local x, y = self.cursorX, self.cursorY
|
||||||
local col
|
if y == 0 then
|
||||||
if x == 0 then col = LEVEL_PAIR_COL
|
-- Pokemon Pair: spans 4 columns (32px), 1 row (8px)
|
||||||
elseif x == 1 then col = LEVEL_COL
|
local px = (MON_COL[x] or 12) * 8
|
||||||
else col = MON_COL[x] end
|
local py = MON_PAIR_ROW * 8
|
||||||
local row
|
return px, py, 32, 8
|
||||||
if y == 0 then row = MON_PAIR_ROW
|
elseif y == 1 then
|
||||||
elseif y == 1 then row = MON_ROW
|
-- Single Pokemon: 2x2 tiles (16x16 px)
|
||||||
else row = LEVEL_ROW[y] end
|
local px = (MON_COL[x] or 12) * 8
|
||||||
return col, row
|
local py = MON_ROW * 8
|
||||||
|
return px, py, 16, 16
|
||||||
|
elseif x == 0 then
|
||||||
|
-- Level Pair: 1 column (8px), spans 24px across the full pair
|
||||||
|
local pair = math.floor((y - 2) / 2)
|
||||||
|
local px = LEVEL_PAIR_COL * 8
|
||||||
|
local py = 24 + pair * 24
|
||||||
|
return px, py, 8, 24
|
||||||
|
elseif x == 1 then
|
||||||
|
-- Single Level: 1 column (8px), 12px tall for each stacked card
|
||||||
|
local pair = math.floor((y - 2) / 2)
|
||||||
|
local isBottom = ((y - 2) % 2 == 1)
|
||||||
|
local px = LEVEL_COL * 8
|
||||||
|
local py = 24 + pair * 24 + (isBottom and 12 or 0)
|
||||||
|
return px, py, 8, 12
|
||||||
|
else
|
||||||
|
-- Exact Card: 2 columns (16px), 12px tall for each stacked card
|
||||||
|
local pair = math.floor((y - 2) / 2)
|
||||||
|
local isBottom = ((y - 2) % 2 == 1)
|
||||||
|
local px = (MON_COL[x] or 12) * 8
|
||||||
|
local py = 24 + pair * 24 + (isBottom and 12 or 0)
|
||||||
|
return px, py, 16, 12
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- CardFlip_DisplayCardFaceUp: the level digit at the box origin + (3,1) and the
|
local FACE_DOWN_TILES = {
|
||||||
-- 3x3 Pokepic one row further down.
|
{ 0x08, 0x09, 0x09, 0x09, 0x0a },
|
||||||
|
{ 0x0b, 0x28, 0x2b, 0x28, 0x0c },
|
||||||
|
{ 0x0b, 0x2c, 0x2d, 0x2e, 0x0c },
|
||||||
|
{ 0x0b, 0x2f, 0x30, 0x31, 0x0c },
|
||||||
|
{ 0x0b, 0x32, 0x33, 0x34, 0x0c },
|
||||||
|
{ 0x0d, 0x0e, 0x0e, 0x0e, 0x0f },
|
||||||
|
}
|
||||||
|
|
||||||
|
local FACE_UP_TILES = {
|
||||||
|
{ 0x18, 0x19, 0x19, 0x19, 0x1a },
|
||||||
|
{ 0x1b, 0x35, 0x28, 0x28, 0x1c },
|
||||||
|
{ 0x0b, 0x28, 0x28, 0x28, 0x0c },
|
||||||
|
{ 0x0b, 0x28, 0x28, 0x28, 0x0c },
|
||||||
|
{ 0x0b, 0x28, 0x28, 0x28, 0x0c },
|
||||||
|
{ 0x1d, 0x1e, 0x1e, 0x1e, 0x1f },
|
||||||
|
}
|
||||||
|
|
||||||
|
local MON_ANCHORS = {
|
||||||
|
[0] = 24, -- Pikachu (tiles 24..32 in card_flip_2)
|
||||||
|
[1] = 33, -- Jigglypuff (tiles 33..41 in card_flip_2)
|
||||||
|
[2] = 42, -- Poliwag (tiles 42..50 in card_flip_2)
|
||||||
|
[3] = 51, -- Oddish (tiles 51..59 in card_flip_2)
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Draw face-down, flipping, or face-up card at (2, 0) or (2, 6)
|
||||||
function CardFlip:drawCards()
|
function CardFlip:drawCards()
|
||||||
|
local s1, s2 = self:sheets()
|
||||||
|
|
||||||
for slot = 1, 2 do
|
for slot = 1, 2 do
|
||||||
local box = CARD_BOX[slot]
|
local box = CARD_BOX[slot]
|
||||||
Chrome.box(box.x, box.y, CARD_BOX_W, CARD_BOX_H)
|
local bx, by = box.x * 8, box.y * 8
|
||||||
local chosen = (slot - 1) == self.which
|
local chosen = (slot - 1) == self.which
|
||||||
if self.faceUp and chosen then
|
local isFlipping = (self.phase == "flipping") and chosen
|
||||||
Chrome.print(tostring(CardFlip.level(self.faceUp) + 1), box.x + 3,
|
local isFaceUp = (self.faceUp and chosen)
|
||||||
box.y + 1)
|
|
||||||
Chrome.print(CardFlip.MON_LABELS[CardFlip.mon(self.faceUp)],
|
if isFaceUp or (isFlipping and (self.flipTimer or 0) >= 4) then
|
||||||
box.x + 1, box.y + 3)
|
local activeCard = self.faceUp or self.targetCard or 0
|
||||||
elseif self.phase == "choose" and chosen then
|
local lvl = CardFlip.level(activeCard) + 1
|
||||||
Chrome.cursor(box.x, box.y + 3)
|
local mon = CardFlip.mon(activeCard)
|
||||||
|
local monPal = CARDFLIP_PALS.bg[mon + 1] or CARDFLIP_PALS.bg[1]
|
||||||
|
|
||||||
|
s1.palette = CARDFLIP_PALS.bg[0]
|
||||||
|
for cy = 1, 6 do
|
||||||
|
for cx = 1, 5 do
|
||||||
|
local tid = FACE_UP_TILES[cy][cx]
|
||||||
|
s1:draw(tid, box.x + cx - 1, box.y + cy - 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Level digit at (box.x + 3, box.y + 1)
|
||||||
|
if isFaceUp or (isFlipping and (self.flipTimer or 0) >= 8) then
|
||||||
|
Chrome.print(tostring(lvl), box.x + 3, box.y + 1)
|
||||||
|
|
||||||
|
-- Draw 3x3 Pokemon pic from card_flip_2 (s2) at (box.x + 1, box.y + 2)
|
||||||
|
s2.palette = monPal
|
||||||
|
local anchor = MON_ANCHORS[mon] or 24
|
||||||
|
for py = 0, 2 do
|
||||||
|
for px = 0, 2 do
|
||||||
|
local tid = anchor + py * 3 + px
|
||||||
|
s2:draw(tid, box.x + 1 + px, box.y + 2 + py)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
s1.palette = CARDFLIP_PALS.bg[0]
|
||||||
|
for cy = 1, 6 do
|
||||||
|
for cx = 1, 5 do
|
||||||
|
local tid = FACE_DOWN_TILES[cy][cx]
|
||||||
|
s1:draw(tid, box.x + cx - 1, box.y + cy - 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.phase == "choose" and chosen then
|
||||||
|
-- Authentic OAM red selection box around 5x6 card (40x48 px)
|
||||||
|
self:drawOamBox(bx, by, 40, 48)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function CardFlip:drawPanel()
|
function CardFlip:drawPanel()
|
||||||
Chrome.clear()
|
|
||||||
self:drawBoard()
|
self:drawBoard()
|
||||||
self:drawCards()
|
self:drawCards()
|
||||||
Chrome.textbox(COIN_BOX_X, COIN_BOX_Y, COIN_BOX_W - 2, COIN_BOX_H - 2)
|
|
||||||
Chrome.print("COIN", COIN_LABEL_X, COIN_LABEL_Y)
|
-- Dialogue / Message box at (0, 12), 10 wide, 6 tall (interior 8x4)
|
||||||
Chrome.print(Chrome.number(self:coins(), 4, true), COIN_VALUE_X, COIN_VALUE_Y)
|
|
||||||
if self.lines then
|
if self.lines then
|
||||||
Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2)
|
Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, 8, 4)
|
||||||
for i, line in ipairs(self.lines) do
|
for i, line in ipairs(self.lines) do
|
||||||
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
|
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Coin box at (9, 15), 11 wide, 3 tall (interior 9x1)
|
||||||
|
Chrome.textbox(COIN_BOX_X, COIN_BOX_Y, 9, 1)
|
||||||
|
Chrome.print("COIN", COIN_LABEL_X, COIN_LABEL_Y)
|
||||||
|
Chrome.print(Chrome.number(self:coins(), 4, true), COIN_VALUE_X, COIN_VALUE_Y)
|
||||||
|
|
||||||
if self.phase == "bet" then
|
if self.phase == "bet" then
|
||||||
local col, row = self:cursorCell()
|
self.betBlink = (self.betBlink or 0) + 1
|
||||||
Chrome.cursor(col - 1, row)
|
if (self.betBlink % 32) < 24 then
|
||||||
|
local px, py, w, h = self:cursorBounds()
|
||||||
|
self:drawOamBox(px, py, w, h)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
if self.phase == "ask" or self.phase == "again" then
|
if self.phase == "ask" or self.phase == "again" then
|
||||||
-- YesNoBox: a 6x5 box at (14,7) with YES at (16,8) and NO at (16,10).
|
-- YesNoBox: a 6x5 box at (14,7) with YES at (16,8) and NO at (16,10).
|
||||||
Chrome.textbox(14, 7, 4, 3)
|
Chrome.textbox(14, 7, 4, 3)
|
||||||
@@ -652,3 +982,4 @@ function CardFlip:drawWidescreen(winW, winH)
|
|||||||
end
|
end
|
||||||
|
|
||||||
return CardFlip
|
return CardFlip
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ function Chrome.fitOrigin(winW, winH, scale)
|
|||||||
local x, y, w, h = playfieldRect(winW, winH)
|
local x, y, w, h = playfieldRect(winW, winH)
|
||||||
return x + math.floor((w - Chrome.SCREEN_W * 8 * scale) / 2),
|
return x + math.floor((w - Chrome.SCREEN_W * 8 * scale) / 2),
|
||||||
y + math.floor((h - Chrome.SCREEN_H * 8 * scale) / 2)
|
y + math.floor((h - Chrome.SCREEN_H * 8 * scale) / 2)
|
||||||
|
- Chrome.positionLift(winW, winH, scale)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Chrome.positionLift(winW, winH, scale)
|
||||||
|
local ok, ScreenPosition = pcall(require, "src.core.ScreenPosition")
|
||||||
|
if not ok or ScreenPosition.skinActive(winW, winH) then return 0 end
|
||||||
|
local _, _, _, h = playfieldRect(winW, winH)
|
||||||
|
return ScreenPosition.lift(h, Chrome.SCREEN_H * 8 * (scale
|
||||||
|
or Chrome.fitScale(winW, winH)), ScreenPosition.safeTop())
|
||||||
end
|
end
|
||||||
|
|
||||||
-- A bordered box, tile coords. Leaves the draw color black for text.
|
-- A bordered box, tile coords. Leaves the draw color black for text.
|
||||||
|
|||||||
@@ -189,11 +189,18 @@ defineString("CREDIT_END", "END")
|
|||||||
|
|
||||||
-- STAFF and everything after it is a heading. Anything BELOW this id is a
|
-- STAFF and everything after it is a heading. Anything BELOW this id is a
|
||||||
-- person as far as ParseCredits is concerned.
|
-- person as far as ParseCredits is concerned.
|
||||||
Credits.STAFF = defineString("STAFF", {
|
-- Credits_Staff differs per edition, including the centring spaces
|
||||||
|
-- (data/credits_strings.asm:54-60).
|
||||||
|
Credits.STAFF = defineString("STAFF",
|
||||||
|
require("src.core.GameVersion").get() == "silver" and {
|
||||||
|
" #MON",
|
||||||
|
" SILVER VERSION",
|
||||||
|
" PORT STAFF",
|
||||||
|
} or {
|
||||||
" #MON",
|
" #MON",
|
||||||
" GOLD VERSION",
|
" GOLD VERSION",
|
||||||
" PORT STAFF",
|
" PORT STAFF",
|
||||||
})
|
})
|
||||||
defineString("DIRECTOR", " DIRECTOR")
|
defineString("DIRECTOR", " DIRECTOR")
|
||||||
defineString("PROGRAMMING", " PROGRAMMING")
|
defineString("PROGRAMMING", " PROGRAMMING")
|
||||||
defineString("ENGINE_DESIGN", " ENGINE DESIGN")
|
defineString("ENGINE_DESIGN", " ENGINE DESIGN")
|
||||||
|
|||||||
@@ -147,8 +147,8 @@ function GoldSilverIntro.new(game, opts)
|
|||||||
-- the movie's full 2335 frames over an empty screen and reads exactly
|
-- the movie's full 2335 frames over an empty screen and reads exactly
|
||||||
-- like "the intro does not work". Say so instead: the fix is a
|
-- like "the intro does not work". Say so instead: the fix is a
|
||||||
-- re-import, and nothing else in the boot chain will mention it.
|
-- re-import, and nothing else in the boot chain will mention it.
|
||||||
Logger.warn("gold intro: no intro.lua in the cache -- re-import Gold "
|
Logger.warn("gen2 intro: no intro.lua in the cache -- re-import "
|
||||||
.. "or the movie plays blank")
|
.. "this version or the movie plays blank")
|
||||||
end
|
end
|
||||||
self.images = {}
|
self.images = {}
|
||||||
self.sheets = {}
|
self.sheets = {}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function MainMenu.new(game, opts)
|
|||||||
|
|
||||||
self.save = opts.save
|
self.save = opts.save
|
||||||
if self.save == nil and opts.hasSave ~= false then
|
if self.save == nil and opts.hasSave ~= false then
|
||||||
local loaded = Save.load("gold")
|
local loaded = Save.load()
|
||||||
self.save = loaded
|
self.save = loaded
|
||||||
end
|
end
|
||||||
self.hasSave = opts.hasSave
|
self.hasSave = opts.hasSave
|
||||||
|
|||||||
@@ -34,8 +34,14 @@ local NamePick = {}
|
|||||||
NamePick.__index = NamePick
|
NamePick.__index = NamePick
|
||||||
NamePick.isOpaque = true
|
NamePick.isOpaque = true
|
||||||
|
|
||||||
-- data/player_names.asm PlayerNameArray, Gold's half of the IF.
|
-- data/player_names.asm PlayerNameArray, one half of the IF per edition.
|
||||||
local PRESETS = { "GOLD", "HIRO", "TAYLOR", "KARL" }
|
local PRESETS = { "GOLD", "HIRO", "TAYLOR", "KARL" }
|
||||||
|
local PRESETS_SILVER = { "SILVER", "KAMON", "OSCAR", "MAX" }
|
||||||
|
|
||||||
|
local function presetsFor()
|
||||||
|
local silver = require("src.core.GameVersion").get() == "silver"
|
||||||
|
return silver and PRESETS_SILVER or PRESETS
|
||||||
|
end
|
||||||
|
|
||||||
-- menu_coords 0, 0, 10, TEXTBOX_Y - 1 (TEXTBOX_Y = 12).
|
-- menu_coords 0, 0, 10, TEXTBOX_Y - 1 (TEXTBOX_Y = 12).
|
||||||
local BOX_X1, BOX_Y1, BOX_X2, BOX_Y2 = 0, 0, 10, 11
|
local BOX_X1, BOX_Y1, BOX_X2, BOX_Y2 = 0, 0, 10, 11
|
||||||
@@ -60,7 +66,7 @@ function NamePick.new(game, opts)
|
|||||||
self.game = game
|
self.game = game
|
||||||
self.onDone = opts.onDone
|
self.onDone = opts.onDone
|
||||||
self.items = { "NEW NAME" }
|
self.items = { "NEW NAME" }
|
||||||
for _, name in ipairs(opts.presets or PRESETS) do
|
for _, name in ipairs(opts.presets or presetsFor()) do
|
||||||
self.items[#self.items + 1] = name
|
self.items[#self.items + 1] = name
|
||||||
end
|
end
|
||||||
-- `db 1 ; default option`: the cursor starts on NEW NAME, not on a preset.
|
-- `db 1 ; default option`: the cursor starts on NEW NAME, not on a preset.
|
||||||
@@ -106,7 +112,7 @@ function NamePick:openNaming()
|
|||||||
if name and #name > 0 then
|
if name and #name > 0 then
|
||||||
self:choose(name)
|
self:choose(name)
|
||||||
else
|
else
|
||||||
self:choose(self.items[2] or "GOLD")
|
self:choose(self.items[2] or presetsFor()[1])
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
@@ -146,7 +152,7 @@ function NamePick:update(_dt)
|
|||||||
if self.fontOk then
|
if self.fontOk then
|
||||||
self:openNaming()
|
self:openNaming()
|
||||||
else
|
else
|
||||||
self:choose(self.items[2] or "GOLD")
|
self:choose(self.items[2] or presetsFor()[1])
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
-- A preset returns through MovePlayerPicLeft, so the pic walks back
|
-- A preset returns through MovePlayerPicLeft, so the pic walks back
|
||||||
@@ -227,5 +233,7 @@ function NamePick:drawWidescreen(winW, winH)
|
|||||||
end
|
end
|
||||||
|
|
||||||
NamePick.PRESETS = PRESETS
|
NamePick.PRESETS = PRESETS
|
||||||
|
NamePick.PRESETS_SILVER = PRESETS_SILVER
|
||||||
|
NamePick.presetsFor = presetsFor
|
||||||
|
|
||||||
return NamePick
|
return NamePick
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ end
|
|||||||
|
|
||||||
-- Naming presets are boot config the same way Gen 1 reads them
|
-- Naming presets are boot config the same way Gen 1 reads them
|
||||||
-- (field.boot.namePresets), so a total conversion that replaces the list once
|
-- (field.boot.namePresets), so a total conversion that replaces the list once
|
||||||
-- replaces it for both games; NamePick.PRESETS (data/player_names.asm
|
-- replaces it for both games; NamePick.presetsFor (data/player_names.asm
|
||||||
-- PlayerNameArray) is Gold's fallback.
|
-- PlayerNameArray) is the running edition's fallback.
|
||||||
local function namePresets(game, who, fallback)
|
local function namePresets(game, who, fallback)
|
||||||
local boot = game and game.data and game.data.field
|
local boot = game and game.data and game.data.field
|
||||||
and game.data.field.boot
|
and game.data.field.boot
|
||||||
@@ -351,9 +351,9 @@ function OakSpeech:openNamePick(step)
|
|||||||
picColors = self.playerColors,
|
picColors = self.playerColors,
|
||||||
presets = step.presets
|
presets = step.presets
|
||||||
or namePresets(self.game, step.presetsWho or step.who or "player",
|
or namePresets(self.game, step.presetsWho or step.who or "player",
|
||||||
step.presetsFallback or NamePick.PRESETS),
|
step.presetsFallback or NamePick.presetsFor()),
|
||||||
onDone = function(name)
|
onDone = function(name)
|
||||||
name = name or "GOLD"
|
name = name or NamePick.presetsFor()[1]
|
||||||
self.game.save.player.name = name
|
self.game.save.player.name = name
|
||||||
self.game.stack:pop() -- NamePick
|
self.game.stack:pop() -- NamePick
|
||||||
self.busy = false
|
self.busy = false
|
||||||
|
|||||||
@@ -196,6 +196,15 @@ local ROWS = {
|
|||||||
return VideoMode.normalize(options.videoMode) == "borderless"
|
return VideoMode.normalize(options.videoMode) == "borderless"
|
||||||
and "FULL" or "WINDOWED"
|
and "FULL" or "WINDOWED"
|
||||||
end },
|
end },
|
||||||
|
{ label = "SCREEN POS", key = "screenPos", port = true,
|
||||||
|
cycle = function(options, delta)
|
||||||
|
local ScreenPosition = require("src.core.ScreenPosition")
|
||||||
|
options.screenPos = ScreenPosition.cycle(options.screenPos, delta)
|
||||||
|
ScreenPosition.setMode(options.screenPos)
|
||||||
|
end,
|
||||||
|
text = function(options)
|
||||||
|
return require("src.core.ScreenPosition").label(options.screenPos)
|
||||||
|
end },
|
||||||
{ id = "touchControls", label = "TOUCH PAD", port = true,
|
{ id = "touchControls", label = "TOUCH PAD", port = true,
|
||||||
text = function(options)
|
text = function(options)
|
||||||
local tc = options.touchControls
|
local tc = options.touchControls
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ function PcMenu:beginChangeBox(index)
|
|||||||
self.saveTimer = 0
|
self.saveTimer = 0
|
||||||
self.saved = nil
|
self.saved = nil
|
||||||
local existed = self.saveExists
|
local existed = self.saveExists
|
||||||
if existed == nil then existed = Save.exists("gold") end
|
if existed == nil then existed = Save.exists() end
|
||||||
self.existed = existed
|
self.existed = existed
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ function SaveMenu.new(game, opts)
|
|||||||
self.onDone = opts.onDone
|
self.onDone = opts.onDone
|
||||||
self.writer = opts.writer or Save.save
|
self.writer = opts.writer or Save.save
|
||||||
local existed = opts.existed
|
local existed = opts.existed
|
||||||
if existed == nil then existed = Save.exists("gold") end
|
if existed == nil then existed = Save.exists() end
|
||||||
self.existed = existed
|
self.existed = existed
|
||||||
-- confirm -> overwrite (only when a file exists) -> saving -> done
|
-- confirm -> overwrite (only when a file exists) -> saving -> done
|
||||||
self.phase = "confirm"
|
self.phase = "confirm"
|
||||||
|
|||||||
@@ -219,10 +219,16 @@ local TWO_LINES = {
|
|||||||
|
|
||||||
function SlotMachine.matchFirstTwo(bet, r1, r2)
|
function SlotMachine.matchFirstTwo(bet, r1, r2)
|
||||||
local building = SlotMachine.NO_MATCH
|
local building = SlotMachine.NO_MATCH
|
||||||
|
local matchingSevens = false
|
||||||
for _, line in ipairs(TWO_LINES[(bet or 0) % 4] or {}) do
|
for _, line in ipairs(TWO_LINES[(bet or 0) % 4] or {}) do
|
||||||
if r1[line[1]] == r2[line[2]] then building = r1[line[1]] end
|
if r1[line[1]] == r2[line[2]] then
|
||||||
|
building = r1[line[1]]
|
||||||
|
if building == SlotMachine.SEVEN then
|
||||||
|
matchingSevens = true
|
||||||
end
|
end
|
||||||
return building, building == SlotMachine.SEVEN
|
end
|
||||||
|
end
|
||||||
|
return building, matchingSevens
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------------------------------------------------------------------- bias
|
-- ------------------------------------------------------------------- bias
|
||||||
@@ -403,13 +409,14 @@ end
|
|||||||
-- matching SEVENs on a line this bet buys.
|
-- matching SEVENs on a line this bet buys.
|
||||||
function SlotMachine.spinReel2ToSevens(position, bet, stopped1)
|
function SlotMachine.spinReel2ToSevens(position, bet, stopped1)
|
||||||
local strip = SlotMachine.REELS[2]
|
local strip = SlotMachine.REELS[2]
|
||||||
|
local pos = SlotMachine.advance(position)
|
||||||
for _ = 1, SEARCH_LIMIT do
|
for _ = 1, SEARCH_LIMIT do
|
||||||
local window = { SlotMachine.window(strip, position) }
|
local window = { SlotMachine.window(strip, pos) }
|
||||||
local building, sevens = SlotMachine.matchFirstTwo(bet, stopped1, window)
|
local building, sevens = SlotMachine.matchFirstTwo(bet, stopped1, window)
|
||||||
if building ~= SlotMachine.NO_MATCH and sevens then return position end
|
if building ~= SlotMachine.NO_MATCH and sevens then return pos end
|
||||||
position = SlotMachine.advance(position)
|
pos = SlotMachine.advance(pos)
|
||||||
end
|
end
|
||||||
return position
|
return pos
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------------------------------------------------------- reel 3's theatre
|
-- ------------------------------------------------------- reel 3's theatre
|
||||||
@@ -580,8 +587,8 @@ end
|
|||||||
-- ------------------------------------------------------------------ layout
|
-- ------------------------------------------------------------------ layout
|
||||||
local COINS_X, COINS_Y = 5, 1
|
local COINS_X, COINS_Y = 5, 1
|
||||||
local PAYOUT_X, PAYOUT_Y = 11, 1
|
local PAYOUT_X, PAYOUT_Y = 11, 1
|
||||||
-- REEL_X_COORD / TILE_WIDTH.
|
-- REEL_X_COORD / TILE_WIDTH (5, 9, 13) for the 3 reel apertures (columns 5-6, 9-10, 13-14)
|
||||||
local REEL_X = { 6, 10, 14 }
|
local REEL_X = { 5, 9, 13 }
|
||||||
-- Slots_UpdateReelPositionAndOAM's y ladder, converted from OAM space (which
|
-- Slots_UpdateReelPositionAndOAM's y ladder, converted from OAM space (which
|
||||||
-- sits 16px above the screen) to tile rows: bottom, middle, top, and the
|
-- sits 16px above the screen) to tile rows: bottom, middle, top, and the
|
||||||
-- fourth symbol that only half shows.
|
-- fourth symbol that only half shows.
|
||||||
@@ -617,7 +624,7 @@ SlotMachine.TEXTS = TEXTS
|
|||||||
-- _SlotsLinedUpText: "lined up!" / "Won @<wStringBuffer2> coins!", with the
|
-- _SlotsLinedUpText: "lined up!" / "Won @<wStringBuffer2> coins!", with the
|
||||||
-- matched symbol's 2x2 tiles printed to its left by .Text_PrintPayout.
|
-- matched symbol's 2x2 tiles printed to its left by .Text_PrintPayout.
|
||||||
local function linedUpLines(payout)
|
local function linedUpLines(payout)
|
||||||
return { "lined up!", ("Won %d coins!"):format(payout) }
|
return { " lined up!", ("Won %d coins!"):format(payout) }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Slots_PlaySFX's labels, spelled the pokegold way (Sound.GEN2_ALIASES is what
|
-- Slots_PlaySFX's labels, spelled the pokegold way (Sound.GEN2_ALIASES is what
|
||||||
@@ -729,30 +736,41 @@ function SlotMachine:startSpin()
|
|||||||
self.reel = 1
|
self.reel = 1
|
||||||
self.delay = 32
|
self.delay = 32
|
||||||
self.message = TEXTS.start
|
self.message = TEXTS.start
|
||||||
|
self.stopped = nil
|
||||||
|
self.matched = SlotMachine.NO_MATCH
|
||||||
|
self.matchingSevens = false
|
||||||
|
self.reel3Action = nil
|
||||||
|
self.golemAnim = nil
|
||||||
|
self.chanseyAnim = nil
|
||||||
|
self.reel2Pause = nil
|
||||||
for i = 1, 3 do
|
for i = 1, 3 do
|
||||||
self.rate[i] = 4 -- ReelAction_NormalRate
|
self.rate[i] = 4 -- ReelAction_NormalRate
|
||||||
self.stops[i] = nil
|
self.stops[i] = nil
|
||||||
|
self.distance[i] = 0
|
||||||
end
|
end
|
||||||
self:sfx(SFX_START)
|
self:sfx(SFX_START)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Slots_SpinReel, per reel per frame: the action jumptable runs only on a slot
|
-- Slots_SpinReel, per reel per frame: the action jumptable runs only on a slot
|
||||||
-- boundary, and the position advances when the distance's low nibble wraps.
|
-- boundary, and the position advances whenever 16 pixels are traversed.
|
||||||
function SlotMachine:spinReels()
|
function SlotMachine:spinReels()
|
||||||
for i = 1, 3 do
|
for i = 1, 3 do
|
||||||
local rate = self.rate[i]
|
local rate = self.rate[i]
|
||||||
if rate > 0 then
|
if rate > 0 then
|
||||||
self.distance[i] = (self.distance[i] + rate) % 256
|
self.distance[i] = (self.distance[i] or 0) + rate
|
||||||
if self.distance[i] % 16 == 0 then
|
while self.distance[i] >= 16 do
|
||||||
|
self.distance[i] = self.distance[i] - 16
|
||||||
self.positions[i] = SlotMachine.advance(self.positions[i])
|
self.positions[i] = SlotMachine.advance(self.positions[i])
|
||||||
-- A reel with a resting slot chosen stops the moment it reaches it.
|
-- A reel with a resting slot chosen stops the moment it reaches it.
|
||||||
if self.stops[i] and self.positions[i] == self.stops[i] then
|
if self.stops[i] and self.positions[i] == self.stops[i] then
|
||||||
self.rate[i] = 0
|
self.rate[i] = 0
|
||||||
|
self.distance[i] = 0
|
||||||
self.stopped = self.stopped or {}
|
self.stopped = self.stopped or {}
|
||||||
self.stopped[i] = { SlotMachine.window(SlotMachine.REELS[i],
|
self.stopped[i] = { SlotMachine.window(SlotMachine.REELS[i],
|
||||||
self.positions[i]) }
|
self.positions[i]) }
|
||||||
self:sfx(SFX_STOP)
|
self:sfx(SFX_STOP)
|
||||||
self:reelStopped(i)
|
self:reelStopped(i)
|
||||||
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -775,11 +793,17 @@ function SlotMachine:pressStop()
|
|||||||
self.stops[1] = SlotMachine.stopReel1(here, self.bias)
|
self.stops[1] = SlotMachine.stopReel1(here, self.bias)
|
||||||
elseif i == 2 then
|
elseif i == 2 then
|
||||||
local r1 = self.stopped[1]
|
local r1 = self.stopped[1]
|
||||||
if SlotMachine.reel2SkipsToSeven(self.bet, self.bias, r1, self.random) then
|
local hereWindow = { SlotMachine.window(SlotMachine.REELS[2], here) }
|
||||||
|
local _, hereSevens = SlotMachine.matchFirstTwo(self.bet, r1, hereWindow)
|
||||||
|
local doSkip = SlotMachine.reel2SkipsToSeven(self.bet, self.bias, r1, self.random)
|
||||||
|
if hereSevens and not doSkip then
|
||||||
|
self.stops[2] = here
|
||||||
|
elseif doSkip then
|
||||||
-- ReelAction_SetUpReel2SkipTo7 pauses the reel for 32 frames and then
|
-- ReelAction_SetUpReel2SkipTo7 pauses the reel for 32 frames and then
|
||||||
-- fast-spins it at double rate; the pause is the tell.
|
-- fast-spins it at double rate; the pause is the tell.
|
||||||
self.stops[2] = SlotMachine.spinReel2ToSevens(here, self.bet, r1)
|
self.stops[2] = SlotMachine.spinReel2ToSevens(here, self.bet, r1)
|
||||||
self.rate[2] = 8
|
self.reel2Pause = 32
|
||||||
|
self.rate[2] = 0 -- paused during the 32-frame tell
|
||||||
else
|
else
|
||||||
self.stops[2] = SlotMachine.stopReel2(here, self.bias, self.bet, r1)
|
self.stops[2] = SlotMachine.stopReel2(here, self.bias, self.bet, r1)
|
||||||
end
|
end
|
||||||
@@ -789,33 +813,55 @@ function SlotMachine:pressStop()
|
|||||||
self.matchingSevens = sevens
|
self.matchingSevens = sevens
|
||||||
local action = SlotMachine.reel3Action(sevens, self.bias, self.random)
|
local action = SlotMachine.reel3Action(sevens, self.bias, self.random)
|
||||||
self.reel3Action = action
|
self.reel3Action = action
|
||||||
if action == SlotMachine.REEL3_STOP then
|
if action == SlotMachine.REEL3_STOP or action == "stop" then
|
||||||
self.stops[3] = SlotMachine.stopReel3(here, self.bias, self.bet, r1, r2)
|
self.stops[3] = SlotMachine.stopReel3(here, self.bias, self.bet, r1, r2)
|
||||||
elseif action == SlotMachine.REEL3_SLOW then
|
elseif action == SlotMachine.REEL3_SLOW or action == "slowAdvance" then
|
||||||
self.stops[3] = SlotMachine.slowAdvance(here, self.bias, self.bet, r1, r2)
|
self.stops[3] = SlotMachine.slowAdvance(here, self.bias, self.bet, r1, r2)
|
||||||
self.rate[3] = 1 -- ReelAction_QuarterRate
|
self.rate[3] = 1 -- ReelAction_QuarterRate slow crawl
|
||||||
elseif action == SlotMachine.REEL3_GOLEM then
|
elseif action == SlotMachine.REEL3_GOLEM or action == "golem" then
|
||||||
local count = SlotMachine.golemCount(here, self.bias, self.bet, r1, r2,
|
local count = SlotMachine.golemCount(here, self.bias, self.bet, r1, r2,
|
||||||
self.random)
|
self.random)
|
||||||
|
if count == 0 then count = 3 end
|
||||||
local target = here
|
local target = here
|
||||||
for _ = 1, count do target = SlotMachine.advance(target) end
|
for _ = 1, count do target = SlotMachine.advance(target) end
|
||||||
self.stops[3] = target
|
self.stops[3] = target
|
||||||
self.golems = count
|
self.golems = count
|
||||||
self.rate[3] = 8
|
self.golemAnim = {
|
||||||
|
count = count,
|
||||||
|
state = "falling",
|
||||||
|
var1 = 48,
|
||||||
|
x = 100,
|
||||||
|
y = 44 - 112,
|
||||||
|
animFrame = 0,
|
||||||
|
animTimer = 0,
|
||||||
|
}
|
||||||
|
self.rate[3] = 0 -- reel 3 stepped by each golem impact
|
||||||
else
|
else
|
||||||
local target = SlotMachine.eggDrops(here, self.bet, r1, r2)
|
local target = SlotMachine.eggDrops(here, self.bet, r1, r2)
|
||||||
self.stops[3] = target
|
self.stops[3] = target
|
||||||
self.rate[3] = 16 -- ReelAction_QuadrupleRate, the egg drop
|
self.chanseyAnim = {
|
||||||
|
state = "walking",
|
||||||
|
xcoord = 0,
|
||||||
|
x = -24,
|
||||||
|
y = 44,
|
||||||
|
animTimer = 0,
|
||||||
|
animPose = 0,
|
||||||
|
}
|
||||||
|
self.rate[3] = 0 -- paused until Chansey drops egg
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- A reel already sitting on its resting slot has nowhere to turn.
|
-- A reel already sitting on its resting slot has nowhere to turn.
|
||||||
|
-- Only trigger immediate halt if no active pause tell or special animation is running.
|
||||||
|
if not self.reel2Pause and not self.golemAnim and not self.chanseyAnim then
|
||||||
if self.stops[i] == self.positions[i] then
|
if self.stops[i] == self.positions[i] then
|
||||||
self.rate[i] = 0
|
self.rate[i] = 0
|
||||||
|
self.distance[i] = 0
|
||||||
self.stopped = self.stopped or {}
|
self.stopped = self.stopped or {}
|
||||||
self.stopped[i] = self:reelWindow(i)
|
self.stopped[i] = self:reelWindow(i)
|
||||||
self:sfx(SFX_STOP)
|
self:sfx(SFX_STOP)
|
||||||
self:reelStopped(i)
|
self:reelStopped(i)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function SlotMachine:reelStopped(i)
|
function SlotMachine:reelStopped(i)
|
||||||
@@ -823,6 +869,9 @@ function SlotMachine:reelStopped(i)
|
|||||||
self.reel = i + 1
|
self.reel = i + 1
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
self.golemAnim = nil
|
||||||
|
self.chanseyAnim = nil
|
||||||
|
self.reel2Pause = nil
|
||||||
-- SlotsAction_FlashIfWin: a win flashes the object palette for 16 frames
|
-- SlotsAction_FlashIfWin: a win flashes the object palette for 16 frames
|
||||||
-- before the payout is counted out; a loss skips straight past it.
|
-- before the payout is counted out; a loss skips straight past it.
|
||||||
local r1, r2, r3 = self.stopped[1], self.stopped[2], self.stopped[3]
|
local r1, r2, r3 = self.stopped[1], self.stopped[2], self.stopped[3]
|
||||||
@@ -906,13 +955,134 @@ function SlotMachine:update(_dt)
|
|||||||
if phase == "spinning" then
|
if phase == "spinning" then
|
||||||
-- SlotsAction_WaitStart clears hJoypadSum first, so a press held from the
|
-- SlotsAction_WaitStart clears hJoypadSum first, so a press held from the
|
||||||
-- bet menu cannot stop reel one.
|
-- bet menu cannot stop reel one.
|
||||||
if self.delay > 0 then
|
if (self.delay or 0) > 0 then
|
||||||
self.delay = self.delay - 1
|
self.delay = self.delay - 1
|
||||||
self:spinReels()
|
self:spinReels()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if self.reel2Pause and self.reel2Pause > 0 then
|
||||||
|
self.reel2Pause = self.reel2Pause - 1
|
||||||
|
if self.reel2Pause <= 0 then
|
||||||
|
self.reel2Pause = nil
|
||||||
|
self.rate[2] = 8 -- resume fast-spin after the 32-frame tell
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.golemAnim then
|
||||||
|
local g = self.golemAnim
|
||||||
|
-- Cycle animation frames every 8 ticks (~7.5 fps) matching the original pacing
|
||||||
|
g.animTimer = (g.animTimer or 0) + 1
|
||||||
|
if g.animTimer >= 8 then
|
||||||
|
g.animTimer = 0
|
||||||
|
g.animFrame = ((g.animFrame or 0) + 1) % 4
|
||||||
|
end
|
||||||
|
|
||||||
|
if g.state == "falling" then
|
||||||
|
if g.var1 > 32 then
|
||||||
|
g.var1 = g.var1 - 1
|
||||||
|
local angle = (g.var1 * math.pi) / 32
|
||||||
|
local yOffset = math.floor(112 * math.sin(angle) + 0.5)
|
||||||
|
g.y = 44 + yOffset
|
||||||
|
g.x = 100
|
||||||
|
else
|
||||||
|
-- Landed on Reel 3!
|
||||||
|
g.y = 44
|
||||||
|
g.x = 100
|
||||||
|
g.state = "rolling"
|
||||||
|
g.xoffset = 0
|
||||||
|
g.animTimer = 0
|
||||||
|
g.animFrame = 0
|
||||||
|
self:sfx("Sfx_PlacePuzzlePieceDown")
|
||||||
|
-- Advance reel 3 by 1 slot per Golem impact
|
||||||
|
self.positions[3] = SlotMachine.advance(self.positions[3])
|
||||||
|
self.distance[3] = 0
|
||||||
|
end
|
||||||
|
elseif g.state == "rolling" then
|
||||||
|
g.xoffset = (g.xoffset or 0) + 1
|
||||||
|
g.x = 100 - g.xoffset
|
||||||
|
|
||||||
|
if g.xoffset >= 88 then
|
||||||
|
-- Rolled past reel 1 (100 - 88 = 12px) off the screen -> restart or end
|
||||||
|
g.count = g.count - 1
|
||||||
|
if g.count > 0 then
|
||||||
|
g.state = "falling"
|
||||||
|
g.var1 = 48
|
||||||
|
g.x = 100
|
||||||
|
g.y = 44 - 112
|
||||||
|
else
|
||||||
|
-- All Golems finished; halt reel 3 at target
|
||||||
|
self.golemAnim = nil
|
||||||
|
self.rate[3] = 0
|
||||||
|
self.distance[3] = 0
|
||||||
|
self.stopped = self.stopped or {}
|
||||||
|
self.stopped[3] = self:reelWindow(3)
|
||||||
|
self:sfx(SFX_STOP)
|
||||||
|
self:reelStopped(3)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if self.chanseyAnim then
|
||||||
|
local c = self.chanseyAnim
|
||||||
|
if c.state == "walking" then
|
||||||
|
c.xcoord = (c.xcoord or 0) + 1
|
||||||
|
c.x = c.xcoord - 24
|
||||||
|
c.y = 44
|
||||||
|
|
||||||
|
-- Cycle walking poses 0->1->2->3 (maps to Chansey 1->2->3->4) every 6 frames
|
||||||
|
c.animTimer = (c.animTimer or 0) + 1
|
||||||
|
if c.animTimer >= 6 then
|
||||||
|
c.animTimer = 0
|
||||||
|
c.animPose = ((c.animPose or 0) + 1) % 4
|
||||||
|
end
|
||||||
|
|
||||||
|
if c.xcoord % 16 == 0 then self:sfx("Sfx_JumpOverLedge") end
|
||||||
|
|
||||||
|
if c.x >= 88 then
|
||||||
|
-- Reached reel 3! Switch to tell pause (pose 4 = arm raised)
|
||||||
|
c.x = 88
|
||||||
|
c.state = "egg_pause"
|
||||||
|
c.delay = 14
|
||||||
|
c.animPose = 3
|
||||||
|
end
|
||||||
|
elseif c.state == "egg_pause" then
|
||||||
|
c.delay = (c.delay or 14) - 1
|
||||||
|
if c.delay <= 0 then
|
||||||
|
-- Switch to Chansey 5 (egg drop pose) and spawn egg
|
||||||
|
c.animPose = 4
|
||||||
|
c.state = "egg_drop"
|
||||||
|
c.eggTimer = 0
|
||||||
|
c.eggStartX = c.x + 14
|
||||||
|
c.eggStartY = c.y + 8
|
||||||
|
c.eggX = c.eggStartX
|
||||||
|
c.eggY = c.eggStartY
|
||||||
|
c.eggVisible = true
|
||||||
|
self:sfx("Sfx_Present")
|
||||||
|
end
|
||||||
|
elseif c.state == "egg_drop" then
|
||||||
|
c.eggTimer = (c.eggTimer or 0) + 1
|
||||||
|
local t = c.eggTimer / 16
|
||||||
|
if t > 1 then t = 1 end
|
||||||
|
c.eggX = c.eggStartX + t * (108 - c.eggStartX)
|
||||||
|
c.eggY = c.eggStartY + t * (56 - c.eggStartY) - math.sin(t * math.pi) * 8
|
||||||
|
|
||||||
|
if c.eggTimer >= 16 then
|
||||||
|
-- Egg landed on Reel 3!
|
||||||
|
c.eggVisible = false
|
||||||
|
c.state = "spinning"
|
||||||
|
self:sfx("Sfx_PlacePuzzlePieceDown")
|
||||||
|
self.rate[3] = 16 -- fast drop reel 3 to jackpot
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
self.message = nil
|
self.message = nil
|
||||||
if input:wasPressed("a") then self:pressStop() end
|
if input:wasPressed("a") then
|
||||||
|
if not self.stops[self.reel] and not self.reel2Pause and not self.golemAnim and not self.chanseyAnim then
|
||||||
|
self:pressStop()
|
||||||
|
end
|
||||||
|
end
|
||||||
self:spinReels()
|
self:spinReels()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -959,57 +1129,302 @@ end
|
|||||||
|
|
||||||
-- ------------------------------------------------------------------- draw
|
-- ------------------------------------------------------------------- draw
|
||||||
--
|
--
|
||||||
-- The cart's reel art is unextracted (see the header), so a symbol draws as its
|
-- Authentic Color Game Boy palettes and tile graphics matching pret/pokegold
|
||||||
-- two-letter label inside a 2x2 cell. A `slots` entry in menu_gfx.lua switches
|
local TileSheet = require("src.ui.gen2.TileSheet")
|
||||||
-- this to the real tiles without any other change.
|
local GbcPalette = require("src.render.GbcPalette")
|
||||||
function SlotMachine:sheet()
|
|
||||||
if self.sheetCache == nil then
|
local GBC_PALS = {
|
||||||
local data = self.game and self.game.data
|
bg = {
|
||||||
local gfx = data and data.gen2MenuGfx and data.gen2MenuGfx.slots
|
[0] = { { 255, 255, 255 }, { 198, 206, 231 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 0: Base Frame
|
||||||
if gfx and gfx.image then
|
[1] = { { 255, 255, 255 }, { 247, 82, 49 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 1: Vileplume / Active Lights
|
||||||
local TileSheet = require("src.ui.gen2.TileSheet")
|
[2] = { { 255, 255, 255 }, { 123, 255, 0 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 2: Bet 3 Indicators
|
||||||
self.sheetCache = TileSheet.new({ path = gfx.image, wide = gfx.wide or 16,
|
[3] = { { 255, 255, 255 }, { 255, 123, 255 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 3: Bet 2 Indicators
|
||||||
firstTile = gfx.firstTile or 0 })
|
[4] = { { 255, 255, 255 }, { 123, 173, 255 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 4: Bet 1 Indicators
|
||||||
|
[5] = { { 255, 255, 90 }, { 255, 255, 49 }, { 198, 198, 74 }, { 0, 0, 0 } }, -- 5: Yellow Highlights
|
||||||
|
[6] = { { 255, 255, 255 }, { 132, 156, 239 }, { 206, 181, 0 }, { 0, 0, 0 } }, -- 6: Textbox frame
|
||||||
|
[7] = { { 255, 255, 255 }, { 173, 173, 173 }, { 107, 107, 107 }, { 0, 0, 0 } }, -- 7: Inactive / Gray
|
||||||
|
},
|
||||||
|
obj = {
|
||||||
|
[0] = { { 255, 255, 255 }, { 247, 82, 49 }, { 255, 0, 0 }, { 0, 0, 0 } }, -- 0: Seven (Red)
|
||||||
|
[1] = { { 255, 255, 255 }, { 99, 206, 8 }, { 41, 115, 0 }, { 0, 0, 0 } }, -- 1: Pokeball (Green/Red)
|
||||||
|
[2] = { { 255, 255, 255 }, { 99, 206, 8 }, { 247, 82, 49 }, { 0, 0, 0 } }, -- 2: Cherry
|
||||||
|
[3] = { { 255, 255, 255 }, { 255, 255, 49 }, { 165, 123, 24 }, { 0, 0, 0 } }, -- 3: Pikachu (Yellow)
|
||||||
|
[4] = { { 255, 255, 255 }, { 255, 255, 49 }, { 123, 173, 255 }, { 0, 0, 0 } }, -- 4: Squirtle (Blue/Yellow)
|
||||||
|
[5] = { { 255, 255, 255 }, { 255, 255, 49 }, { 165, 123, 24 }, { 0, 0, 0 } }, -- 5: Staryu / Golem (Rock)
|
||||||
|
[6] = { { 255, 255, 255 }, { 255, 198, 173 }, { 255, 107, 255 }, { 0, 0, 0 } }, -- 6: Chansey (Pink)
|
||||||
|
[7] = { { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 }, { 0, 0, 0 } }, -- 7: Flashing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
local TILEMAP = nil
|
||||||
|
local function getTilemap()
|
||||||
|
if TILEMAP == nil then
|
||||||
|
local path = "assets/generated/slots/gold_slots.tilemap"
|
||||||
|
local f = io.open(path, "rb")
|
||||||
|
if f then
|
||||||
|
local data = f:read("*a")
|
||||||
|
f:close()
|
||||||
|
TILEMAP = {}
|
||||||
|
for i = 1, #data do
|
||||||
|
TILEMAP[i] = string.byte(data, i)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
self.sheetCache = false
|
TILEMAP = false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return self.sheetCache or nil
|
return TILEMAP or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function cell(tx, ty, label)
|
function SlotMachine:sheets()
|
||||||
local G = love.graphics
|
if self.sheet1 == nil then
|
||||||
G.setColor(0, 0, 0, 1)
|
self.sheet1 = TileSheet.new({ path = "assets/generated/slots/gold_slots_1.png", wide = 2, firstTile = 0 })
|
||||||
G.rectangle("line", tx * 8, ty * 8, 16, 16)
|
self.sheet2 = TileSheet.new({ path = "assets/generated/slots/gold_slots_2.png", wide = 2, firstTile = 0 })
|
||||||
Chrome.print(label, tx, ty + 1)
|
self.sheet3 = TileSheet.new({ path = "assets/generated/slots/gold_slots_3.png", wide = 3, firstTile = 0 })
|
||||||
|
end
|
||||||
|
return self.sheet1, self.sheet2, self.sheet3
|
||||||
|
end
|
||||||
|
|
||||||
|
function SlotMachine:drawBackground()
|
||||||
|
local s1, s2 = self:sheets()
|
||||||
|
local tm = getTilemap()
|
||||||
|
if not tm or not s1:available() then
|
||||||
|
-- Fallback simple background if assets unavailable
|
||||||
|
Chrome.clear()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local bet = self.bet or 0
|
||||||
|
|
||||||
|
for ty = 0, 11 do
|
||||||
|
for tx = 0, 19 do
|
||||||
|
local idx = ty * 20 + tx + 1
|
||||||
|
local tileId = tm[idx]
|
||||||
|
|
||||||
|
-- Palette attribution matching _CGB_SlotMachine
|
||||||
|
local pal = 0
|
||||||
|
if (tx <= 2 or tx >= 17) and ty >= 2 and ty <= 11 then
|
||||||
|
if ty >= 6 and ty <= 7 then pal = 4
|
||||||
|
elseif ty >= 4 and ty <= 9 then pal = 3
|
||||||
|
else pal = 2 end
|
||||||
|
elseif tx >= 4 and tx <= 15 and ty >= 2 and ty <= 3 then
|
||||||
|
pal = 1 -- Vileplume
|
||||||
|
elseif (tx == 3 or tx == 16) and ty >= 2 and ty <= 11 then
|
||||||
|
local isLit = false
|
||||||
|
if ty == 6 or ty == 7 then isLit = (bet >= 1)
|
||||||
|
elseif ty == 4 or ty == 5 or ty == 8 or ty == 9 then isLit = (bet >= 2)
|
||||||
|
elseif ty == 2 or ty == 3 or ty == 10 or ty == 11 then isLit = (bet >= 3)
|
||||||
|
end
|
||||||
|
if isLit then
|
||||||
|
pal = 1
|
||||||
|
-- Use lit lights tile
|
||||||
|
if tileId == 0x23 then tileId = 0x14
|
||||||
|
elseif tileId == 0x24 then tileId = 0x15 end
|
||||||
|
else
|
||||||
|
pal = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local colors = GBC_PALS.bg[pal]
|
||||||
|
s1.palette = colors
|
||||||
|
s2.palette = colors
|
||||||
|
|
||||||
|
if tileId < 0x25 then
|
||||||
|
s1:draw(tileId, tx, ty)
|
||||||
|
else
|
||||||
|
s2:draw(tileId - 0x25, tx, ty)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function SlotMachine:drawReels()
|
function SlotMachine:drawReels()
|
||||||
|
local _, s2 = self:sheets()
|
||||||
|
local G = love.graphics
|
||||||
|
|
||||||
for i = 1, 3 do
|
for i = 1, 3 do
|
||||||
local strip = SlotMachine.REELS[i]
|
local strip = SlotMachine.REELS[i]
|
||||||
local position = self.positions[i]
|
local pos = self.positions[i]
|
||||||
-- .LoadOAM reads FOUR consecutive strip entries from REEL_POSITION and lays
|
local a = pos
|
||||||
-- them bottom upward, which is what the three repeated entries at the end
|
if a == 0 then a = 0x0f end
|
||||||
-- of each strip are for: position 14 reads indices 14, 15, 16 and 17
|
a = (a - 1) % 16
|
||||||
-- without wrapping. Only the lower three are on a pay line; the fourth
|
local rx = REEL_X[i] * 8
|
||||||
-- half-shows at the top of the window.
|
local dy = math.floor(self.distance[i] or 0)
|
||||||
for row = 1, 4 do
|
|
||||||
local symbol = strip[position + row]
|
-- Draw 4 consecutive 2x2 symbols from bottom to top, exactly matching SlotMachine.window
|
||||||
cell(REEL_X[i], REEL_ROW[row], SlotMachine.LABELS[symbol] or "?")
|
for row = 0, 3 do
|
||||||
|
local sym = strip[a + row + 1]
|
||||||
|
local py = 64 - (row * 16) + dy
|
||||||
|
local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0]
|
||||||
|
s2.palette = pal
|
||||||
|
|
||||||
|
-- 2x2 tiles in 2-wide sheet:
|
||||||
|
-- sym + 0 = top-left (col 0, row 0)
|
||||||
|
-- sym + 1 = top-right (col 1, row 0)
|
||||||
|
-- sym + 2 = bottom-left (col 0, row 1)
|
||||||
|
-- sym + 3 = bottom-right (col 1, row 1)
|
||||||
|
local t0 = s2:quad(sym + 0)
|
||||||
|
local t1 = s2:quad(sym + 1)
|
||||||
|
local t2 = s2:quad(sym + 2)
|
||||||
|
local t3 = s2:quad(sym + 3)
|
||||||
|
local img = s2:image()
|
||||||
|
|
||||||
|
if img and t0 and t1 and t2 and t3 then
|
||||||
|
local function drawSym()
|
||||||
|
G.draw(img, t0, rx, py)
|
||||||
|
G.draw(img, t1, rx + 8, py)
|
||||||
|
G.draw(img, t2, rx, py + 8)
|
||||||
|
G.draw(img, t3, rx + 8, py + 8)
|
||||||
|
end
|
||||||
|
if GbcPalette.available() then
|
||||||
|
GbcPalette.with(pal, drawSym)
|
||||||
|
else
|
||||||
|
drawSym()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Fallback label
|
||||||
|
cell(REEL_X[i], REEL_ROW[row + 1], SlotMachine.LABELS[sym] or "?")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function SlotMachine:drawLights()
|
function SlotMachine:actorsImage()
|
||||||
local lit = {}
|
if self.actorsLoaded == nil then
|
||||||
-- Slots_IlluminateBetLights lights the rows for THIS bet and every smaller
|
local Assets = require("src.render.Assets")
|
||||||
-- one: `dec a / jr z` falls through from three to two to one.
|
local ok, img = pcall(Assets.image, "assets/generated/slots/gold_slots_actors.png")
|
||||||
for bet = 1, (self.bet or 0) do
|
if not (ok and img) then
|
||||||
for _, row in ipairs(LIGHT_ROWS[bet] or {}) do lit[row] = true end
|
ok, img = pcall(Assets.image, "assets/generated/slots/gold_slots_3.png")
|
||||||
|
end
|
||||||
|
self.actorsLoaded = (ok and img) or false
|
||||||
|
if self.actorsLoaded then
|
||||||
|
local G = love.graphics
|
||||||
|
-- 24x240 sheet:
|
||||||
|
-- Y=0: Golem 1 (Standing, 24x32)
|
||||||
|
-- Y=32: Golem 2 (Ball, 24x32)
|
||||||
|
-- Y=64: Chansey 1 (Standing / Step 1, 24x32)
|
||||||
|
-- Y=96: Chansey 2 (Step 2, 24x32)
|
||||||
|
-- Y=128: Chansey 3 (Step 3, 24x32)
|
||||||
|
-- Y=160: Chansey 4 (Arm raised / Step 4, 24x32)
|
||||||
|
-- Y=192: Chansey 5 (Egg Drop pose, 24x32)
|
||||||
|
-- Y=224: Egg (8x16 at X=0)
|
||||||
|
self.quadGolemStand = G.newQuad(0, 0, 24, 32, 24, 240)
|
||||||
|
self.quadGolemBall = G.newQuad(0, 32, 24, 32, 24, 240)
|
||||||
|
self.quadChansey1 = G.newQuad(0, 64, 24, 32, 24, 240)
|
||||||
|
self.quadChansey2 = G.newQuad(0, 96, 24, 32, 24, 240)
|
||||||
|
self.quadChansey3 = G.newQuad(0, 128, 24, 32, 24, 240)
|
||||||
|
self.quadChansey4 = G.newQuad(0, 160, 24, 32, 24, 240)
|
||||||
|
self.quadChanseyDrop = G.newQuad(0, 192, 24, 32, 24, 240)
|
||||||
|
self.quadEgg = G.newQuad(0, 224, 8, 16, 24, 240)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return self.actorsLoaded or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Redraw the top Vileplume row (rows 2..3) and bottom frame brackets (rows 10..11)
|
||||||
|
-- over the reels with solid backdrop to naturally mask any sprite overhang like the Game Boy hardware does.
|
||||||
|
function SlotMachine:drawOverlays()
|
||||||
|
local s1, s2 = self:sheets()
|
||||||
|
local tm = getTilemap()
|
||||||
|
if not tm or not s1:available() then return end
|
||||||
|
|
||||||
|
local G = love.graphics
|
||||||
|
-- Solid backdrop over header (rows 0..3) and footer (rows 10..11) between columns 4..15
|
||||||
|
G.setColor(198 / 255, 198 / 255, 74 / 255, 1)
|
||||||
|
G.rectangle("fill", 4 * 8, 2 * 8, 12 * 8, 2 * 8)
|
||||||
|
G.rectangle("fill", 4 * 8, 10 * 8, 12 * 8, 2 * 8)
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
|
||||||
|
for _, ty in ipairs({ 2, 3, 10, 11 }) do
|
||||||
|
for tx = 4, 15 do
|
||||||
|
local idx = ty * 20 + tx + 1
|
||||||
|
local tileId = tm[idx]
|
||||||
|
local pal = (ty <= 3) and 1 or 0
|
||||||
|
local colors = GBC_PALS.bg[pal]
|
||||||
|
s1.palette = colors
|
||||||
|
s2.palette = colors
|
||||||
|
|
||||||
|
if tileId < 0x25 then
|
||||||
|
s1:draw(tileId, tx, ty)
|
||||||
|
else
|
||||||
|
s2:draw(tileId - 0x25, tx, ty)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Draw Golem sprite animation
|
||||||
|
if self.golemAnim then
|
||||||
|
local actors = self:actorsImage()
|
||||||
|
local g = self.golemAnim
|
||||||
|
if actors then
|
||||||
|
local quad = self.quadGolemBall
|
||||||
|
local scaleX = 1
|
||||||
|
local scaleY = 1
|
||||||
|
if g.state == "falling" then
|
||||||
|
quad = self.quadGolemBall
|
||||||
|
elseif g.state == "rolling" then
|
||||||
|
-- Frameset_SlotsGolem: 0=Standing, 1=Ball, 2=StandingYFlip, 3=BallXFlip
|
||||||
|
local rotFrame = (g.animFrame or 0) % 4
|
||||||
|
if rotFrame == 0 then
|
||||||
|
quad = self.quadGolemStand
|
||||||
|
scaleX = 1
|
||||||
|
scaleY = 1
|
||||||
|
elseif rotFrame == 1 then
|
||||||
|
quad = self.quadGolemBall
|
||||||
|
scaleX = 1
|
||||||
|
scaleY = 1
|
||||||
|
elseif rotFrame == 2 then
|
||||||
|
quad = self.quadGolemStand
|
||||||
|
scaleX = 1
|
||||||
|
scaleY = -1
|
||||||
|
elseif rotFrame == 3 then
|
||||||
|
quad = self.quadGolemBall
|
||||||
|
scaleX = -1
|
||||||
|
scaleY = 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
local function drawGolem()
|
||||||
|
-- Draw rotated around center (ox=12, oy=16)
|
||||||
|
G.draw(actors, quad,
|
||||||
|
math.floor(g.x + 12),
|
||||||
|
math.floor(g.y + 16),
|
||||||
|
0, scaleX, scaleY, 12, 16)
|
||||||
|
end
|
||||||
|
if GbcPalette.available() then
|
||||||
|
GbcPalette.with(GBC_PALS.obj[5], drawGolem)
|
||||||
|
else
|
||||||
|
drawGolem()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Draw Chansey & Egg sprite animation
|
||||||
|
if self.chanseyAnim then
|
||||||
|
local actors = self:actorsImage()
|
||||||
|
local c = self.chanseyAnim
|
||||||
|
if actors then
|
||||||
|
local quad = self.quadChansey1
|
||||||
|
if c.state == "walking" then
|
||||||
|
local walkCycle = { self.quadChansey1, self.quadChansey2, self.quadChansey3, self.quadChansey4 }
|
||||||
|
quad = walkCycle[((c.animPose or 0) % 4) + 1] or self.quadChansey1
|
||||||
|
elseif c.state == "egg_pause" then
|
||||||
|
quad = self.quadChansey4
|
||||||
|
elseif c.state == "egg_drop" or c.state == "spinning" then
|
||||||
|
quad = self.quadChanseyDrop
|
||||||
|
end
|
||||||
|
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
local function drawChansey()
|
||||||
|
G.draw(actors, quad, math.floor(c.x), math.floor(c.y or 44))
|
||||||
|
if c.eggVisible and c.eggX and c.eggY then
|
||||||
|
G.draw(actors, self.quadEgg, math.floor(c.eggX), math.floor(c.eggY))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if GbcPalette.available() then
|
||||||
|
GbcPalette.with(GBC_PALS.obj[6], drawChansey)
|
||||||
|
else
|
||||||
|
drawChansey()
|
||||||
end
|
end
|
||||||
for _, row in ipairs({ 2, 4, 6, 8, 10 }) do
|
|
||||||
for _, col in ipairs(LIGHT_COLS) do
|
|
||||||
Chrome.print(lit[row] and "*" or "-", col, row)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1022,39 +1437,79 @@ function SlotMachine:drawMessage()
|
|||||||
end
|
end
|
||||||
if self.matched and self.matched ~= SlotMachine.NO_MATCH
|
if self.matched and self.matched ~= SlotMachine.NO_MATCH
|
||||||
and self.phase == "payoutText" then
|
and self.phase == "payoutText" then
|
||||||
cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y,
|
local _, s2 = self:sheets()
|
||||||
SlotMachine.LABELS[self.matched] or "?")
|
local sym = self.matched
|
||||||
|
local pal = GBC_PALS.obj[math.floor(sym / 4)] or GBC_PALS.obj[0]
|
||||||
|
s2.palette = pal
|
||||||
|
local t0 = s2:quad(sym + 0)
|
||||||
|
local t1 = s2:quad(sym + 1)
|
||||||
|
local t2 = s2:quad(sym + 2)
|
||||||
|
local t3 = s2:quad(sym + 3)
|
||||||
|
local img = s2:image()
|
||||||
|
local G = love.graphics
|
||||||
|
local px, py = PAYOUT_SYMBOL_X * 8, PAYOUT_SYMBOL_Y * 8
|
||||||
|
if img and t0 and t1 and t2 and t3 then
|
||||||
|
local function drawWin()
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
G.draw(img, t0, px, py)
|
||||||
|
G.draw(img, t1, px + 8, py)
|
||||||
|
G.draw(img, t2, px, py + 8)
|
||||||
|
G.draw(img, t3, px + 8, py + 8)
|
||||||
|
end
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
if GbcPalette.available() then
|
||||||
|
GbcPalette.with(pal, drawWin)
|
||||||
|
else
|
||||||
|
drawWin()
|
||||||
|
end
|
||||||
|
else
|
||||||
|
cell(PAYOUT_SYMBOL_X, PAYOUT_SYMBOL_Y, SlotMachine.LABELS[self.matched] or "?")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function SlotMachine:drawPanel()
|
function SlotMachine:drawPanel()
|
||||||
Chrome.clear()
|
self:drawBackground()
|
||||||
self:drawLights()
|
self:drawReels()
|
||||||
|
self:drawOverlays()
|
||||||
|
|
||||||
-- PRINTNUM_LEADINGZEROS | 2 bytes, 4 digits, for both counters.
|
-- PRINTNUM_LEADINGZEROS | 2 bytes, 4 digits, for both counters.
|
||||||
Chrome.print(Chrome.number(self:coins(), 4, true), COINS_X, COINS_Y)
|
Chrome.print(Chrome.number(self:coins(), 4, true), COINS_X, COINS_Y)
|
||||||
Chrome.print(Chrome.number(self.payoutLeft or 0, 4, true), PAYOUT_X, PAYOUT_Y)
|
Chrome.print(Chrome.number(self.payoutLeft or 0, 4, true), PAYOUT_X, PAYOUT_Y)
|
||||||
self:drawReels()
|
|
||||||
if self.phase == "bet" then
|
if self.phase == "bet" then
|
||||||
Chrome.textbox(BET_BOX_X, BET_BOX_Y, BET_BOX_W - 2, BET_BOX_H - 2)
|
-- Left speech textbox for "Bet how many coins?"
|
||||||
|
Chrome.textbox(0, 12, 12, 4)
|
||||||
|
Chrome.print(TEXTS.betHowMany[1], 1, 14)
|
||||||
|
Chrome.print(TEXTS.betHowMany[2], 1, 16)
|
||||||
|
|
||||||
|
-- Right menu for bet choices (14, 10 to 19, 17)
|
||||||
|
Chrome.textbox(14, 10, 4, 6)
|
||||||
for i, label in ipairs(BET_ROWS) do
|
for i, label in ipairs(BET_ROWS) do
|
||||||
local ty = BET_LABEL_Y + (i - 1) * BET_SPACING
|
local ty = 12 + (i - 1) * 2
|
||||||
if i == self.betIndex then Chrome.cursor(BET_LABEL_X - 1, ty) end
|
if i == self.betIndex then Chrome.cursor(15, ty) end
|
||||||
Chrome.print(label, BET_LABEL_X, ty)
|
Chrome.print(label, 16, ty)
|
||||||
end
|
end
|
||||||
if not self.message then
|
|
||||||
Chrome.textbox(TEXT_BOX_X, TEXT_BOX_Y, TEXT_BOX_W - 2, TEXT_BOX_H - 2)
|
if self.message then
|
||||||
for i, line in ipairs(TEXTS.betHowMany) do
|
-- If "Not enough coins." message is shown, overlay full speech box
|
||||||
|
Chrome.textbox(0, 12, 18, 4)
|
||||||
|
for i, line in ipairs(self.message) do
|
||||||
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
|
Chrome.print(line, TEXT_X, TEXT_Y + (i - 1) * TEXT_LINE)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
elseif self.phase == "again" then
|
||||||
self:drawMessage()
|
-- Speech box: "Play again?"
|
||||||
if self.phase == "again" then
|
Chrome.textbox(0, 12, 18, 4)
|
||||||
-- PlaceYesNoBox `lb bc, 14, 12`: a 6x5 box at (14,12) with YES at (16,13).
|
Chrome.print(TEXTS.playAgain[1], TEXT_X, TEXT_Y)
|
||||||
|
|
||||||
|
-- PlaceYesNoBox at (14, 12): 6x5 box with YES at (16,13), NO at (16,15)
|
||||||
Chrome.textbox(14, 12, 4, 3)
|
Chrome.textbox(14, 12, 4, 3)
|
||||||
Chrome.print("YES", 16, 13)
|
Chrome.print("YES", 16, 13)
|
||||||
Chrome.print("NO", 16, 15)
|
Chrome.print("NO", 16, 15)
|
||||||
Chrome.cursor(15, 13 + (self.againChoice - 1) * 2)
|
Chrome.cursor(15, 13 + (self.againChoice - 1) * 2)
|
||||||
|
else
|
||||||
|
self:drawMessage()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
-- wing-flap (Frameset_GSIntroHoOhLugia), spark trails, A/Start to continue.
|
-- wing-flap (Frameset_GSIntroHoOhLugia), spark trails, A/Start to continue.
|
||||||
-- drawWidescreen fills the window with sky/clouds so widescreen has no
|
-- drawWidescreen fills the window with sky/clouds so widescreen has no
|
||||||
-- pillarbox voids; the 160x144 art stays aspect-centered on top.
|
-- pillarbox voids; the 160x144 art stays aspect-centered on top.
|
||||||
|
-- Every Gold/Silver difference arrives as a title.lua key, defaulted to Gold.
|
||||||
|
|
||||||
-- src/render/Assets.lua is the mod-override choke point: a raw
|
-- src/render/Assets.lua is the mod-override choke point: a raw
|
||||||
-- love.graphics.newImage skips overrides/ and AssetTransform output.
|
-- love.graphics.newImage skips overrides/ and AssetTransform output.
|
||||||
@@ -16,7 +17,7 @@ local TitleState = {}
|
|||||||
TitleState.__index = TitleState
|
TitleState.__index = TitleState
|
||||||
TitleState.isOpaque = true
|
TitleState.isOpaque = true
|
||||||
|
|
||||||
-- title_bg_gold.pal mid-sky shade (sampled from composed title_screen.png).
|
-- title_bg_gold.pal mid-sky shade, for a cache built before title.sky existed.
|
||||||
local SKY = { 123 / 255, 165 / 255, 255 / 255, 1 }
|
local SKY = { 123 / 255, 165 / 255, 255 / 255, 1 }
|
||||||
-- ...and its grey stand-in, for when COLOR is not GBC. The title art is the
|
-- ...and its grey stand-in, for when COLOR is not GBC. The title art is the
|
||||||
-- one thing in the port baked with its colours in (see the extractor), so the
|
-- one thing in the port baked with its colours in (see the extractor), so the
|
||||||
@@ -61,6 +62,15 @@ function TitleState.new(game, opts)
|
|||||||
self.hoohY = tonumber(title.hoohY) or 56
|
self.hoohY = tonumber(title.hoohY) or 56
|
||||||
self.cloudY = tonumber(title.cloudY) or 88
|
self.cloudY = tonumber(title.cloudY) or 88
|
||||||
self.cloudScrollEvery = tonumber(title.cloudScrollEvery) or 8
|
self.cloudScrollEvery = tonumber(title.cloudScrollEvery) or 8
|
||||||
|
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
|
||||||
|
self.hoohBobAmplitude = tonumber(title.hoohBobAmplitude) or 2
|
||||||
|
self.hoohBobStep = tonumber(title.hoohBobStep) or 1
|
||||||
|
local sky = title.sky
|
||||||
|
self.sky = (type(sky) == "table" and #sky >= 3)
|
||||||
|
and { sky[1], sky[2], sky[3], 1 } or SKY
|
||||||
|
local below = title.below
|
||||||
|
self.below = (type(below) == "table" and #below >= 3)
|
||||||
|
and { below[1], below[2], below[3], 1 } or { 1, 1, 1, 1 }
|
||||||
|
|
||||||
self.hoohColor, self.hoohGray = {}, {}
|
self.hoohColor, self.hoohGray = {}, {}
|
||||||
local paths = title.hoohFrames
|
local paths = title.hoohFrames
|
||||||
@@ -80,8 +90,12 @@ function TitleState.new(game, opts)
|
|||||||
self.sequence = title.hoohSequence or {
|
self.sequence = title.hoohSequence or {
|
||||||
{ 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 },
|
{ 1, 10 }, { 2, 9 }, { 3, 10 }, { 4, 10 }, { 3, 9 }, { 5, 10 },
|
||||||
}
|
}
|
||||||
|
-- A frame shows duration + 1 ticks: GetSpriteAnimFrame stores the byte on
|
||||||
|
-- the advancing tick and only decrements on the ones after
|
||||||
|
-- (engine/sprite_anims/core.asm:400-434). Both editions' framesets total
|
||||||
|
-- 64 ticks with it, locking the wing beat to the 64-tick sine bob.
|
||||||
self.seqIndex = 1
|
self.seqIndex = 1
|
||||||
self.seqLeft = self.sequence[1] and self.sequence[1][2] or 10
|
self.seqLeft = (self.sequence[1] and self.sequence[1][2] or 10) + 1
|
||||||
self.frame = 1
|
self.frame = 1
|
||||||
|
|
||||||
-- AnimSeq_GSIntroHoOhLugia's SPRITEANIMSTRUCT_VAR1.
|
-- AnimSeq_GSIntroHoOhLugia's SPRITEANIMSTRUCT_VAR1.
|
||||||
@@ -89,12 +103,21 @@ function TitleState.new(game, opts)
|
|||||||
self.frameCounter = 0
|
self.frameCounter = 0
|
||||||
self.cloudScroll = 0
|
self.cloudScroll = 0
|
||||||
self.trails = {}
|
self.trails = {}
|
||||||
-- UpdateTitleTrailSprite / TitleTrailCoords (intro_menu.asm), in pixels.
|
-- UpdateTitleTrailSprite / TitleTrailCoords (intro_menu.asm:1069-1124), in
|
||||||
self.trailSpawns = {
|
-- pixels.
|
||||||
|
self.trailSpawns = title.trailSpawns or {
|
||||||
{ 80, 88 }, { 104, 88 }, { 104, 88 }, { 120, 88 },
|
{ 80, 88 }, { 104, 88 }, { 104, 88 }, { 120, 88 },
|
||||||
{ 120, 88 }, { 88, 88 },
|
{ 120, 88 }, { 88, 88 },
|
||||||
}
|
}
|
||||||
self.trailSpawnIndex = 1
|
self.trailSpawnIndex = 1
|
||||||
|
-- AnimSeq_GSTitleTrail (engine/sprite_anims/functions.asm:720-818).
|
||||||
|
self.trailMode = title.trailMode or "gold"
|
||||||
|
self.trailSpawnEvery = tonumber(title.trailSpawnEvery) or 4
|
||||||
|
self.trailStepX = tonumber(title.trailStepX) or 4
|
||||||
|
self.trailStepY = tonumber(title.trailStepY) or 1
|
||||||
|
self.trailBobAmplitude = tonumber(title.trailBobAmplitude) or 2
|
||||||
|
self.trailPhaseStep = tonumber(title.trailPhaseStep) or 3
|
||||||
|
self.trailPhase = tonumber(title.trailPhase)
|
||||||
-- How far past the 160px frame trails may fly (GB pixels); set each draw.
|
-- How far past the 160px frame trails may fly (GB pixels); set each draw.
|
||||||
self.trailMaxX = 200
|
self.trailMaxX = 200
|
||||||
self.musicStarted = false
|
self.musicStarted = false
|
||||||
@@ -119,47 +142,56 @@ function TitleState:enter()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm): VAR1 counts up
|
-- Sprites_Sine hands back the byte the ASM leaves in a, so the down half of
|
||||||
-- one per frame and the struct's Y offset becomes `d * sin(VAR1 * pi/32)` with
|
-- the wave arrives in two's complement and is a signed pixel delta here.
|
||||||
-- d = 2 on Gold (Silver counts DOWN with d = 8). Sprites_Sine hands back the
|
local function signed(value)
|
||||||
-- byte the ASM leaves in a, so the down half of the wave arrives in two's
|
if value >= 0x80 then return value - 0x100 end
|
||||||
-- complement and has to be read as a signed pixel delta here.
|
|
||||||
function TitleState:hoohBob()
|
|
||||||
local value = SpriteAnims.sine(self.hoohPhase, 2)
|
|
||||||
if value >= 0x80 then value = value - 0x100 end
|
|
||||||
return value
|
return value
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- AnimSeq_GSIntroHoOhLugia (engine/sprite_anims/functions.asm:820-838).
|
||||||
|
function TitleState:hoohBob()
|
||||||
|
return signed(SpriteAnims.sine(self.hoohPhase, self.hoohBobAmplitude))
|
||||||
|
end
|
||||||
|
|
||||||
function TitleState:advanceHooh()
|
function TitleState:advanceHooh()
|
||||||
self.hoohPhase = (self.hoohPhase + 1) % 256
|
self.hoohPhase = (self.hoohPhase + self.hoohBobStep) % 256
|
||||||
self.seqLeft = self.seqLeft - 1
|
self.seqLeft = self.seqLeft - 1
|
||||||
if self.seqLeft > 0 then return end
|
if self.seqLeft > 0 then return end
|
||||||
self.seqIndex = self.seqIndex + 1
|
self.seqIndex = self.seqIndex + 1
|
||||||
if self.seqIndex > #self.sequence then self.seqIndex = 1 end
|
if self.seqIndex > #self.sequence then self.seqIndex = 1 end
|
||||||
local step = self.sequence[self.seqIndex]
|
local step = self.sequence[self.seqIndex]
|
||||||
self.frame = step[1]
|
self.frame = step[1]
|
||||||
self.seqLeft = step[2]
|
self.seqLeft = step[2] + 1
|
||||||
end
|
end
|
||||||
|
|
||||||
function TitleState:spawnTrail()
|
function TitleState:spawnTrail()
|
||||||
if not (self.trailColor or self.trailGray) then return end
|
if not (self.trailColor or self.trailGray) then return end
|
||||||
if self.frameCounter % 4 ~= 0 then return end
|
if #self.trailSpawns == 0 then return end
|
||||||
|
if self.frameCounter % self.trailSpawnEvery ~= 0 then return end
|
||||||
local spawn = self.trailSpawns[self.trailSpawnIndex]
|
local spawn = self.trailSpawns[self.trailSpawnIndex]
|
||||||
self.trailSpawnIndex = self.trailSpawnIndex % #self.trailSpawns + 1
|
self.trailSpawnIndex = self.trailSpawnIndex % #self.trailSpawns + 1
|
||||||
if not spawn then return end
|
if not spawn then return end
|
||||||
self.trails[#self.trails + 1] = {
|
self.trails[#self.trails + 1] = {
|
||||||
x = spawn[1], y = spawn[2], phase = love.math.random(0, 255),
|
x = spawn[1], y = spawn[2],
|
||||||
|
phase = self.trailPhase or love.math.random(0, 255),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
function TitleState:stepTrails()
|
function TitleState:stepTrails()
|
||||||
local alive = {}
|
local alive = {}
|
||||||
local maxX = self.trailMaxX or 200
|
local maxX = self.trailMaxX or 200
|
||||||
|
local silver = self.trailMode == "silver"
|
||||||
for _, t in ipairs(self.trails) do
|
for _, t in ipairs(self.trails) do
|
||||||
t.x = t.x + 4
|
t.x = t.x + self.trailStepX
|
||||||
t.y = t.y + 1
|
t.y = t.y + self.trailStepY
|
||||||
t.phase = t.phase + 3
|
t.phase = t.phase + self.trailPhaseStep
|
||||||
t.drawY = t.y + math.floor(math.sin(t.phase / 16) * 2)
|
if silver then
|
||||||
|
t.drawY = t.y + signed(SpriteAnims.sine(t.phase, self.trailBobAmplitude))
|
||||||
|
else
|
||||||
|
t.drawY = t.y
|
||||||
|
+ math.floor(math.sin(t.phase / 16) * self.trailBobAmplitude)
|
||||||
|
end
|
||||||
if t.x < maxX then alive[#alive + 1] = t end
|
if t.x < maxX then alive[#alive + 1] = t end
|
||||||
end
|
end
|
||||||
self.trails = alive
|
self.trails = alive
|
||||||
@@ -254,14 +286,17 @@ function TitleState:drawWidescreen(winW, winH)
|
|||||||
-- Let trails fly into the side bands.
|
-- Let trails fly into the side bands.
|
||||||
self.trailMaxX = math.ceil((winW - ox) / scale) + 16
|
self.trailMaxX = math.ceil((winW - ox) / scale) + 16
|
||||||
|
|
||||||
-- Sky above the cloud line, paper white below : edge to edge. The fill has
|
-- Sky above the cloud line, title.below under it (Gold's white cloud
|
||||||
-- to match whichever baked set is showing, or the surround would stay blue
|
-- field, Silver's black sea) : edge to edge. The fill has to match
|
||||||
-- around a grey screen.
|
-- whichever baked set is showing, or the surround would stay blue around a
|
||||||
local sky = self:gray() and SKY_GRAY or SKY
|
-- grey screen.
|
||||||
|
local sky = self:gray() and SKY_GRAY or self.sky
|
||||||
G.setColor(sky[1], sky[2], sky[3], 1)
|
G.setColor(sky[1], sky[2], sky[3], 1)
|
||||||
G.rectangle("fill", 0, 0, winW, math.max(0, cloudTop))
|
G.rectangle("fill", 0, 0, winW, math.max(0, cloudTop))
|
||||||
G.setColor(1, 1, 1, 1)
|
local below = self.below
|
||||||
|
G.setColor(below[1], below[2], below[3], 1)
|
||||||
G.rectangle("fill", 0, cloudTop, winW, winH - cloudTop)
|
G.rectangle("fill", 0, cloudTop, winW, winH - cloudTop)
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
|
||||||
-- Clouds across the full window width, aligned to the GB cloud band.
|
-- Clouds across the full window width, aligned to the GB cloud band.
|
||||||
G.push()
|
G.push()
|
||||||
|
|||||||
@@ -918,16 +918,58 @@ function Kit.rowsThatFit(h, rowH, gap, minRows, maxRows)
|
|||||||
return math.max(minRows or 1, math.min(maxRows or 99, per))
|
return math.max(minRows or 1, math.min(maxRows or 99, per))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Kit.dragX = nil
|
||||||
|
Kit.dragY = nil
|
||||||
|
Kit.dragAccum = 0
|
||||||
|
|
||||||
|
function Kit.dragBegin(x, y)
|
||||||
|
Kit.dragX, Kit.dragY, Kit.dragAccum = x, y, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
function Kit.dragAdd(dy)
|
||||||
|
if Kit.dragX then Kit.dragAccum = Kit.dragAccum + (dy or 0) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Kit.dragEnd()
|
||||||
|
Kit.dragX, Kit.dragY, Kit.dragAccum = nil, nil, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local function dragOriginIn(x, y, w, h)
|
||||||
|
if not Kit.dragX then return false end
|
||||||
|
local x1, y1, x2, y2 = x, y, x + w, y + h
|
||||||
|
local c = Kit._clipRect
|
||||||
|
if c then
|
||||||
|
x1, y1 = math.max(x1, c.x), math.max(y1, c.y)
|
||||||
|
x2, y2 = math.min(x2, c.x + c.w), math.min(y2, c.y + c.h)
|
||||||
|
end
|
||||||
|
return Kit.dragX >= x1 and Kit.dragX <= x2
|
||||||
|
and Kit.dragY >= y1 and Kit.dragY <= y2
|
||||||
|
end
|
||||||
|
|
||||||
-- Mouse wheel over a paginated list turns PAGES. The wheel still has to do
|
-- Mouse wheel over a paginated list turns PAGES. The wheel still has to do
|
||||||
-- something (users expect it), but it moves a bounded page index rather than
|
-- something (users expect it), but it moves a bounded page index rather than
|
||||||
-- driving a pixel offset, so there is no scroll state and no interpolation.
|
-- driving a pixel offset, so there is no scroll state and no interpolation.
|
||||||
function Kit.wheelPage(x, y, w, h, page, total, perPage)
|
function Kit.wheelPage(x, y, w, h, page, total, perPage)
|
||||||
if Kit.blockClicks or (Kit.wheelY or 0) == 0 then return page end
|
if Kit.blockClicks then return page end
|
||||||
if not Kit.hit(x, y, w, h) then return page end
|
|
||||||
local pages = math.max(1, math.ceil(total / math.max(1, perPage)))
|
local pages = math.max(1, math.ceil(total / math.max(1, perPage)))
|
||||||
local moved = Theme.clamp((page or 1) + (Kit.wheelY > 0 and -1 or 1), 1, pages)
|
local out = page
|
||||||
|
if (Kit.wheelY or 0) ~= 0 and Kit.hit(x, y, w, h) then
|
||||||
|
out = math.floor(Theme.clamp((out or 1) + (Kit.wheelY > 0 and -1 or 1),
|
||||||
|
1, pages))
|
||||||
Kit.wheelY = 0
|
Kit.wheelY = 0
|
||||||
return math.floor(moved)
|
end
|
||||||
|
local acc = Kit.dragAccum or 0
|
||||||
|
if acc ~= 0 and dragOriginIn(x, y, w, h) then
|
||||||
|
local stepPx = math.max(1, math.floor((h or 0) / 2))
|
||||||
|
local flips = acc >= 0 and math.floor(acc / stepPx)
|
||||||
|
or -math.floor(-acc / stepPx)
|
||||||
|
if flips ~= 0 then
|
||||||
|
local want = (out or 1) + flips
|
||||||
|
out = math.floor(Theme.clamp(want, 1, pages))
|
||||||
|
Kit.dragAccum = out ~= want and 0 or acc - flips * stepPx
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
function Kit.scrollExtent(contentH, viewH)
|
function Kit.scrollExtent(contentH, viewH)
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ local PAL = {
|
|||||||
railBlue = { 70, 150, 255 },
|
railBlue = { 70, 150, 255 },
|
||||||
railGold = { 255, 203, 5 }, -- Yellow cartridge (bright)
|
railGold = { 255, 203, 5 }, -- Yellow cartridge (bright)
|
||||||
railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal)
|
railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal)
|
||||||
|
railSilver = { 190, 198, 210 }, -- Silver cartridge (cool light metal)
|
||||||
}
|
}
|
||||||
-- Semantic aliases kept so ported call sites read the same as before.
|
-- Semantic aliases kept so ported call sites read the same as before.
|
||||||
PAL.cardBorder = PAL.line
|
PAL.cardBorder = PAL.line
|
||||||
@@ -321,10 +322,12 @@ function Theme.meter(x, y, w, h, pct, c)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- The 4px version rail across the top of both windows: the only brand
|
-- The 4px version rail across the top of both windows: the only brand
|
||||||
-- colour on screen (Red / Blue / Yellow / Gold cartridge colours).
|
-- colour on screen (Red / Blue / Yellow / Gold / Silver cartridge colours).
|
||||||
function Theme.versionRail(x, y, w, h)
|
function Theme.versionRail(x, y, w, h)
|
||||||
if not G then return end
|
if not G then return end
|
||||||
local bars = { PAL.railRed, PAL.railBlue, PAL.railGold, PAL.railAmber }
|
local bars = {
|
||||||
|
PAL.railRed, PAL.railBlue, PAL.railGold, PAL.railAmber, PAL.railSilver,
|
||||||
|
}
|
||||||
local seg = w / #bars
|
local seg = w / #bars
|
||||||
for i, c in ipairs(bars) do
|
for i, c in ipairs(bars) do
|
||||||
Theme.fill(x + (i - 1) * seg, y, seg, h, c, 1)
|
Theme.fill(x + (i - 1) * seg, y, seg, h, c, 1)
|
||||||
|
|||||||
@@ -846,15 +846,20 @@ function OverworldState:useSoftboiledFieldMove(user, target)
|
|||||||
if not user or not user.stats or not target or not target.stats
|
if not user or not user.stats or not target or not target.stats
|
||||||
or target == user or target.hp <= 0
|
or target == user or target.hp <= 0
|
||||||
or target.hp >= target.stats.hp or user.hp <= heal then
|
or target.hp >= target.stats.hp or user.hp <= heal then
|
||||||
Game.stack:push(TextBox.new(Game, Strings("It won't have\nany effect.")))
|
Game.stack:push(TextBox.new(Game,
|
||||||
|
romText(Game.data, "_ItemUseNoEffectText", "It won't have\nany effect.")))
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
local before = target.hp
|
||||||
user.hp = user.hp - heal
|
user.hp = user.hp - heal
|
||||||
target.hp = math.min(target.stats.hp, target.hp + heal)
|
target.hp = math.min(target.stats.hp, target.hp + heal)
|
||||||
require("src.core.Sound").play(Game.data, "Heal_HP")
|
require("src.core.Sound").play(Game.data, "Heal_HP")
|
||||||
local def = Game.data.pokemon[target.species]
|
local def = Game.data.pokemon[target.species]
|
||||||
|
-- _PotionText's second slot is the recovered amount, same as
|
||||||
|
-- ItemEffects.lua's potion message -- the engine fallback never shows it
|
||||||
Game.stack:push(TextBox.new(Game,
|
Game.stack:push(TextBox.new(Game,
|
||||||
Strings("%s's HP\nwas restored!", target.nickname or def.name)))
|
romText(Game.data, "_PotionText", "%s's HP\nwas restored!",
|
||||||
|
target.nickname or def.name, target.hp - before)))
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2126,7 +2131,8 @@ function OverworldState:tryHiddenObject(fx, fy)
|
|||||||
-- leaves the spot unfound; _CantCarryMoreText is the Toss line (#872)
|
-- leaves the spot unfound; _CantCarryMoreText is the Toss line (#872)
|
||||||
local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item
|
local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item
|
||||||
Game.stack:push(TextBox.new(Game,
|
Game.stack:push(TextBox.new(Game,
|
||||||
Strings("%s found\n%s!", save.player.name, name) .. "\f"
|
romText(Game.data, "_FoundHiddenItemText", "%s found\n%s!",
|
||||||
|
save.player.name, name) .. "\f"
|
||||||
.. romText(Game.data, "_HiddenItemBagFullText",
|
.. romText(Game.data, "_HiddenItemBagFullText",
|
||||||
"But, {PLAYER} has\nno more room for\vother items!")))
|
"But, {PLAYER} has\nno more room for\vother items!")))
|
||||||
return true
|
return true
|
||||||
@@ -2137,7 +2143,8 @@ function OverworldState:tryHiddenObject(fx, fy)
|
|||||||
-- text_asm tail runs it as PlaySoundWaitForCurrent +
|
-- text_asm tail runs it as PlaySoundWaitForCurrent +
|
||||||
-- WaitForSoundToFinish once the box has printed (hidden_items.asm)
|
-- WaitForSoundToFinish once the box has printed (hidden_items.asm)
|
||||||
Game.stack:push(TextBox.new(Game,
|
Game.stack:push(TextBox.new(Game,
|
||||||
Strings("%s found\n%s!", save.player.name, name),
|
romText(Game.data, "_FoundHiddenItemText", "%s found\n%s!",
|
||||||
|
save.player.name, name),
|
||||||
nil, TextBox.soundOpts(Game, "Get_Item2")))
|
nil, TextBox.soundOpts(Game, "Get_Item2")))
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
@@ -2795,8 +2802,8 @@ function OverworldState:talkTo(npc)
|
|||||||
"No more room for\nitems!")
|
"No more room for\nitems!")
|
||||||
if GameVersion.isYellow() then
|
if GameVersion.isYellow() then
|
||||||
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
|
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
|
||||||
noRoom = Strings("%s found\n%s!", Game.save.player.name, name)
|
noRoom = romText(Game.data, "_FoundItemText", "%s found\n%s!",
|
||||||
.. "\f" .. noRoom
|
Game.save.player.name, name) .. "\f" .. noRoom
|
||||||
end
|
end
|
||||||
Game.stack:push(TextBox.new(Game, noRoom))
|
Game.stack:push(TextBox.new(Game, noRoom))
|
||||||
return
|
return
|
||||||
@@ -2813,7 +2820,8 @@ function OverworldState:talkTo(npc)
|
|||||||
local ddef = Game.data.items[d.item]
|
local ddef = Game.data.items[d.item]
|
||||||
-- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm)
|
-- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm)
|
||||||
Game.stack:push(TextBox.new(Game,
|
Game.stack:push(TextBox.new(Game,
|
||||||
Strings("%s found\n%s!", Game.save.player.name, name), nil,
|
romText(Game.data, "_FoundItemText", "%s found\n%s!",
|
||||||
|
Game.save.player.name, name), nil,
|
||||||
TextBox.soundOpts(Game,
|
TextBox.soundOpts(Game,
|
||||||
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")))
|
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")))
|
||||||
return
|
return
|
||||||
@@ -3745,7 +3753,7 @@ function OverworldState:applyFieldPoison()
|
|||||||
local queue = {}
|
local queue = {}
|
||||||
for _, mon in ipairs(fainted) do
|
for _, mon in ipairs(fainted) do
|
||||||
local name = mon.nickname or Game.data.pokemon[mon.species].name
|
local name = mon.nickname or Game.data.pokemon[mon.species].name
|
||||||
table.insert(queue, Strings("%s\nfainted!", name))
|
table.insert(queue, romText(Game.data, "_PokemonFaintedText", "%s\nfainted!", name))
|
||||||
end
|
end
|
||||||
local alive = false
|
local alive = false
|
||||||
for _, mon in ipairs(save.party) do
|
for _, mon in ipairs(save.party) do
|
||||||
|
|||||||
@@ -1569,11 +1569,11 @@ function World:readVar(varId)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Script_checkver: 0 for Gold, 1 for Silver (constants/misc_constants.asm
|
-- Script_checkver: 0 for Gold, 1 for Silver (constants/misc_constants.asm
|
||||||
-- GS_VERSION). Only the Goldenrod prize counters and a handful of gift mons
|
-- GS_VERSION).
|
||||||
-- read it, and the port has no Silver cache yet, so an unset version is Gold.
|
|
||||||
function World:gsVersion()
|
function World:gsVersion()
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
local save = self.game and self.game.save
|
local save = self.game and self.game.save
|
||||||
local version = (save and save.version) or "gold"
|
local version = (save and save.version) or GameVersion.get()
|
||||||
return version == "silver" and 1 or 0
|
return version == "silver" and 1 or 0
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -6633,8 +6633,9 @@ function World:nameRival(onDone)
|
|||||||
if save then
|
if save then
|
||||||
save.rival = save.rival or {}
|
save.rival = save.rival or {}
|
||||||
-- NameRival ends on `ld hl, wRivalName / ld de, .DefaultName / call
|
-- NameRival ends on `ld hl, wRivalName / ld de, .DefaultName / call
|
||||||
-- InitName`, and .DefaultName is "SILVER@" on Gold
|
-- InitName`, and .DefaultName is "SILVER@" on Gold and "GOLD@" on
|
||||||
-- (engine/events/specials.asm:80-91). The default has to be written
|
-- Silver (engine/events/specials.asm:80-94). The default has to be
|
||||||
|
-- written
|
||||||
-- HERE rather than ridden in from the seed: wRivalName starts as
|
-- HERE rather than ridden in from the seed: wRivalName starts as
|
||||||
-- InitializeNPCNames' "???" (src/core/gen2/Save.lua), which is what the
|
-- InitializeNPCNames' "???" (src/core/gen2/Save.lua), which is what the
|
||||||
-- pre-naming Cherrygrove battle prints.
|
-- pre-naming Cherrygrove battle prints.
|
||||||
@@ -6646,7 +6647,7 @@ function World:nameRival(onDone)
|
|||||||
if name and name:gsub(" ", "") ~= "" then
|
if name and name:gsub(" ", "") ~= "" then
|
||||||
save.rival.name = name
|
save.rival.name = name
|
||||||
else
|
else
|
||||||
save.rival.name = "SILVER"
|
save.rival.name = self:gsVersion() == 1 and "GOLD" or "SILVER"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if onDone then onDone(name) end
|
if onDone then onDone(name) end
|
||||||
@@ -10026,8 +10027,11 @@ function World:draw()
|
|||||||
G.clear(0.07, 0.05, 0.02, 1)
|
G.clear(0.07, 0.05, 0.02, 1)
|
||||||
|
|
||||||
if not self.mapImage or not self.player then
|
if not self.mapImage or not self.player then
|
||||||
G.setColor(0.85, 0.57, 0.13, 1)
|
local silver = self:gsVersion() == 1
|
||||||
G.printf("POKEMON GOLD", 0, math.floor(h * 0.38), w, "center")
|
if silver then G.setColor(0.74, 0.78, 0.83, 1)
|
||||||
|
else G.setColor(0.85, 0.57, 0.13, 1) end
|
||||||
|
G.printf(silver and "POKEMON SILVER" or "POKEMON GOLD",
|
||||||
|
0, math.floor(h * 0.38), w, "center")
|
||||||
G.setColor(0.92, 0.90, 0.82, 1)
|
G.setColor(0.92, 0.90, 0.82, 1)
|
||||||
G.printf(self.status or "No map.",
|
G.printf(self.status or "No map.",
|
||||||
0, math.floor(h * 0.48), w, "center")
|
0, math.floor(h * 0.48), w, "center")
|
||||||
@@ -10064,6 +10068,15 @@ function World:draw()
|
|||||||
if self.shake then
|
if self.shake then
|
||||||
self.camera.y = self.camera.y + (self.shake.phase or 0)
|
self.camera.y = self.camera.y + (self.shake.phase or 0)
|
||||||
end
|
end
|
||||||
|
local ScreenPosition = require("src.core.ScreenPosition")
|
||||||
|
local posLift = 0
|
||||||
|
if not ScreenPosition.skinActive(w, h) then
|
||||||
|
posLift = ScreenPosition.lift(h, 144 * self:fitScale(),
|
||||||
|
ScreenPosition.safeTop())
|
||||||
|
end
|
||||||
|
if posLift > 0 then
|
||||||
|
self.camera.y = self.camera.y + posLift / s
|
||||||
|
end
|
||||||
|
|
||||||
local override = pipelineId and self:drawPipeline(pipelineId, w, h, s) or nil
|
local override = pipelineId and self:drawPipeline(pipelineId, w, h, s) or nil
|
||||||
|
|
||||||
@@ -10089,7 +10102,7 @@ function World:draw()
|
|||||||
local pad = POKEPIC.pad[math.floor(pw / 8)] or POKEPIC.pad[7]
|
local pad = POKEPIC.pad[math.floor(pw / 8)] or POKEPIC.pad[7]
|
||||||
G.push()
|
G.push()
|
||||||
G.translate(math.floor((w - 160 * sPic) / 2),
|
G.translate(math.floor((w - 160 * sPic) / 2),
|
||||||
math.floor((h - 144 * sPic) / 2))
|
math.floor((h - 144 * sPic) / 2) - posLift)
|
||||||
G.scale(sPic, sPic)
|
G.scale(sPic, sPic)
|
||||||
G.setColor(1, 1, 1, 1)
|
G.setColor(1, 1, 1, 1)
|
||||||
local function body()
|
local function body()
|
||||||
@@ -10140,8 +10153,10 @@ function World:draw()
|
|||||||
-- The survey overlay is a developer aid, not part of the game: POKEPORT_DEV
|
-- The survey overlay is a developer aid, not part of the game: POKEPORT_DEV
|
||||||
-- (or the F3 toggle) shows it, a normal boot does not.
|
-- (or the F3 toggle) shows it, a normal boot does not.
|
||||||
if self.showDebugHud then
|
if self.showDebugHud then
|
||||||
G.setColor(0.85, 0.57, 0.13, 1)
|
local silver = self:gsVersion() == 1
|
||||||
G.printf("POKEMON GOLD", 0, 10, w, "center")
|
if silver then G.setColor(0.74, 0.78, 0.83, 1)
|
||||||
|
else G.setColor(0.85, 0.57, 0.13, 1) end
|
||||||
|
G.printf(silver and "POKEMON SILVER" or "POKEMON GOLD", 0, 10, w, "center")
|
||||||
G.setColor(0.92, 0.90, 0.82, 1)
|
G.setColor(0.92, 0.90, 0.82, 1)
|
||||||
local label = string.format("%s (%d,%d) %s · %s · zoom %s",
|
local label = string.format("%s (%d,%d) %s · %s · zoom %s",
|
||||||
self.map.id, p.cellX, p.cellY, p.facing,
|
self.map.id, p.cellX, p.cellY, p.facing,
|
||||||
|
|||||||
@@ -58,6 +58,38 @@ check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") <
|
|||||||
"secondary display monitoring starts before initial discovery")
|
"secondary display monitoring starts before initial discovery")
|
||||||
check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true),
|
check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true),
|
||||||
"a disconnected active display is rebound without replacing a live one")
|
"a disconnected active display is rebound without replacing a live one")
|
||||||
|
check(source:find("private static volatile boolean secondaryHostResumed = false;",
|
||||||
|
1, true), "secondary output tracks the primary activity lifecycle")
|
||||||
|
check(source:find("if (on && secondaryHostResumed)", 1, true)
|
||||||
|
and source:find("self == null || !secondaryHostResumed || !secondaryEnabled",
|
||||||
|
1, true),
|
||||||
|
"paused hosts cannot reopen secondary output from a late mod frame")
|
||||||
|
|
||||||
|
local pause = position("protected void onPause()")
|
||||||
|
local paused = assert(source:find("secondaryHostResumed = false;", pause, true))
|
||||||
|
local teardown = assert(source:find("teardownSecondaryDisplay();", pause, true))
|
||||||
|
local pauseSuper = assert(source:find("super.onPause();", pause, true))
|
||||||
|
check(pause < paused and paused < teardown and teardown < pauseSuper,
|
||||||
|
"pause blocks secondary setup before dismissing its output")
|
||||||
|
|
||||||
|
local resume = position("public void onResume()")
|
||||||
|
local resumeSuper = assert(source:find("super.onResume();", resume, true))
|
||||||
|
local resumed = assert(source:find("secondaryHostResumed = true;", resume, true))
|
||||||
|
local resumeSetup = assert(source:find("setupSecondaryDisplay();", resume, true))
|
||||||
|
check(resume < resumeSuper and resumeSuper < resumed and resumed < resumeSetup,
|
||||||
|
"resume permits secondary setup only after the primary activity resumes")
|
||||||
|
|
||||||
|
local destroy = position("protected void onDestroy()")
|
||||||
|
local destroyTeardown = assert(source:find("teardownSecondaryDisplay();", destroy, true))
|
||||||
|
local destroySuper = assert(source:find("super.onDestroy();", destroy, true))
|
||||||
|
check(destroy < destroyTeardown and destroyTeardown < destroySuper,
|
||||||
|
"destroy always dismisses secondary output before SDL destruction")
|
||||||
|
|
||||||
|
local mainFile = assert(io.open("main.lua", "rb"))
|
||||||
|
local main = mainFile:read("*a")
|
||||||
|
mainFile:close()
|
||||||
|
check(main:find('require("src.render.SecondScreen").setEnabled(false)', 1, true),
|
||||||
|
"returning from a game disables mod-owned secondary output")
|
||||||
|
|
||||||
check(not source:lower():find("openxr", 1, true),
|
check(not source:lower():find("openxr", 1, true),
|
||||||
"generic Android activity must not require OpenXR")
|
"generic Android activity must not require OpenXR")
|
||||||
|
|||||||
@@ -41,12 +41,21 @@ end
|
|||||||
-- Mock isReady
|
-- Mock isReady
|
||||||
RomImporter.isReady = function(v)
|
RomImporter.isReady = function(v)
|
||||||
return v == "red" or v == "gold" or v == "blue" or v == "yellow"
|
return v == "red" or v == "gold" or v == "blue" or v == "yellow"
|
||||||
|
or v == "silver"
|
||||||
end
|
end
|
||||||
|
|
||||||
local ok = RomImporter.syncAndroidShortcuts("gold")
|
local ok = RomImporter.syncAndroidShortcuts("gold")
|
||||||
check(ok == true, "syncAndroidShortcuts returns true on Android")
|
check(ok == true, "syncAndroidShortcuts returns true on Android")
|
||||||
check(#capturedShortcuts == 4, "syncAndroidShortcuts caps at 4 items")
|
check(#capturedShortcuts == 4, "syncAndroidShortcuts caps at 4 items")
|
||||||
check(capturedShortcuts[1] == "gold", "activeVersion 'gold' is placed first")
|
check(capturedShortcuts[1] == "gold", "activeVersion 'gold' is placed first")
|
||||||
|
check(capturedShortcuts[2] == "red" and capturedShortcuts[3] == "blue"
|
||||||
|
and capturedShortcuts[4] == "yellow",
|
||||||
|
"the rest follow GameVersion.ORDER until the cap")
|
||||||
|
|
||||||
|
capturedShortcuts = nil
|
||||||
|
RomImporter.syncAndroidShortcuts("silver")
|
||||||
|
check(#capturedShortcuts == 4, "a fifth ready game does not widen the payload")
|
||||||
|
check(capturedShortcuts[1] == "silver", "activeVersion 'silver' is placed first")
|
||||||
|
|
||||||
-- Test with subset of ready games (e.g. only Red and Gold)
|
-- Test with subset of ready games (e.g. only Red and Gold)
|
||||||
RomImporter.isReady = function(v)
|
RomImporter.isReady = function(v)
|
||||||
@@ -78,4 +87,4 @@ end
|
|||||||
love.system.getLaunchGame = savedGetLaunchGame
|
love.system.getLaunchGame = savedGetLaunchGame
|
||||||
love.system.updateShortcuts = nil
|
love.system.updateShortcuts = nil
|
||||||
|
|
||||||
print("8/8 checks passed (android_shortcuts_payload_test)")
|
T.finish("android_shortcuts_payload_test")
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
-- BattleState:throwBall()'s can't-be-caught path used to queue two
|
||||||
|
-- separate Strings() literals ("It dodged the\nthrown BALL!" then "This
|
||||||
|
-- POKéMON\ncan't be caught!"). The real ROM label _ItemUseBallText00
|
||||||
|
-- combines both as one \f-paged string. TextBox.new() would split \f
|
||||||
|
-- itself, but throwBall() queues through self:sayNext(), which goes
|
||||||
|
-- through the battle queue's own BattleState:startMessage() -- and that
|
||||||
|
-- one only splits on \n/\v, not \f (confirmed live: the \f landed
|
||||||
|
-- mid-line and the second sentence overflowed off the box instead of
|
||||||
|
-- starting a fresh page). The fix resolves the label once, then splits
|
||||||
|
-- it the same way TextBox.lua does and queues one sayNext per page. This
|
||||||
|
-- test fakes the label and checks the two pages reach the queue as two
|
||||||
|
-- separate messages, in order, not merged into one with a raw \f still
|
||||||
|
-- inside it.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
|
||||||
|
local function mkbattle()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 10) }
|
||||||
|
local game = { data = Data, save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end } }
|
||||||
|
local battle = BattleState.newWild(game, "FIXMON_C", 8)
|
||||||
|
battle.ghost = true -- forces the can't-be-caught path
|
||||||
|
return battle
|
||||||
|
end
|
||||||
|
|
||||||
|
-- throwBall defers its message-queuing work into self.queue via one
|
||||||
|
-- top-level self:act(fn); run only that one function to reach the
|
||||||
|
-- say() calls the fix touches. It's the last entry throwBall itself
|
||||||
|
-- appends (after the immediate sayAuto), and it further queues its own
|
||||||
|
-- self:act(function() self:executeAction(...) end) for the actual enemy
|
||||||
|
-- turn -- deliberately NOT run here (out of scope, and running the queue
|
||||||
|
-- generically after mutation risks looping into a real turn simulation)
|
||||||
|
local function runThrowBallAct(battle)
|
||||||
|
for i = #battle.queue, 1, -1 do
|
||||||
|
if battle.queue[i].fn then
|
||||||
|
battle.queue[i].fn()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function textEntries(battle)
|
||||||
|
local out = {}
|
||||||
|
for _, entry in ipairs(battle.queue) do
|
||||||
|
if entry.text then out[#out + 1] = entry.text end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- translated: the faked label's two \f-separated pages reach the queue
|
||||||
|
-- as two separate messages, in order, and neither one still contains a
|
||||||
|
-- raw \f (which would mean the battle queue's own renderer has to deal
|
||||||
|
-- with it, and it can't)
|
||||||
|
do
|
||||||
|
local battle = mkbattle()
|
||||||
|
Data.text._ItemUseBallText00 = "FAKE-DODGE!\fFAKE-CANTCATCH!"
|
||||||
|
battle:throwBall("FIX_BALL")
|
||||||
|
runThrowBallAct(battle)
|
||||||
|
local texts = textEntries(battle)
|
||||||
|
T.eq(texts[1], "FAKE-DODGE!", "page 1 reaches the queue on its own")
|
||||||
|
T.eq(texts[2], "FAKE-CANTCATCH!", "page 2 follows right after, still in order")
|
||||||
|
for _, t in ipairs(texts) do
|
||||||
|
T.check(not t:find("\f", 1, true), "no queued message still carries a raw \\f")
|
||||||
|
end
|
||||||
|
Data.text._ItemUseBallText00 = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- vanilla: no catalog entry still falls back to the two English pages,
|
||||||
|
-- split the same way
|
||||||
|
do
|
||||||
|
local battle = mkbattle()
|
||||||
|
battle:throwBall("FIX_BALL")
|
||||||
|
runThrowBallAct(battle)
|
||||||
|
local texts = textEntries(battle)
|
||||||
|
T.eq(texts[1], "It dodged the\nthrown BALL!", "vanilla page 1")
|
||||||
|
T.eq(texts[2], "This POKéMON\ncan't be caught!", "vanilla page 2")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("battle_ball_dodge_romtext")
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
-- BattleState:storeCaughtMon() queues up to two plain-Lua-literal
|
||||||
|
-- messages: the new-Pokedex-data line (_ItemUseBallText06) and, when the
|
||||||
|
-- party is full, the box-transfer line (_ItemUseBallText07/08, keyed on
|
||||||
|
-- EVENT_MET_BILL -- two full, independently-translated ROM strings, not
|
||||||
|
-- one template with a substituted PC name). This test fakes all three
|
||||||
|
-- labels and checks the queued messages use them, not the English
|
||||||
|
-- literals.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
|
||||||
|
local function findText(battle, needle)
|
||||||
|
for _, entry in ipairs(battle.queue) do
|
||||||
|
if entry.text and entry.text:find(needle, 1, true) then return entry.text end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mkbattle(partySize, metBill)
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = {}
|
||||||
|
for i = 1, partySize do
|
||||||
|
save.party[i] = Pokemon.new(Data, "FIXMON_A", 5)
|
||||||
|
end
|
||||||
|
save.flags = save.flags or {}
|
||||||
|
save.flags.EVENT_MET_BILL = metBill
|
||||||
|
local game = { data = Data, save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end } }
|
||||||
|
return BattleState.newWild(game, "FIXMON_C", 8)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- empty party: Party.add succeeds, only the new-Pokedex-data message fires
|
||||||
|
do
|
||||||
|
local battle = mkbattle(0, false)
|
||||||
|
Data.text._ItemUseBallText06 = "FAKE-DEX {RAM:wEnemyMonNick} FAKE!"
|
||||||
|
battle:storeCaughtMon()
|
||||||
|
T.eq(findText(battle, "FAKE-DEX"), "FAKE-DEX " .. battle.enemy.name .. " FAKE!",
|
||||||
|
"a translated _ItemUseBallText06 reaches the new-Pokedex-data message")
|
||||||
|
Data.text._ItemUseBallText06 = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- full party, EVENT_MET_BILL true: box transfer via _ItemUseBallText07
|
||||||
|
do
|
||||||
|
local battle = mkbattle(6, true)
|
||||||
|
Data.text._ItemUseBallText07 = "FAKE-BILL {RAM:wBoxMonNicks} FAKE!"
|
||||||
|
battle:storeCaughtMon()
|
||||||
|
T.eq(findText(battle, "FAKE-BILL"), "FAKE-BILL " .. battle.enemy.name .. " FAKE!",
|
||||||
|
"EVENT_MET_BILL true routes through the translated _ItemUseBallText07")
|
||||||
|
Data.text._ItemUseBallText07 = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- full party, EVENT_MET_BILL false: box transfer via _ItemUseBallText08
|
||||||
|
do
|
||||||
|
local battle = mkbattle(6, false)
|
||||||
|
Data.text._ItemUseBallText08 = "FAKE-SOMEONE {RAM:wBoxMonNicks} FAKE!"
|
||||||
|
battle:storeCaughtMon()
|
||||||
|
T.eq(findText(battle, "FAKE-SOMEONE"), "FAKE-SOMEONE " .. battle.enemy.name .. " FAKE!",
|
||||||
|
"EVENT_MET_BILL false routes through the translated _ItemUseBallText08")
|
||||||
|
Data.text._ItemUseBallText08 = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- vanilla, full party, EVENT_MET_BILL true: English literal, BILL's PC
|
||||||
|
do
|
||||||
|
local battle = mkbattle(6, true)
|
||||||
|
battle:storeCaughtMon()
|
||||||
|
T.eq(findText(battle, "transferred"),
|
||||||
|
battle.enemy.name .. " was\ntransferred to\nBILL's PC!",
|
||||||
|
"no catalog entry falls back to the English BILL's-PC literal")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("battle_catch_messages_romtext")
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- BattleState:onFaint's "%s\nfainted!" collapsed two distinct ROM
|
||||||
|
-- strings (_EnemyMonFaintedText already carries its own "Enemy" wording;
|
||||||
|
-- _PlayerMonFaintedText does not) into one literal, substituted with
|
||||||
|
-- displayName(battler) -- which itself runs the enemy name through a
|
||||||
|
-- SEPARATE Strings("Enemy %s", ...) call. The fix passes the raw
|
||||||
|
-- battler.name and lets each label supply its own wording. This test
|
||||||
|
-- fakes both labels and checks the raw name reaches the right one, with
|
||||||
|
-- no "Enemy" ever duplicated.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
|
||||||
|
local function mkbattle()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 10) }
|
||||||
|
local game = { data = Data, save = save,
|
||||||
|
stack = { top = function() return nil end, push = function() end } }
|
||||||
|
return BattleState.newWild(game, "FIXMON_C", 8)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- finds the queued say-message text (onFaint queues several entries: a
|
||||||
|
-- wait, then the say)
|
||||||
|
local function findText(battle)
|
||||||
|
for _, entry in ipairs(battle.queue) do
|
||||||
|
if entry.text then return entry.text end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- enemy: translated _EnemyMonFaintedText reaches onFaint, raw name only
|
||||||
|
do
|
||||||
|
local battle = mkbattle()
|
||||||
|
Data.text._EnemyMonFaintedText = "FAKE-ENEMY {RAM:wEnemyMonNick} FAKE!"
|
||||||
|
battle:onFaint(battle.enemy)
|
||||||
|
T.eq(findText(battle), "FAKE-ENEMY " .. battle.enemy.name .. " FAKE!",
|
||||||
|
"a translated _EnemyMonFaintedText reaches the enemy faint message")
|
||||||
|
Data.text._EnemyMonFaintedText = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- enemy vanilla: the English literal already carries "Enemy " itself
|
||||||
|
do
|
||||||
|
local battle = mkbattle()
|
||||||
|
battle:onFaint(battle.enemy)
|
||||||
|
T.eq(findText(battle), "Enemy " .. battle.enemy.name .. "\nfainted!",
|
||||||
|
"no catalog entry falls back to the English literal, Enemy included")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- player: translated _PlayerMonFaintedText reaches onFaint
|
||||||
|
do
|
||||||
|
local battle = mkbattle()
|
||||||
|
Data.text._PlayerMonFaintedText = "FAKE-PLAYER {RAM:wBattleMonNick} FAKE!"
|
||||||
|
battle:onFaint(battle.player)
|
||||||
|
T.eq(findText(battle), "FAKE-PLAYER " .. battle.player.name .. " FAKE!",
|
||||||
|
"a translated _PlayerMonFaintedText reaches the player faint message")
|
||||||
|
Data.text._PlayerMonFaintedText = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("battle_fainted_message_romtext")
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
-- BoxMenu's RELEASE confirmation ("Once released,\n%s is\ngone forever.
|
||||||
|
-- OK?") used to be a bare Lua literal. tests/engine/pc_release.lua only
|
||||||
|
-- ever runs with an empty Data.text, so it can't tell a properly-wired
|
||||||
|
-- t._OnceReleasedText or "..." fallback apart from a literal that never
|
||||||
|
-- looked at t at all -- every assertion there passes either way. This
|
||||||
|
-- test drives the same interactive release flow with a faked
|
||||||
|
-- Data.text._OnceReleasedText and checks the pushed TextBox's raw text
|
||||||
|
-- (captured via a TextBox.new spy, so real pagination/choice behavior is
|
||||||
|
-- untouched) uses the translated value, not the English literal.
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
local ids = T.fixtures.ids
|
||||||
|
require("src.render.Font").load(Data)
|
||||||
|
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local Boxes = require("src.pokemon.Boxes")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local BoxMenu = require("src.ui.BoxMenu")
|
||||||
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
|
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Sound = require("src.core.Sound")
|
||||||
|
|
||||||
|
local realCry, realPlay = Sound.playCry, Sound.play
|
||||||
|
Sound.playCry = function() end
|
||||||
|
Sound.play = function() end
|
||||||
|
|
||||||
|
-- spy: records every raw text TextBox.new receives, without disturbing
|
||||||
|
-- pagination/choice re-push, so the interactive flow behaves exactly like
|
||||||
|
-- pc_release.lua's
|
||||||
|
local captured
|
||||||
|
local realNew = TextBox.new
|
||||||
|
TextBox.new = function(game, text, onDone, opts)
|
||||||
|
captured[#captured + 1] = text
|
||||||
|
return realNew(game, text, onDone, opts)
|
||||||
|
end
|
||||||
|
|
||||||
|
local stack = { states = {} }
|
||||||
|
function stack:push(s) self.states[#self.states + 1] = s end
|
||||||
|
function stack:pop()
|
||||||
|
local t = self.states[#self.states]
|
||||||
|
self.states[#self.states] = nil
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
function stack:top() return self.states[#self.states] end
|
||||||
|
function stack:update(dt)
|
||||||
|
local t = self:top()
|
||||||
|
if t and t.update then t:update(dt) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local pressed = {}
|
||||||
|
local function press(btn)
|
||||||
|
pressed = { [btn] = true }
|
||||||
|
stack:update(1 / 60)
|
||||||
|
pressed = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function topMt() return getmetatable(stack:top()) end
|
||||||
|
local function mash(btn, cond, n)
|
||||||
|
for _ = 1, (n or 400) do
|
||||||
|
if cond() then return true end
|
||||||
|
press(btn)
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mkGame()
|
||||||
|
stack.states = {}
|
||||||
|
captured = {}
|
||||||
|
local game = {
|
||||||
|
data = Data,
|
||||||
|
save = SaveData.newGame(),
|
||||||
|
stack = stack,
|
||||||
|
input = {
|
||||||
|
wasPressed = function(_, key) return pressed[key] or false end,
|
||||||
|
isDown = function() return false end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
game.save.options = game.save.options or {}
|
||||||
|
game.save.options.textSpeed = 1
|
||||||
|
local box = Boxes.active(game.save)
|
||||||
|
box[1] = Pokemon.new(Data, ids.species[1], 5)
|
||||||
|
return game, box
|
||||||
|
end
|
||||||
|
|
||||||
|
local function releaseFirstMon(game)
|
||||||
|
stack:push(BoxMenu.new(game))
|
||||||
|
press("down"); press("down"); press("a") -- open RELEASE list
|
||||||
|
T.check(topMt() == ListMenu, "RELEASE opens the box list")
|
||||||
|
press("a") -- choose the first (only) mon
|
||||||
|
T.check(mash("a", function() return topMt() == ChoiceBox end),
|
||||||
|
"confirm choice opens")
|
||||||
|
-- the confirmation TextBox is captured[1] the moment it was pushed,
|
||||||
|
-- before this mash even ran
|
||||||
|
return captured[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- monName (BoxMenu.lua): mon.nickname or def.name
|
||||||
|
local function monName(box)
|
||||||
|
local mon = box[1]
|
||||||
|
local def = Data.pokemon[mon.species]
|
||||||
|
return mon.nickname or def.name
|
||||||
|
end
|
||||||
|
|
||||||
|
-- translated: the fake value must reach the pushed TextBox
|
||||||
|
do
|
||||||
|
local game, box = mkGame()
|
||||||
|
local name = monName(box)
|
||||||
|
Data.text._OnceReleasedText = "FAKE {RAM:wStringBuffer} released!"
|
||||||
|
local text = releaseFirstMon(game)
|
||||||
|
T.eq(text, "FAKE " .. name .. " released!",
|
||||||
|
"a translated _OnceReleasedText reaches the release confirmation")
|
||||||
|
Data.text._OnceReleasedText = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- vanilla: with no catalog entry, the English literal still substitutes
|
||||||
|
do
|
||||||
|
local game, box = mkGame()
|
||||||
|
local name = monName(box)
|
||||||
|
local text = releaseFirstMon(game)
|
||||||
|
T.eq(text, "Once released,\n" .. name .. " is\ngone forever. OK?",
|
||||||
|
"no catalog entry still falls back to the English literal")
|
||||||
|
end
|
||||||
|
|
||||||
|
TextBox.new = realNew
|
||||||
|
Sound.playCry, Sound.play = realCry, realPlay
|
||||||
|
T.finish("box_release_confirmation_romtext")
|
||||||
@@ -23,6 +23,7 @@ T.eq(GameVersion.generation("red"), 1, "Red is Gen 1")
|
|||||||
T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1")
|
T.eq(GameVersion.generation("blue"), 1, "Blue is Gen 1")
|
||||||
T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1")
|
T.eq(GameVersion.generation("yellow"), 1, "Yellow is Gen 1")
|
||||||
T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
|
T.eq(GameVersion.generation("gold"), 2, "Gold is Gen 2")
|
||||||
|
T.eq(GameVersion.generation("silver"), 2, "Silver is Gen 2")
|
||||||
|
|
||||||
-- ------- 2. manifest: gen2compat is opt-in and defaults off
|
-- ------- 2. manifest: gen2compat is opt-in and defaults off
|
||||||
|
|
||||||
@@ -422,6 +423,7 @@ local GEN2_HOOKS = {
|
|||||||
-- nextFn gets nil there).
|
-- nextFn gets nil there).
|
||||||
"battle.catch_exp", "battle.low_health_alarm", "battle.overlay",
|
"battle.catch_exp", "battle.low_health_alarm", "battle.overlay",
|
||||||
"battle.bottom_ui_visible", "battle.status_hud_visible",
|
"battle.bottom_ui_visible", "battle.status_hud_visible",
|
||||||
|
"battle.move_grid_navigation",
|
||||||
-- One pic path resolver for both games: the Gen 1 site is the SHARED
|
-- One pic path resolver for both games: the Gen 1 site is the SHARED
|
||||||
-- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook
|
-- src/pokemon/Sprites.lua and Gold's own battle screen calls the same hook
|
||||||
-- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no
|
-- with the Gen 1 ctx keys plus `letter` and `shiny`, which Red has no
|
||||||
|
|||||||
@@ -39,45 +39,54 @@ end
|
|||||||
local edited = 0
|
local edited = 0
|
||||||
local hooks = { editTouchControls = function() edited = edited + 1 end }
|
local hooks = { editTouchControls = function() edited = edited + 1 end }
|
||||||
|
|
||||||
local gold = LauncherSettings.open(hooks, "gold")
|
for _, version in ipairs({ "gold", "silver" }) do
|
||||||
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
|
local model = LauncherSettings.open(hooks, version)
|
||||||
check(has(gold, "VIBRATION"), "and VIBRATION")
|
check(has(model, "TOUCH PAD"), version .. " gear offers TOUCH PAD")
|
||||||
check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
|
check(has(model, "VIBRATION"), version .. " and VIBRATION")
|
||||||
check(has(gold, "VOID FILL"), "and VOID FILL")
|
check(has(model, "TOUCH CONTROLS"), version .. " and the layout editor")
|
||||||
|
check(has(model, "VOID FILL"), version .. " and VOID FILL")
|
||||||
|
|
||||||
local voidFill = findRow(gold, "VOID FILL")
|
local voidFill = findRow(model, "VOID FILL")
|
||||||
eq(voidFill.value(), "FADE ", "VOID FILL defaults to FADE")
|
eq(voidFill.value(), "FADE ", version .. " VOID FILL defaults to FADE")
|
||||||
voidFill.step(1)
|
voidFill.step(1)
|
||||||
eq(gold.opts.gold.voidFill, "water", "right stores water in the gold block")
|
eq(model.opts.gold.voidFill, "water",
|
||||||
eq(voidFill.value(), "WATER", "and the row reads WATER")
|
version .. " right stores water in the gold block")
|
||||||
voidFill.step(-1)
|
eq(voidFill.value(), "WATER", version .. " and the row reads WATER")
|
||||||
eq(gold.opts.gold.voidFill, "fade", "left restores fade")
|
voidFill.step(-1)
|
||||||
|
eq(model.opts.gold.voidFill, "fade", version .. " left restores fade")
|
||||||
|
|
||||||
-- Every write has to land in the gold block: the flat keys beside it are
|
-- Every write has to land in the gen2 block: the flat keys beside it are
|
||||||
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299).
|
-- Red's, and no Gen 2 boot reads them (src/core/gen2/Save.lua:299).
|
||||||
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gold
|
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gen 2
|
||||||
-- rows leave them exactly as they found them.
|
-- rows leave them exactly as they found them.
|
||||||
local flatPad = gold.opts.touchControls
|
local flatPad = model.opts.touchControls
|
||||||
local flatBuzz = gold.opts.haptics
|
local flatBuzz = model.opts.haptics
|
||||||
|
|
||||||
local pad = findRow(gold, "TOUCH PAD")
|
local pad = findRow(model, "TOUCH PAD")
|
||||||
local before = pad.value()
|
local before = pad.value()
|
||||||
pad.step(1)
|
pad.step(1)
|
||||||
check(pad.value() ~= before, "stepping TOUCH PAD flips it")
|
check(pad.value() ~= before, version .. " stepping TOUCH PAD flips it")
|
||||||
check(type(gold.opts.gold) == "table", "into the gold block")
|
check(type(model.opts.gold) == "table", version .. " into the gold block")
|
||||||
eq(gold.opts.gold.touchControls.enabled, false, "which now carries enabled")
|
eq(model.opts.gold.touchControls.enabled, false,
|
||||||
eq(gold.opts.touchControls, flatPad, "leaving the flat Gen 1 key alone")
|
version .. " which now carries enabled")
|
||||||
|
eq(model.opts.touchControls, flatPad,
|
||||||
|
version .. " leaving the flat Gen 1 key alone")
|
||||||
|
eq(model.opts.silver, nil,
|
||||||
|
version .. " and inventing no second Gen 2 block beside it")
|
||||||
|
|
||||||
local buzz = findRow(gold, "VIBRATION")
|
local buzz = findRow(model, "VIBRATION")
|
||||||
local buzzBefore = buzz.value()
|
local buzzBefore = buzz.value()
|
||||||
buzz.step(1)
|
buzz.step(1)
|
||||||
check(buzz.value() ~= buzzBefore, "stepping VIBRATION moves the level")
|
check(buzz.value() ~= buzzBefore, version .. " stepping VIBRATION moves the level")
|
||||||
eq(gold.opts.gold.haptics, TouchControls.normalizeHaptics(gold.opts.gold.haptics),
|
eq(model.opts.gold.haptics,
|
||||||
"VIBRATION stores a level the shared module knows")
|
TouchControls.normalizeHaptics(model.opts.gold.haptics),
|
||||||
eq(gold.opts.haptics, flatBuzz, "also without touching Red's")
|
version .. " VIBRATION stores a level the shared module knows")
|
||||||
|
eq(model.opts.haptics, flatBuzz, version .. " also without touching Red's")
|
||||||
|
|
||||||
findRow(gold, "TOUCH CONTROLS").action()
|
edited = 0
|
||||||
eq(edited, 1, "the editor row reaches the host hook")
|
findRow(model, "TOUCH CONTROLS").action()
|
||||||
|
eq(edited, 1, version .. " the editor row reaches the host hook")
|
||||||
|
end
|
||||||
|
|
||||||
-- The Gen 1 gear is untouched by the extraction: same three rows, still on
|
-- The Gen 1 gear is untouched by the extraction: same three rows, still on
|
||||||
-- the flat table.
|
-- the flat table.
|
||||||
@@ -94,6 +103,8 @@ eq(findRow(red, "TOUCH PAD").value() ~= nil, true, "and reading it back")
|
|||||||
-- rather than dead, on both sides.
|
-- rather than dead, on both sides.
|
||||||
eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
|
eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
|
||||||
"no hook, no editor row on Gold")
|
"no hook, no editor row on Gold")
|
||||||
|
eq(has(LauncherSettings.open(nil, "silver"), "TOUCH CONTROLS"), false,
|
||||||
|
"nor on Silver")
|
||||||
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
|
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
|
||||||
"nor on Red")
|
"nor on Red")
|
||||||
-- The Edit row hands the screen to the host, and the host has to know WHICH
|
-- The Edit row hands the screen to the host, and the host has to know WHICH
|
||||||
|
|||||||
@@ -155,28 +155,34 @@ modImp.mods = mods
|
|||||||
modImp._ensureMods = function() return mods end
|
modImp._ensureMods = function() return mods end
|
||||||
LauncherView.draw(modImp)
|
LauncherView.draw(modImp)
|
||||||
LauncherView.draw(modImp)
|
LauncherView.draw(modImp)
|
||||||
check((modImp._modScrollMax or 0) > 0,
|
local reg = modImp._tabRegionRect
|
||||||
"60 mods overflow the list viewport inside the panel")
|
check((modImp._tabScrollMax.mods or 0) > reg.h,
|
||||||
local list = modImp._modListRect
|
"60 installed mods are ONE continuous list: the region's travel spans "
|
||||||
check(list.x + list.w
|
.. "the whole list, not one page of it")
|
||||||
<= modImp._tabRegionRect.x + modImp._tabRegionRect.w - Kit.scrollBarW(),
|
eq(modImp._pages.mods, nil, "and no page state is ever minted for it")
|
||||||
"the rows stop short of the region's scrollbar gutter")
|
pointer(reg.x + 10, reg.y + 40)
|
||||||
pointer(list.x + 10, list.y + 10)
|
|
||||||
modImp._wheelY = -1
|
modImp._wheelY = -1
|
||||||
LauncherView.draw(modImp)
|
LauncherView.draw(modImp)
|
||||||
check((modImp.modScroll or 0) > 0, "a notch over the mod list scrolls the list")
|
check((modImp._tabScroll.mods or 0) > 0,
|
||||||
eq(modImp._tabScroll.mods or 0, 0, "not the panel region around it")
|
"a notch over the region scrolls the list like any other tab")
|
||||||
eq(modImp._pageScroll, 0, "and not the page behind that")
|
eq(modImp._pageScroll, 0, "without reaching the page behind it")
|
||||||
|
|
||||||
|
modImp._tabScroll.mods = modImp._tabScrollMax.mods
|
||||||
|
LauncherView.draw(modImp)
|
||||||
|
pointer(reg.x + 10, reg.y + reg.h * 0.5)
|
||||||
|
modImp._wheelY = -1
|
||||||
|
LauncherView.draw(modImp)
|
||||||
|
eq(modImp._pages.mods, nil,
|
||||||
|
"bottoming the list out never auto-advances any pager")
|
||||||
|
eq(modImp._tabScroll.mods, modImp._tabScrollMax.mods,
|
||||||
|
"the list just rests at its end")
|
||||||
|
|
||||||
modImp._modActions = "mod1"
|
modImp._modActions = "mod1"
|
||||||
local shielded = modImp.modScroll
|
local shieldedAt = modImp._tabScroll.mods
|
||||||
local shieldedPage = modImp._pageScroll
|
|
||||||
pointer(list.x + 10, list.y + 10)
|
|
||||||
modImp._wheelY = -1
|
modImp._wheelY = -1
|
||||||
LauncherView.draw(modImp)
|
LauncherView.draw(modImp)
|
||||||
eq(modImp.modScroll, shielded, "a shielded mod list ignores the notch")
|
eq(modImp._tabScroll.mods, shieldedAt,
|
||||||
eq(modImp._tabScroll.mods or 0, 0, "and so does the region under the scrim")
|
"a shielded mod list ignores the notch under the scrim")
|
||||||
eq(modImp._pageScroll, shieldedPage, "and the page behind that")
|
|
||||||
modImp._modActions = nil
|
modImp._modActions = nil
|
||||||
modImp._wheelY = 0
|
modImp._wheelY = 0
|
||||||
LauncherView.draw(modImp)
|
LauncherView.draw(modImp)
|
||||||
@@ -225,32 +231,27 @@ dragMods.mods = mods
|
|||||||
dragMods._ensureMods = function() return mods end
|
dragMods._ensureMods = function() return mods end
|
||||||
LauncherView.draw(dragMods)
|
LauncherView.draw(dragMods)
|
||||||
LauncherView.draw(dragMods)
|
LauncherView.draw(dragMods)
|
||||||
local dlist = dragMods._modListRect
|
local dreg = dragMods._tabRegionRect
|
||||||
local dListMax = dragMods._modScrollMax
|
|
||||||
local dRegionMax = dragMods._tabScrollMax.mods
|
local dRegionMax = dragMods._tabScrollMax.mods
|
||||||
check(dListMax > 0 and dRegionMax > 0,
|
check(dRegionMax > 0, "the mods region scrolls its overscan like any tab")
|
||||||
"the mods tab has both an inner list and a region to scroll")
|
LauncherView.touchpressed(dragMods, 7, dreg.x + 20, dreg.y + 30)
|
||||||
LauncherView.touchpressed(dragMods, 7, dlist.x + 20, dlist.y + 30)
|
LauncherView.touchmoved(dragMods, 7, dreg.x + 20, dreg.y + 30 - 60)
|
||||||
LauncherView.touchmoved(dragMods, 7, dlist.x + 20, dlist.y + 30 - 60)
|
eq(dragMods._tabScroll.mods, math.min(60, dRegionMax),
|
||||||
eq(dragMods.modScroll, math.min(60, dListMax),
|
"a drag moves the region by the finger's travel")
|
||||||
"the first pixels of the drag move the list")
|
LauncherView.touchmoved(dragMods, 7, dreg.x + 20,
|
||||||
eq(dragMods._tabScroll.mods or 0, 0, "and nothing else")
|
dreg.y + 30 - 60 - dRegionMax * 2)
|
||||||
LauncherView.touchmoved(dragMods, 7, dlist.x + 20,
|
eq(dragMods._tabScroll.mods, dRegionMax, "carrying on saturates the region")
|
||||||
dlist.y + 30 - 60 - dListMax - dRegionMax * 2)
|
|
||||||
eq(dragMods.modScroll, dListMax, "carrying on saturates the list")
|
|
||||||
eq(dragMods._tabScroll.mods, dRegionMax,
|
|
||||||
"then the same gesture walks the region to its bottom")
|
|
||||||
check((dragMods._pageScroll or 0) > 0, "and only then reaches the page")
|
check((dragMods._pageScroll or 0) > 0, "and only then reaches the page")
|
||||||
LauncherView.touchreleased(dragMods, 7, dlist.x + 20, dlist.y - 900)
|
LauncherView.touchreleased(dragMods, 7, dreg.x + 20, dreg.y - 900)
|
||||||
|
|
||||||
dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } }
|
dragMods._skins = { { id = "s1", source = "user", controls = 8, pages = 1 } }
|
||||||
for i = 2, 12 do
|
for i = 2, 12 do
|
||||||
dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 }
|
dragMods._skins[i] = { id = "s" .. i, source = "user", controls = 8, pages = 1 }
|
||||||
end
|
end
|
||||||
dragMods._ensureSkins = function() return dragMods._skins end
|
dragMods._ensureSkins = function() return dragMods._skins end
|
||||||
dragMods.modScroll = 0
|
local heldModsPage = dragMods._pages.mods or 1
|
||||||
local heldModScroll = dragMods.modScroll
|
local heldModsAt = dragMods._tabScroll.mods
|
||||||
local overList = dlist.y + 30
|
local overList = dreg.y + 60
|
||||||
dragMods:_switchTab("skins")
|
dragMods:_switchTab("skins")
|
||||||
LauncherView.draw(dragMods)
|
LauncherView.draw(dragMods)
|
||||||
LauncherView.draw(dragMods)
|
LauncherView.draw(dragMods)
|
||||||
@@ -260,8 +261,10 @@ LauncherView.touchpressed(dragMods, 9, sreg.x + 20, overList)
|
|||||||
LauncherView.touchmoved(dragMods, 9, sreg.x + 20, overList - 200)
|
LauncherView.touchmoved(dragMods, 9, sreg.x + 20, overList - 200)
|
||||||
check((dragMods._tabScroll.skins or 0) > 0,
|
check((dragMods._tabScroll.skins or 0) > 0,
|
||||||
"a drag on the skins tab scrolls the skins tab")
|
"a drag on the skins tab scrolls the skins tab")
|
||||||
eq(dragMods.modScroll, heldModScroll,
|
eq(dragMods._pages.mods or 1, heldModsPage,
|
||||||
"and leaves the mod list where the player parked it")
|
"and leaves the mod list on the page the player parked it")
|
||||||
|
eq(dragMods._tabScroll.mods, heldModsAt,
|
||||||
|
"with its region offset held for the return trip")
|
||||||
LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400)
|
LauncherView.touchreleased(dragMods, 9, sreg.x + 20, sreg.y - 400)
|
||||||
|
|
||||||
love.graphics.polygon = love.graphics.polygon or function() end
|
love.graphics.polygon = love.graphics.polygon or function() end
|
||||||
@@ -297,6 +300,88 @@ LauncherView.draw(edgeImp)
|
|||||||
check((edgeImp._tabScroll.skins or 0) > 0,
|
check((edgeImp._tabScroll.skins or 0) > 0,
|
||||||
"which reaches the tab region even though the cursor is below it")
|
"which reaches the tab region even though the cursor is below it")
|
||||||
|
|
||||||
|
Kit.blockClicks = false
|
||||||
|
Kit._clipRect = nil
|
||||||
|
Kit.wheelY = 0
|
||||||
|
Kit.mouseX, Kit.mouseY = 400, 400
|
||||||
|
Kit.dragBegin(50, 50)
|
||||||
|
Kit.dragAdd(120)
|
||||||
|
local pg = Kit.wheelPage(0, 0, 100, 100, 1, 100, 10)
|
||||||
|
eq(pg, 3, "a drag that crossed two half-heights turns two pages")
|
||||||
|
eq(Kit.dragAccum, 20, "and keeps the remainder for the next flip")
|
||||||
|
Kit.dragAdd(-140)
|
||||||
|
pg = Kit.wheelPage(0, 0, 100, 100, pg, 100, 10)
|
||||||
|
eq(pg, 1, "dragging back down returns those pages")
|
||||||
|
eq(Kit.dragAccum, -20, "with the remainder's sign preserved")
|
||||||
|
Kit.dragAdd(-80)
|
||||||
|
pg = Kit.wheelPage(0, 0, 100, 100, pg, 100, 10)
|
||||||
|
eq(pg, 1, "a drag past the first page clamps to it")
|
||||||
|
eq(Kit.dragAccum, 0, "and drops the pile-up so reversing is instant")
|
||||||
|
Kit.dragAdd(200)
|
||||||
|
pg = Kit.wheelPage(200, 200, 100, 100, pg, 100, 10)
|
||||||
|
eq(pg, 1, "a drag that began outside the list is not the list's")
|
||||||
|
eq(Kit.dragAccum, 200, "and its travel stays queued")
|
||||||
|
Kit.dragEnd()
|
||||||
|
eq(Kit.dragAccum, 0, "releasing the button retires the gesture")
|
||||||
|
|
||||||
|
window(360, 780)
|
||||||
|
local mouseImp = skinLauncher(12)
|
||||||
|
LauncherView.draw(mouseImp)
|
||||||
|
LauncherView.draw(mouseImp)
|
||||||
|
local mreg = mouseImp._tabRegionRect
|
||||||
|
local mmax = mouseImp._tabScrollMax.skins
|
||||||
|
check(mmax > 0, "the mouse-dragged panel has travel")
|
||||||
|
local mdown = false
|
||||||
|
love.mouse.isDown = function() return mdown end
|
||||||
|
|
||||||
|
pointer(mreg.x + 20, mreg.y + 40)
|
||||||
|
mdown = true
|
||||||
|
LauncherView.update(mouseImp, 0.016)
|
||||||
|
check(mouseImp._clickPt == nil,
|
||||||
|
"a press over a scrollable region mints no click")
|
||||||
|
check(mouseImp._mouseAt ~= nil, "it arms a drag instead")
|
||||||
|
pointer(mreg.x + 20, mreg.y + 40 - 200)
|
||||||
|
LauncherView.update(mouseImp, 0.016)
|
||||||
|
eq(mouseImp._tabScroll.skins, math.min(200, mmax),
|
||||||
|
"dragging the held mouse scrolls the panel by its travel")
|
||||||
|
eq(mouseImp._pageScroll or 0, 0,
|
||||||
|
"while the panel still has travel, the page waits")
|
||||||
|
pointer(mreg.x + 20, mreg.y + 40 - 200 - mmax * 2)
|
||||||
|
LauncherView.update(mouseImp, 0.016)
|
||||||
|
eq(mouseImp._tabScroll.skins, mmax,
|
||||||
|
"a longer mouse drag reaches the panel's bottom")
|
||||||
|
check((mouseImp._pageScroll or 0) > 0, "and spills into the page from there")
|
||||||
|
mdown = false
|
||||||
|
LauncherView.update(mouseImp, 0.016)
|
||||||
|
check(mouseImp._clickPt == nil, "a released drag is not a click")
|
||||||
|
check(mouseImp._mouseAt == nil, "and the gesture is retired")
|
||||||
|
|
||||||
|
pointer(mreg.x + 20, mreg.y + 40)
|
||||||
|
mdown = true
|
||||||
|
LauncherView.update(mouseImp, 0.016)
|
||||||
|
check(mouseImp._clickPt == nil, "a fresh press still holds its click back")
|
||||||
|
mdown = false
|
||||||
|
LauncherView.update(mouseImp, 0.016)
|
||||||
|
check(mouseImp._clickPt ~= nil,
|
||||||
|
"press and release without travel is still a tap, on release")
|
||||||
|
mouseImp._clickPt = nil
|
||||||
|
|
||||||
|
LauncherView.draw(gameImp)
|
||||||
|
LauncherView.draw(gameImp)
|
||||||
|
check((gameImp._noDragN or 0) > 0, "the game tab publishes its cartridge rect")
|
||||||
|
local cart = gameImp._noDragRects[1]
|
||||||
|
pointer(cart.x + cart.w / 2, cart.y + cart.h / 2)
|
||||||
|
mdown = true
|
||||||
|
LauncherView.update(gameImp, 0.016)
|
||||||
|
check(gameImp._clickPt ~= nil,
|
||||||
|
"a press on the cartridge clicks at once so its own spin-drag still owns "
|
||||||
|
.. "the gesture")
|
||||||
|
check(gameImp._mouseAt == nil, "and never arms the scroll drag")
|
||||||
|
gameImp._clickPt = nil
|
||||||
|
mdown = false
|
||||||
|
LauncherView.update(gameImp, 0.016)
|
||||||
|
love.mouse.isDown = nil
|
||||||
|
|
||||||
local function read(path)
|
local function read(path)
|
||||||
local f = assert(io.open(path, "r"))
|
local f = assert(io.open(path, "r"))
|
||||||
local src = f:read("*a")
|
local src = f:read("*a")
|
||||||
@@ -308,8 +393,8 @@ local view = read("src/import/LauncherView.lua")
|
|||||||
check(view:find("Kit.scrollBegin(", 1, true) ~= nil,
|
check(view:find("Kit.scrollBegin(", 1, true) ~= nil,
|
||||||
"the panel dispatch opens a scroll region")
|
"the panel dispatch opens a scroll region")
|
||||||
check(view:find("Kit.scrollEnd(", 1, true) ~= nil, "and closes it")
|
check(view:find("Kit.scrollEnd(", 1, true) ~= nil, "and closes it")
|
||||||
check(view:find("modListWantsWheel", 1, true) ~= nil,
|
check(view:find("modListWantsWheel", 1, true) == nil,
|
||||||
"the nested mod list is asked before the region takes a notch")
|
"no nested list steals the wheel from the region any more")
|
||||||
check(view:find("start.region", 1, true) ~= nil,
|
check(view:find("start.region", 1, true) ~= nil,
|
||||||
"a touch drag that began in the region scrolls the region")
|
"a touch drag that began in the region scrolls the region")
|
||||||
check(view:find("Kit.scrollGutter(", 1, true) ~= nil,
|
check(view:find("Kit.scrollGutter(", 1, true) ~= nil,
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ do
|
|||||||
"a version id names exactly that game")
|
"a version id names exactly that game")
|
||||||
eq(table.concat(ModTargets.expand("GEN1"), ","), "red,blue,yellow",
|
eq(table.concat(ModTargets.expand("GEN1"), ","), "red,blue,yellow",
|
||||||
"gen1 is every Gen 1 game, case-insensitive")
|
"gen1 is every Gen 1 game, case-insensitive")
|
||||||
eq(table.concat(ModTargets.expand("gen2"), ","), "gold",
|
eq(table.concat(ModTargets.expand("gen2"), ","), "gold,silver",
|
||||||
"gen2 is every Gen 2 game")
|
"gen2 is every Gen 2 game")
|
||||||
|
eq(table.concat(ModTargets.expand("silver"), ","), "silver",
|
||||||
|
"and each of them names itself")
|
||||||
eq(table.concat(ModTargets.expand("all"), ","),
|
eq(table.concat(ModTargets.expand("all"), ","),
|
||||||
table.concat(GameVersion.ORDER, ","), "all is the launcher order itself")
|
table.concat(GameVersion.ORDER, ","), "all is the launcher order itself")
|
||||||
eq(ModTargets.expand("silver"), nil, "a game this engine has no cache for")
|
eq(ModTargets.expand("crystal"), nil, "a game this engine has no cache for")
|
||||||
eq(ModTargets.expand("gen9"), nil, "a generation with no games is unknown")
|
eq(ModTargets.expand("gen9"), nil, "a generation with no games is unknown")
|
||||||
eq(ModTargets.expand(7), nil, "a non-string token is not a game")
|
eq(ModTargets.expand(7), nil, "a non-string token is not a game")
|
||||||
end
|
end
|
||||||
@@ -56,7 +58,7 @@ end
|
|||||||
do
|
do
|
||||||
eq(list(mf({})), "red,blue,yellow",
|
eq(list(mf({})), "red,blue,yellow",
|
||||||
"a manifest with no games key is Gen 1, which is what it was tested as")
|
"a manifest with no games key is Gen 1, which is what it was tested as")
|
||||||
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold",
|
eq(list(mf({ gen2compat = true })), "red,blue,yellow,gold,silver",
|
||||||
"gen2compat keeps Gen 1 and adds Gen 2")
|
"gen2compat keeps Gen 1 and adds Gen 2")
|
||||||
eq(mf({}).gen2compat, false, "and the derived flag agrees")
|
eq(mf({}).gen2compat, false, "and the derived flag agrees")
|
||||||
eq(mf({ gen2compat = true }).gen2compat, true, "both ways")
|
eq(mf({ gen2compat = true }).gen2compat, true, "both ways")
|
||||||
@@ -67,14 +69,14 @@ end
|
|||||||
|
|
||||||
do
|
do
|
||||||
local gen2 = mf({ games = { "gen2" } })
|
local gen2 = mf({ games = { "gen2" } })
|
||||||
eq(list(gen2), "gold", "games can name Gen 2 alone")
|
eq(list(gen2), "gold,silver", "games can name Gen 2 alone")
|
||||||
eq(gen2.gen2compat, true, "which IS the gen2compat claim the gate reads")
|
eq(gen2.gen2compat, true, "which IS the gen2compat claim the gate reads")
|
||||||
local both = mf({ games = { "gen1", "gen2" } })
|
local both = mf({ games = { "gen1", "gen2" } })
|
||||||
eq(list(both), "red,blue,yellow,gold", "or both generations")
|
eq(list(both), "red,blue,yellow,gold,silver", "or both generations")
|
||||||
local one = mf({ games = { "blue" } })
|
local one = mf({ games = { "blue" } })
|
||||||
eq(list(one), "blue", "or one single game")
|
eq(list(one), "blue", "or one single game")
|
||||||
eq(one.gen2compat, false, "a Gen 1 game is not a Gen 2 claim")
|
eq(one.gen2compat, false, "a Gen 1 game is not a Gen 2 claim")
|
||||||
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold",
|
eq(list(mf({ games = { "red" }, gen2compat = true })), "red,gold,silver",
|
||||||
"an old gen2compat beside a new games list still adds its game")
|
"an old gen2compat beside a new games list still adds its game")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -224,10 +226,12 @@ do
|
|||||||
local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({
|
local bad = ModProfile.decode(require("src.core.SaveSerializer").encode({
|
||||||
format = "g1rmodlist", formatVersion = 1,
|
format = "g1rmodlist", formatVersion = 1,
|
||||||
profile = { name = "P", enabledByVersion = {
|
profile = { name = "P", enabledByVersion = {
|
||||||
gold = { a = true }, silver = { a = true }, red = "nope" } },
|
gold = { a = true }, silver = { a = true }, crystal = { a = true },
|
||||||
|
red = "nope" } },
|
||||||
}))
|
}))
|
||||||
eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept")
|
eq(bad.enabledByVersion.gold.a, true, "a shared file's known game is kept")
|
||||||
eq(bad.enabledByVersion.silver, nil, "an unknown game is dropped on read")
|
eq(bad.enabledByVersion.silver.a, true, "every one of them, not just the first")
|
||||||
|
eq(bad.enabledByVersion.crystal, nil, "an unknown game is dropped on read")
|
||||||
eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table")
|
eq(bad.enabledByVersion.red, nil, "and so is a bucket that is not a table")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
-- Two OverworldController.lua messages used to be bare Lua literals:
|
||||||
|
-- applyFieldPoison()'s "%s\nfainted!" (the third of three collapsed
|
||||||
|
-- fainted-message ROM strings, _PokemonFaintedText) and
|
||||||
|
-- useSoftboiledFieldMove()'s "It won't have\nany effect."/"%s's HP\nwas
|
||||||
|
-- restored!" (the same _ItemUseNoEffectText/_PotionText labels
|
||||||
|
-- ItemEffects.lua's real potion message already uses -- _PotionText's
|
||||||
|
-- second slot is the actual amount healed, which the old literal never
|
||||||
|
-- showed at all).
|
||||||
|
--
|
||||||
|
-- Uses the debug.setupvalue technique already established in
|
||||||
|
-- oaks_pc_flow.lua to fake the module-level Game/TextBox upvalues
|
||||||
|
-- ROM-free, without going through the heavy OverworldState:enter().
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local OW = require("src.world.OverworldController")
|
||||||
|
|
||||||
|
local function setUpvalue(fn, name, val)
|
||||||
|
local i = 1
|
||||||
|
while true do
|
||||||
|
local n = debug.getupvalue(fn, i)
|
||||||
|
if not n then return false end
|
||||||
|
if n == name then debug.setupvalue(fn, i, val); return true end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local pushed = {}
|
||||||
|
local textBoxStub = {
|
||||||
|
new = function(_, text, onDone, opts)
|
||||||
|
return { text = text, onDone = onDone, opts = opts }
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
local realSound = package.loaded["src.core.Sound"]
|
||||||
|
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
|
||||||
|
|
||||||
|
local function mkGame()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||||
|
pushed = {}
|
||||||
|
return {
|
||||||
|
data = Data, save = save,
|
||||||
|
stack = { push = function(_, item) pushed[#pushed + 1] = item end },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, name in ipairs({ "applyFieldPoison", "useSoftboiledFieldMove" }) do
|
||||||
|
T.check(setUpvalue(OW[name], "Game", mkGame()), ("Game upvalue on %s"):format(name))
|
||||||
|
T.check(setUpvalue(OW[name], "TextBox", textBoxStub), ("TextBox upvalue on %s"):format(name))
|
||||||
|
end
|
||||||
|
|
||||||
|
local fakeSelf = setmetatable({}, { __index = OW })
|
||||||
|
|
||||||
|
-- ---- applyFieldPoison: _PokemonFaintedText ----
|
||||||
|
do
|
||||||
|
local game = mkGame()
|
||||||
|
setUpvalue(OW.applyFieldPoison, "Game", game)
|
||||||
|
local mon = game.save.party[1]
|
||||||
|
mon.status = "PSN"
|
||||||
|
mon.hp = 1 -- one poison tick (1 dmg by default) faints it
|
||||||
|
game.save.poisonSteps = 3 -- (3+1) % 4 == 0: this step ticks poison
|
||||||
|
|
||||||
|
Data.text._PokemonFaintedText = "FAKE {RAM:wNameBuffer} FAKE!"
|
||||||
|
fakeSelf:applyFieldPoison()
|
||||||
|
T.eq(pushed[1] and pushed[1].text, "FAKE " .. (mon.nickname or "FIXMON A") .. " FAKE!",
|
||||||
|
"a translated _PokemonFaintedText reaches the field-poison faint message")
|
||||||
|
Data.text._PokemonFaintedText = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- vanilla: no catalog entry, English literal
|
||||||
|
do
|
||||||
|
local game = mkGame()
|
||||||
|
setUpvalue(OW.applyFieldPoison, "Game", game)
|
||||||
|
local mon = game.save.party[1]
|
||||||
|
mon.status = "PSN"
|
||||||
|
mon.hp = 1
|
||||||
|
game.save.poisonSteps = 3
|
||||||
|
|
||||||
|
fakeSelf:applyFieldPoison()
|
||||||
|
T.eq(pushed[1] and pushed[1].text,
|
||||||
|
(mon.nickname or "FIXMON A") .. "\nfainted!",
|
||||||
|
"no catalog entry falls back to the English fainted literal")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- useSoftboiledFieldMove: _ItemUseNoEffectText / _PotionText ----
|
||||||
|
do
|
||||||
|
local game = mkGame()
|
||||||
|
setUpvalue(OW.useSoftboiledFieldMove, "Game", game)
|
||||||
|
local user = Pokemon.new(Data, "FIXMON_A", 20)
|
||||||
|
local target = Pokemon.new(Data, "FIXMON_B", 20)
|
||||||
|
target.hp = target.stats.hp -- already full: no effect
|
||||||
|
|
||||||
|
Data.text._ItemUseNoEffectText = "FAKE-NOEFFECT!"
|
||||||
|
local ok = fakeSelf:useSoftboiledFieldMove(user, target)
|
||||||
|
T.check(ok == false, "a full-HP target reports no effect")
|
||||||
|
T.eq(pushed[1] and pushed[1].text, "FAKE-NOEFFECT!",
|
||||||
|
"a translated _ItemUseNoEffectText reaches the no-effect message")
|
||||||
|
Data.text._ItemUseNoEffectText = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local game = mkGame()
|
||||||
|
setUpvalue(OW.useSoftboiledFieldMove, "Game", game)
|
||||||
|
local user = Pokemon.new(Data, "FIXMON_A", 20)
|
||||||
|
local target = Pokemon.new(Data, "FIXMON_B", 20)
|
||||||
|
target.hp = target.stats.hp - 10 -- missing exactly 10 HP
|
||||||
|
|
||||||
|
Data.text._PotionText = "FAKE {RAM:wNameBuffer} healed {NUM:wHPBarHPDifference, 2, 3}!"
|
||||||
|
local ok = fakeSelf:useSoftboiledFieldMove(user, target)
|
||||||
|
T.check(ok == true, "a damaged target heals successfully")
|
||||||
|
T.eq(pushed[1] and pushed[1].text,
|
||||||
|
"FAKE " .. (target.nickname or "FIXMON B") .. " healed 10!",
|
||||||
|
"a translated _PotionText reaches the heal message, amount included")
|
||||||
|
Data.text._PotionText = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- vanilla: no catalog entry, the fallback's single %s slot still fills
|
||||||
|
-- correctly (the amount is silently dropped by design, same as
|
||||||
|
-- ItemEffects.lua's own _PotionText fallback -- not a regression, this
|
||||||
|
-- matches the pre-fix literal's behavior exactly)
|
||||||
|
do
|
||||||
|
local game = mkGame()
|
||||||
|
setUpvalue(OW.useSoftboiledFieldMove, "Game", game)
|
||||||
|
local user = Pokemon.new(Data, "FIXMON_A", 20)
|
||||||
|
local target = Pokemon.new(Data, "FIXMON_B", 20)
|
||||||
|
target.hp = target.stats.hp - 10
|
||||||
|
local ok = fakeSelf:useSoftboiledFieldMove(user, target)
|
||||||
|
T.check(ok == true, "a damaged target heals successfully (vanilla)")
|
||||||
|
T.eq(pushed[1] and pushed[1].text,
|
||||||
|
(target.nickname or "FIXMON B") .. "'s HP\nwas restored!",
|
||||||
|
"no catalog entry falls back to the English literal (no amount shown)")
|
||||||
|
end
|
||||||
|
|
||||||
|
package.loaded["src.core.Sound"] = realSound
|
||||||
|
T.finish("overworld_field_faint_heal_romtext")
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
-- OverworldState:tryHiddenObject()'s "%s found\n%s!" message used to
|
||||||
|
-- substitute both the player name and the item name into one bare Lua
|
||||||
|
-- literal. The real _FoundHiddenItemText label leads with a {PLAYER}
|
||||||
|
-- named token, which romText auto-fills from a 2-arg call in the same
|
||||||
|
-- order the literal already used -- this test checks both slots land
|
||||||
|
-- correctly (an accidental argument swap is the easy mistake this shape
|
||||||
|
-- invites).
|
||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.modkit")
|
||||||
|
local Data = T.fixtures.fresh()
|
||||||
|
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local OW = require("src.world.OverworldController")
|
||||||
|
|
||||||
|
local function setUpvalue(fn, name, val)
|
||||||
|
local i = 1
|
||||||
|
while true do
|
||||||
|
local n = debug.getupvalue(fn, i)
|
||||||
|
if not n then return false end
|
||||||
|
if n == name then debug.setupvalue(fn, i, val); return true end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local pushed = {}
|
||||||
|
local textBoxStub = {
|
||||||
|
new = function(_, text, onDone, opts)
|
||||||
|
return { text = text, onDone = onDone, opts = opts }
|
||||||
|
end,
|
||||||
|
soundOpts = function() return {} end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local MAP_ID = "FIX_TOWN"
|
||||||
|
Data.field.hiddenItems[MAP_ID] = { { x = 3, y = 3, item = "FIX_BALL" } }
|
||||||
|
|
||||||
|
local function mkGame()
|
||||||
|
local save = SaveData.newGame()
|
||||||
|
save.player.name = "FAKEPLAYER"
|
||||||
|
pushed = {}
|
||||||
|
return {
|
||||||
|
data = Data, save = save,
|
||||||
|
stack = { push = function(_, item) pushed[#pushed + 1] = item end },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
T.check(setUpvalue(OW.tryHiddenObject, "Game", mkGame()), "Game upvalue on tryHiddenObject")
|
||||||
|
T.check(setUpvalue(OW.tryHiddenObject, "TextBox", textBoxStub), "TextBox upvalue on tryHiddenObject")
|
||||||
|
|
||||||
|
local fakeSelf = setmetatable({ map = { id = MAP_ID } }, { __index = OW })
|
||||||
|
|
||||||
|
-- translated: player name and item name both land in the right slots
|
||||||
|
do
|
||||||
|
local game = mkGame()
|
||||||
|
setUpvalue(OW.tryHiddenObject, "Game", game)
|
||||||
|
Data.text._FoundHiddenItemText = "FAKE {PLAYER} found FAKE {RAM:wNameBuffer} FAKE!"
|
||||||
|
local found = fakeSelf:tryHiddenObject(3, 3)
|
||||||
|
T.check(found == true, "the hidden item at (3,3) is found")
|
||||||
|
T.eq(pushed[1] and pushed[1].text,
|
||||||
|
"FAKE FAKEPLAYER found FAKE FIX BALL FAKE!",
|
||||||
|
"a translated _FoundHiddenItemText fills both {PLAYER} and the item name")
|
||||||
|
Data.text._FoundHiddenItemText = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- vanilla: no catalog entry, so romText falls back to plain
|
||||||
|
-- Strings(fallback, ...) -- the fallback literal is "%s found\n%s!" (both
|
||||||
|
-- slots plain %s, matching the pre-fix literal's own shape), not the
|
||||||
|
-- {PLAYER} token the real label uses, since Strings() never does
|
||||||
|
-- {TOKEN} substitution on its own. (A {PLAYER}-token fallback would
|
||||||
|
-- still render correctly too, since the real TextBox.new always runs
|
||||||
|
-- TextBox.substitute over whatever text it's given -- but this test
|
||||||
|
-- stubs TextBox without that call, and the fallback shouldn't lean on a
|
||||||
|
-- substitution pass happening downstream regardless.)
|
||||||
|
do
|
||||||
|
local game = mkGame()
|
||||||
|
setUpvalue(OW.tryHiddenObject, "Game", game)
|
||||||
|
game.save.hiddenTaken = {} -- fresh spot
|
||||||
|
local found = fakeSelf:tryHiddenObject(3, 3)
|
||||||
|
T.check(found == true, "the hidden item is found again in a fresh game")
|
||||||
|
T.eq(pushed[1] and pushed[1].text, "FAKEPLAYER found\nFIX BALL!",
|
||||||
|
"with no catalog entry, the fallback still fills both the player "
|
||||||
|
.. "and item name via plain %s substitution")
|
||||||
|
end
|
||||||
|
|
||||||
|
T.finish("overworld_hidden_item_romtext")
|
||||||
@@ -59,7 +59,7 @@ package.loaded["src.import.RomImporter"] = nil
|
|||||||
RomImporter = require("src.import.RomImporter")
|
RomImporter = require("src.import.RomImporter")
|
||||||
|
|
||||||
local function clearSavesInbox()
|
local function clearSavesInbox()
|
||||||
for _, ver in ipairs({ "red", "blue", "yellow", "gold" }) do
|
for _, ver in ipairs({ "red", "blue", "yellow", "gold", "silver" }) do
|
||||||
local dir = "imports/saves/" .. ver
|
local dir = "imports/saves/" .. ver
|
||||||
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
|
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
|
||||||
love.filesystem.remove(dir .. "/" .. name)
|
love.filesystem.remove(dir .. "/" .. name)
|
||||||
@@ -137,6 +137,8 @@ check(createdDirs["imports/saves/yellow"] == true,
|
|||||||
"RES-01: ensureSavesInboxDir creates imports/saves/yellow/")
|
"RES-01: ensureSavesInboxDir creates imports/saves/yellow/")
|
||||||
check(createdDirs["imports/saves/gold"] == true,
|
check(createdDirs["imports/saves/gold"] == true,
|
||||||
"RES-01: ensureSavesInboxDir creates imports/saves/gold/")
|
"RES-01: ensureSavesInboxDir creates imports/saves/gold/")
|
||||||
|
check(createdDirs["imports/saves/silver"] == true,
|
||||||
|
"RES-01: ensureSavesInboxDir creates imports/saves/silver/")
|
||||||
|
|
||||||
-- NXSAV-02: notice/hint includes save dir + per-game imports/saves/<version>/ MTP path
|
-- NXSAV-02: notice/hint includes save dir + per-game imports/saves/<version>/ MTP path
|
||||||
ri = freshImporter()
|
ri = freshImporter()
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||||
|
|
||||||
|
local T = require("tests.harness")
|
||||||
|
local check, eq = T.check, T.eq
|
||||||
|
love = love or require("tests.love_stub")
|
||||||
|
|
||||||
|
local ScreenPosition = require("src.core.ScreenPosition")
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
local Renderer = require("src.render.Renderer")
|
||||||
|
local Chrome = require("src.ui.gen2.Chrome")
|
||||||
|
local Zoom = require("src.render.Zoom")
|
||||||
|
|
||||||
|
eq(ScreenPosition.normalize(nil), "center", "no setting is CENTER")
|
||||||
|
eq(ScreenPosition.normalize("junk"), "center", "garbage degrades to CENTER")
|
||||||
|
eq(ScreenPosition.normalize("top"), "top", "top passes through")
|
||||||
|
eq(ScreenPosition.label("upper"), "UPPER", "upper reads UPPER")
|
||||||
|
|
||||||
|
local seen, v = {}, "center"
|
||||||
|
for _ = 1, 3 do
|
||||||
|
seen[#seen + 1] = ScreenPosition.label(v)
|
||||||
|
v = ScreenPosition.cycle(v, 1)
|
||||||
|
end
|
||||||
|
eq(table.concat(seen, ","), "CENTER,UPPER,TOP", "the row cycles CENTER,UPPER,TOP")
|
||||||
|
eq(ScreenPosition.cycle("top", 1), "center", "and wraps back to CENTER")
|
||||||
|
eq(ScreenPosition.cycle("center", -1), "top", "stepping back lands on TOP")
|
||||||
|
|
||||||
|
ScreenPosition.setMode("center")
|
||||||
|
eq(ScreenPosition.lift(640, 288), 0, "CENTER never lifts")
|
||||||
|
|
||||||
|
ScreenPosition.setMode("top")
|
||||||
|
eq(ScreenPosition.lift(640, 288), 176, "TOP lifts the centered origin to 0")
|
||||||
|
eq(ScreenPosition.lift(288, 288), 0, "no slack, no lift")
|
||||||
|
eq(ScreenPosition.lift(144, 288), 0, "negative slack, no lift")
|
||||||
|
eq(ScreenPosition.lift(640, 288, 40), 136, "TOP stops at the safe-area inset")
|
||||||
|
eq(ScreenPosition.lift(640, 288, 999), 0,
|
||||||
|
"a safe inset past center degrades to centered")
|
||||||
|
|
||||||
|
ScreenPosition.setMode("upper")
|
||||||
|
eq(ScreenPosition.lift(640, 288), 88, "UPPER lands halfway between")
|
||||||
|
eq(ScreenPosition.lift(640, 288, 40), 88, "a small inset leaves UPPER alone")
|
||||||
|
eq(ScreenPosition.lift(640, 288, 120), 56, "a large inset pushes UPPER down")
|
||||||
|
|
||||||
|
ScreenPosition.applyOptions({ screenPos = "top" })
|
||||||
|
eq(ScreenPosition.mode, "top", "applyOptions takes the stored key")
|
||||||
|
ScreenPosition.applyOptions(nil)
|
||||||
|
eq(ScreenPosition.mode, "center", "applyOptions without options is CENTER")
|
||||||
|
|
||||||
|
local function setWindow(w, h)
|
||||||
|
love.graphics.getDimensions = function() return w, h end
|
||||||
|
love.graphics.getPixelDimensions = function() return w, h end
|
||||||
|
end
|
||||||
|
|
||||||
|
setWindow(360, 640)
|
||||||
|
TouchSkin.setActive(nil)
|
||||||
|
Renderer:init()
|
||||||
|
Zoom.offset = 0
|
||||||
|
|
||||||
|
ScreenPosition.setMode("center")
|
||||||
|
local r = Renderer:frameRects()
|
||||||
|
eq(r.Sp, 2, "360x640 fits two whole GB pixels")
|
||||||
|
eq(r.lift, 0, "CENTER: no lift")
|
||||||
|
eq(r.oy, 176, "CENTER: the letterbox is centered")
|
||||||
|
local _, vhCenter = Renderer:worldViewSize()
|
||||||
|
|
||||||
|
ScreenPosition.setMode("top")
|
||||||
|
r = Renderer:frameRects()
|
||||||
|
eq(r.lift, 176, "TOP: the full centered slack lifts away")
|
||||||
|
eq(r.oy, 0, "TOP: the letterbox sits at the top edge")
|
||||||
|
eq(r.uoy, 0, "TOP: the UI letterbox follows")
|
||||||
|
eq(r.ox, math.floor((360 - 320) / 2), "TOP: horizontal centering is untouched")
|
||||||
|
local _, vhTop = Renderer:worldViewSize()
|
||||||
|
eq(vhTop, vhCenter + 2 * math.ceil(176 / 2),
|
||||||
|
"TOP: the world canvas grows to keep the bottom covered")
|
||||||
|
|
||||||
|
ScreenPosition.setMode("upper")
|
||||||
|
r = Renderer:frameRects()
|
||||||
|
eq(r.oy, 88, "UPPER: the letterbox centers in the upper half")
|
||||||
|
|
||||||
|
ScreenPosition.setMode("top")
|
||||||
|
local ox, oy = Chrome.fitOrigin(360, 640)
|
||||||
|
eq(oy, 0, "TOP: Gold's letterbox sits at the top edge")
|
||||||
|
eq(ox, math.floor((360 - 320) / 2), "TOP: Gold's horizontal centering is untouched")
|
||||||
|
ScreenPosition.setMode("center")
|
||||||
|
local _, cy = Chrome.fitOrigin(360, 640)
|
||||||
|
eq(cy, 176, "CENTER: Gold's letterbox is centered")
|
||||||
|
|
||||||
|
ScreenPosition.setMode("top")
|
||||||
|
local skin = assert(TouchSkin.parse([[
|
||||||
|
overlays = 1
|
||||||
|
overlay0_name = "bezel"
|
||||||
|
overlay0_full_screen = true
|
||||||
|
overlay0_normalized = true
|
||||||
|
overlay0_viewport = "0.1,0.1,0.8,0.4"
|
||||||
|
overlay0_descs = 1
|
||||||
|
overlay0_desc0 = "nul,0.5,0.5,rect,0.02,0.02"
|
||||||
|
]]))
|
||||||
|
TouchSkin.setActive(skin)
|
||||||
|
TouchSkin.setOverlayLive(false)
|
||||||
|
r = Renderer:frameRects()
|
||||||
|
eq(r.cut, true, "the skin viewport cuts the playfield")
|
||||||
|
eq(r.lift, 0, "a skin viewport disables the lift")
|
||||||
|
eq(select(1, ScreenPosition.skinActive(360, 640)), true,
|
||||||
|
"skinActive sees the viewport")
|
||||||
|
local _, sy = Chrome.fitOrigin(360, 640)
|
||||||
|
local _, cy2 = (function()
|
||||||
|
ScreenPosition.setMode("center")
|
||||||
|
return Chrome.fitOrigin(360, 640)
|
||||||
|
end)()
|
||||||
|
eq(sy, cy2, "with a skin the Gold origin ignores the mode")
|
||||||
|
TouchSkin.setActive(nil)
|
||||||
|
ScreenPosition.setMode("center")
|
||||||
|
|
||||||
|
T.finish("screen_position")
|
||||||