Compare commits

..

79 Commits

Author SHA1 Message Date
bryanthaboi 9be21380c7 Merge pull request #581 from bryanthaboi/dev
bug smashing on a saturday
2026-08-01 08:14:38 -04:00
bryanthaboi 22fd196045 Merge pull request #579 from castdrian/metal
feat: metal support and make ios usable
2026-08-01 08:13:34 -04:00
bryanthaboi d5293dffb8 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-01 08:10:22 -04:00
bryanthaboi 9326b07583 CLOSES #415, CLOSES #484, CLOSES #488, CLOSES #492, CLOSES #497, CLOSES #541, CLOSES #559, CLOSES #562, CLOSES #563, CLOSES #564, CLOSES #565, CLOSES #566, CLOSES #567, CLOSES #568, CLOSES #569, CLOSES #570, CLOSES #571, CLOSES #572 2026-08-01 08:10:20 -04:00
Adrian Castro 90437a5d8d fix(ios): export saves from Love directory 2026-08-01 13:07:36 +02:00
Adrian Castro c4ec5dc1c5 test(ios): cover save export picker 2026-08-01 12:58:42 +02:00
bryanthaboi 3b19826f1e Merge pull request #543 from BartInTheField/render-second-screen-seam 2026-08-01 06:56:38 -04:00
Adrian Castro 95e4715bc3 fix(ios): declare Love 12 compatibility 2026-08-01 12:54:05 +02:00
Adrian Castro 81edc97d86 fix(ios): package updater boot module 2026-08-01 12:44:26 +02:00
Adrian Castro 92c8bac714 fix(ios): support direct picker results 2026-08-01 12:36:18 +02:00
Adrian Castro 1df04aa18d fix(ios): route native picker results 2026-08-01 12:32:22 +02:00
Bart in 't Veld 218b9249a2 render: add render.compose seam + second-screen bridge
Expose a generic seam so a mod can drive a second screen without the
engine owning any dual-screen layout policy:

- render.compose hook in Renderer:endFrame hands a mod the finished
  world + UI canvases, their SGB zones, the frame metrics,
  Renderer:blitCanvas (lifted from the internal blit closure) and the
  SecondScreen bridge. Return true to take over the window; no wrap (or
  calling next) runs the normal single-window composite byte-for-byte.
- SecondScreen.lua + the Android Presentation bridge (love_android_
  secondary_* in common/android.cpp, GameActivity secondary display)
  as the optional physical-second-display transport.

No battle-render changes: a mod lays out the two screens (including any
battle split) itself. Ships with a unit test, no-mod parity via
gate_hooks, and docs/modding.md (D14).
2026-08-01 09:35:24 +02:00
bryanthaboi 50947eec2a Merge pull request #489 from mresnick67/android-step-bridge 2026-08-01 00:21:40 -04:00
bryanthaboi 2624bb0210 Merge pull request #537 from sirj0k3r/hook/exp 2026-08-01 00:21:20 -04:00
bryanthaboi 6e9685f302 Merge pull request #538 from sirj0k3r/hook/low-health 2026-08-01 00:21:10 -04:00
bryanthaboi 898bf0c71e Merge pull request #560 from bryanthaboi/dev 2026-07-31 23:55:26 -04:00
bryanthaboi 5a7add8eaa Merge pull request #554 from andrewqsantos/feature/mobile-safe-area 2026-07-31 23:54:11 -04:00
bryanthaboi 66334dfacd Merge pull request #558 from erereck/fix/mod-configurable-bag-capacity 2026-07-31 23:53:54 -04:00
bryanthaboi a33f3b1ceb mobile fixes 2026-07-31 23:47:28 -04:00
erereck 6e724cedf7 Honor modded bag capacity 2026-08-01 00:32:00 -03:00
bryanthaboi e949c78639 Merge pull request #556 from bryanthaboi/dev 2026-07-31 23:03:09 -04:00
bryanthaboi 9bcfdb1f0d mobile fixes 2026-07-31 23:01:25 -04:00
Andrew Quenehen fccb122c59 Respect iOS/Android safe areas in launcher and touch chrome.
Layout interactive UI against love.window.getSafeArea so notch, Dynamic Island, and home-indicator insets no longer clip controls, while keeping the game framebuffer edge-to-edge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 23:53:26 -03:00
bryanthaboi e2b2a5fb9f Merge pull request #555 from bryanthaboi/dev 2026-07-31 22:41:53 -04:00
bryanthaboi f2a3e5f05b mobile updates CLOSES #482, CLOSES #553 2026-07-31 22:39:01 -04:00
bryanthaboi 5f855568c8 Merge pull request #539 from hernan0078/ios-support 2026-07-31 22:16:24 -04:00
bryanthaboi 24696e3be2 Merge pull request #524 from kaosregulator/claude/multi-game-low-end-support-o16gz5
Claude/multi game low end support  and some fixes o16gz5
2026-07-31 20:26:09 -04:00
bryanthaboi b17dce232e Merge pull request #547 from bryanthaboi/dev
bug squashing and what not
2026-07-31 20:21:37 -04:00
bryanthaboi e1b20723fd Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-07-31 20:20:38 -04:00
bryanthaboi fdab15a6dc CLOSES #503, CLOSES #511, CLOSES #515, CLOSES #518, CLOSES #522, CLOSES #523, CLOSES #525, CLOSES #528, CLOSES #529, CLOSES #533, CLOSES #535, CLOSES #536 2026-07-31 20:20:37 -04:00
sirj0k3r 33899c237e + Implemented tests for battle.low_health_alarm hook 2026-07-31 22:17:19 +01:00
sirj0k3r 0fad222569 + Implemented tests for battle.catch_exp and battle.exp_award hooks 2026-07-31 22:10:26 +01:00
hernan0078 9c5f33e187 Fix #482: guard love.system.pickFile so Import ROM cannot crash
Pressing Import ROM on iOS takes the whole app down:

  src/import/RomImporter.lua: attempt to call field 'pickFile' (a nil value)

love.system.pickFile is a NATIVE BRIDGE, not part of LOVE. It exists only on
builds that compiled one -- Android, and iOS builds patched by
mobile/ios/patch_love_src.py -- so on a build without it the field is simply
nil. RomImporter:546 routes iOS down the same path as Android
(`mobileOS == "Android" or mobileOS == "iOS"`), and all three mobile pick
sites called the field unguarded.

That is why the reports say "any version": nothing about it is version
specific. Red, Blue and Yellow all reach the same call.

Every one of those call sites already handles a device with no document
picker -- Choose falls back to "No picker available, copy your ROM into:"
plus the save directory, and the mod / save rows have their own notices --
and love.system.createFile at its single call site was already guarded this
way. These three were not, so the fallback that was written for exactly this
case could never be reached.

Route them through one small helper that answers false when the bridge is
absent. A build without a picker now degrades to the copy-into-the-save-folder
flow, which on iOS is a working path: the Files app exposes the app's
Documents folder and GRBootstrap sweeps what lands there into the save dir.

tests/rom_importer_no_picker_test.lua covers Import ROM, Import mod and
Import save with the bridge missing, and asserts the picker is still used
when it is present. Reverting the fix reproduces the reported error exactly.

Reported in #482 (confirmed by three people) and #512.
2026-07-31 16:38:15 -04:00
sirj0k3r ddcce5b95a + Implemented Exp hook 2026-07-31 21:14:23 +01:00
sirj0k3r 6fd9741fab + Implemented Low Health Alert hook for modding 2026-07-31 21:12:21 +01:00
bryanthaboi 4916fc8f4b Merge pull request #532 from TitaniteScale/symlink-mod-support
Recognize symlinked mod dirs on Linux (mods/ dev workflow)
2026-07-31 15:22:48 -04:00
Jake Eaker fc19e58678 added symlink support for mods 2026-07-31 13:47:53 -04:00
bryanthaboi 9610bcfb72 Merge pull request #527 from bryanthaboi/dev
bug fixes and new features
2026-07-31 12:04:18 -04:00
bryanthaboi 1bc252741a rom finder 2026-07-31 11:58:21 -04:00
Myles Resnick caa65182dc Android: hardware step-counter bridge for step-sync mods
love.system.syncHealthSteps() now exists on Android, matching the iOS
Health bridge merged in #452 and using the same JNI route as the SAF
picker (wrap_System.cpp -> System.cpp -> common/android.cpp ->
GameActivity over JNI):

- GameActivity.syncHealthSteps: one-shot read of the hardware
  TYPE_STEP_COUNTER sensor (cumulative since boot, counted by the OS
  whether or not any app runs). The reading is anchored in
  SharedPreferences so a walk is never credited twice; a reading below
  the anchor means the phone rebooted, which re-anchors without
  crediting. Deltas (50k clamp) merge into steps_pending.json in the
  save identity dir - the same contract as the iOS GRHealthBridge, so
  the Pokewalker mod works unchanged on both platforms.
- ACTIVITY_RECOGNITION declared in the app manifest (Android 10+
  runtime prompt on first sync; granted -> the sensor read runs
  immediately via onRequestPermissionsResult). The build script's
  permission trim leaves it alone.
- Nothing in the base game calls the new seam; without a consumer mod
  the only cost is one dormant manifest permission.
- build_android.sh: shadow-build from a space-free temp dir when the
  checkout path contains spaces - ndk-build is GNU make underneath and
  cannot cope with paths like "xCode Projects".
- mobile/ANDROID.md: step-bridge dev notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 11:11:54 -04:00
bryanthaboi 97e3fb296c Update tool_mod_hooks.lua 2026-07-31 10:45:12 -04:00
Claude 74eeb411ba Add luacheck config + lint script
Introduce a tuned .luacheckrc and scripts/lint.sh so the engine has a
standing static-analysis baseline -- the tool that would have caught both
bugs in the previous commit before they shipped.

The config is high-signal by design: it keeps the categories that catch
real defects (undefined globals/locals, unused values, unreachable code)
and mutes the cosmetic ones the codebase deliberately lives with (a self/dt
an interface requires but a method ignores, documented empty fall-through
branches, long lines). It marks `love` mutable (games assign callbacks onto
it) and teaches it LuaJIT's table.unpack.

.luacheckrc is tracked via a .gitignore exception, matching how .github and
.gitignore opt out of the blanket dotfile ignore.

`luacheck src` now reports 7 benign warnings and 0 errors, down from 185.
Also drop one dead `require` (ItemEffects loaded src.pokemon.Pokemon and
never used it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 14:35:40 +00:00
Claude 040ca3f332 Fix two latent bugs surfaced by static analysis
Both are code paths that never run in a green test today but crash or
misbehave the moment a mod or a link failure exercises them.

1. Music.lua: applyVolume built its `music.volume` hook context from the
   private `state` table, but was defined *above* `local state = {...}`, so
   those reads bound to the nil global `state`. Any mod registering the
   music.volume hook crashed with "attempt to index a nil value (global
   'state')" the first time a volume was applied. Forward-declare `state`
   above applyVolume. Regression test drives a file-backed song through the
   hook and asserts the context resolves.

2. Tournament.lua: `local battle, why = isHost and newHost() or newGuest()`
   had two defects. The and/or idiom truncates a call to its first result,
   so `why` (the specific failure reason) was always dropped and every link
   failure showed the generic "Link battle can't start" instead of e.g.
   "same mods on both games". Worse, when a host's newHost() returned nil,
   the `or` fell through and wrongly called newGuest() as the host. Split
   into an explicit if/else so the reason is preserved and each role calls
   its own constructor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 14:34:58 +00:00
bryanthaboi 5ec440792e Merge pull request #347 from kevindjacobson/agent/tool-mod-hooks
Add lifecycle and HUD hooks for tool mods
2026-07-31 10:29:04 -04:00
bryanthaboi 9d3ee6a597 mod manager now supports auto updates 2026-07-31 10:19:51 -04:00
bryanthaboi 6c4c9d14e2 Merge pull request #520 from johnjohto/fix-true-color-evolution-trade-494
Fix true-color evolution and trade sprites
2026-07-31 10:18:44 -04:00
bryanthaboi d165189032 Merge pull request #521 from johnjohto/fix-fighting-dojo-master-gate-495
Fix Karate Master gate
2026-07-31 10:08:02 -04:00
johnjohto 84bf25fcd5 Fix Karate Master gate 2026-07-31 09:47:53 -04:00
bryanthaboi 3945b9d078 on screen controller editing CLOSES #327 2026-07-31 09:23:38 -04:00
bryanthaboi 6796804593 CLOSES #505, CLOSES #510, CLOSES #513 2026-07-31 09:08:56 -04:00
johnjohto 858c5bc9b8 Fix true-color evolution and trade sprites 2026-07-31 09:05:19 -04:00
bryanthaboi 71bdbb7e48 CLOSES #519 2026-07-31 08:27:29 -04:00
bryanthaboi c346c78697 Merge pull request #499 from johnjohto/fix-battle-menu-cursor
Fix battle menu cursor edges
2026-07-31 08:14:55 -04:00
bryanthaboi ba2bac5bed Merge branch 'dev' into fix-battle-menu-cursor 2026-07-31 08:14:08 -04:00
bryanthaboi bcfc94d448 Merge pull request #366 from pmarcus93/android-respect-rotation-lock
Respect the device rotation lock on Android
2026-07-31 08:08:19 -04:00
bryanthaboi bebfc87cd4 yellow palette / pikachu yellow 2026-07-31 08:07:23 -04:00
Claude c8e035d332 Add graphics performance tier for low-end devices
Introduce an OPTIONS -> PERFORMANCE setting that scales the port's
optional presentation extras down for weaker hardware, so older/lower-end
devices can run the game smoothly.

The tier governs the three heaviest non-faithful extras -- the 3D TILT,
the GBC FX post-process shader, and survey ZOOM (which renders connected
neighbor maps) -- plus a hard FPS ceiling. It never touches game logic,
which is fixed-step off dt, so every tier plays identically.

- src/core/Performance.lua: tiers (auto/high/balanced/low), a conservative
  device auto-detect (ARM handhelds -> low, phones -> balanced, normal
  desktops -> high), per-tier caps, and the option-row cycle. Zero
  requires, like GameVersion.
- Game:applyOptions clamps the *live* presentation state against the tier
  without rewriting stored options, so a lower tier hides the player's
  TILT/GBC FX/ZOOM/FPS choices and a higher tier restores them exactly.
- Zoom.offsetRange floors the range at FIT when survey is disallowed, so
  the option row, hotkey, and mouse wheel all stop at close-up on LOW.
- New save.options.performance default "auto"; OPTIONS row heads the
  display group and re-applies live.
- Tests: tests/engine/performance_tiers.lua (ROM-free); mod_ui_tests row
  golden updated for the spliced row.

AUTO resolves to HIGH on a normal desktop and on every options.lua that
predates the option, so the common case is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 01:44:17 +00:00
bryanthaboi e58929428c Merge pull request #498 from johnjohto/fix-cerulean-badge-house-exit 2026-07-30 18:24:26 -04:00
johnjohto a389416b7b Fix battle menu cursor edges
Battle commands toggled directly between rows and columns. An outward press therefore moved the cursor to the opposite command instead of leaving it at the edge.
2026-07-30 17:53:53 -04:00
johnjohto 0a8bca1a6d Fix Cerulean badge menu exit
The selected badge list stayed on the stack while its description opened. Cancelling the next list only closed that newer menu, leaving the NPC conversation open.
2026-07-30 17:49:02 -04:00
bryanthaboi efad8b325b Merge pull request #490 from bryanthaboi/dev
ios and some bugs
2026-07-30 16:12:24 -04:00
bryanthaboi 4c5f776128 Update README.md 2026-07-30 16:04:34 -04:00
bryanthaboi c1495e0d5e ios 2026-07-30 16:03:35 -04:00
bryanthaboi a97168a4f0 Update ios-install.md 2026-07-30 15:53:49 -04:00
bryanthaboi a50ad30176 Merge pull request #464 from mresnick67/pokewalker-0.2.0-followup
iOS: stop fusing mods; Pokewalker moves to its own repo
2026-07-30 15:51:55 -04:00
bryanthaboi c1f65106de Merge pull request #457 from johnjohto/fix-trainer-evolution-order
Delay evolution until trainer text ends
2026-07-30 14:56:24 -04:00
johnjohto 4250e3aa32 Delay evolution until trainer text ends 2026-07-30 14:03:36 -04:00
bryanthaboi cecb24bb0e Merge pull request #480 from bryanthaboi/dev
johnny boys fixes
2026-07-30 13:55:19 -04:00
bryanthaboi b99b497893 Merge pull request #479 from johnjohto/fix-save-editor-items
Fix save editor item crash on Android
2026-07-30 13:53:24 -04:00
johnjohto e16a45d6ba Avoid invalid save editor clips
On compact Android viewports the item picker can end up with no list
space. Passing its negative height to setScissor crashes LÖVE.
Use an empty clip region instead.
2026-07-30 13:28:18 -04:00
bryanthaboi 0e29046c31 Merge pull request #458 from johnjohto/fix-yellow-bills-pikachu
Add Bill's House Pikachu scenes
2026-07-30 13:02:50 -04:00
johnjohto 5cbada493a Add Bill's House Pikachu scenes 2026-07-30 12:52:17 -04:00
bryanthaboi 4c79db3d5d Merge pull request #472 from bryanthaboi/dev
stupid ass android
2026-07-30 12:28:57 -04:00
bryanthaboi c7e46b6563 stupid ass android 2026-07-30 12:26:27 -04:00
Myles Resnick fe1714d50e iOS: stop fusing mods; Pokewalker moves to its own repo
Follow-up to #452, from dogfooding the merged build on a real iPhone.

A mod fused into game.love sits in the read-only app bundle: the mod
manager's Delete can't remove it and it reappears every launch, and an
imported newer version can never fully replace it. iOS now packs no
mods, matching every other platform - mods install as .zips at runtime
with a real install/upgrade/delete lifecycle.

With nothing fusing it, mods/pokewalker moves out of this tree to its
canonical home, https://github.com/mresnick67/Gen1ReComp-Pokewalker
(already at 0.2.0 there with move learning and a paged credit dialog;
in-tree 0.1.0 was stale). docs/ios-install.md points at it. The
event:save.loaded coverage-debt entry returns to gate_meta_coverage:
its coverage came from the mod's test suite and leaves with it.

Local reproduction of the ci workflow on this branch: all tiers, mod
lint, fingerprint + meta-coverage gates green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 11:44:16 -04:00
Marcus Pereira cd33858020 Respect the device rotation lock on Android
The manifest asks for android:screenOrientation="fullUser", but SDL
overrides it at window creation: SDLActivity.setOrientationBis, given a
resizable window and no SDL_HINT_ORIENTATIONS -- which is what conf.lua
produces on Android -- requests SCREEN_ORIENTATION_FULL_SENSOR. The
*_SENSOR constants follow the accelerometer even when the player has
turned auto-rotate off, so the game rotated anyway on a locked device.

Override setOrientationBis in GameActivity to remap SDL's request onto
the matching *_USER constant after super has run. The same orientations
stay allowed and SDL keeps deciding which ones those are; only the
tie-break changes, from the sensor to the system rotation setting.
2026-07-28 23:22:35 -03:00
Kevin Jacobson fb58fa788b Expose window margins to tool HUDs 2026-07-28 14:09:46 -07:00
Kevin Jacobson d09cf80da5 Add persistent HUD hook for tool mods 2026-07-28 13:58:42 -07:00
Kevin Jacobson aab980b05a Add lifecycle hooks for tool mods 2026-07-28 13:33:53 -07:00
194 changed files with 17996 additions and 1096 deletions
+1
View File
@@ -40,6 +40,7 @@ body:
- Windows
- Linux
- Android
- iOS
- Multiple platforms
validations:
required: true
+17 -3
View File
@@ -1,8 +1,8 @@
name: Release
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
# Mac runner, and publishes them as a GitHub Release.
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on
# the self-hosted Mac runner, and publishes them as a GitHub Release.
#
# Versioning:
# - First ever release is 0.1.0.
@@ -170,6 +170,15 @@ jobs:
set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
- name: Build iOS
run: |
set -euo pipefail
# Device Release IPA; signs with the Apple Development identity on
# the runner (auto team detection). Users on other Apple IDs still
# re-sign or build via docs/ios-install.md.
scripts/build_ios.sh --fetch --device --release \
--version "${{ steps.ver.outputs.version }}"
- name: Build Anbernic RG34XXSP port
run: |
set -euo pipefail
@@ -226,6 +235,10 @@ jobs:
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
ipa="dist/ios/gen1recomp.ipa"
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
# Anbernic handheld port (suffix names the CFW it targets, so a
# future RG35XX/other-CFW pack can ship alongside it).
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
@@ -344,6 +357,7 @@ jobs:
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}-ios.ipa" \
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
+1
View File
@@ -16,6 +16,7 @@ __pycache__/
.*
!.github/
!.gitignore
!.luacheckrc
# Android build outputs / local SDK path / packaged payload (keep love-android sources)
mobile/android/app/build/
+43
View File
@@ -0,0 +1,43 @@
-- Static-analysis config for `luacheck` (https://luacheck.readthedocs.io).
--
-- Run it over the engine with: luacheck src (or scripts/lint.sh)
--
-- The point is a high-signal baseline: the categories left on are the ones
-- that catch real defects -- undefined globals/locals (the class that hid a
-- `music.volume` crash: applyVolume read a `state` that was still the nil
-- global), unused values, unreachable code, redefinitions. The cosmetic
-- categories the codebase deliberately lives with (a `self`/`dt` an
-- interface requires but a given method ignores, documented empty
-- fall-through branches, the odd long line) are muted so they don't drown
-- the signal.
std = "luajit"
-- LÖVE exposes `love` as a mutable table: games assign their callbacks onto
-- it (love.wheelmoved, love.run, ...), so it is a regular global, not
-- read-only -- otherwise every callback registration reads as a violation.
globals = { "love" }
read_globals = {
"jit",
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
-- it; without this, every `table.unpack` reads as an undefined field.
table = { fields = { "unpack" } },
}
-- Vendored/native trees and the test suites have their own conventions.
exclude_files = {
"mobile/",
"tests/",
"tools/save-editor/",
}
ignore = {
"212", -- unused argument -- self/dt kept for a shared method signature
"213", -- unused loop variable -- `for _, v in` where only v is wanted
"421", -- shadowing a local -- deliberate re-use in a few tight scopes
"431", -- shadowing an upvalue
"432", -- shadowing an argument
"542", -- empty if branch -- documented fall-throughs, not gaps
"631", -- line is too long
}
+30 -11
View File
@@ -50,25 +50,26 @@ supplied by the player.
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
game content input.
or download a disassembly. A canonical US Poke Red, Blue, or Yellow ROM is the
only game content input.
The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again. Red and Blue can both be imported and played
side by side.
do not ask for the ROM again. Red, Blue, and Yellow can all be imported and
played side by side.
## Quick Start
Open the desktop app. On first boot, choose your legally obtained `.gb` file
or drop it onto the window. Import takes a few seconds and the game starts
automatically.
Open the desktop app. On first boot, choose your legally obtained `.gb` /
`.gbc` file or drop it onto the window. Import takes a few seconds and the
game starts automatically.
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
verifies SHA-1 before creating any game data:
Only the canonical 1 MiB US Red, Blue, and Yellow ROMs are accepted. The
importer verifies SHA-1 before creating any game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact
@@ -107,6 +108,17 @@ supported out of the box.
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
and persist in `options.lua`.
### Low-end devices
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
normal desktops → HIGH, unchanged). It only scales presentation; the
fixed-step game logic is identical on every tier, and a lower tier hides
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
### Rulesets
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
@@ -141,11 +153,11 @@ famous bugs included:
## Running From Source
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
Requires LÖVE 11.x. Place a Red, Blue, or Yellow ROM in the project folder and
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
```sh
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Blue.gb / Yellow.gbc
scripts/run.sh
```
@@ -176,6 +188,13 @@ even on a different computer, as long as the same folder comes along.
already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch.
## iOS
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
(Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To
build and install from source on a Mac instead, see
[docs/ios-install.md](docs/ios-install.md).
## Handhelds
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
+18 -2
View File
@@ -1,4 +1,9 @@
function love.conf(t)
-- PhysFS ignores symlinks unless told otherwise, so a mod dev-linked into
-- mods/ (ln -s, matching the mklink /J workflow on Windows) is invisible
-- to love.filesystem.getDirectoryItems without this.
love.filesystem.setSymlinksEnabled(true)
local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1"
if arg then
@@ -44,7 +49,7 @@ function love.conf(t)
t.window.minwidth = 480
t.window.minheight = 360
end
t.version = "11.5"
t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1
t.modules.joystick = true
t.modules.physics = false
@@ -62,7 +67,10 @@ function love.conf(t)
-- the game follow the device. The renderer letterboxes the 160x144
-- viewport into whatever size results, and the on-screen touch controls
-- re-lay themselves out from the new window size, so both orientations
-- just work. iOS follows the Info.plist orientations
-- just work. FULL_SENSOR ignores the device's rotation lock, so
-- GameActivity.setOrientationBis remaps it to FULL_USER after SDL has
-- run: same orientations allowed, but auto-rotate being off now wins.
-- iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
-- Starting size is a tall portrait hint; the OS resizes to the real
@@ -78,6 +86,14 @@ function love.conf(t)
-- player to copy their ROM there instead of needing a native file
-- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua).
t.externalstorage = osName == "Android"
-- LOVE 11.x exposes the phone's accelerometer as a 3-axis joystick by
-- default. Gravity keeps one axis pinned near +/-1.0 whenever the phone
-- is held upright, so it streams past-deadzone joystickaxis events that
-- both hid the touch overlay every instant (it reads as "a controller is
-- in use") and, via the generic-joystick axis mapping (#459), walked the
-- player around by tilt. Nothing in the game reads the accelerometer,
-- so drop the device entirely (#468).
t.accelerometerjoystick = false
else
t.window.resizable = true
end
+686
View File
@@ -0,0 +1,686 @@
-- Generated by tools/build_data.py. DO NOT EDIT.
-- Pokemon Yellow palettes (COLORS=OG YELLOW): SuperPalettes +
-- CGBBasePalettes (authentic GBC look) as 4 8-bit RGB colors
-- per palette (color 0 first), plus MonsterPalettes.
return {
cgbBase = {
["0F"] = {
{ 255, 255, 255 },
{ 107, 8, 255 },
{ 0, 74, 255 },
{ 8, 8, 8 },
},
BADGE = {
{ 255, 255, 255 },
{ 189, 66, 0 },
{ 140, 115, 90 },
{ 25, 25, 25 },
},
BLACK = {
{ 255, 255, 255 },
{ 25, 25, 25 },
{ 25, 25, 25 },
{ 25, 25, 25 },
},
BLUEMON = {
{ 255, 255, 255 },
{ 132, 148, 255 },
{ 0, 8, 206 },
{ 25, 25, 25 },
},
BROWNMON = {
{ 255, 255, 255 },
{ 239, 148, 82 },
{ 140, 74, 41 },
{ 25, 25, 25 },
},
CAVE = {
{ 255, 255, 255 },
{ 189, 66, 0 },
{ 140, 115, 90 },
{ 25, 25, 25 },
},
CELADON = {
{ 255, 255, 255 },
{ 41, 255, 41 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
CERULEAN = {
{ 255, 255, 255 },
{ 41, 66, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
CINNABAR = {
{ 255, 255, 255 },
{ 255, 66, 66 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
CYANMON = {
{ 255, 255, 255 },
{ 132, 214, 255 },
{ 0, 140, 255 },
{ 25, 25, 25 },
},
FUCHSIA = {
{ 255, 255, 255 },
{ 255, 123, 123 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
GAMEFREAK = {
{ 255, 255, 255 },
{ 255, 156, 0 },
{ 156, 156, 0 },
{ 25, 25, 25 },
},
GRAYMON = {
{ 255, 255, 255 },
{ 165, 189, 82 },
{ 90, 90, 41 },
{ 25, 25, 25 },
},
GREENBAR = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 0, 255, 0 },
{ 25, 25, 25 },
},
GREENMON = {
{ 255, 255, 255 },
{ 140, 255, 90 },
{ 8, 181, 49 },
{ 25, 25, 25 },
},
INDIGO = {
{ 255, 255, 255 },
{ 90, 66, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
LAVENDER = {
{ 255, 255, 255 },
{ 206, 33, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
LOGO1 = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 0, 0 },
{ 255, 0, 0 },
},
LOGO2 = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 58, 58, 206 },
{ 0, 0, 140 },
},
MEWMON = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 8, 8 },
{ 25, 25, 25 },
},
PALLET = {
{ 255, 255, 255 },
{ 189, 140, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
PEWTER = {
{ 255, 255, 255 },
{ 148, 148, 123 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
PIKACHUS_BEACH = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
PIKACHUS_BEACH_TITLE = {
{ 255, 255, 255 },
{ 74, 74, 74 },
{ 255, 173, 0 },
{ 25, 25, 25 },
},
PIKACHU_PORTRAIT = {
{ 255, 255, 255 },
{ 255, 148, 0 },
{ 156, 58, 8 },
{ 25, 25, 25 },
},
PINKMON = {
{ 255, 255, 255 },
{ 255, 123, 148 },
{ 255, 0, 49 },
{ 25, 25, 25 },
},
PURPLEMON = {
{ 255, 255, 255 },
{ 206, 123, 255 },
{ 156, 0, 181 },
{ 25, 25, 25 },
},
REDBAR = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 0, 0 },
{ 25, 25, 25 },
},
REDMON = {
{ 255, 255, 255 },
{ 255, 140, 0 },
{ 255, 0, 0 },
{ 25, 25, 25 },
},
ROUTE = {
{ 255, 255, 255 },
{ 132, 255, 33 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
SAFFRON = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
SLOTS1 = {
{ 255, 255, 255 },
{ 206, 8, 255 },
{ 255, 0, 0 },
{ 25, 25, 25 },
},
SLOTS2 = {
{ 255, 255, 255 },
{ 255, 33, 156 },
{ 255, 255, 0 },
{ 25, 25, 25 },
},
SLOTS3 = {
{ 255, 255, 255 },
{ 66, 255, 0 },
{ 255, 255, 0 },
{ 25, 25, 25 },
},
SLOTS4 = {
{ 255, 255, 255 },
{ 0, 255, 255 },
{ 255, 255, 0 },
{ 25, 25, 25 },
},
TOWNMAP = {
{ 255, 255, 255 },
{ 0, 173, 255 },
{ 82, 230, 0 },
{ 8, 8, 8 },
},
VERMILION = {
{ 255, 255, 255 },
{ 255, 156, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
VIRIDIAN = {
{ 255, 255, 255 },
{ 156, 255, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
YELLOWBAR = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 148, 0 },
{ 25, 25, 25 },
},
YELLOWMON = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 230, 115, 0 },
{ 25, 25, 25 },
},
},
order = {
"ROUTE",
"PALLET",
"VIRIDIAN",
"PEWTER",
"CERULEAN",
"LAVENDER",
"VERMILION",
"CELADON",
"FUCHSIA",
"CINNABAR",
"INDIGO",
"SAFFRON",
"TOWNMAP",
"LOGO1",
"LOGO2",
"0F",
"MEWMON",
"BLUEMON",
"REDMON",
"CYANMON",
"PURPLEMON",
"BROWNMON",
"GREENMON",
"PINKMON",
"YELLOWMON",
"GRAYMON",
"SLOTS1",
"SLOTS2",
"SLOTS3",
"SLOTS4",
"BLACK",
"GREENBAR",
"YELLOWBAR",
"REDBAR",
"BADGE",
"CAVE",
"GAMEFREAK",
"PIKACHUS_BEACH",
"PIKACHU_PORTRAIT",
"PIKACHUS_BEACH_TITLE",
},
palettes = {
["0F"] = {
{ 255, 255, 247 },
{ 197, 165, 247 },
{ 90, 165, 247 },
{ 25, 16, 16 },
},
BADGE = {
{ 255, 255, 247 },
{ 165, 123, 90 },
{ 181, 173, 165 },
{ 49, 49, 49 },
},
BLACK = {
{ 255, 255, 247 },
{ 49, 49, 49 },
{ 49, 49, 49 },
{ 49, 49, 49 },
},
BLUEMON = {
{ 255, 255, 247 },
{ 173, 181, 255 },
{ 74, 82, 165 },
{ 49, 49, 49 },
},
BROWNMON = {
{ 255, 255, 247 },
{ 214, 189, 148 },
{ 148, 115, 82 },
{ 49, 49, 49 },
},
CAVE = {
{ 255, 255, 247 },
{ 165, 123, 90 },
{ 181, 173, 165 },
{ 49, 49, 49 },
},
CELADON = {
{ 255, 255, 247 },
{ 181, 255, 181 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
CERULEAN = {
{ 255, 255, 247 },
{ 181, 189, 255 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
CINNABAR = {
{ 255, 255, 247 },
{ 255, 123, 115 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
CYANMON = {
{ 255, 255, 247 },
{ 214, 230, 255 },
{ 58, 197, 230 },
{ 49, 49, 49 },
},
FUCHSIA = {
{ 255, 255, 247 },
{ 255, 214, 214 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
GAMEFREAK = {
{ 255, 255, 247 },
{ 230, 197, 115 },
{ 165, 165, 90 },
{ 49, 49, 49 },
},
GRAYMON = {
{ 255, 255, 247 },
{ 206, 206, 148 },
{ 132, 132, 115 },
{ 49, 49, 49 },
},
GREENBAR = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 0, 173, 0 },
{ 49, 49, 49 },
},
GREENMON = {
{ 255, 255, 247 },
{ 197, 230, 148 },
{ 107, 173, 123 },
{ 49, 49, 49 },
},
INDIGO = {
{ 255, 255, 247 },
{ 140, 140, 206 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
LAVENDER = {
{ 255, 255, 247 },
{ 222, 189, 239 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
LOGO1 = {
{ 255, 255, 247 },
{ 247, 247, 140 },
{ 173, 0, 33 },
{ 173, 0, 33 },
},
LOGO2 = {
{ 255, 255, 247 },
{ 247, 247, 140 },
{ 148, 148, 197 },
{ 58, 58, 132 },
},
MEWMON = {
{ 255, 255, 247 },
{ 255, 247, 181 },
{ 222, 132, 132 },
{ 49, 49, 49 },
},
PALLET = {
{ 255, 255, 247 },
{ 230, 222, 255 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
PEWTER = {
{ 255, 255, 247 },
{ 189, 189, 181 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
PIKACHUS_BEACH = {
{ 255, 255, 247 },
{ 255, 247, 181 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
PIKACHUS_BEACH_TITLE = {
{ 255, 255, 247 },
{ 132, 132, 132 },
{ 255, 206, 74 },
{ 49, 49, 49 },
},
PIKACHU_PORTRAIT = {
{ 255, 255, 247 },
{ 230, 189, 74 },
{ 148, 115, 82 },
{ 49, 49, 49 },
},
PINKMON = {
{ 255, 255, 247 },
{ 255, 197, 214 },
{ 255, 148, 173 },
{ 49, 49, 49 },
},
PURPLEMON = {
{ 255, 255, 247 },
{ 222, 181, 247 },
{ 181, 123, 189 },
{ 49, 49, 49 },
},
REDBAR = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 214, 74, 49 },
{ 49, 49, 49 },
},
REDMON = {
{ 255, 255, 247 },
{ 255, 197, 90 },
{ 214, 74, 49 },
{ 49, 49, 49 },
},
ROUTE = {
{ 255, 255, 247 },
{ 189, 214, 156 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
SAFFRON = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
SLOTS1 = {
{ 255, 255, 247 },
{ 222, 181, 247 },
{ 214, 74, 49 },
{ 49, 49, 49 },
},
SLOTS2 = {
{ 255, 255, 247 },
{ 255, 189, 214 },
{ 239, 239, 66 },
{ 49, 49, 49 },
},
SLOTS3 = {
{ 255, 255, 247 },
{ 189, 255, 165 },
{ 239, 239, 66 },
{ 49, 49, 49 },
},
SLOTS4 = {
{ 255, 255, 247 },
{ 189, 239, 255 },
{ 239, 239, 66 },
{ 49, 49, 49 },
},
TOWNMAP = {
{ 255, 255, 247 },
{ 165, 214, 255 },
{ 140, 189, 82 },
{ 25, 16, 16 },
},
VERMILION = {
{ 255, 255, 247 },
{ 255, 206, 132 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
VIRIDIAN = {
{ 255, 255, 247 },
{ 214, 255, 173 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
YELLOWBAR = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 230, 189, 74 },
{ 49, 49, 49 },
},
YELLOWMON = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 230, 189, 74 },
{ 49, 49, 49 },
},
},
pokemon = {
ABRA = "YELLOWMON",
AERODACTYL = "GRAYMON",
ALAKAZAM = "YELLOWMON",
ARBOK = "PURPLEMON",
ARCANINE = "REDMON",
ARTICUNO = "BLUEMON",
BEEDRILL = "YELLOWMON",
BELLSPROUT = "GREENMON",
BLASTOISE = "CYANMON",
BULBASAUR = "GREENMON",
BUTTERFREE = "CYANMON",
CATERPIE = "GREENMON",
CHANSEY = "PINKMON",
CHARIZARD = "REDMON",
CHARMANDER = "REDMON",
CHARMELEON = "REDMON",
CLEFABLE = "PINKMON",
CLEFAIRY = "PINKMON",
CLOYSTER = "GRAYMON",
CUBONE = "GRAYMON",
DEWGONG = "BLUEMON",
DIGLETT = "BROWNMON",
DITTO = "GRAYMON",
DODRIO = "BROWNMON",
DODUO = "BROWNMON",
DRAGONAIR = "BLUEMON",
DRAGONITE = "BROWNMON",
DRATINI = "GRAYMON",
DROWZEE = "YELLOWMON",
DUGTRIO = "BROWNMON",
EEVEE = "GRAYMON",
EKANS = "PURPLEMON",
ELECTABUZZ = "YELLOWMON",
ELECTRODE = "YELLOWMON",
EXEGGCUTE = "PINKMON",
EXEGGUTOR = "GREENMON",
FARFETCHD = "BROWNMON",
FEAROW = "BROWNMON",
FLAREON = "REDMON",
GASTLY = "PURPLEMON",
GENGAR = "PURPLEMON",
GEODUDE = "GRAYMON",
GLOOM = "REDMON",
GOLBAT = "BLUEMON",
GOLDEEN = "REDMON",
GOLDUCK = "CYANMON",
GOLEM = "GRAYMON",
GRAVELER = "GRAYMON",
GRIMER = "PURPLEMON",
GROWLITHE = "BROWNMON",
GYARADOS = "BLUEMON",
HAUNTER = "PURPLEMON",
HITMONCHAN = "BROWNMON",
HITMONLEE = "BROWNMON",
HORSEA = "CYANMON",
HYPNO = "YELLOWMON",
IVYSAUR = "GREENMON",
JIGGLYPUFF = "PINKMON",
JOLTEON = "YELLOWMON",
JYNX = "MEWMON",
KABUTO = "BROWNMON",
KABUTOPS = "BROWNMON",
KADABRA = "YELLOWMON",
KAKUNA = "YELLOWMON",
KANGASKHAN = "BROWNMON",
KINGLER = "REDMON",
KOFFING = "PURPLEMON",
KRABBY = "REDMON",
LAPRAS = "CYANMON",
LICKITUNG = "PINKMON",
MACHAMP = "GRAYMON",
MACHOKE = "GRAYMON",
MACHOP = "GRAYMON",
MAGIKARP = "REDMON",
MAGMAR = "REDMON",
MAGNEMITE = "GRAYMON",
MAGNETON = "GRAYMON",
MANKEY = "BROWNMON",
MAROWAK = "GRAYMON",
MEOWTH = "YELLOWMON",
METAPOD = "GREENMON",
MEW = "MEWMON",
MEWTWO = "MEWMON",
MOLTRES = "REDMON",
MR_MIME = "PINKMON",
MUK = "PURPLEMON",
NIDOKING = "PURPLEMON",
NIDOQUEEN = "BLUEMON",
NIDORAN_F = "BLUEMON",
NIDORAN_M = "PURPLEMON",
NIDORINA = "BLUEMON",
NIDORINO = "PURPLEMON",
NINETALES = "YELLOWMON",
ODDISH = "GREENMON",
OMANYTE = "BLUEMON",
OMASTAR = "BLUEMON",
ONIX = "GRAYMON",
PARAS = "REDMON",
PARASECT = "REDMON",
PERSIAN = "YELLOWMON",
PIDGEOT = "BROWNMON",
PIDGEOTTO = "BROWNMON",
PIDGEY = "BROWNMON",
PIKACHU = "YELLOWMON",
PINSIR = "BROWNMON",
POLIWAG = "BLUEMON",
POLIWHIRL = "BLUEMON",
POLIWRATH = "BLUEMON",
PONYTA = "REDMON",
PORYGON = "GRAYMON",
PRIMEAPE = "BROWNMON",
PSYDUCK = "YELLOWMON",
RAICHU = "YELLOWMON",
RAPIDASH = "REDMON",
RATICATE = "GRAYMON",
RATTATA = "GRAYMON",
RHYDON = "GRAYMON",
RHYHORN = "GRAYMON",
SANDSHREW = "BROWNMON",
SANDSLASH = "BROWNMON",
SCYTHER = "GREENMON",
SEADRA = "CYANMON",
SEAKING = "REDMON",
SEEL = "BLUEMON",
SHELLDER = "GRAYMON",
SLOWBRO = "PINKMON",
SLOWPOKE = "PINKMON",
SNORLAX = "PINKMON",
SPEAROW = "BROWNMON",
SQUIRTLE = "CYANMON",
STARMIE = "GRAYMON",
STARYU = "REDMON",
TANGELA = "BLUEMON",
TAUROS = "GRAYMON",
TENTACOOL = "CYANMON",
TENTACRUEL = "CYANMON",
VAPOREON = "CYANMON",
VENOMOTH = "PURPLEMON",
VENONAT = "PURPLEMON",
VENUSAUR = "GREENMON",
VICTREEBEL = "GREENMON",
VILEPLUME = "REDMON",
VOLTORB = "YELLOWMON",
VULPIX = "REDMON",
WARTORTLE = "CYANMON",
WEEDLE = "YELLOWMON",
WEEPINBELL = "GREENMON",
WEEZING = "PURPLEMON",
WIGGLYTUFF = "PINKMON",
ZAPDOS = "YELLOWMON",
ZUBAT = "BLUEMON",
},
source = "pokeyellow data/sgb/sgb_palettes.asm + data/pokemon/palettes.asm",
}
+9 -7
View File
@@ -1,10 +1,9 @@
-- BikeShop (BIKE_SHOP) flavor dialogue
-- Source: pokered/scripts/BikeShop.asm, pokered/text/BikeShop.asm
--
-- TEXT_BIKESHOP_CLERK is skipped: it drives the actual voucher-for-bicycle
-- exchange (YesNoChoice purchase menu, GiveItem, RemoveItemByID, SetEvent
-- EVENT_GOT_BICYCLE). That is a significant standalone feature outside the
-- scope of these two flavor NPCs and is left unported here.
-- TEXT_BIKESHOP_CLERK lives in data/scripts/story2.lua (M.BIKE_SHOP): the
-- voucher exchange and the BICYCLE/CANCEL price window need more than
-- command rows (#568).
return {
BIKE_SHOP = {
@@ -20,12 +19,15 @@ return {
-- CheckEvent EVENT_GOT_BICYCLE ; jr nz, .gotBike
-- before the player owns a bike -> TheseBikesAreExpensiveText
-- after the player owns a bike -> CoolBikeText
-- The check reads the bag, not the event: the port hands out the
-- BICYCLE itself (a key item, so it cannot be tossed), and that also
-- reads right on saves written before the clerk set the event (#567).
TEXT_BIKESHOP_YOUNGSTER = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_BICYCLE" }, -- 2
{ "jump_if_true", 5 }, -- 3
{ "check_item", "BICYCLE" }, -- 2
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4
{ "jump", 6 }, -- 5
{ "jump", "end" }, -- 5
{ "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6
},
},
@@ -35,13 +35,25 @@ local function middleAgedMan(game, ow, npc, done)
-- .loop: print WhichBadgeText, then show the badge list menu again
push(game, t._CeruleanBadgeHouseMiddleAgedManWhichBadgeText, function()
local ListMenu = require("src.ui.ListMenu")
local Strings = require("src.core.Strings")
local items = {}
for _, id in ipairs(BADGE_ORDER) do
local idef = game.data.items[id]
items[#items + 1] = { label = idef and idef.name or id, value = id }
end
-- PrintListMenuEntries prints ListMenuCancelText once it hits the
-- list's $FF terminator (home/list_menu.asm), so every
-- DisplayListMenuID list ends in an on-screen CANCEL row; picking it
-- takes DisplayListMenuIDLoop's ExitListMenu path, the same .done
-- exit as B (#569)
items[#items + 1] = { label = Strings("CANCEL") }
local menu = ListMenu.new(game, "", items, {
onChoose = function(item)
game.stack:pop()
if not item.value then
push(game, t._CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText, done)
return
end
push(game, t[BADGE_TEXT[item.value]], loop)
end,
onCancel = function()
+33 -18
View File
@@ -3,6 +3,10 @@
-- Both NPCs use text_asm with an hRandomAdd roll to pick one of several
-- flavor lines (no flags, no branching outcome) -- ported as a weighted
-- math.random pick each time the NPC is talked to.
--
-- Yellow renames Slowbro -> Electrode in the text labels (see
-- tools/yellow_symbol_aliases.py); look up both so Red/Blue and Yellow
-- share this script.
local M = {}
@@ -12,6 +16,29 @@ local function push(game, ow, npc, done, text)
game.stack:push(TextBox.new(game, text, done))
end
-- CeruleanCitySlowbroText / CeruleanCityElectrodeText
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
local function talkMon(game, ow, npc, done)
local t = game.data.text
local roll = math.random(0, 255)
local text
if roll >= 180 then
text = t._CeruleanCitySlowbroTookASnoozeText
or t._CeruleanCityElectrodeTookASnoozeText
elseif roll >= 120 then
text = t._CeruleanCitySlowbroIsLoafingAroundText
or t._CeruleanCityElectrodeIsLoafingAroundText
elseif roll >= 60 then
text = t._CeruleanCitySlowbroTurnedAwayText
or t._CeruleanCityElectrodeTurnedAwayText
else
text = t._CeruleanCitySlowbroIgnoredOrdersText
or t._CeruleanCityElectrodeIgnoredOrdersText
end
push(game, ow, npc, done, text)
end
M.CERULEAN_CITY = {
talk = {
-- CeruleanCityCooltrainerF1Text (scripts/CeruleanCity.asm:362-393)
@@ -23,32 +50,20 @@ M.CERULEAN_CITY = {
local text
if roll >= 180 then
text = t._CeruleanCityCooltrainerF1SlowbroUseSonicboomText
or t._CeruleanCityCooltrainerF1ElectrodeUseSonicboomText
elseif roll >= 100 then
text = t._CeruleanCityCooltrainerF1SlowbroPunchText
or t._CeruleanCityCooltrainerF1ElectrodePunchText
else
text = t._CeruleanCityCooltrainerF1SlowbroWithdrawText
or t._CeruleanCityCooltrainerF1ElectrodeWithdrawText
end
push(game, ow, npc, done, text)
end,
-- CeruleanCitySlowbroText (scripts/CeruleanCity.asm:395-436)
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
TEXT_CERULEANCITY_SLOWBRO = function(game, ow, npc, done)
local t = game.data.text
local roll = math.random(0, 255)
local text
if roll >= 180 then
text = t._CeruleanCitySlowbroTookASnoozeText
elseif roll >= 120 then
text = t._CeruleanCitySlowbroIsLoafingAroundText
elseif roll >= 60 then
text = t._CeruleanCitySlowbroTurnedAwayText
else
text = t._CeruleanCitySlowbroIgnoredOrdersText
end
push(game, ow, npc, done, text)
end,
TEXT_CERULEANCITY_SLOWBRO = talkMon,
-- Yellow map object uses TEXT_CERULEANCITY_ELECTRODE
TEXT_CERULEANCITY_ELECTRODE = talkMon,
},
}
+41
View File
@@ -85,6 +85,47 @@ return {
alreadyGotLabel = "_GameCornerGentlemanCloselyWatchTheReelsText",
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
}),
-- Yellow keeps all three giveaways with the same events and amounts
-- but renames the objects and their text labels: FISHING_GURU ->
-- FISHING_GURU1, CLERK2 -> MIDDLE_AGED_MAN2, GENTLEMAN ->
-- FISHING_GURU2 (pokeyellow/scripts/GameCorner.asm). The Red/Blue
-- keys above never match there, so Yellow needs its own three (#552).
TEXT_GAMECORNER_FISHING_GURU1 = coinGiver({
event = "EVENT_GOT_10_COINS",
amount = 10,
askLabel = "_GameCornerFishingGuru1WantToPlayText",
askFallback = "Kid, do you want\nto play?",
receivedLabel = "_GameCornerFishingGuru1Received10CoinsText",
coinCaseFullLabel = "_GameCornerFishingGuru1DontNeedMyCoinsText",
coinCaseFullFallback = "You don't need my\ncoins!",
alreadyGotLabel = "_GameCornerFishingGuru1WinsComeAndGoText",
alreadyGotFallback = "Wins seem to come\nand go.",
}),
TEXT_GAMECORNER_MIDDLE_AGED_MAN2 = coinGiver({
event = "EVENT_GOT_20_COINS_2",
amount = 20,
askLabel = "_GameCornerMiddleAgedMan2WantSomeCoinsText",
askFallback = "What's up? Want\nsome coins?",
receivedLabel = "_GameCornerMiddleAgedMan2Received20CoinsText",
coinCaseFullLabel = "_GameCornerMiddleAgedMan2YouHaveLotsOfCoinsText",
coinCaseFullFallback = "You have lots of\ncoins!",
alreadyGotLabel = "_GameCornerMiddleAgedMan2INeedMoreCoinsText",
alreadyGotFallback = "Darn! I need more\ncoins for the\vPOKéMON I want!",
}),
TEXT_GAMECORNER_FISHING_GURU2 = coinGiver({
event = "EVENT_GOT_20_COINS",
amount = 20,
askLabel = "_GameCornerFishingGuru2ThrowingMeOffText",
askFallback = "Hey, what? You're\nthrowing me off!\vHere are some\vcoins, shoo!",
receivedLabel = "_GameCornerFishingGuru2Received20CoinsText",
coinCaseFullLabel = "_GameCornerFishingGuru2YouGotYourOwnCoinsText",
coinCaseFullFallback = "You've got your\nown coins!",
alreadyGotLabel = "_GameCornerFishingGuru2CloselyWatchTheReelsText",
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
}),
},
},
}
@@ -0,0 +1,95 @@
-- Viridian School House's two readables (data/events/hidden_events.asm,
-- hidden_events_for VIRIDIAN_SCHOOL_HOUSE):
-- hidden_text_predef 3, 0 PrintBlackboardLinkCableText, ViridianSchoolBlackboard
-- hidden_text_predef 3, 4 PrintNotebookText, ViridianSchoolNotebook
-- tools/extract/field.py only parses `hidden_event` rows, so neither
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
-- were dead A presses (#503). Same hook shape, and the same sibling asm
-- file, as the Celadon roof house in data/scripts/celadon_eevee.lua (#391);
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither
-- tile gates on facing.
local Menu = require("src.ui.Menu")
local TextBox = require("src.render.TextBox")
-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm):
-- StatusAilmentText1/2 are the two columns of the 12x8 box at the top left
-- (hlcoord 0, 0 + `lb bc, 6, 10`); picking a status prints its
-- ViridianBlackboardStatusPointers entry and jumps back to .blackboardLoop,
-- QUIT or B falls through to .exitBlackboard.
local STATUS_LABELS = {
{ " SLP", "_ViridianBlackboardSleepText" },
{ " PSN", "_ViridianBlackboardPoisonText" },
{ " PAR", "_ViridianBlackboardPrlzText" },
{ " BRN", "_ViridianBlackboardBurnText" },
{ " FRZ", "_ViridianBlackboardFrozenText" },
}
local function blackboard(game)
local text = game.data.text or {}
local items, showMenu, askHeading
function showMenu()
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 12, th = 8, rowStep = 1 }))
end
-- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop
-- pass, immediately before HandleMenuInput
function askHeading()
game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
showMenu))
end
items = {}
for i, row in ipairs(STATUS_LABELS) do
local label, key = row[1], row[2]
items[i] = { label = label, onSelect = function()
game.stack:push(TextBox.new(game, text[key] or label, askHeading))
end }
end
-- no onSelect: Menu's own pop closes the box, matching .exitBlackboard
items[#items + 1] = { label = " QUIT" }
game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText1
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
askHeading))
end
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
-- pages 1-3 each end in TurnPageSchoolNotebook (TurnPageText + YesNoChoice)
-- and NO stops the read; page 4 turns without asking and runs straight into
-- page 5, the girl catching you at it.
local function notebook(game)
local text = game.data.text or {}
local function page(n, after)
return TextBox.new(game, text["_ViridianSchoolNotebookText" .. n] or "", after)
end
local function turnPage(nextPage)
return function()
game.stack:push(TextBox.new(game, text._TurnPageText or "Turn the page?",
nil, { choice = function(yes)
if yes then game.stack:push(nextPage()) end
end }))
end
end
local function page5() return page(5) end
local function page4() return page(4, function() game.stack:push(page5()) end) end
local function page3() return page(3, turnPage(page4)) end
local function page2() return page(2, turnPage(page3)) end
game.stack:push(page(1, turnPage(page2)))
end
return {
VIRIDIAN_SCHOOL_HOUSE = {
onInteract = function(game, ow, fx, fy)
if fx == 3 and fy == 0 then
blackboard(game)
return true
end
if fx == 3 and fy == 4 then
notebook(game)
return true
end
return false
end,
},
}
+1
View File
@@ -60,6 +60,7 @@ local files = {
"data.scripts.flavor.victory_road_2f",
"data.scripts.flavor.viridian_city",
"data.scripts.flavor.viridian_nickname_house",
"data.scripts.flavor.viridian_school_house", -- #503
"data.scripts.flavor.wardens_house",
}
+16 -8
View File
@@ -143,17 +143,25 @@ return {
-- OaksLabRivalExclamationScript: "!" over the rival
{ "emote", RIVAL, "shock" },
}
-- .RivalPushesPlayerAwayFromEeveeBall + the PAD_RIGHT x2 shove:
-- the rival cuts across to the ball WHILE the player standing
-- below it is bumped two tiles right (both movements run in the
-- same beat, so the walk overlaps the shove like the original)
-- .RivalPushesPlayerAwayFromEeveeBall is DOWN then RIGHT x3 (the $07
-- bytes are Yellow's own step-right encoding, decoded by
-- engine/overworld/movement.asm Func_5288 -> Func_532b), and the
-- PAD_RIGHT x2 shove is NOT queued alongside it:
-- OaksLabRivalTakesPokeballScript .asm_1c564 polls every frame and
-- only simulates the pair once wNPCNumScriptedSteps reads 1 -- i.e.
-- as the rival begins the LAST byte, the step onto the tile the
-- player is standing on. Starting both on one row had Red stroll
-- off the Eevee while the rival was still at the top of the table
-- (#559).
if py == 4 then
rows[#rows + 1] = { "walk_npc", RIVAL,
{ "down", "right", "right", "right" }, { wait = false } }
rows[#rows + 1] = { "walk_npc", RIVAL, { "down", "right", "right" } }
-- this one runs concurrently with the shove below
rows[#rows + 1] = { "walk_npc", RIVAL, { "right" }, { wait = false } }
rows[#rows + 1] = { "face_player_dir", "left" }
rows[#rows + 1] = { "move_player", "right", 2 }
-- let the rival finish the last stretch to (7,4)
rows[#rows + 1] = { "wait", 40 }
-- move_player blocks for both tiles, so the rival has already
-- landed on (7,4); this is just the beat before he turns up
rows[#rows + 1] = { "wait", 20 }
else
rows[#rows + 1] = { "move_npc_to", RIVAL, 7, 4 }
end
+18 -3
View File
@@ -183,6 +183,7 @@ M.BILLS_HOUSE = {
overworld = ow },
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
require("src.world.PikachuFollower").onBillEnteredMachine(game, ow)
done()
end
if ow.player.facing == "down" then
@@ -273,6 +274,9 @@ M.BILLS_HOUSE = {
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
end
if not flags.EVENT_MET_BILL_2 then
require("src.world.PikachuFollower").onBillsHouseEnter(game, ow)
end
end,
}
@@ -643,7 +647,12 @@ M.WARDENS_HOUSE = {
TEXT_WARDENSHOUSE_WARDEN = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
{ "jump_if_true", 13 }, -- 3
-- #535: previously jumped to the same silent-end target as the
-- give-then-thank fallthrough (row 13), so the warden said nothing
-- on every visit after the trade. pokered's .got_item branch
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText,
-- so route here to the new row 17 that does the same.
{ "jump_if_true", 17 }, -- 3
{ "check_item", "GOLD_TEETH" }, -- 4
{ "jump_if_false", 15 }, -- 5
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
@@ -654,9 +663,15 @@ M.WARDENS_HOUSE = {
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
{ "jump", 16 }, -- 13 (already got it)
{ "jump", 16 }, -- 14 (unused)
{ "jump", 18 }, -- 13 (already got it this convo; jp .done)
{ "jump", 18 }, -- 14 (unused)
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
{ "jump", 18 }, -- 16 (#535: skip the new explanation row below)
-- #535: pokered .got_item branch (scripts/WardensHouse.asm) --
-- printed on every subsequent talk once EVENT_GOT_HM04 is set.
-- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm):
-- HM04 teaches Strength, and hints at the Safari Zone secret house.
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 17
},
},
}
+130 -11
View File
@@ -413,22 +413,141 @@ M.POKEMON_FAN_CLUB = {
},
}
-- BikeShopClerkText (scripts/BikeShop.asm) runs three ways: the BICYCLE is
-- already yours, you are carrying the BIKE VOUCHER, or you get the sales
-- pitch. The pitch draws its own window (TextBoxBorder hlcoord 0,0, b=4
-- c=15) holding BikeShopMenuText and BikeShopMenuPrice, and leaves it up
-- while the clerk keeps talking in the bottom box: the original never
-- erases it before TextScriptEnd (#568).
local BikeShopWindow = {}
BikeShopWindow.__index = BikeShopWindow
function BikeShopWindow.new(game, footer, onChoose)
local self = setmetatable({}, BikeShopWindow)
self.game = game
self.onChoose = onChoose
self.index = 1
self.active = true
-- BikeShopClerkDoYouLikeItText stays on screen under the window for as
-- long as the menu is up. The text box pushed on top types it out and
-- pops itself; this copy of its last page takes over from there, so the
-- bottom box never blanks between the pitch and the answer. Paginated
-- with the themed column budget so the copy breaks where the box did.
local TextBox = require("src.render.TextBox")
local box = require("src.ui.Theme").textBox or {}
local pages = TextBox.paginate(TextBox.substitute(game, footer), box.maxCols)
self.footer = pages[#pages]
return self
end
function BikeShopWindow:update()
-- one answer only: the boxes pushed by onChoose sit on top of this
-- state, but a second A on the same frame must not fire it twice
if not self.active then return end
local input = self.game.input
-- HandleMenuInput with wMenuWrappingEnabled clear: the two rows clamp
if input:wasPressed("up") then
self.index = 1
elseif input:wasPressed("down") then
self.index = 2
elseif input:wasPressed("a") or input:wasPressed("b") then
local cancelled = input:wasPressed("b") -- bit B_PAD_B -> .cancel
require("src.core.Sound").play(self.game.data, "Press_AB")
self.active = false
self.onChoose(not cancelled and self.index == 1)
end
end
function BikeShopWindow:draw()
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
Font.drawBox(0, 0, 17, 6)
love.graphics.setColor(0, 0, 0, 1)
local bike = self.game.data.items.BICYCLE
Font.draw(bike and bike.name or "BICYCLE", 16, 16) -- hlcoord 2, 2
Font.draw("¥1000000", 64, 24) -- hlcoord 8, 3
Font.draw(Strings("CANCEL"), 16, 32) -- `next` skips a row
-- wTopMenuItemX 1, wTopMenuItemY 2, rows two apart
Font.drawCode(Theme.cursor, 8, self.index == 1 and 16 or 32)
if self.footer then
-- same geometry TextBox resolves against, so a themed box matches
local box = Theme.textBox or {}
local tx, ty = box.tx or 0, box.ty or 12
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(tx, ty, box.tw or 20, box.th or 6)
love.graphics.setColor(0, 0, 0, 1)
for i, line in ipairs(self.footer) do
Font.draw(line, (tx + 1) * 8, (ty + 2 * i) * 8)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
M.BIKE_SHOP = {
talk = {
TEXT_BIKESHOP_CLERK = function(game, ow, npc, done)
local Bag = require("src.inventory.Bag")
local Flags = require("src.script.Flags")
local TextBox = require("src.render.TextBox")
if (game.save.inventory.BICYCLE or 0) > 0 then
game.stack:push(TextBox.new(game, "How's the\nBICYCLE treating\nyou?", done))
elseif (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
game.save.inventory.BIKE_VOUCHER = nil
game.save.inventory.BICYCLE = 1
game.stack:push(TextBox.new(game,
("Oh, that's a\nBIKE VOUCHER!\f%s exchanged\nit for a BICYCLE!")
:format(game.save.player.name), done))
else
game.stack:push(TextBox.new(game,
"A BICYCLE costs\n¥1000000. Sorry,\nno instalments!", done))
local t = game.data.text
local function say(text, after, opts)
game.stack:push(TextBox.new(game, text, after, opts))
end
-- CheckEvent EVENT_GOT_BICYCLE. Saves made before the clerk started
-- setting the event still have the bike in the bag, so either counts.
if (game.save.inventory.BICYCLE or 0) > 0
or Flags.get(game.save, "EVENT_GOT_BICYCLE") then
say(t._BikeShopClerkHowDoYouLikeYourBicycleText, done)
return
end
-- .dontHaveBike: IsItemInBag BIKE_VOUCHER
if (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
say(t._BikeShopClerkOhThatsAVoucherText, function()
-- GiveItem's `jr nc, .BagFull`: the voucher is only spent once
-- the BICYCLE is actually in the bag
if not Bag.add(game.save, "BICYCLE", 1) then
say(t._BikeShopBagFullText, done)
return
end
Bag.remove(game.save, "BIKE_VOUCHER", 1)
Flags.set(game.save, "EVENT_GOT_BICYCLE")
-- BikeShopExchangedVoucherText carries sound_get_key_item; the
-- map runs EnableAutoTextBoxDrawing, so the box still waits for
-- a button once the jingle has played (auto.wait, #247)
say(t._BikeShopExchangedVoucherText, done, {
auto = { wait = true, sound = function()
return require("src.core.Sound").play(game.data, "Get_Key_Item")
end },
})
end)
return
end
-- .dontHaveVoucher: welcome, then the BICYCLE/CANCEL window
say(t._BikeShopClerkWelcomeText, function()
local pitch = t._BikeShopClerkDoYouLikeItText
game.stack:push(BikeShopWindow.new(game, pitch, function(bought)
local function comeAgain()
say(t._BikeShopComeAgainText, function()
game.stack:pop() -- the window, still up under the text
done()
end)
end
if bought then
-- a million is out of anyone's reach: BikeShopCantAffordText
say(t._BikeShopCantAffordText, comeAgain)
else
comeAgain()
end
end))
-- PrintText BikeShopClerkDoYouLikeItText, then straight into
-- HandleMenuInput: the box types out and hands over without
-- waiting, leaving the window's copy of the line on screen
say(pitch, nil, { auto = { delay = 0 } })
end)
end,
},
}
+72 -15
View File
@@ -111,8 +111,9 @@ M.POKEMON_TOWER_5F = {
--
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
-- Silph Scope check at the trigger -- the scope only decides whether the
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
-- balls dodged). An earlier version of this port turned the player back
-- disguise sticks (IsGhostBattle -> makeGhost: "too scared to move", balls
-- dodged) or comes off in the unveil (makeUnveiledGhost, #492). An earlier
-- version of this port turned the player back
-- without the scope and never opened the battle, which made 6F
-- impassable on any route that skips Rocket Hideout; vanilla lets the
-- battle open and a POKE_DOLL end it (see wBattleResult below).
@@ -133,7 +134,13 @@ M.POKEMON_TOWER_6F = {
-- or not the scope revealed it, so the "can't be caught" state rides
-- the battle instead of IsGhostBattle alone (#444)
battle.noCatch = true
if not game.save.inventory.SILPH_SCOPE then
-- InitWildBattle enters disguised for the RESTLESS SOUL either way
-- (core.asm:6698-6700). The scope does not skip the disguise, it
-- buys the unveil PrintBeginningBattleText .isMarowak plays over it
-- before the battle proceeds as an ordinary wild one (#492).
if game.save.inventory.SILPH_SCOPE then
battle:makeUnveiledGhost()
else
battle:makeGhost()
end
battle.onFinish = function(result)
@@ -353,6 +360,38 @@ M.ROCKET_HIDEOUT_B4F = {
end))
end,
-- Yellow swaps Rocket1/Rocket2 for Jessie & James and keeps a single
-- grunt, spelled ROCKETHIDEOUTB4F_ROCKET / TEXT_ROCKETHIDEOUTB4F_ROCKET
-- (pokeyellow/scripts/RocketHideoutB4F.asm), so the Red/Blue key above
-- never matched there and the LIFT KEY never dropped (#552, a
-- regression of #105). Yellow also moves the drop off the second
-- talk: RocketHideoutB4FRocketEndBattleText is text_promptbutton +
-- text_asm, so its SetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5 run the instant the battle ends.
TEXT_ROCKETHIDEOUTB4F_ROCKET = function(game, ow, npc, done)
if not ow:trainerDefeated(npc) then
ow:engageTrainer(npc, function()
-- engageTrainer records the win before it calls back, so this
-- is the end-battle text's SetEvent + ShowObject
if ow:trainerDefeated(npc)
and not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
local Commands = require("src.script.Commands")
Commands.show_object(
{ game = game, save = game.save, overworld = ow },
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
end
done()
end)
return
end
-- RocketHideoutB4FRocketAfterBattleText: later talks only reprint
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._RocketHideoutB4FRocketAfterBattleText
or "Oh no! I dropped\nthe LIFT KEY!", done))
end,
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
-- owns both the engage and the BeatGiovanniScript aftermath.
@@ -502,45 +541,56 @@ M.GAME_CORNER = {
end))
end)
end,
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
-- object const and from every one of his text labels
-- (GameCornerClerkText, pokeyellow/scripts/GameCorner.asm) with an
-- identical body, so each line resolves under both spellings and the
-- handler is bound to both text ids just below (#552).
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local t = game.data.text
local function line(suffix, fallback)
return t["_GameCornerClerk1" .. suffix]
or t["_GameCornerClerk" .. suffix]
or fallback
end
game.stack:push(TextBox.new(game,
(t._GameCornerClerk1DoYouNeedSomeGameCoinsText
or "Do you need some\ngame coins?\f¥1000 for 50."), function()
line("DoYouNeedSomeGameCoinsText",
"Do you need some\ngame coins?\f¥1000 for 50."), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1PleaseComePlaySometimeText
or "No? Please come\nplay sometime!", done))
line("PleaseComePlaySometimeText",
"No? Please come\nplay sometime!"), done))
return
end
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
-- the COIN CASE and room for at least 9 coins (Has9990Coins)
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1DontHaveCoinCaseText
or "You don't have a\nCOIN CASE!", done))
line("DontHaveCoinCaseText",
"You don't have a\nCOIN CASE!"), done))
return
end
if (game.save.coins or 0) >= 9990 then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1CoinCaseIsFullText
or "Oops! Your COIN\nCASE is full.", done))
line("CoinCaseIsFullText",
"Oops! Your COIN\nCASE is full."), done))
return
end
if game.save.money < 1000 then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1CantAffordTheCoinsText
or "You can't afford\nthe coins!", done))
line("CantAffordTheCoinsText",
"You can't afford\nthe coins!"), done))
return
end
game.save.money = game.save.money - 1000
game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
game.stack:push(TextBox.new(game,
(t._GameCornerClerk1ThanksHereAre50CoinsText
or "Thanks! Here are\nyour 50 coins!")
line("ThanksHereAre50CoinsText",
"Thanks! Here are\nyour 50 coins!")
.. ("\fCOINS: %d"):format(game.save.coins), done))
end))
end))
@@ -548,6 +598,13 @@ M.GAME_CORNER = {
},
}
-- Yellow's coin clerk is GAMECORNER_CLERK / TEXT_GAMECORNER_CLERK where Red
-- and Blue spell him CLERK1, and the two text_asm bodies are identical, so
-- one handler answers both ids. Without the alias the Yellow clerk fell
-- through to his extracted offer line with no yes/no box behind it (#552).
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK =
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
-- The six mon prizes differ between Red and Blue; the three TM prizes are
-- identical, so they are shared and appended to each version's mon list.
+23
View File
@@ -171,6 +171,28 @@ local function dojoBall(species, ownBall, otherBall, askKey)
end
end
-- FightingDojoDefaultScript does not use the Master's facing direction for
-- this battle. It checks exactly the cell left of him, turns both sprites
-- toward one another, and then displays his trainer text. Keeping this out
-- of trainer sight also lets him keep his original downward-facing pose.
local function dojoMasterGate(game, ow, x, y)
if x ~= 4 or y ~= 3 or game.save.flags.EVENT_BEAT_KARATE_MASTER then
return false
end
local master
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "FIGHTINGDOJO_KARATE_MASTER" then
master = npc
break
end
end
if not master or ow:trainerDefeated(master) then return false end
ow.player.facing = "right"
master:facePlayer(ow.player)
ow:engageTrainer(master)
return true
end
M.FIGHTING_DOJO = {
talk = {
TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL =
@@ -197,6 +219,7 @@ M.FIGHTING_DOJO = {
end
return false
end,
onStep = dojoMasterGate,
}
-- -------------------------------------------------------------------
+12 -1
View File
@@ -790,8 +790,19 @@ local function bikeGateGuard(coords, stopText, explainText)
local t = text(game)
push(game, t[stopText] or "Hey! Wait up!", function()
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript
-- (scripts/Route16Gate1F.asm, Route18Gate1F.asm) simulate one
-- PAD_RIGHT step after the refusal text, and only clear
-- wJoyIgnore / hand control back once that step finishes
-- (PlayerMovingRightScript). Without it the player was left
-- parked beside the guard's counter with no way past. #518
local function shoveRight()
ow:scriptMove(ow.player, "right", 1)
end
if dist > 0 then
ow:scriptMove(ow.player, "up", dist)
ow:scriptMove(ow.player, "up", dist, shoveRight)
else
shoveRight()
end
end)
end)
+7
View File
@@ -65,6 +65,13 @@ display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
put the row back.
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
to AUTO, which reads this device as an ARM Linux handheld and resolves to
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
so the overworld runs smoothly on the H700 out of the box. Bump it to
BALANCED or HIGH from OPTIONS if you want the extras and your device keeps
up; see [Performance tier](new-features.md#performance-tier-low-end-devices).
The pack bundles the LÖVE 11.5 aarch64 runtime from
[PortMaster](https://portmaster.games/), so the device does not need a
separate `love_11.5` runtime download on first launch. The launcher resolves
+5 -1
View File
@@ -12,7 +12,7 @@ this port.
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|---|---|---|---|---|
| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) |
| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
@@ -38,6 +38,10 @@ this port.
## Notes
- Mods may patch `constants.bagSize` through the public content registry. The
native `save.lua` format keeps every existing item when the configured
limit changes; exporting to a cartridge `.sav` still writes only the first
20 bag slots because the original SRAM layout has no room for more.
- PC Box **overflow handling** was deliberately changed even though the
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
or blocks the deposit," this port spills into the next box with room.
+3 -3
View File
@@ -1,5 +1,8 @@
# Build & install on your iPhone — step by step
Don't want to install Xcode? Download the release IPA and sideload it with
AltStore instead — see [ios-sideload.md](ios-sideload.md).
This guide assumes **zero** programming experience. Follow it top to
bottom and you'll have the game running on your own iPhone in roughly an
hour (most of it is waiting for downloads).
@@ -93,9 +96,6 @@ ZIP**, then double-click the zip to unpack it.
## Optional goodies
- **Pokéwalker mode** (real steps → EXP): in-game **mod manager →
POKEWALKER → SYNC STEPS on**, allow step access when iOS asks, and go
for a walk.
- **Mods**: launcher → **MODS** tab → **Import mod .zip**.
- **Save import/export**: buttons on each game's tab, using the normal
iOS file picker.
+48
View File
@@ -0,0 +1,48 @@
# Sideload the iOS build with AltStore
Every GitHub Release ships an IPA (`gen1recomp-*-ios.ipa`). Install it on
your iPhone or iPad with [AltStore Classic](https://altstore.io/) — AltStore
re-signs the app with **your** free Apple ID so you do not need a Mac or
Xcode.
## 1. Install AltStore
Follow the official guide for your computer:
- [How to Install (Windows)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-windows)
- [How to Install (macOS)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-macos)
You will install **AltServer** on the computer, then use it to put AltStore
on the phone. What AltServer is and why it needs to stay running:
- [AltServer](https://faq.altstore.io/altstore-classic/altserver)
Stuck? Start here:
- [Troubleshooting Guide](https://faq.altstore.io/altstore-classic/troubleshooting-guide)
## 2. Install the game
1. Download `gen1recomp-*-ios.ipa` from
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
2. Open **AltStore** on the phone (AltServer must be running on the same
WiFi, or keep the phone plugged into the computer).
3. Tap **My Apps → +** (or share the IPA into AltStore) and pick the file.
4. Sign in with your Apple ID when prompted. Wait for the install to finish.
5. On first launch: Settings → **Privacy & Security → Developer Mode** (iOS
16+), and Settings → **General → VPN & Device Management** → Trust your
Apple ID if asked.
Then open the app, import your own legal `.gb` ROM on the Red/Blue tab, and
play.
## Refresh / 7-day limit
With a free Apple ID, sideloaded apps stop launching after **7 days**. Keep
AltServer running so AltStore can refresh them, or open AltStore and refresh
manually before they expire. Saves on the phone are kept across refreshes.
## Prefer building it yourself?
Building from source on a Mac (no AltStore) is covered in
[ios-install.md](ios-install.md).
+33
View File
@@ -167,5 +167,38 @@ The console understands these verbs (anything else is evaluated as Lua):
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
- `help` — list the verbs.
## Tool input and title-menu hooks
Tool mods that need to act once per game logic tick can wrap `input.step`.
It runs immediately before queued button edges are promoted, so input added by
the wrapper is visible during that same fixed step. The callback receives
`(next, game, dt)` and must call `next(game, dt)`.
`ui.title_menu.items` receives `(next, game, items)` and follows the same
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
for a tool to offer a fresh-session action before gameplay begins.
Ephemeral tools can wrap `save.write(next, game)` and return `false` to veto a
progress write before world state is captured or any bytes reach disk.
`render.hud` receives `(next, game, viewport)` after the finished game frame is
composited and before touch controls draw. The window-space viewport contains
`width`, `height`, `gameX`, `gameY`, `gameWidth`, `gameHeight`, `scale`, `dpiX`,
and `dpiY`, so a tool can use the letterbox margins without drawing over the
playfield or pushing an updating game state.
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
the mod full control of the window, while calling `next` runs the engine's
normal single-window composite so the mod can decorate around it. `ctx` carries
the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
palette-correct blit of either canvas into an arbitrary screen rect, and the
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`)
for driving a second physical display. This is what lets a mod lay the two
passes out as two stacked Game Boy screens, or push one onto a second screen,
without the engine knowing the layout.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
+88 -7
View File
@@ -86,14 +86,20 @@ Game Boy equivalent:
## Colors mode
The `2` key (and the Options menu COLORS row) cycles the display mode
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**.
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**
(on Blue the first slot labels **OG BLUE**; on Yellow, **OG YELLOW**).
The first three are the real colorizations; the rest are DMG-shade novelties:
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
red BG palette + one green OBJ palette, every map, no per-map variation
(Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally).
The player/NPCs stay green over the red terrain via the OBP bake +
post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`).
- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart --
one global BG palette + one OBJ palette, every map, no per-map variation
(Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally).
The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP
bake + post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`, or Blue's blue/pink
pair).
- **OG YELLOW** (Yellow playthrough, same `ogred` save id): Pokemon Yellow's
authentic GBC look from `CGBBasePalettes` (`data/palettes_yellow.lua`,
sourced from pret/pokeyellow). Per-map / per-species colors, not a single
boot-ROM ramp -- Yellow was CGB-enhanced.
- **SGB** (default): the per-map Super Game Boy region palettes
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
real SGB. (This is the mode formerly mislabeled "GBC".)
@@ -137,6 +143,41 @@ effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
its launcher because the device reports `"Linux"` while its GPU is in the
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
## Performance tier (low-end devices)
The Options **PERFORMANCE** row scales the port's optional presentation
extras down for weaker hardware. The extras it governs are the three
heaviest things the port adds on top of the original -- the whole-screen 3D
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
only in how much eye-candy the renderer is allowed to do.
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
| ------------ | ---- | ------ | ----------- | ----------------- |
| **HIGH** | on | on | on | none |
| **BALANCED** | off | off | on | none |
| **LOW** | off | off | off | 60 |
| **AUTO** | picks a default from the device (below) |||
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
desktops to **BALANCED**, and everything else -- a normal desktop, and
every existing `options.lua` that predates this option -- to **HIGH**,
so the common case is unchanged. See `src/core/Performance.detect`.
- AUTO only chooses the *default*; all four tiers are selectable, so a
wrong guess is one row away from being overridden.
- The clamps are applied **live** against your stored options and never
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
FX / ZOOM without forgetting them -- raising the tier restores exactly
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
saved choice on a clamped tier: it's your preference, waiting for a tier
that can afford it.)
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
## Peer-to-peer link play (lua-enet)
Trades and link battles connect two copies of the game directly over
@@ -151,6 +192,13 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
also exercises real UDP pairing.
Red, Blue, and Yellow copies link with each other, as the real cable
does. The compatibility fingerprint hashes only data a link mode can
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
only R/B/Y link-surface difference) no longer read as different games
(issue #511). Moving the fingerprint is a link parity change: builds
from before this fix will refuse to pair with builds after it.
## Fair play in link and online matches
A link session is decided by the battle and nothing else, so for its
@@ -265,6 +313,14 @@ the last controller restores it immediately. Layout re-derives from the
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
the overlay on and lets the mouse act as a finger (`=0` forces it off).
The launcher's **Touch Controls** button opens a drag editor: move each
button freely, **Disable** to hide the overlay permanently (for
controllers / emulation handhelds -- distinct from the temporary
gamepad auto-hide), **Reset** for defaults, **Done** to save into
`options.lua` as normalized window fractions so rotation keeps the
relative placement. In-game, Options → **TOUCH PAD** toggles the same
on/off flag without leaving a play session.
## Translation support
Every string the player can read is now reachable from a mod, so a
@@ -324,7 +380,8 @@ semantics - so the two windows read as one app. Six tabs:
party dock, so deposit and withdraw live in one place. Empty slots are
clickable and create a mon there.
- **Items**: money, a searchable item picker (replacing the arrows that
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
cycled one id at a time through ~250 items), the configurable bag (20 slots
by default), PC storage
with no slot cap, and the eight badges as toggle chips.
- **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid.
@@ -367,3 +424,27 @@ kind, number, height/weight, dex text) to a PNG at 4x scale under
`prints/` in the save directory, then reports the filename in a dialog.
No printer hardware or link cable emulation involved; the file is the
printout.
## Find Mods (community mod indexes)
A FIND MODS tab sits beside MODS in the launcher and browses a published
mod index: a metadata-only feed listing mods that live in their authors'
own repositories. No index ships with the launcher and none is ever added
automatically, so the tab opens on an "Add an index" prompt until you name
one; paste an index URL or its `owner/repo` and it is remembered in
`options.lua`. More than one index can be added, and the listings merge.
## Soft reset (all versions)
Holding A, B, START and SELECT together restarts the game the way flicking
a Game Boy's power switch did, dropping straight back to the title screen.
It works from anywhere, including mid-battle, which the QUIT entry on the
start menu cannot do: the original combo is how stationary and gift
Pokemon get their stats rerolled without sitting through a full relaunch.
Unsaved progress is discarded, exactly as on hardware.
As on the original, the four buttons have to stay held for 16 straight
polls (better than a quarter of a second) and any direction in the mix
cancels it, so it is hard to hit by accident -- including on the on-screen
touch controls, where it would take four fingers held on four separate
controls.
+90 -15
View File
@@ -10,7 +10,7 @@
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
local Game, EditorApp, Importer
local Game, EditorApp, Importer, TouchEditor
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
@@ -134,6 +134,26 @@ function closeEditor()
end
end
-- ------------------------------------------------------------ touch controls editor
-- Suspends the launcher while the player drags on-screen buttons / toggles
-- the overlay off (#327). No ROM cache needed -- options.lua only.
local touchEditorHost
local closeTouchControlsEditor -- forward declaration
local function openTouchControlsEditor()
touchEditorHost = Importer
Importer = nil
TouchEditor = require("src.ui.TouchControlsEditor")
TouchEditor.load({ onClose = function() closeTouchControlsEditor() end })
end
function closeTouchControlsEditor()
if TouchEditor and TouchEditor.unload then TouchEditor.unload() end
TouchEditor = nil
Importer = touchEditorHost
touchEditorHost = nil
end
local function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue / Yellow); scripted and
-- headless runs fall back to POKEPORT_VERSION, then Red. Set the active
@@ -239,11 +259,17 @@ function love.load(args)
Importer = RomImporter.new(function(version)
Importer = nil
bootGame(version)
end, { launcher = true, forceImport = forceImport, onEditSave = openEditor })
end, {
launcher = true,
forceImport = forceImport,
onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor,
})
end
function love.update(dt)
if editorMode then return EditorApp.update(dt) end
if TouchEditor then return TouchEditor.update(dt) end
if Importer then return Importer:update(dt) end
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
@@ -283,6 +309,7 @@ end
function love.draw()
if editorMode then return EditorApp.draw() end
if TouchEditor then return TouchEditor.draw() end
if Importer then return Importer:draw() end
Game:draw()
@@ -303,60 +330,61 @@ end
function love.keypressed(key, scancode, isrepeat)
if editorMode then return EditorApp.keypressed(key) end
if TouchEditor then return TouchEditor.keypressed(key) end
if Importer then return Importer:keypressed(key) end
Game:keypressed(key)
end
function love.keyreleased(key)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return end
Game:keyreleased(key)
end
function love.gamepadpressed(joystick, button)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:gamepadpressed(joystick, button) end
Game:gamepadpressed(joystick, button)
end
function love.gamepadreleased(joystick, button)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:gamepadreleased(joystick, button) end
Game:gamepadreleased(joystick, button)
end
function love.gamepadaxis(joystick, axis, value)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
Game:gamepadaxis(joystick, axis, value)
end
function love.joystickpressed(joystick, button)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickpressed(joystick, button) end
Game:joystickpressed(joystick, button)
end
function love.joystickreleased(joystick, button)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickreleased(joystick, button) end
Game:joystickreleased(joystick, button)
end
function love.joystickaxis(joystick, axis, value)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickaxis(joystick, axis, value) end
Game:joystickaxis(joystick, axis, value)
end
function love.joystickhat(joystick, hat, direction)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return Importer:joystickhat(joystick, hat, direction) end
Game:joystickhat(joystick, hat, direction)
end
function love.joystickremoved(joystick)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return end
Game:joystickremoved(joystick)
end
@@ -365,7 +393,7 @@ end
-- direction's key-up can be delivered to the OS instead of the game while
-- unfocused, so reset input on either transition rather than trust it.
function love.focus(f)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then
if Importer.focus then Importer:focus(f) end
return
@@ -375,13 +403,19 @@ end
-- v is true when the window becomes visible again, false on minimize.
function love.visible(v)
if editorMode then return end
if editorMode or TouchEditor then return end
if Importer then return end
Game:visible(v)
end
function love.touchpressed(id, x, y, dx, dy, pressure)
if editorMode then return end
if TouchEditor then
-- iOS synthesizes mousepressed for the primary touch (same as the
-- launcher); Android drives the editor through love.touch directly.
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchpressed(id, x, y)
end
if Importer then
-- iOS: LÖVE already synthesizes a mousepressed for the primary touch,
-- and love.mousepressed below forwards that to the Importer, so
@@ -399,12 +433,20 @@ end
function love.touchmoved(id, x, y, dx, dy, pressure)
if editorMode then return end
if TouchEditor then
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchmoved(id, x, y)
end
if Importer then return end
Game:touchmoved(id, x, y)
end
function love.touchreleased(id, x, y, dx, dy, pressure)
if editorMode then return end
if TouchEditor then
if love.system.getOS() == "iOS" then return end
return TouchEditor.touchreleased(id, x, y)
end
if Importer then return end
Game:touchreleased(id, x, y)
end
@@ -414,12 +456,36 @@ function love.wheelmoved(x, y)
if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end
return
end
if TouchEditor then return end
if Importer then return end
Game:wheelmoved(x, y)
end
function love.mousepressed(x, y, button)
if Importer then return Importer:mousepressed(x, y, button) end
function love.mousepressed(x, y, button, istouch)
if TouchEditor then
-- Android primary touch already arrived via love.touchpressed; a second
-- mouse path would double-fire Done / begin a second drag.
if love.system.getOS() == "Android" then return end
return TouchEditor.mousepressed(x, y, button)
end
if Importer then
-- The same double-fire TouchEditor guards against, which the launcher was
-- missing: love.touchpressed above forwards the primary touch to the
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that
-- same touch, so one tap ran every launcher button twice. On Import that
-- meant two choose() calls and two stacked SAF picker activities: the
-- player picked their ROM, the top picker closed, and the second was still
-- underneath asking for it again, which is the "import the file twice"
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
-- USB mouse) working, which an Android-wide return would have broken.
--
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
-- returns early on iOS and never forwards, so there the synthesized mouse
-- press is the ONLY event the launcher gets. Filtering istouch on both
-- killed every tap on iOS outright.
if istouch and love.system.getOS() == "Android" then return end
return Importer:mousepressed(x, y, button)
end
if editorMode and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, button)
end
@@ -429,6 +495,10 @@ function love.mousepressed(x, y, button)
end
function love.mousereleased(x, y, button)
if TouchEditor then
if love.system.getOS() == "Android" then return end
return TouchEditor.mousereleased(x, y, button)
end
if Importer then return end
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
@@ -439,6 +509,10 @@ function love.mousereleased(x, y, button)
end
function love.mousemoved(x, y)
if TouchEditor then
if love.system.getOS() == "Android" then return end
return TouchEditor.mousemoved(x, y)
end
if editorMode or Importer then return end
if mouseTouch and Game and love.mouse.isDown(1) then
Game:touchmoved("mouse", x, y)
@@ -446,6 +520,7 @@ function love.mousemoved(x, y)
end
function love.textinput(text)
if TouchEditor then return end
if Importer then return Importer:textinput(text) end
if editorMode and EditorApp.textinput then
return EditorApp.textinput(text)
+24 -1
View File
@@ -50,6 +50,29 @@ chosen file under the app save directory as `picked_rom.gb`,
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache).
### Step bridge (Pokéwalker mod)
`love.system.syncHealthSteps()``GameActivity.syncHealthSteps` (same
JNI route as the picker: `common/android.cpp`
`modules/system/System.cpp``wrap_System.cpp`). The Java side does a
one-shot read of the hardware `TYPE_STEP_COUNTER` sensor (cumulative
since boot, counted by the OS whether or not any app runs), anchors the
reading in `SharedPreferences` so a walk is never credited twice
(a reading below the anchor means the phone rebooted → re-anchor without
crediting), and stages the delta as `steps_pending.json` in the save
identity dir — the same contract as the iOS `GRHealthBridge`. Nothing in
the base game calls it; the consumer is the
[Pokéwalker mod](https://github.com/mresnick67/Gen1ReComp-Pokewalker),
installed as a mod `.zip` at runtime (its SYNC STEPS option defaults
off).
Android 10+ gates the sensor behind the `ACTIVITY_RECOGNITION` runtime
permission (declared in `app/src/main/AndroidManifest.xml`; keep it out
of the build script's permission trim). The first
`syncHealthSteps()` call shows the system prompt; on grant the sensor
read runs immediately (`onRequestPermissionsResult`,
`STEP_PERMISSION_REQUEST_CODE`).
### SDK / NDK
love-android 11.5a expects:
@@ -82,7 +105,7 @@ scripts, tests, and mobile build sources are excluded.
| --- | --- |
| `app.application_id` | `com.theboisclub.pokemonred` |
| `app.name` | Pokemon Red |
| `app.orientation` | `portrait` |
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept |
@@ -8,6 +8,11 @@
the link screen shows as "(Operation not permitted)" (issue #287).
scripts/build_android.sh must not strip this again. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
counter, which Android 10+ gates behind this runtime permission.
Requested only on the first sync call (the Pokéwalker mod's SYNC
STEPS option); scripts/build_android.sh must not strip it. -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<!-- OpenGL ES 2.0 -->
<uses-feature android:glEsVersion="0x00020000" />
<!-- Touchscreen support -->
@@ -219,6 +219,18 @@ bool showCreateDocument(const char *suggestedName)
return result;
}
bool syncHealthSteps()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "syncHealthSteps", "()Z");
jboolean result = env->CallStaticBooleanMethod(activity, method);
env->DeleteLocalRef(activity);
return result;
}
/*
* Helper functions for the filesystem module
*/
@@ -851,4 +863,51 @@ const char *getArg0()
} // android
} // love
extern "C" __attribute__((visibility("default")))
int love_android_secondary_ready()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "hasSecondaryDisplay", "()Z");
jboolean ready = JNI_FALSE;
if (m)
ready = env->CallStaticBooleanMethod(activity, m);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
return ready ? 1 : 0;
}
extern "C" __attribute__((visibility("default")))
void love_android_push_secondary(const void *rgba, int w, int h)
{
if (!rgba || w <= 0 || h <= 0)
return;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "updateSecondaryFrame", "(Ljava/nio/ByteBuffer;II)V");
if (m)
{
jobject buf = env->NewDirectByteBuffer((void*) rgba, (jlong) w * (jlong) h * 4);
env->CallStaticVoidMethod(activity, m, buf, w, h);
env->DeleteLocalRef(buf);
}
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
}
extern "C" __attribute__((visibility("default")))
void love_android_secondary_enable(int on)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "setSecondaryEnabled", "(Z)V");
if (m)
env->CallStaticVoidMethod(activity, m, on ? JNI_TRUE : JNI_FALSE);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
}
#endif // LOVE_ANDROID
@@ -74,6 +74,13 @@ bool showFilePicker(const char *destFilename = nullptr);
**/
bool showCreateDocument(const char *suggestedName = nullptr);
/**
* Pokéwalker step bridge: asks GameActivity to read the hardware step
* counter and stage steps_pending.json in the save identity dir (see
* GameActivity.syncHealthSteps). Returns whether a sync could start.
*/
bool syncHealthSteps();
/*
* Helper functions for the filesystem module
*/
@@ -212,6 +212,15 @@ bool System::createFile(const char *suggestedName) const
#endif
}
bool System::syncHealthSteps() const
{
#ifdef LOVE_ANDROID
return love::android::syncHealthSteps();
#else
return false;
#endif
}
bool System::hasBackgroundMusic() const
{
#if defined(LOVE_ANDROID)
@@ -127,6 +127,12 @@ public:
**/
virtual bool createFile(const char *suggestedName = nullptr) const;
/**
* Pokéwalker: stage pending real-world steps (steps_pending.json in the
* save dir) from the platform step source. Android-only; false elsewhere.
*/
virtual bool syncHealthSteps() const;
/**
* Gets if the user is playing music on background.
* Throws an exception on unsupported platforms.
@@ -109,6 +109,12 @@ int w_createFile(lua_State *L)
return 1;
}
int w_syncHealthSteps(lua_State *L)
{
luax_pushboolean(L, instance()->syncHealthSteps());
return 1;
}
int w_hasBackgroundMusic(lua_State *L)
{
lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -126,6 +132,7 @@ static const luaL_Reg functions[] =
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
@@ -41,12 +41,20 @@ import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.res.AssetManager;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.os.Vibrator;
import android.util.Log;
import android.util.DisplayMetrics;
@@ -66,6 +74,7 @@ public class GameActivity extends SDLActivity {
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int FILE_PICKER_REQUEST_CODE = 4;
public static final int FILE_CREATE_REQUEST_CODE = 5;
public static final int STEP_PERMISSION_REQUEST_CODE = 6;
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
@@ -82,8 +91,27 @@ public class GameActivity extends SDLActivity {
// basename as its body, so RomImporter:focus can say so in the launcher
// instead of leaving the player on "No ROM imported" (issue #442).
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
// consumed by the Pokéwalker mod, same contract as the iOS
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
// (cumulative since boot, counted by the OS whether or not any app is
// running), anchored in SharedPreferences so a walk is never credited
// twice.
private static final String PENDING_STEPS_FILENAME = "steps_pending.json";
private static final String STEP_PREFS = "pokewalker_steps";
private static final String STEP_PREF_ANCHOR = "anchor";
private static final String STEP_PREF_ANCHOR_WALLTIME = "anchor_walltime";
private static final long STEP_MAX_PER_SYNC = 50000;
// Destination basename for the in-flight SAF pick (set by showFilePicker).
// Saved/restored across instance state: the picker is a separate activity
// and Android may destroy this one while it is up (memory pressure, or
// "Don't keep activities"). A recreated instance still receives
// onActivityResult, so without this a mod or save pick came back with the
// field reset and was filed as picked_rom.gb, which Lua then rejected as a
// bad ROM instead of installing it (#553).
private String pendingPickFilename = PICKED_ROM_FILENAME;
private static final String STATE_PENDING_PICK = "pendingPickFilename";
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
private String pendingCreateSuggestedName = "export.sav";
private static boolean immersiveActive = false;
@@ -148,6 +176,14 @@ public class GameActivity extends SDLActivity {
}
super.onCreate(savedInstanceState);
if (savedInstanceState != null) {
// Restore the in-flight SAF destinations, so a pick that returns to
// a recreated activity still lands under the basename it asked for.
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
if (pick != null) pendingPickFilename = pick;
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
if (create != null) pendingCreateSuggestedName = create;
}
metrics = getResources().getDisplayMetrics();
// Set low-latency audio values
@@ -295,12 +331,62 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel();
}
teardownSecondaryDisplay();
super.onPause();
}
@Override
public void onResume() {
super.onResume();
setupSecondaryDisplay();
}
/**
* SDL decides the activity's requested orientation at window creation
* (SDLActivity.setOrientationBis). With a resizable window and no
* SDL_HINT_ORIENTATIONS -- exactly what conf.lua produces on Android --
* it asks for SCREEN_ORIENTATION_FULL_SENSOR, and that request overrides
* the android:screenOrientation="fullUser" set in the manifest. The
* *_SENSOR constants follow the accelerometer even when the player has
* turned auto-rotate off, so the game kept rotating on a device whose
* rotation was locked.
*
* Remap SDL's choice onto the matching *_USER constant, which allows the
* same orientations but defers to the system rotation setting. Applied
* after super so SDL keeps deciding *which* orientations the window may
* take; this only changes who breaks the tie, the sensor or the player.
*/
@Override
public void setOrientationBis(int w, int h, boolean resizable, String hint) {
super.setOrientationBis(w, h, resizable, hint);
// The *_USER constants only exist from API 18; below that the sensor
// ones are all there is, so leave SDL's request alone.
if (android.os.Build.VERSION.SDK_INT < 18) {
return;
}
int requested = getRequestedOrientation();
int userRequested;
switch (requested) {
case ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR:
userRequested = ActivityInfo.SCREEN_ORIENTATION_FULL_USER;
break;
case ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE:
userRequested = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE;
break;
case ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT:
userRequested = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT;
break;
default:
// SENSOR / plain LANDSCAPE / PORTRAIT etc: either already
// explicit or never produced by setOrientationBis.
return;
}
Log.d("GameActivity", "requestedOrientation " + requested + " -> " + userRequested
+ " (honour the device rotation lock)");
setRequestedOrientation(userRequested);
}
@Keep
@@ -467,6 +553,160 @@ public class GameActivity extends SDLActivity {
}
}
/**
* Step sync, called from Lua as love.system.syncHealthSteps()
* (see modules/system/wrap_System.cpp). Asynchronous like the picker:
* returns whether a sync could be started; the result lands later as
* steps_pending.json in the save identity dir, where the Pokéwalker
* mod's poll consumes it.
*
* Android 10+ gates the step counter behind the ACTIVITY_RECOGNITION
* runtime permission; the first call shows the system prompt and a later
* sync (the mod retries on save load / option change) delivers.
*/
@Keep
public static boolean syncHealthSteps() {
final GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (android.os.Build.VERSION.SDK_INT >= 29
&& ActivityCompat.checkSelfPermission(self,
Manifest.permission.ACTIVITY_RECOGNITION)
!= PackageManager.PERMISSION_GRANTED) {
self.runOnUiThread(new Runnable() {
@Override
public void run() {
ActivityCompat.requestPermissions(self,
new String[]{Manifest.permission.ACTIVITY_RECOGNITION},
STEP_PERMISSION_REQUEST_CODE);
}
});
return true;
}
self.startStepSensorRead();
return true;
}
/**
* One-shot read of the cumulative hardware step counter. The sensor
* usually reports its cached value moments after registration; some
* devices hold the event until the next physical step, so the listener
* is given 20 seconds before being torn down (the next sync retries).
*/
private void startStepSensorRead() {
final SensorManager manager =
(SensorManager) getSystemService(Context.SENSOR_SERVICE);
if (manager == null) return;
Sensor counter = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
if (counter == null) {
Log.d("GameActivity", "no step counter sensor on this device");
return;
}
final SensorEventListener listener = new SensorEventListener() {
private boolean delivered = false;
@Override
public void onSensorChanged(SensorEvent event) {
if (delivered || event.values.length == 0) return;
delivered = true;
manager.unregisterListener(this);
deliverSteps((long) event.values[0]);
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
};
if (!manager.registerListener(listener, counter,
SensorManager.SENSOR_DELAY_NORMAL)) {
Log.d("GameActivity", "step counter listener registration failed");
return;
}
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
@Override
public void run() {
// No-op if the listener already delivered and unregistered.
manager.unregisterListener(listener);
}
}, 20000);
}
/**
* Convert a cumulative counter reading into pending steps. The counter
* resets to zero on reboot: a reading below the stored anchor re-anchors
* without crediting (steps walked between the reboot and this sync are
* lost, which errs on the honest side).
*/
private void deliverSteps(long counterNow) {
SharedPreferences prefs = getSharedPreferences(STEP_PREFS, MODE_PRIVATE);
long anchor = prefs.getLong(STEP_PREF_ANCHOR, -1);
long now = System.currentTimeMillis();
if (anchor < 0 || counterNow < anchor) {
prefs.edit()
.putLong(STEP_PREF_ANCHOR, counterNow)
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
.apply();
Log.d("GameActivity", "step anchor set at " + counterNow);
return;
}
long steps = Math.min(counterNow - anchor, STEP_MAX_PER_SYNC);
long fromWalltime = prefs.getLong(STEP_PREF_ANCHOR_WALLTIME, now);
if (steps <= 0) return;
prefs.edit()
.putLong(STEP_PREF_ANCHOR, counterNow)
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
.apply();
File dir = saveIdentityDir();
if (!dir.isDirectory() && !dir.mkdirs()) {
Log.d("GameActivity", "cannot create save dir for steps: " + dir);
return;
}
File pending = new File(dir, PENDING_STEPS_FILENAME);
long total = steps;
// Merge with an unconsumed earlier delivery so steps are never lost
// (same contract as the iOS bridge).
if (pending.isFile()) {
try {
byte[] raw = new byte[(int) Math.min(pending.length(), 4096)];
FileInputStream in = new FileInputStream(pending);
int read = in.read(raw);
in.close();
if (read > 0) {
org.json.JSONObject old =
new org.json.JSONObject(new String(raw, 0, read, "UTF-8"));
total += Math.max(0, old.optLong("steps", 0));
}
} catch (Exception e) {
Log.d("GameActivity", "ignoring unreadable pending steps: " + e);
}
}
try {
org.json.JSONObject payload = new org.json.JSONObject();
payload.put("steps", total);
payload.put("from", isoTime(fromWalltime));
payload.put("to", isoTime(now));
File tmp = new File(dir, PENDING_STEPS_FILENAME + ".tmp");
FileOutputStream out = new FileOutputStream(tmp);
out.write(payload.toString().getBytes("UTF-8"));
out.close();
if (!tmp.renameTo(pending)) {
tmp.delete();
Log.d("GameActivity", "could not publish pending steps");
return;
}
Log.d("GameActivity", total + " steps pending for the Pokewalker mod");
} catch (Exception e) {
Log.d("GameActivity", "could not write pending steps: " + e);
}
}
private static String isoTime(long millis) {
java.text.SimpleDateFormat format =
new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US);
format.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
return format.format(new java.util.Date(millis));
}
private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null;
OutputStream out = null;
@@ -490,6 +730,13 @@ public class GameActivity extends SDLActivity {
}
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
@@ -690,6 +937,17 @@ public class GameActivity extends SDLActivity {
}
break;
}
case STEP_PERMISSION_REQUEST_CODE: {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Log.d("GameActivity", "Step permission granted");
// Deliver right away so the sync the player just
// opted into doesn't wait for the next launch.
startStepSensorRead();
} else {
Log.d("GameActivity", "Did not get step permission.");
}
break;
}
default:
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
@@ -872,4 +1130,182 @@ public class GameActivity extends SDLActivity {
return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
}
}
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
// physical display. Driven from the engine through love_android_secondary_*
// in src/jni/love/src/common/android.cpp.
private static volatile SecondaryPresentation secondaryPresentation;
private static volatile boolean secondaryEnabled = false;
@Keep
public static void setSecondaryEnabled(final boolean on) {
secondaryEnabled = on;
final GameActivity self = (GameActivity) mSingleton;
if (self == null) return;
self.runOnUiThread(new Runnable() {
@Override public void run() {
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
}
});
}
private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
try {
android.hardware.display.DisplayManager dm =
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
if (dm == null) return;
Display chosen = null;
for (Display d : dm.getDisplays()) {
android.graphics.Point size = new android.graphics.Point();
d.getRealSize(size);
Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName()
+ " size=" + size.x + "x" + size.y);
if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) {
chosen = d;
}
}
if (chosen == null) {
Display[] pres =
dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
if (pres != null && pres.length > 0) chosen = pres[0];
}
if (chosen == null) {
Log.d("GameActivity", "no secondary display found");
return;
}
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
p.show();
secondaryPresentation = p;
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
} catch (Throwable t) {
Log.d("GameActivity", "secondary display setup failed: " + t);
secondaryPresentation = null;
}
}
private static void teardownSecondaryDisplay() {
SecondaryPresentation p = secondaryPresentation;
secondaryPresentation = null;
if (p != null) {
try { p.dismiss(); } catch (Throwable t) {}
}
}
@Keep
public static boolean hasSecondaryDisplay() {
return secondaryPresentation != null;
}
@Keep
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
SecondaryPresentation p = secondaryPresentation;
if (p != null && buf != null && w > 0 && h > 0) {
p.updateFrame(buf, w, h);
}
}
private static class SecondaryPresentation extends android.app.Presentation {
private final FrameView frameView;
SecondaryPresentation(Context context, Display display) {
super(context, display);
frameView = new FrameView(context);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
android.view.Window w = getWindow();
if (w != null) {
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
w.setLayout(WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT);
}
setContentView(frameView);
applyImmersive();
frameView.post(new Runnable() {
@Override public void run() { applyImmersive(); }
});
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) applyImmersive();
}
private void applyImmersive() {
android.view.Window w = getWindow();
if (w == null) return;
if (android.os.Build.VERSION.SDK_INT >= 30) {
w.setDecorFitsSystemWindows(false);
android.view.WindowInsetsController c = w.getInsetsController();
if (c != null) {
c.hide(android.view.WindowInsets.Type.systemBars());
c.setSystemBarsBehavior(
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
w.getDecorView().setSystemUiVisibility(
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
frameView.updateFrame(buf, w, h);
}
}
private static class FrameView extends View {
private android.graphics.Bitmap bitmap;
private final android.graphics.Rect dst = new android.graphics.Rect();
private final android.graphics.Paint paint = new android.graphics.Paint();
private final Object lock = new Object();
private int fw, fh;
FrameView(Context context) {
super(context);
paint.setFilterBitmap(false);
paint.setAntiAlias(false);
setBackgroundColor(0xFF000000);
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
synchronized (lock) {
if (bitmap == null || fw != w || fh != h) {
if (bitmap != null) bitmap.recycle();
bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888);
fw = w; fh = h;
}
buf.rewind();
bitmap.copyPixelsFromBuffer(buf);
}
postInvalidate();
}
@Override
protected void onDraw(android.graphics.Canvas canvas) {
synchronized (lock) {
if (bitmap == null || fw == 0 || fh == 0) return;
int vw = getWidth(), vh = getHeight();
int s = Math.min(vw / fw, vh / fh);
if (s < 1) s = 1;
int dw = fw * s, dh = fh * s;
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
dst.set(dx, dy, dx + dw, dy + dh);
canvas.drawColor(0xFF000000);
canvas.drawBitmap(bitmap, null, dst, paint);
}
}
}
}
+2 -6
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Pokemon Red</string>
<string>gen1recomp</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
@@ -40,7 +40,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
@@ -49,10 +49,6 @@
<false/>
<key>UILaunchStoryboardName</key>
<string>Launch Screen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>opengles-2</string>
</array>
<key>UIStatusBarHidden</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
+13
View File
@@ -0,0 +1,13 @@
# Nuzlocke
An enforced Gen 1 Nuzlocke.
Oak configures Slow Start, whether duplicate evolutionary families are skipped
or consume an area's encounter, and whether Safari maps are separate areas.
After Slow Start, the mod enforces mandatory nicknames for starters, gifts,
and catches; one capture per area; no duplicate evolutionary families; and
permanent death. A party Pokémon says that it died and is removed immediately.
If the final party member dies, the game runs the credits to THE END and then
deletes the active save.
+224
View File
@@ -0,0 +1,224 @@
-- Nuzlocke rules. Uses the current engine's internal seams while the
-- equivalent public API hooks are being added.
return function(mod)
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
steps = next(steps, speech)
mod.ui.insertStepAfter(steps, "oak_welcome", {
id = "nuzlocke_intro", kind = "say", pic = "oak",
text = "A Nuzlocke is a\npromise.\fEvery loss is\npermanent.",
})
mod.ui.insertStepAfter(steps, "nuzlocke_intro", {
id = "nuzlocke_slow_start", kind = "yesno", pic = "oak",
saveKey = "slow_start", defaultNo = true,
text = "Use SLOW START?\nRules start with\nPOKé BALLS.",
})
mod.ui.insertStepAfter(steps, "nuzlocke_slow_start", {
id = "nuzlocke_dupes", kind = "choice", pic = "oak",
saveKey = "dupes_mode", text = "When you meet a\nknown family?",
choices = { "SKIP", "LOSE" }, values = { "skip", "strict" },
})
mod.ui.insertStepAfter(steps, "nuzlocke_dupes", {
id = "nuzlocke_safari", kind = "yesno", pic = "oak",
saveKey = "safari_sectors", text = "Separate SAFARI\nsectors?",
})
mod.ui.insertStepAfter(steps, "nuzlocke_safari", {
id = "nuzlocke_close", kind = "say", pic = "oak",
text = "Give every friend\na name. Keep them\nsafe. Good luck!",
})
return steps
end)
mod.events:on("intro.oak_speech.answered", function(ev)
if ev.saveKey then mod.save:set(ev.saveKey, ev.value) end
end)
local function active(game, battle)
if not (game and game.save) or (battle and (battle.demo or battle.ghost)) then return false end
if not mod.save:get("slow_start", false) then return true end
if mod.save:get("balls_unlocked", false) then return true end
for id, count in pairs(game.save.inventory or {}) do
if count > 0 and game.data.items[id] and game.data.items[id].ball then
mod.save:set("balls_unlocked", true)
return true
end
end
return false
end
local function areaKey(game, battle)
if battle and battle.safari and not mod.save:get("safari_sectors", false) then
return "SAFARI_ZONE"
end
return (game.overworld and game.overworld.map and game.overworld.map.id)
or (game.save.player and game.save.player.map) or "UNKNOWN"
end
local function family(data, species)
local found, pending = {}, { species }
while #pending > 0 do
local id = table.remove(pending)
if not found[id] then
found[id] = true
for _, evo in ipairs((data.pokemon[id] or {}).evolutions or {}) do pending[#pending + 1] = evo.species end
for parent, def in pairs(data.pokemon or {}) do
for _, evo in ipairs(def.evolutions or {}) do
if evo.species == id then pending[#pending + 1] = parent end
end
end
end
end
return found
end
local function ownsFamily(game, species)
local members = family(game.data, species)
local function owns(mon) return mon and members[mon.species] end
for _, mon in ipairs(game.save.party or {}) do if owns(mon) then return true end end
for _, box in ipairs(game.save.boxes or {}) do
for _, mon in ipairs(box) do if owns(mon) then return true end end
end
return false
end
local function caughtAreas()
local areas = mod.save:get("caught_areas")
if type(areas) ~= "table" then areas = {}; mod.save:set("caught_areas", areas) end
return areas
end
local function denied(game, battle, species)
if not active(game, battle) then return nil end
if caughtAreas()[areaKey(game, battle)] then return "area" end
if ownsFamily(game, species) then return "dupes" end
end
mod.events:on("pokemon.caught", function(ev)
-- A successful capture proves that Slow Start has ended even when it was
-- the last ball in the bag.
if mod.save:get("slow_start", false) then mod.save:set("balls_unlocked", true) end
if active(ev.game, ev.battle) then
caughtAreas()[areaKey(ev.game, ev.battle)] = ev.species
mod.save:set("caught_areas", caughtAreas())
end
end)
mod.events:on("game.ready", function()
local BattleState = require("src.battle.BattleState")
local Commands = require("src.script.Commands")
local Party = require("src.pokemon.Party")
local Boxes = require("src.pokemon.Boxes")
local Pokemon = require("src.pokemon.Pokemon")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Strings = require("src.core.Strings")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local Bag = require("src.inventory.Bag")
BattleState.askNicknameUI = function(self, mon)
self.lockedBall, self.blankForAskName = nil, false
return self:buildScreen("NamingScreen", {
title = Strings("NICKNAME?"), maxLen = 10,
onDone = function(name) mon.nickname = name or "A" end,
})
end
-- Gifts and starters use the same mandatory naming screen.
Commands.give_pokemon = function(ctx, species, level)
local gift = { ctx = ctx, species = species, level = level }
if ctx.game.mods then ctx.game.mods.events:emit("pokemon.before_give", gift) end
local mon = Pokemon.new(ctx.game.data, gift.species, gift.level)
ctx.game.stringBuffer, ctx.pendingPokemonName = ctx.game.data.pokemon[gift.species].name or gift.species, gift.species
BattleState.stampOT(ctx.save, mon)
local inParty = Party.add(ctx.save.party, mon)
local boxNum = inParty and nil or Boxes.deposit(ctx.save, mon)
if not inParty and not boxNum then ctx.lastCheck = false; return end
if ctx.save.pokedex then ctx.save.pokedex.seen[gift.species], ctx.save.pokedex.owned[gift.species] = true, true end
ctx.lastCheck, ctx.addedToParty, ctx.boxNum = true, inParty, boxNum
if ctx.runner then
Screens.push(ctx.game, "NamingScreen", {
title = Strings("NICKNAME?"), maxLen = 10,
onDone = function(name) mon.nickname = name or "A"; ctx.runner:resume() end,
})
ctx.runner:yield()
else mon.nickname = "A" end
if boxNum then
ctx.game.boxMonNicks, ctx.game.stringBuffer = mon.nickname, tostring(boxNum)
if ctx.runner then Commands.show_text(ctx, "_SentToBoxText") end
end
end
local vanillaThrowBall = BattleState.throwBall
BattleState.throwBall = function(self, ball)
local reason = denied(self.game, self, self.enemy and self.enemy.mon.species)
if reason then
if reason == "dupes" and mod.save:get("dupes_mode", "skip") == "strict" then
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
mod.save:set("caught_areas", caughtAreas())
end
Bag.add(self.game.save, ball, 1, self.game.data)
self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
or "You already have\nthis POKéMON family!")
return
end
return vanillaThrowBall(self, ball)
end
local vanillaOnFaint = BattleState.onFaint
BattleState.onFaint = function(self, battler)
if not (battler.isPlayer and active(self.game, self)) then return vanillaOnFaint(self, battler) end
if battler.faintQueued then return end
battler.faintQueued = true
if self.participants then self.participants[battler.mon] = nil end
Runtime.emit("battle.fainted", { battle = self, battler = battler })
for i, mon in ipairs(self.game.save.party) do
if mon == battler.mon then table.remove(self.game.save.party, i); break end
end
self:actNext(function()
battler.fainted = true
require("src.core.Sound").playCry(self.data, battler.mon.species)
require("src.core.Sound").play(self.data, "Faint_Fall")
self.fx = self.fx or {}; self.fx.faint = { battler = battler, frames = 30 }
end)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 30 })
self:sayNext(Strings("%s\ndied!", battler.name))
self:act(function() self:playerMonFainted() end)
end
local vanillaPlayerFainted = BattleState.playerMonFainted
BattleState.playerMonFainted = function(self)
if active(self.game, self) and not Party.firstHealthy(self.game.save.party) then
self.nuzlockeGameOver, self.result, self.afterQueue = true, "nuzlocke_game_over", "finish"
self:sayNext(Strings("All of your\nPOKéMON are dead..."))
return
end
return vanillaPlayerFainted(self)
end
local vanillaFinish = BattleState.finish
BattleState.finish = function(self)
if not self.nuzlockeGameOver then return vanillaFinish(self) end
self.nuzlockeGameOver = nil
self.game.stack:pop()
Runtime.emit("battle.ended", { battle = self, result = "nuzlocke_game_over" })
-- Game over has no victory lap: delete the slot, then use Credits only
-- as its existing THE END renderer / A-or-B wait screen.
local version = GameVersion.get()
local slot = SaveData.activeSlot(version)
if slot then SaveData.deleteSlot(version, slot)
elseif love and love.filesystem then
local main = SaveData.saveFilename(version)
love.filesystem.remove(main); love.filesystem.remove(main .. ".bak"); love.filesystem.remove(main .. ".tmp")
end
local ending = Screens.push(self.game, "Credits", function()
require("src.core.Music").stop()
while self.game.stack:top() do self.game.stack:pop() end
Screens.push(self.game, "IntroMovie", function()
if self.game.makeTitleState then self.game.stack:push(self.game:makeTitleState()) end
end)
end)
ending.phase, ending.timer = "end_wait", 0
end
end)
end
+19
View File
@@ -0,0 +1,19 @@
{
"id": "nuzlocke",
"name": "Nuzlocke",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "GAMEPLAY",
"game_version": ">=0.0.0-dev <1.0.0",
"priority": 100,
"permissions": [
"engine_internals"
],
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "A configurable Gen 1 Nuzlocke with permanent death and area catches.",
"github": "bryanthaboi/nuzlocke"
}
-17
View File
@@ -1,17 +0,0 @@
# Changelog
All notable changes to this mod are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.0.0] - 2026-07-30
### Added
- Opt-in SYNC STEPS option: Apple Health step counts (delivered by the iOS
build's native bridge as `steps_pending.json`) convert to EXP.
- STEPS PER EXP option (10 / 20 / 50, default 20).
- GIVE EXP TO option: lead mon (default) or whole party split.
- Level-ups applied with the engine's growth curves and rare-candy stat
math; walk-report textbox at quiet moments.
- Guardrails: steps anchored to the last sync (never credited twice),
50,000-step clamp per sync, engine `levelCap` respected.
-92
View File
@@ -1,92 +0,0 @@
# Pokéwalker (Apple Health) — a Gen1Recomp mod
Your real-world steps become EXP for your Pokémon party — the HeartGold/
SoulSilver Pokéwalker, except it's the iPhone already in your pocket.
A mod for [gen1recomp](https://github.com/bryanthaboi/gen1recomp)
(the Gen 1 Recompilation Project). Opt-in, data-safe, and dormant on any
platform that doesn't provide the native step source (see
[Requirements](#requirements)).
## Install
Grab `pokewalker-<version>.modpkg` from
[Releases](https://github.com/mresnick67/Gen1ReComp-Pokewalker/releases)
(or use GitHub's *Code → Download ZIP* — the importer handles both), then:
- **In the launcher:** MODS tab → **Import mod .zip** → pick the file, or
drag it onto the window on desktop.
- **iOS:** you can also drop the zip into the app's folder in the Files
app; it installs on next launch.
Then, in the **mod manager → POKEWALKER → options**, turn on **SYNC
STEPS**. iOS asks for read-only access to your step count the first time.
## Options
| Option | Values | Default |
|---|---|---|
| SYNC STEPS | on / off | **off** |
| STEPS PER EXP | 10 / 20 / 50 | 20 |
| GIVE EXP TO | lead mon / whole party (split) | lead mon |
## Mechanics & guardrails
- EXP applies through the engine's own growth curves and rare-candy stat
math, so levels, stats, and HP top-ups are exact.
- Steps are anchored to the last sync — the same walk is never credited
twice — and any single sync is clamped to 50,000 steps.
- The engine `levelCap` constant is respected.
- Credits land at quiet moments (save load, map change, battle end) with a
walk-report textbox.
## Requirements
The Lua mod is platform-neutral, but it feeds on a **native step bridge**
that currently ships in an iOS build of gen1recomp. Without the bridge the
mod loads and stays dormant — safe to install anywhere.
### The bridge contract (for porters)
Any platform can light this mod up by providing:
- `love.system.syncHealthSteps()``boolean` — kick off an async step
query (requesting OS permission on first use). On completion, write
**`steps_pending.json`** to the LÖVE save directory:
```json
{ "steps": 4312, "from": "2026-07-30T08:00:00Z", "to": "2026-07-30T17:00:00Z" }
```
Count steps from a persisted anchor (last successful sync) so a walk is
never delivered twice, and **merge** with an unconsumed pending file
rather than overwriting it. The mod consumes and deletes the file.
The reference iOS implementation is a small Swift class (HealthKit
`HKStatisticsQuery` over `stepCount`) exposed to Lua through a one-line
`wrap_System.cpp` addition. Open an issue here if you're porting the
bridge (Android: Health Connect / Google Fit would slot straight in).
## Known limitations (v1)
- Level-ups granted while walking don't prompt for new moves, and level
evolutions wait for the next in-battle level — same behavior as
over-leveling with rare candies.
- Steps sync on launch/activation; no background delivery yet.
## Developing
From a gen1recomp checkout with this mod at `mods/pokewalker` and an
imported data cache:
```sh
luajit mods/pokewalker/tests/pokewalker_test.lua
python3 tools/modkit.py validate mods/pokewalker --base imported
python3 tools/modkit.py pack mods/pokewalker
```
## License
MIT — see [LICENSE](LICENSE). Not affiliated with Nintendo, Game Freak,
or The Pokémon Company. This mod contains no ROM-derived content
(`modkit lint` clean).
-144
View File
@@ -1,144 +0,0 @@
-- Pokéwalker: Apple Health steps become party EXP (iOS builds).
--
-- The Swift side (mobile/ios/native/GRHealthBridge.swift) owns HealthKit:
-- love.system.syncHealthSteps() requests read access on first use, counts
-- steps since the last sync anchor, and drops steps_pending.json in the
-- save dir. This mod consumes that file at quiet moments (save loaded, map
-- transitions, battle end), converts steps to EXP, and applies level-ups
-- with the same stat math the engine uses.
--
-- Opt-in: everything is inert until SYNC STEPS is enabled in this mod's
-- options (the HealthKit permission sheet appears on first enable). On
-- non-iOS platforms love.system.syncHealthSteps does not exist and the mod
-- stays dormant.
--
-- Known v1 limits (documented in README.md): level-ups applied here do not
-- prompt for new moves (like over-leveling past a learnset entry with rare
-- candies) and do not trigger level evolutions until the next battle candy
-- or level gained in battle.
local PENDING = "steps_pending.json"
return function(mod)
mod.options:define({
{ key = "enabled", label = "SYNC STEPS", type = "toggle", default = false },
{ key = "rate", label = "STEPS PER EXP", type = "choice", default = "20",
choices = { { "10", "10" }, { "20", "20" }, { "50", "50" } } },
{ key = "target", label = "GIVE EXP TO", type = "choice", default = "lead",
choices = { { "LEAD MON", "lead" }, { "WHOLE PARTY", "party" } } },
})
local Json = require("src.link.Json")
local Growth = require("src.pokemon.Growth")
local Stats = require("src.pokemon.Stats")
local game
local function active()
return love.system.syncHealthSteps ~= nil and mod.options:get("enabled")
end
-- Ask the native side to refresh steps_pending.json. Async: results are
-- picked up by a later consume() (next map change / battle end).
local function requestSync()
if active() then love.system.syncHealthSteps() end
end
-- Add EXP to one mon, bumping levels with the engine's own stat math
-- (mirrors the rare-candy path in src/inventory/ItemEffects.lua).
-- Returns the EXP actually absorbed and any levels gained.
local function applyToMon(mon, xp, data)
local def = data.pokemon[mon.species]
if not def or not mon.level then return 0, {} end
local cap = (data.constants and data.constants.levelCap) or 100
if mon.level >= cap then return 0, {} end
local maxExp = Growth.expForLevel(def.growthRate, cap, data.growth_rates)
local before = mon.exp or 0
mon.exp = math.min(maxExp, before + xp)
local absorbed = mon.exp - before
if absorbed <= 0 then return 0, {} end
local levels = {}
local newLevel = Growth.levelForExp(def.growthRate, mon.exp, cap,
data.growth_rates)
while mon.level < newLevel do
mon.level = mon.level + 1
local old = mon.stats
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
mon.hp = math.min(mon.stats.hp,
(mon.hp or 0) + (mon.stats.hp - (old and old.hp or 0)))
levels[#levels + 1] = mon.level
end
return absorbed, levels
end
-- A short walk-report textbox, shown only when the overworld is idle;
-- when a script is already running the report is silently skipped (the
-- EXP is applied regardless, and the log has the numbers).
local function report(steps, total, leveled)
local msg = ("You walked %d steps!\nYour party gained %d EXP."):format(steps, total)
if #leveled > 0 then
msg = msg .. ("\n%s grew to L%d!"):format(leveled[1].name, leveled[1].level)
end
-- The report is decoration: it must never break the credit. mod.world
-- materializes lazily (and can itself error in headless contexts), so
-- the access lives inside the pcall too.
pcall(function()
local world = mod.world
if world then world:queueScript({ { "show_text", msg } }) end
end)
end
local function consume()
if not (game and active()) then return end
local raw = love.filesystem.read(PENDING)
if not raw then return end
local decoded = Json.decode(raw)
local steps = decoded and tonumber(decoded.steps) or 0
love.filesystem.remove(PENDING)
if steps <= 0 then return end
local party = game.save and game.save.party
if not party or #party == 0 then return end
local rate = tonumber(mod.options:get("rate")) or 20
local xp = math.floor(steps / rate)
if xp <= 0 then return end
local total, leveled = 0, {}
local targets = {}
if mod.options:get("target") == "party" then
for _, mon in ipairs(party) do targets[#targets + 1] = mon end
else
targets[1] = party[1]
end
local share = math.max(1, math.floor(xp / #targets))
for _, mon in ipairs(targets) do
local absorbed, levels = applyToMon(mon, share, game.data)
total = total + absorbed
for _, level in ipairs(levels) do
leveled[#leveled + 1] =
{ name = mon.nickname or mon.species, level = level }
end
end
if total <= 0 then return end
mod.log:info("credited %d steps -> %d EXP (%d level-ups)",
steps, total, #leveled)
report(steps, total, leveled)
end
-- game.ready is the sanctioned way to obtain the Game object; the party
-- only exists once a save is loaded or created.
mod.events:on("game.ready", function(payload)
game = payload.game
requestSync()
end)
mod.events:on("save.loaded", function()
requestSync()
consume()
end)
mod.events:on("save.created", function() requestSync() end)
-- Quiet moments where a walk report can safely appear.
mod.events:on("map.entered", function() consume() end)
mod.events:on("battle.ended", function() consume() end)
-- Flipping SYNC STEPS on triggers the HealthKit permission sheet
-- immediately rather than on the next boot.
mod.events:on("mod.options_changed", function() requestSync() end)
end
-16
View File
@@ -1,16 +0,0 @@
{
"id": "pokewalker",
"name": "Pokewalker (Apple Health)",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "MECHANIC",
"game_version": ">=0.0.0-0 <2.0.0",
"permissions": ["network", "engine_internals"],
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Your real-world steps (Apple Health) become EXP for your party. Opt-in: enable SYNC STEPS in this mod's options. Needs an iOS build with the Health bridge; dormant elsewhere."
}
-33
View File
@@ -1,33 +0,0 @@
-- Sharing metadata (25-community-and-ecosystem.md 3.2). Read by tooling
-- and the manager detail pane; never by the loader's merge.
return {
summary = "Real-world steps from Apple Health become EXP for your party.",
author = "mresnick67",
contact = "https://github.com/mresnick67/Gen1ReComp-Pokewalker",
tags = { "mechanic", "ios", "health", "opt-in", "field" },
differences = {
changed = {},
added = {
"an opt-in SYNC STEPS option: Apple Health step counts convert to "
.. "EXP at a configurable rate (10/20/50 steps per EXP)",
"EXP lands on the lead mon or splits across the party, applied with "
.. "the engine's own growth curves and rare-candy stat math",
"a walk-report textbox at quiet moments (save load, map change, "
.. "battle end)",
},
known = {
"needs an iOS build that ships the native Health bridge "
.. "(love.system.syncHealthSteps); on every other platform the mod "
.. "loads but stays dormant",
"level-ups granted while walking do not prompt for new moves and do "
.. "not trigger level evolutions until the next in-battle level "
.. "(same as over-leveling with rare candies)",
"steps sync on launch/activation; no background delivery yet",
},
},
credits = {
{ who = "Nintendo's Pokewalker (HGSS)", for_ = "the idea this recreates" },
{ who = "bryanthaboi/gen1recomp", for_ = "the engine and mod platform" },
},
compat = { engine = ">=1.0.0 <2.0.0", modApi = 2 },
}
-61
View File
@@ -1,61 +0,0 @@
-- Standalone: luajit mods/pokewalker/tests/pokewalker_test.lua
-- Exercises the stated effect: opt-in gating, the native-bridge seam, and
-- steps converting to EXP through the engine's own growth math.
--
-- Shipped-mod suites are auto-run by the ROM-free T4 tier, so this runs
-- against the committed fixture dataset (T.fixtures), never data/generated.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
-- The native step bridge only exists inside the iOS/Android apps; stand it
-- in so the mod sees the same surface it does on device.
local syncCalls = 0
love.system = love.system or {}
love.system.syncHealthSteps = function()
syncCalls = syncCalls + 1
return true
end
local run = T.sdk.loadMod("mods/pokewalker", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local events = run.loader.events
local Growth = require("src.pokemon.Growth")
local Pokemon = require("src.pokemon.Pokemon")
local mon = Pokemon.new(Data, "FIXMON_A", 5)
local game = { data = Data, save = { party = { mon } } }
-- Dormant until opted in: seeded steps survive every event untouched and
-- the native bridge is never poked (no permission prompt without consent).
love.filesystem.write("steps_pending.json", '{"steps": 20000}')
events:emit("game.ready", { game = game })
events:emit("map.entered", {})
T.check(love.filesystem.read("steps_pending.json") ~= nil,
"opt-out leaves pending steps untouched")
T.eq(syncCalls, 0, "opt-out never calls the native bridge")
-- Opted in: 20000 steps at the default 20 steps/EXP credit the lead mon.
run.loader.modOptions.pokewalker = { enabled = true }
local def = Data.pokemon[mon.species]
local expBefore = mon.exp
events:emit("save.loaded", {})
T.eq(mon.exp, expBefore + 1000, "20000 steps at 20 steps/EXP = +1000 EXP")
local expectedLevel = Growth.levelForExp(def.growthRate, mon.exp, 100,
Data.growth_rates)
T.check(mon.level > 5, "enough EXP to actually level (fixture curve)")
T.eq(mon.level, expectedLevel, "level matches the engine growth curve")
T.check(mon.stats.hp > 0 and mon.hp <= mon.stats.hp,
"stat recalc keeps HP within the new maximum")
T.check(love.filesystem.read("steps_pending.json") == nil,
"pending file is consumed exactly once")
T.check(syncCalls > 0, "opt-in requests a native sync")
-- A consumed file plus more events must not double-credit.
local expAfter = mon.exp
events:emit("map.entered", {})
T.eq(mon.exp, expAfter, "no pending file, no phantom EXP")
run.release()
T.finish("pokewalker")
+8
View File
@@ -35,6 +35,7 @@ TARGET="all"
NOTARY_PROFILE="notary-profile"
NOTARIZE=true
IOS_RELEASE=false
IOS_IPA=false
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
@@ -48,6 +49,7 @@ while [ $# -gt 0 ]; do
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
--no-notarize) NOTARIZE=false ;;
--release) IOS_RELEASE=true ;;
--ipa) IOS_IPA=true ;;
*) fail "unknown argument: $1" ;;
esac
shift
@@ -298,6 +300,9 @@ build_android() {
if [ "$VERSION_EXPLICIT" = true ]; then
args+=(--version "$VERSION")
fi
if [ "$IOS_IPA" = true ]; then
args+=(--ipa)
fi
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
}
@@ -308,6 +313,9 @@ build_ios() {
if [ "$IOS_RELEASE" = true ]; then
args+=(--release)
fi
if [ "$VERSION_EXPLICIT" = true ]; then
args+=(--version "$VERSION")
fi
"$ROOT/scripts/build_ios.sh" ${args[@]+"${args[@]}"}
}
+29 -3
View File
@@ -198,6 +198,10 @@ pack_game_love() {
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
# row opens it in-process, so it must be inside the archive (see build.sh).
# Deliberately NO fused mods: a mod inside game.love sits in the read-only
# APK, so the mod manager's Delete can't remove it and it reappears every
# launch. Pokewalker ships as an importable .zip instead, which gives it
# a real install/upgrade/delete lifecycle.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
@@ -290,10 +294,32 @@ require_android_sdk() {
# --------------------------------------------------------------- gradle
run_gradle() {
local task="assembleEmbedNoRecordDebug"
say "building APK ($task)"
local build_dir="$ANDROID_DIR"
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
# the project path ("Your APP_BUILD_SCRIPT points to an unknown file").
# When this checkout lives at a spaced path (e.g. "~/xCode Projects/..."),
# shadow the android tree to a space-free location and build there; the
# shadow persists across runs so gradle/ndk builds stay incremental.
case "$ANDROID_DIR" in
*" "*)
build_dir="${TMPDIR:-/tmp}/gen1recomp-android-shadow"
say "path contains spaces (ndk-build cannot handle them);"
say "shadow-building in: $build_dir"
mkdir -p "$build_dir"
rsync -a --delete \
--exclude=".gradle" --exclude="app/build" --exclude="love/build" \
--exclude="local.properties" \
"$ANDROID_DIR/" "$build_dir/"
if [ -f "$ANDROID_DIR/local.properties" ]; then
cp "$ANDROID_DIR/local.properties" "$build_dir/local.properties"
fi
;;
esac
say "building APK ($task)"
if ! (
cd "$ANDROID_DIR"
cd "$build_dir"
./gradlew --no-daemon "$task"
); then
fail "gradle $task failed.
@@ -302,7 +328,7 @@ run_gradle() {
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
fi
local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
if [ -d "$out_dir" ]; then
say "APK output:"
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
+219 -17
View File
@@ -2,7 +2,8 @@
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 11.5's
# official iOS Xcode project (love-11.5-ios-source.zip).
#
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--package-only]
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--install]
# [--version X.Y.Z] [--package-only]
#
# (default) Simulator Debug (ad-hoc signed)
# --device iphoneos SDK; signing team auto-detected from the
@@ -10,6 +11,7 @@
# --install after a --device build, install the app onto the
# first connected iPhone/iPad (unlock it first)
# --release Release configuration
# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
#
@@ -19,6 +21,7 @@
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
#
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
# dist/ios/gen1recomp.ipa (device builds only)
# mobile/ios/build/Build/Products/<Config>-<sdk>/gen1recomp.app
set -euo pipefail
@@ -47,7 +50,7 @@ DISPLAY_NAME="gen1recomp"
# capabilities like HealthKit are involved), so a per-team default
# lets anyone build without colliding with someone else's app
# 4. simulator: the project default (no App ID registration involved)
BUNDLE_ID="${GEN1_BUNDLE_ID:-}"
BUNDLE_ID="${GEN1_BUNDLE_ID:-com.theboisclub.gen1recomp}"
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
fi
@@ -62,6 +65,12 @@ DEVICE=false
RELEASE=false
PACKAGE_ONLY=false
INSTALL=false
CREATE_IPA=false
# Last resort for an incomplete source export, mirroring build_android.sh.
MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}"
MANIFESTS=""
VERSION=""
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
@@ -74,23 +83,48 @@ while [ $# -gt 0 ]; do
--release) RELEASE=true ;;
--package-only) PACKAGE_ONLY=true ;;
--install) INSTALL=true ;;
--ipa) CREATE_IPA=true ;;
--version) VERSION="$2"; shift ;;
-h|--help)
sed -n '2,22p' "$0"
sed -n '2,24p' "$0"
exit 0
;;
*) fail "unknown argument: $1 (try --fetch, --device, --release, --install, or --package-only)" ;;
*) fail "unknown argument: $1 (try --fetch, --device, --release, --version, --install, or --package-only)" ;;
esac
shift
done
if $CREATE_IPA; then
DEVICE=true
fi
VERSION_CODE=""
if [ -n "$VERSION" ]; then
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
fail "invalid --version '$VERSION' (expected X.Y.Z)"
fi
major="${VERSION%%.*}"
rest="${VERSION#*.}"
minor="${rest%%.*}"
patch="${rest##*.}"
VERSION_CODE=$((major * 10000 + minor * 100 + patch))
fi
# ---------------------------------------------------------- signing identity
# Auto-detect the Apple Development team when the caller didn't set one: the
# OU field of the first Apple Development certificate in the keychain (Xcode
# creates that certificate when you sign into Settings -> Accounts).
# Auto-detect the Apple Development team when the caller didn't set one.
# Prefer a *valid* identity from `find-identity` (the parenthetical there is
# the cert id, not the team), then read that cert's OU. Scanning every
# "Apple Development" certificate picks expired personal/work certs first.
detect_team() {
security find-certificate -c "Apple Development" -p 2>/dev/null \
local cn
cn="$(security find-identity -v -p codesigning 2>/dev/null \
| sed -n -E 's/.*"Apple Development: ([^"]+)".*/\1/p' \
| head -1)"
[ -n "$cn" ] || return 1
security find-certificate -c "Apple Development: $cn" -p 2>/dev/null \
| openssl x509 -noout -subject 2>/dev/null \
| sed -n 's/.*OU *= *\([A-Z0-9]*\).*/\1/p' | head -1
| sed -n 's/.*OU *= *\([A-Z0-9]*\).*/\1/p' \
| head -1
}
if $DEVICE && [ -z "${DEVELOPMENT_TEAM:-}" ]; then
DEVELOPMENT_TEAM="$(detect_team || true)"
@@ -204,20 +238,108 @@ apply_ios_branding() {
cp "$OVERLAY_PLIST" "$dest"
}
apply_ios_icon() {
local source="$ROOT/assets/logo/logo.png"
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
[ -f "$source" ] || fail "missing iOS icon source: $source"
[ -d "$target" ] || fail "missing iOS app icon set: $target"
local entry name size
while IFS=: read -r name size; do
sips -z "$size" "$size" "$source" --out "$target/$name" >/dev/null
done <<'EOF'
icon-1024pt@1x.png:1024
icon-29pt@1x.png:29
icon-29pt@2x.png:58
icon-29pt@3x.png:87
icon-40pt@1x.png:40
icon-40pt@2x.png:80
icon-40pt@3x.png:120
icon-60pt@2x.png:120
icon-60pt@3x.png:180
icon-76pt@1x.png:76
icon-76pt@2x.png:152
icon-83.5pt@2x.png:167
EOF
}
# --------------------------------------------------------------- game.love
# Every version's import manifest has to ship or that game's ROM import fails in
# the built app: decodeManifest (src/import/RomImporter.lua) errors outright when
# one is absent, and dev reads them off the source tree, so the miss only ever
# shows up in a build. iOS shipped without the Yellow one in 0.1.45 to 0.1.47
# for exactly that reason.
#
# The list is READ OUT OF src/core/GameVersion.lua rather than hand-kept here, so
# a fourth version cannot silently ship without its manifest, and a missing file
# is recovered from Git or the project repo the same way build_android.sh already
# recovers Yellow's. Recovery is a last resort for an incomplete source export:
# a manifest carries extraction metadata only, never a ROM or game data.
manifest_paths() {
python3 - "$ROOT/src/core/GameVersion.lua" <<'PY'
import re, sys
src = open(sys.argv[1]).read()
print(" ".join(dict.fromkeys(re.findall(r'manifest\s*=\s*"([^"]+)"', src))))
PY
}
manifest_is_valid() {
python3 - "$1" <<'PY'
import json, pathlib, sys
try:
m = json.loads(pathlib.Path(sys.argv[1]).read_text())
except (OSError, ValueError):
raise SystemExit(1)
sha = m.get("romSha1")
raise SystemExit(0 if isinstance(sha, str) and len(sha) == 40 else 1)
PY
}
ensure_manifests() {
MANIFESTS="$(manifest_paths)"
[ -n "$MANIFESTS" ] \
|| fail "could not read any manifest path out of src/core/GameVersion.lua"
local rel staged
for rel in $MANIFESTS; do
if manifest_is_valid "$ROOT/$rel"; then continue; fi
warn "$rel is missing or invalid; recovering it before packaging"
staged="$(mktemp)"
if git -C "$ROOT" show "HEAD:$rel" > "$staged" 2>/dev/null \
&& manifest_is_valid "$staged"; then
mkdir -p "$ROOT/$(dirname "$rel")"
mv "$staged" "$ROOT/$rel"
say "restored $rel from this checkout's Git data"
continue
fi
if command -v curl >/dev/null 2>&1 \
&& curl --fail --location --retry 2 --connect-timeout 15 \
--output "$staged" "$MANIFEST_BASE_URL/$rel" \
&& manifest_is_valid "$staged"; then
mkdir -p "$ROOT/$(dirname "$rel")"
mv "$staged" "$ROOT/$rel"
say "downloaded $rel from the project repository"
continue
fi
rm -f "$staged"
fail "$rel is unavailable: Git recovery failed and $MANIFEST_BASE_URL/$rel could not be downloaded"
done
say "import manifests: $MANIFESTS"
}
pack_game_love() {
say "packing game.love for love-ios resources"
mkdir -p "$RESOURCES_DIR"
rm -f "$LOVE_FILE"
# Same payload as scripts/build.sh / build_android.sh: game sources plus
# tools/save-editor, which the launcher's Edit button opens in-process.
# mods/pokewalker rides inside game.love on iOS only: physfs merges the
# fused archive with the save dir, so the loader discovers it like any
# installed mod, and its Apple Health sync is a no-op everywhere else.
# Deliberately NO fused mods: a mod inside game.love sits in the
# read-only app bundle, so the mod manager's Delete can't remove it and
# it reappears every launch. Mods install as .zips at runtime instead
# (launcher -> MODS -> Import mod .zip), the same lifecycle as every
# other platform.
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
mods/pokewalker \
$MANIFESTS \
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
-x 'data/generated/*' -x 'assets/generated/*')
# NOTE: grep -q here would race pipefail — it exits on first match, unzip
@@ -227,8 +349,19 @@ pack_game_love() {
| grep -E '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' >/dev/null; then
fail "game.love unexpectedly contains generated ROM data"
fi
unzip -Z1 "$LOVE_FILE" | grep -x 'tools/save-editor/App.lua' >/dev/null \
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
# Same required-file gate as scripts/build.sh and scripts/build_android.sh.
# iOS only checked App.lua, which is why the Yellow manifest shipped missing
# in 0.1.45 through 0.1.47: decodeManifest (src/import/RomImporter.lua) errors
# outright when a version's manifest is absent, so Import ROM on Yellow died
# in the built app while dev, which reads the source tree, stayed green.
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
for required in src/update/Boot.lua tools/save-editor/App.lua \
tools/save-editor/Kit.lua tools/save-editor/panels/Party.lua \
$MANIFESTS; do
printf '%s\n' "$archive_entries" | grep -qx "$required" \
|| fail "game.love is missing $required"
done
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
}
@@ -323,6 +456,43 @@ PY
}
# --------------------------------------------------------------- xcodebuild
# love.system.pickFile and createFile are a native bridge compiled in by
# mobile/ios/patch_love_src.py, not part of LÖVE. A build that skipped the
# patch still links and still runs, then finds the field nil the moment anyone
# taps Import ROM (#482). #539 made that degrade to the copy-into-Files flow
# rather than crash, which is the right floor, but a build with no picker at all
# is a silent downgrade, so fail here instead of shipping one.
#
# Checked against the built binary rather than the source, because patching
# love-src proves nothing about what Xcode actually compiled: the shipped
# 0.1.45/0.1.46/0.1.47 IPAs all DO carry the bridge, so the reports that blamed
# a missing patch step were self-built IPAs, exactly the case this catches.
verify_native_bridge() {
local app="$1"
local exe bin missing=""
exe="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \
"$app/Info.plist" 2>/dev/null || true)"
bin="$app/${exe:-love}"
[ -f "$bin" ] || bin="$app/love"
if [ ! -f "$bin" ]; then
warn "no executable inside $(basename "$app"); skipping native bridge check"
return 0
fi
# grep -q here would race pipefail the same way pack_game_love documents:
# it exits on first match, strings dies of SIGPIPE, the pipeline "fails"
# nondeterministically. >/dev/null keeps grep reading the whole stream.
for sym in pickFile createFile; do
strings -a "$bin" | grep -x "$sym" >/dev/null || missing="$missing $sym"
done
if [ -n "$missing" ]; then
fail "built app has no native bridge (missing:$missing).
Import ROM would fall back to copy-into-Files instead of opening the picker.
mobile/ios/patch_love_src.py did not take. Re-run:
scripts/build_ios.sh --fetch && scripts/build_ios.sh"
fi
say "native bridge present (pickFile, createFile)"
}
run_xcodebuild() {
local config sdk destination
if $RELEASE; then
@@ -343,6 +513,15 @@ run_xcodebuild() {
# Prefer -target + SYMROOT over -derivedDataPath: modern Xcode requires
# -scheme whenever -derivedDataPath is set, and love-ios ships no shared schemes.
# Always stamp both: the overlay plist expands $(MARKETING_VERSION) /
# $(CURRENT_PROJECT_VERSION), and love-ios has no project-level defaults.
local marketing_version="$LOVE_VERSION"
local project_version="1"
if [ -n "$VERSION" ]; then
marketing_version="$VERSION"
project_version="$VERSION_CODE"
fi
local args=(
-project "$PROJECT"
-target love-ios
@@ -352,7 +531,8 @@ run_xcodebuild() {
SYMROOT="$BUILD_DIR/Build/Products"
OBJROOT="$BUILD_DIR/Build/Intermediates"
PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID"
MARKETING_VERSION="$LOVE_VERSION"
MARKETING_VERSION="$marketing_version"
CURRENT_PROJECT_VERSION="$project_version"
ONLY_ACTIVE_ARCH=NO
)
@@ -420,12 +600,18 @@ run_xcodebuild() {
cp "$LOVE_FILE" "$app/game.love"
fi
verify_native_bridge "$app"
local dist_dir="$DIST/${config}-${sdk}"
rm -rf "$dist_dir"
mkdir -p "$dist_dir"
cp -R "$app" "$dist_dir/$APP_NAME.app"
say "copied to $dist_dir/$APP_NAME.app"
if $DEVICE; then
package_ipa "$dist_dir/$APP_NAME.app"
fi
say "iOS app: $app"
say "bundle id: $BUNDLE_ID display: $DISPLAY_NAME"
if $DEVICE; then
@@ -439,6 +625,20 @@ run_xcodebuild() {
fi
}
# Pack Payload/<app>.app into dist/ios/gen1recomp.ipa for release / sideload tools.
package_ipa() {
local app="$1"
local ipa="$DIST/$APP_NAME.ipa"
local tmp
tmp="$(mktemp -d "$DIST/ipa.XXXXXX")"
mkdir -p "$tmp/Payload"
cp -R "$app" "$tmp/Payload/$(basename "$app")"
rm -f "$ipa"
(cd "$tmp" && zip -q -r "$ipa" Payload)
rm -rf "$tmp"
say "ipa: $ipa ($(du -h "$ipa" | cut -f1))"
}
# ------------------------------------------------------------ device install
# Installs the freshly built .app onto the first connected iPhone/iPad via
# devicectl. The phone must be paired (plugged in at least once + "Trust
@@ -471,8 +671,10 @@ install_to_device() {
# --------------------------------------------------------------- main
apply_ios_branding
apply_ios_icon
say "applying iOS native bridge patches (picker/Files support)"
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
ensure_manifests
pack_game_love
ensure_game_love_in_xcode
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Static analysis for the engine Lua (config: .luacheckrc).
#
# Complements scripts/test.sh: the tests prove behavior, luacheck catches the
# defects that never run in a green test -- undefined globals/locals (the
# class that hid a music.volume crash: a hook read a `state` that was still
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
# cosmetic categories the codebase lives with, so what prints is worth a look.
#
# scripts/lint.sh lint src/
# scripts/lint.sh src tools lint specific paths
#
# Install once with: luarocks install luacheck
set -uo pipefail
cd "$(dirname "$0")/.."
if ! command -v luacheck >/dev/null 2>&1; then
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
exit 2
fi
luacheck "${@:-src}"
+206 -45
View File
@@ -645,17 +645,63 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
return self
end
-- The disguise itself. InitWildBattle .isGhost (engine/battle/core.asm)
-- swaps only the pic and the nick -- wEnemyMonSpecies2 still holds the real
-- species, so the SGB palette stays the disguised mon's. ghostReal keeps
-- what LoadEnemyMonData puts back when the scope unveils.
local function disguiseAsGhost(self)
self.ghostReal = { name = self.enemy.name, sprite = self.enemy.sprite }
self.enemy.name = "GHOST"
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
monPalette(self.data, self.enemy.mon.species))
self.introText = Strings("The GHOST\nappeared!")
end
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
-- the enemy is "GHOST", you're too scared to attack, and balls fail.
function BattleState:makeGhost()
self.ghost = true
self.enemy.name = "GHOST"
-- the ghost keeps the disguised mon's SGB palette: InitWildBattle
-- swaps only the pic, wEnemyMonSpecies2 still holds the real species
-- (engine/battle/core.asm InitWildBattle .isGhost)
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
monPalette(self.data, self.enemy.mon.species))
self.introText = Strings("The GHOST\nappeared!")
disguiseAsGhost(self)
end
-- The RESTLESS SOUL with the SILPH SCOPE in the bag. Mechanically this is
-- not a ghost battle at all -- IsGhostBattle (core.asm) returns false the
-- moment the scope is in the bag, so the mon attacks, is attacked and rolls
-- for flight normally -- but InitWildBattle still enters disguised: its
-- .isGhost branch fires on wCurOpponent == RESTLESS_SOUL as well. Taking
-- the disguise off is PrintBeginningBattleText .isMarowak's job
-- (engine/battle/common_text.asm), which enter() queues through
-- queueScopeReveal (#492).
function BattleState:makeUnveiledGhost()
self.scopeReveal = true
disguiseAsGhost(self)
end
-- MarowakAnim (engine/battle/ghost_marowak_anim.asm) counted in frames: the
-- ghost pic is copied into OAM and flashed 8 times by xor-ing rOBP1 with
-- $80 (10 frames a side), the BG pic underneath is swapped to the RESTLESS
-- SOUL, rOBP1 is shifted left two bits every 10 frames until the ghost has
-- faded into the paper (3 shifts), and the Marowak copy fades back in the
-- same way (4 shifts). Alpha over the paper is what those OBP shifts look
-- like on screen.
BattleState.GHOST_FLASH_FRAMES = 80
BattleState.GHOST_FADE_OUT_FRAMES = 30
BattleState.GHOST_FADE_IN_FRAMES = 40
BattleState.GHOST_REVEAL_FRAMES = 150
-- The rows .isMarowak prints over the disguise, after the "GHOST appeared!"
-- box enter() has already queued: the unveil line, MarowakAnim, then "Wild
-- MAROWAK appeared!" under the restored name. The fall-through into
-- .playSFX at the end is the same SFX_TRAINER_APPEARED the port does not
-- play for trainer intros either, so it is left out here too.
function BattleState:queueScopeReveal()
local unveiled = self.data.text and self.data.text._UnveiledGhostText
self:say(unveiled
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
self:act(function() self.ghostReveal = { t = 0 } end)
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
self:say(Strings("Wild %s\nappeared!",
self.ghostReal and self.ghostReal.name or self.enemy.name))
end
-- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN,
@@ -669,6 +715,12 @@ end
function BattleState:makeOldManDemo(name)
self.demo = true
self.demoName = name or "OLD MAN"
-- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType:
-- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU
-- gets .profOakName + ProfOakPicBack (pokeyellow core.asm). The thrower
-- name the caller passes IS that distinction here, so it picks the pic too
-- -- data/scripts/story2.lua is the only site that names PROF.OAK (#557).
self.oakDemo = name == "PROF.OAK"
-- Yellow's Pallet intro runs this before the player owns any mon
-- (BATTLE_TYPE_PIKACHU precedes the lab gift), so newWild flagged the
-- battle dead for lack of a party. The demo never sends out, draws, or
@@ -1214,7 +1266,8 @@ function BattleState:enter()
-- fade, the ground-padding measurement and battle_sprite_scales.
local backPath, backTrueColor =
require("src.pokemon.Sprites").playerPath(self.data, "back",
{ kind = "battle", demo = self.demo, battle = self })
{ kind = "battle", demo = self.demo, oakDemo = self.oakDemo,
battle = self })
self.playerBackPic = getImage(backPath,
namedPalette(self.data, "MEWMON"), backTrueColor)
self.showPlayerBack = self.playerBackPic ~= nil
@@ -1229,11 +1282,16 @@ function BattleState:enter()
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
-- sounds with the "Wild X appeared!" box instead of waiting on the A
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
-- ghost gets no cry at all (common_text.asm:43-48).
if self.kind ~= "trainer" and self.kind ~= "link" and not self.ghost then
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
if self.kind ~= "trainer" and self.kind ~= "link"
and not self.ghost and not self.scopeReveal then
queueEnemyCry()
end
self:say(self.introText)
-- the unveil rides on that same box, before _InitBattleCommon clears the
-- intro chrome below (#492)
if self.scopeReveal then self:queueScopeReveal() end
-- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is
-- dismissed both HUD blocks are cleared and ClearSprites drops the
-- pokeball OAM, so the intro chrome never returns for the rest of the
@@ -1507,10 +1565,14 @@ function BattleState:update(dt)
end
local col = (self.menuIndex - 1) % 2
local row = math.floor((self.menuIndex - 1) / 2)
if input:wasPressed("left") or input:wasPressed("right") then
col = 1 - col
elseif input:wasPressed("up") or input:wasPressed("down") then
row = 1 - row
if input:wasPressed("left") then
col = math.max(0, col - 1)
elseif input:wasPressed("right") then
col = math.min(1, col + 1)
elseif input:wasPressed("up") then
row = math.max(0, row - 1)
elseif input:wasPressed("down") then
row = math.min(1, row + 1)
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
@@ -1539,10 +1601,14 @@ function BattleState:update(dt)
end
local col = (self.menuIndex - 1) % 2
local row = math.floor((self.menuIndex - 1) / 2)
if input:wasPressed("left") or input:wasPressed("right") then
col = 1 - col
elseif input:wasPressed("up") or input:wasPressed("down") then
row = 1 - row
if input:wasPressed("left") then
col = math.max(0, col - 1)
elseif input:wasPressed("right") then
col = math.min(1, col + 1)
elseif input:wasPressed("up") then
row = math.max(0, row - 1)
elseif input:wasPressed("down") then
row = math.min(1, row + 1)
end
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
@@ -2655,6 +2721,35 @@ function BattleState:updateFx()
end
end
end
-- The Silph Scope unveil (MarowakAnim; queueScopeReveal parks a wait row
-- of GHOST_REVEAL_FRAMES over it): the ghost flashes, fades out, the pic
-- and the nick swap back the way LoadEnemyMonData restores them, and the
-- real mon fades in (#492).
local gr = self.ghostReveal
if gr and self.enemy then
gr.t = gr.t + 1
local pf = self:picFxFor(self.enemy)
local flashEnd = BattleState.GHOST_FLASH_FRAMES
local outEnd = flashEnd + BattleState.GHOST_FADE_OUT_FRAMES
if gr.t <= flashEnd then
pf.fade = (math.floor((gr.t - 1) / 10) % 2 == 1) and 0.5 or 1
elseif gr.t <= outEnd then
pf.fade = 1 - math.ceil((gr.t - flashEnd) / 10) / 3
else
if not gr.swapped then
gr.swapped = true
local real = self.ghostReal
if real then
self.enemy.name = real.name or self.enemy.name
self.enemy.sprite = real.sprite or self.enemy.sprite
end
end
pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4)
end
if gr.t >= BattleState.GHOST_REVEAL_FRAMES then
self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil
end
end
-- the send-out grow-in (AnimateSendingOutMon): 3+4+5 frames, then
-- the pic draws at full size again
if self.growIn then
@@ -2669,10 +2764,24 @@ function BattleState:updateFx()
-- so a sounding siren rides out the next hit's HP drain instead of
-- dropping out mid-announcement (#293)
self.lowHealthAlarmOn = self:lowHealthAlarmActive()
if self.lowHealthAlarmOn then
Sound.startLoop(self.data, "Low_Health_Alarm")
-- battle.low_health_alarm: on/off toggle for the siren loop, ctx.on
-- mirrors self.lowHealthAlarmOn. Vanilla just starts/stops the loop
-- each frame; a mod can wrap this to reshape the toggle (e.g. force
-- ctx.on false after some budget) before letting vanilla act on it.
if Runtime.wantsHook("battle.low_health_alarm") then
Runtime.call("battle.low_health_alarm", function(ctx)
if ctx.on then
Sound.startLoop(ctx.battle.data, "Low_Health_Alarm")
else
Sound.stopLoop("Low_Health_Alarm")
end
end, { on = self.lowHealthAlarmOn, battle = self })
else
Sound.stopLoop("Low_Health_Alarm")
if self.lowHealthAlarmOn then
Sound.startLoop(self.data, "Low_Health_Alarm")
else
Sound.stopLoop("Low_Health_Alarm")
end
end
end
@@ -3208,7 +3317,10 @@ function BattleState:onFaint(battler)
end
end
function BattleState:enemyMonFainted()
-- Exp for the defeated enemy, shared by the faint path (enemyMonFainted)
-- and, when a mod's battle.catch_exp hook says so, the catch path
-- (storeCaughtMon).
function BattleState:awardExp()
-- exp is split among the mons that fought this enemy
-- (engine/battle/experience.asm); traded mons earn x1.5; each
-- participant gets the full stat exp
@@ -3286,27 +3398,44 @@ function BattleState:enemyMonFainted()
end
end
end
-- with EXP.ALL, participants split half the exp and the other half
-- is divided among the whole party (engine/battle/experience.asm)
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
for _, mon in ipairs(alive) do
applyShare(mon, participants * (expAll and 2 or 1), true)
end
if expAll then
-- the second GainExperience pass sets the gain flags for the WHOLE
-- party, so DivideExpDataByNumMonsGainingExp divides the already
-- halved-and-participant-divided exp again by the party count, and
-- .partyMonLoop still skips fainted mons (core.asm:818-858 +
-- experience.asm:9-13); each mon gets its own GainedText with the
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
-- summary line
for _, mon in ipairs(self.game.save.party) do
if mon.hp > 0 then
applyShare(mon, math.max(1, participants) * #self.game.save.party * 2, "expAll")
-- battle.exp_award: the participant/EXP.ALL split, factored out so a
-- mod can replace it wholesale (e.g. a flat undivided share to every
-- non-fainted party mon) without re-deriving participants/alive.
-- ctx.applyShare(mon, split, announce) is the same helper vanilla uses.
local function vanillaExpAward(ctx)
-- with EXP.ALL, participants split half the exp and the other half
-- is divided among the whole party (engine/battle/experience.asm)
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
for _, mon in ipairs(ctx.alive) do
ctx.applyShare(mon, ctx.participants * (expAll and 2 or 1), true)
end
if expAll then
-- the second GainExperience pass sets the gain flags for the WHOLE
-- party, so DivideExpDataByNumMonsGainingExp divides the already
-- halved-and-participant-divided exp again by the party count, and
-- .partyMonLoop still skips fainted mons (core.asm:818-858 +
-- experience.asm:9-13); each mon gets its own GainedText with the
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
-- summary line
for _, mon in ipairs(self.game.save.party) do
if mon.hp > 0 then
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
end
end
end
end
local awardCtx = { battle = self, participants = participants, alive = alive,
applyShare = applyShare }
if Runtime.wantsHook("battle.exp_award") then
Runtime.call("battle.exp_award", vanillaExpAward, awardCtx)
else
vanillaExpAward(awardCtx)
end
self.participants = {}
end
function BattleState:enemyMonFainted()
self:awardExp()
if self.kind == "trainer" then
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
@@ -3345,10 +3474,12 @@ function BattleState:enemyMonFainted()
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
local shiftSwitchMon = nil
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
-- _TrainerAboutToUseText: "X is" / "about to use" then cont nick,
-- then para "Will PLAYER" / "change POKéMON?" with YES/NO.
self:say(Strings("%s is\nabout to use", self.trainer.name))
self:say(Strings("%s!", nextName))
-- _TrainerAboutToUseText (data/text/text_2.asm): "X is" / "about to
-- use" then a CONT to the nick, not a fresh box -- the box scrolls
-- "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" /
-- "change POKéMON?" with YES/NO.
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
self:sayChoice(
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
function(yes)
@@ -3466,8 +3597,18 @@ function BattleState:enemyMonFainted()
-- PrintEndBattleText prints one text box; a `para` (\f) inside it
-- starts a fresh page, which is a message row of its own here (five
-- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText)
-- TrainerEndBattleText (home/trainers.asm) prints _TrainerNameText
-- (wNameBuffer then ": ", data/text/text_1.asm) and only then the
-- saved pointer, so the loss line opens with the trainer class tag on
-- its first line (RIVAL1/2/3 tag with the rival's name:
-- TrainerNamePointers aims those entries at wTrainerName). The tag
-- prints once, so a `para` page carries no second copy (#566).
local tag = self.trainer and self.trainer.name
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
if page ~= "" then self:sayNext(page) end
if page ~= "" then
self:sayNext(tag and (tag .. ": " .. page) or page)
tag = nil
end
end
end
self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
@@ -3867,6 +4008,12 @@ function BattleState:storeCaughtMon()
-- (item_effects.asm:472-501), regenerating its move list from the
-- base data -- a Mimic'd slot never leaves the battle with it
self:restoreMimicked(self.enemy)
-- battle.catch_exp: vanilla catches never grant exp; a mod can flip
-- this to true to pay out the same award a faint would have.
if Runtime.wantsHook("battle.catch_exp")
and Runtime.call("battle.catch_exp", function() return false end, { battle = self }) then
self:awardExp()
end
local game = self.game
local dex = game.save.pokedex
local species = self.enemy.mon.species
@@ -4296,6 +4443,18 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
end
if battler.fainted then return end
-- MarowakAnim's rOBP1 fades (updateFx): alpha over the paper is what those
-- palette shifts look like, and the test has to sit ahead of the plain
-- fast path below (#492)
local fadePf = self.picFx and self.picFx[battler]
if fadePf and fadePf.fade then
local cr, cg, cb, ca = love.graphics.getColor()
love.graphics.setColor(cr, cg, cb, ca * fadePf.fade)
love.graphics.draw(img, x, y, 0, scale, scale)
love.graphics.setColor(cr, cg, cb, ca)
return
end
local pf = self.picFx and self.picFx[battler]
if not pf or (not pf.kind and not pf.hidden and not pf.minimized
and (pf.ox or 0) == 0 and (pf.oy or 0) == 0) then
@@ -4483,7 +4642,9 @@ function BattleState:sgbBattlePals()
-- true white, which is what drew a white box around each pic on the pink
-- field. Snap every zone's color 0/3 to the global GBC white/black; the
-- mid shades (and the green bar the user prefers) stay untouched.
if PaletteFX.mode == "ogred" then
-- Boot-ROM OG only (Red/Blue): snap zone paper to the global GBC white/black.
-- OG YELLOW keeps each CGBBasePalettes endpoint (already near-white / near-black).
if PaletteFX.mode == "ogred" and not require("src.core.GameVersion").isYellow() then
local white, black = PaletteFX.GBC_BG[1], PaletteFX.GBC_BG[4]
for i = 0, 3 do
local c = out[i]
+6
View File
@@ -93,6 +93,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
-- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a
-- failed accuracy roll (core.asm:5260), and the miss handler still
-- runs the explode effect ("even if Explosion or Selfdestruct
-- missed, its effect still needs to be activated", core.asm:3223),
-- so the user faints against a mid-Fly/Dig target too (#528)
if record and record.onMiss then record.onMiss(ctx, "invulnerable") end
return
end
+38 -5
View File
@@ -183,6 +183,21 @@ local function drawCommandMenu(battle)
Font.drawBox(0, 13, 20, 5)
Font.drawBox(20, 13, 18, 5)
love.graphics.setColor(0, 0, 0, 1)
-- The old-man / PROF.OAK catch demo has no party, so makeOldManDemo parks
-- the WILD mon in battle.player as a placeholder (BattleState:1209). The
-- classic layout's demo branch never names anyone (DisplayBattleMenu,
-- core.asm:2038-2049, just draws the menu), so naming it here printed
-- "What will PIKACHU do?" over Oak's scripted throw (#557). Leave the
-- prompt side blank and run the same scripted hand the classic does.
if battle.demo then
Font.draw(Strings("FIGHT"), 176, 112)
Font.drawCode(0xE1, 240, 112); Font.drawCode(0xE2, 248, 112)
Font.draw(Strings("ITEM"), 176, 128)
Font.draw(Strings("RUN"), 240, 128)
-- next to FIGHT for the first 80 frames, then ITEM
Font.drawCode(0xED, 168, (battle.demoTimer or 0) <= 80 and 112 or 128)
return
end
Font.draw(Strings("What will"), 8, 112)
local who = battle.player and battle.player.name or ""
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
@@ -310,17 +325,35 @@ function WideBattle.draw(battle)
-- Each side keeps its original sprite pixels and placement math: the two
-- 160x144 OAM regions are translated apart and clipped into the wider
-- battlefield rather than either monster being scaled. wideRegion tells
-- drawBattlerPic its own side window is already the clip.
-- drawBattlerPic its own side window is already the clip. A shake moves
-- each region's clip with its contents, so a pic pushed toward a region
-- edge is not sheared off it (a vertical shake used to clip the player's
-- feet at FIELD_BOTTOM).
battle.wideRegion = true
inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
inRegion(sx, 32 + sy, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
battle.wideRegion = nil
drawHUDs(battle, slide)
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the
-- shakes move IS the whole screen: PredefShakeScreenHorizontally,
-- PredefShakeScreenVertically and AnimationShakeScreenHorizontallySlow
-- displace the HUDs and the message window along with the pics, exactly as
-- drawClassic offsets its whole BG canvas. Shaking only the two pic
-- regions left the foe gliding sideways across a nailed-down screen on
-- every applying-attack shake -- TAIL WHIP and every other status move
-- (#562). The OAM anim layer stays put, as it does in drawClassic.
local function shaken(fn)
if sx == 0 and sy == 0 then return fn() end
g.push()
g.translate(sx, sy)
fn()
g.pop()
end
shaken(function() drawHUDs(battle, slide) end)
drawAnimationLayer(battle)
drawTextArea(battle)
shaken(function() drawTextArea(battle) end)
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
g.setColor(1, 1, 1, 0.85)
+8 -8
View File
@@ -14,12 +14,12 @@ local MODULES = {
-- Optional for compatibility with developer and stale caches.
local OPTIONAL = { "audio", "palettes", "icons" }
-- The rules the engine still carries as literals. The constants registry
-- deep-merges over these, so a value has to exist before a mod can patch
-- it; each one is the number the engine hard-codes today, so seeding them
-- changes nothing on a mod-free boot.
-- Vanilla defaults for rules exposed through the constants registry. A
-- value has to exist before a mod can patch it; each one matches the
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
-- boot.
local CONSTANT_DEFAULTS = {
bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua)
bagSize = 20, -- BAG_ITEM_CAPACITY (Bag.capacity fallback)
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
moveMax = 4,
@@ -144,8 +144,8 @@ end
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
-- object index [1] so he behaves like the real leader:
-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers
-- spots the player in his DOWN line and he challenges on sight (#197),
-- * range 0: FightingDojoDefaultScript, not trainer sight, starts his
-- battle from the single tile to his left (#495),
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
-- * after = the "Stay and train at Karate with us!" re-talk line.
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
@@ -160,7 +160,7 @@ function Data:seedFightingDojoKarateMaster()
headers.FightingDojo = headers.FightingDojo or {}
if headers.FightingDojo[1] then return end
headers.FightingDojo[1] = {
range = 4,
range = 0,
battle = "_FightingDojoKarateMasterText",
won = "_FightingDojoKarateMasterDefeatedText",
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
+67 -2
View File
@@ -172,7 +172,28 @@ function Game:returnToTitle()
end
function Game:step(dt)
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
-- the same fixed-step boundary as a physical controller. Run them before
-- Input:step promotes queued edges so a button chosen here is visible to
-- this logic tick, not one tick later. With no wrapper this is a no-op.
ModRuntime.call("input.step", function() end, self, dt)
self.input:step()
-- A+B+SELECT+START held for 16 steps: SoftReset (home/init.asm) stops the
-- audio, whites the palettes out and falls through into Init, i.e. the
-- power-on boot, so everything unsaved is gone and the title sequence
-- comes back -- exactly what returnToTitle does for the START menu's QUIT.
-- The check lives here, above stack:update, so it fires from any state:
-- overworld, battle, a menu or a cutscene, the way _Joypad's does (#563).
-- Input:reset clears the four still-physically-held buttons so the title
-- screen does not read A as a menu choice on its first frame.
-- a tool mod (or a harness) may hand Game a stand-in input with no
-- chord bookkeeping; those simply never soft reset
if self.input.softResetStep and self.input:softResetStep() then
Input:reset()
TouchControls:reset()
self:returnToTitle()
return
end
-- serviced unconditionally: a link battle's ENet transport must not
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
-- on top of BattleState (see LinkBattle.new)
@@ -339,7 +360,13 @@ function Game:draw()
if worldBelow and self.overworld.sgbWorldZones then
worldZones = self.overworld:sgbWorldZones()
end
Renderer:endFrame(zones, worldZones)
local viewport = Renderer:endFrame(zones, worldZones)
-- Persistent tool status is screen-space UI: draw it over the completed
-- render pipeline with exact playfield/margin geometry, but below mobile
-- controls. It never becomes an updating game state.
if ModRuntime.wantsHook("render.hud") then
ModRuntime.call("render.hud", function() end, self, viewport)
end
-- on-screen mobile controls: pure screen-space, over the finished frame
TouchControls:draw()
end
@@ -491,21 +518,36 @@ function Game:gamepadaxis(joystick, axis, value)
Input:gamepadaxis(joystick, axis, value)
end
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
-- generic joystick path anyway: any sensor-style device that still reaches us
-- has gravity pinning an axis past the deadzone, which would hide the touch
-- overlay every instant and steer the player by tilt through the axis-1/2
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
-- never as "* Accelerometer".
local function isAccelerometer(joystick)
local name = joystick and joystick.getName and joystick:getName()
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
end
function Game:joystickpressed(joystick, button)
if isAccelerometer(joystick) then return end
TouchControls:noteGamepad()
Input:joystickpressed(joystick, button)
end
function Game:joystickreleased(joystick, button)
if isAccelerometer(joystick) then return end
Input:joystickreleased(joystick, button)
end
function Game:joystickaxis(joystick, axis, value)
if isAccelerometer(joystick) then return end
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:joystickaxis(joystick, axis, value)
end
function Game:joystickhat(joystick, hat, direction)
if isAccelerometer(joystick) then return end
if direction ~= "c" then TouchControls:noteGamepad() end
Input:joystickhat(joystick, hat, direction)
end
@@ -563,6 +605,11 @@ end
-- Capture the live world state into the save table and persist it.
-- Options are flushed to options.lua as part of SaveData.save.
function Game:writeSave()
-- Tool sessions can be deliberately ephemeral. Give them one narrow veto
-- before captureSave mutates the snapshot or any progress bytes reach disk.
if ModRuntime.call("save.write", function() return true end, self) == false then
return false
end
if self.overworld and self.overworld.captureSave then
self.overworld:captureSave(self.save)
end
@@ -573,7 +620,7 @@ function Game:writeSave()
if ModRuntime.wants("save.writing") then
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
end
SaveData.save(self.save)
return SaveData.save(self.save)
end
-- Persist options.lua only (Options menu / hotkeys 2-5). Keeps settings
@@ -603,7 +650,25 @@ function Game:applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts)
-- Scale the optional presentation extras to the device's performance
-- tier. Every heavy feature was just applied from the stored options
-- above; here we clamp the *live* state down for a weaker device without
-- rewriting what the player saved, so raising the tier later restores
-- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the
-- default on a normal desktop, and every options.lua predating this
-- option) clamps nothing, so it is a no-op for the common case.
local caps = require("src.core.Performance").applyOptions(opts)
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end
local Zoom = require("src.render.Zoom")
Zoom.allowSurvey = caps.survey
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
if caps.fpsMax then
local FrameCap = require("src.core.FrameCap")
if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end
end
Input:applyBindings(opts.bindings)
TouchControls:applyOptions(opts)
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
if gbcCleared then self:writeOptions() end
end
+48
View File
@@ -263,8 +263,56 @@ function Input:isDown(btn)
return self.state[btn] or false
end
-- True when the on-screen overlay is one of the live sources holding this
-- button (see overlayPressed above). Player:turnWindow widens the
-- turn-in-place tap window on touch, where a press and release can never be
-- as short as a physical d-pad's (#415).
function Input:isTouchDown(btn)
local sources = self.sources[btn]
return (sources and sources["touch:" .. btn]) and true or false
end
function Input:wasPressed(btn)
return self.pressed[btn] or false
end
-- Soft reset (#563). _Joypad (engine/joypad.asm) tests the RAW joypad read
-- with `cp PAD_BUTTONS` -- an equality, not a mask -- so the combo counts
-- only while A, B, SELECT and START are the only buttons down; any d-pad
-- direction in the mix cancels it. That test sits ahead of the wJoyIgnore
-- and BIT_DISABLE_JOYPAD masking below it, which is why the reset still
-- works mid-battle and mid-cutscene where ordinary input is thrown away.
-- TrySoftReset then burns one DelayFrame per pass and decrements hSoftReset,
-- seeded with 16 by Init (home/init.asm), so the combo has to survive 16
-- consecutive polls. That hold is also what keeps the on-screen overlay
-- safe: it already takes four separate fingers on four separate controls,
-- and they all have to stay put for better than a quarter of a second.
local SOFT_RESET_FRAMES = 16
function Input:softResetHeld()
if not (self.state.a and self.state.b
and self.state.start and self.state.select) then
return false
end
return not (self.state.up or self.state.down
or self.state.left or self.state.right)
end
-- Ticked once per fixed step by Game:step; true on the step the countdown
-- runs out. hSoftReset is never re-seeded on release in the original, so
-- its count leaks across a whole session; a port that copied that would
-- eventually reset on a stray four-button press, so the counter re-arms as
-- soon as the combo drops.
function Input:softResetStep()
if not self:softResetHeld() then
self.softResetFrames = nil
return false
end
local left = (self.softResetFrames or SOFT_RESET_FRAMES) - 1
self.softResetFrames = left
if left > 0 then return false end
self.softResetFrames = nil
return true
end
return Input
+8 -1
View File
@@ -23,6 +23,13 @@ local volumeScale = 1
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0
-- Forward-declared here so applyVolume (below) closes over the real playback
-- state rather than a nil global: the table literal is assigned further down,
-- but a `local state = {}` there would leave every reference above it bound
-- to the global `state`. Before this, registering the `music.volume` mod
-- hook crashed applyVolume on `state.current` (a nil index).
local state
local function applyVolume(src)
if not src then return end
local vol = VOLUME * volumeScale
@@ -63,7 +70,7 @@ local function applyFilter(src)
end
end
local state = {
state = {
current = nil, -- song label
chip = false, -- the playing song is a synthesized channel program
source = nil, -- currently playing source
+156
View File
@@ -0,0 +1,156 @@
-- Graphics performance tier: one knob that scales the port's optional,
-- non-faithful presentation extras down for weaker hardware.
--
-- The heavy extras are all things the Game Boy never had and this port
-- adds on top: the whole-screen 3D TILT (transforms the entire map as a
-- ground plane), the GBC FX post-process shader (a fullscreen pass), and
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
-- None of this touches game logic, which is fixed-step off dt
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
-- only in how much optional eye-candy the renderer is allowed to do.
--
-- The tier is persisted as save.options.performance:
-- auto pick a default from the device (see detect())
-- high everything on -- the historical behavior
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
-- low no TILT, no GBC FX, no survey zoom, FPS capped
--
-- AUTO only chooses the *default* for the current device; every tier is
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
-- from being overridden. The clamps are applied live in Game:applyOptions
-- against the stored options without rewriting them, so raising the tier
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
--
-- Zero requires: loads during love.conf and under plain Lua for tools and
-- tests, the same way src/core/GameVersion.lua does.
local Performance = {}
-- Option-row order (auto first, then most to least capable).
Performance.TIERS = { "auto", "high", "balanced", "low" }
Performance.LABELS = {
auto = "AUTO",
high = "HIGH",
balanced = "BALANCED",
low = "LOW",
}
-- What each concrete tier permits. `auto` is resolved to one of these
-- before caps are read, so it has no row here. fpsMax = false means no
-- extra ceiling (the player's own MAX FPS still applies).
Performance.CAPS = {
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
}
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
-- renderer / options row read it back. Defaults to the unclamped tier so
-- a pre-boot launcher behaves exactly as it always did.
Performance.tier = "high"
local function loveOS()
if love and love.system and love.system.getOS then
return love.system.getOS()
end
return nil
end
local function processorCount()
if love and love.system and love.system.getProcessorCount then
return love.system.getProcessorCount()
end
return nil
end
-- CPU architecture via LuaJIT's jit.arch when present: "arm"/"arm64" on
-- phones and PortMaster handhelds, "x86"/"x64" on desktops. nil under a
-- plain-Lua tool or test with no jit table, which resolves to HIGH (no
-- clamping) so tooling is never surprised.
local function cpuArch()
return (jit and jit.arch) or nil
end
-- Default tier for the current device. Deliberately conservative: only
-- the platforms that are reliably weak drop below HIGH, and AUTO is always
-- overridable, so a wrong guess costs one OPTIONS row. A normal
-- multi-core desktop -- the common case, and every existing install whose
-- options.lua predates this option -- resolves to HIGH and behaves exactly
-- as before.
function Performance.detect()
local os = loveOS()
local arch = cpuArch()
local isArm = arch == "arm" or arch == "arm64"
local cores = processorCount()
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
-- already ships a build for): the weakest target here.
if isArm and os ~= "Android" and os ~= "iOS" then
return "low"
end
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
if os == "Android" or os == "iOS" then
return "balanced"
end
-- Desktop: a dual-core (or single-core) box is the low-end line.
if cores and cores <= 2 then
return "balanced"
end
return "high"
end
-- Fold a stored option value to a concrete tier, resolving "auto" (and any
-- hand-edited garbage) through detect().
function Performance.resolve(value)
if value == "high" or value == "balanced" or value == "low" then
return value
end
return Performance.detect()
end
-- Normalize a stored value to a valid option id (keeps "auto"); a bad value
-- degrades to auto so a corrupt options.lua still lands on something sane.
function Performance.normalize(value)
for _, id in ipairs(Performance.TIERS) do
if value == id then return id end
end
return "auto"
end
-- Row label for a stored value: the tier's own name (AUTO / HIGH / ...).
function Performance.label(value)
return Performance.LABELS[Performance.normalize(value)]
end
-- Concrete caps for a stored option value (resolving auto).
function Performance.caps(value)
return Performance.CAPS[Performance.resolve(value)]
end
-- Resolve the tier for these options, record it live, and return its caps.
-- Called from Game:applyOptions, which clamps the live presentation modules
-- against the returned caps.
function Performance.applyOptions(opts)
local tier = Performance.resolve(opts and opts.performance)
Performance.tier = tier
return Performance.CAPS[tier]
end
-- Cycle the OPTIONS row: auto -> high -> balanced -> low -> auto (dir -1
-- reverses). Lua's `%` is non-negative for a positive modulus, so a
-- negative dir wraps correctly.
function Performance.cycle(value, dir)
value = Performance.normalize(value)
local i = 1
for idx, id in ipairs(Performance.TIERS) do
if id == value then i = idx break end
end
local n = #Performance.TIERS
local nextIdx = (i - 1 + (dir or 1)) % n + 1
return Performance.TIERS[nextIdx]
end
return Performance
+40
View File
@@ -0,0 +1,40 @@
-- Usable window rect for mobile chrome (notch / Dynamic Island / home
-- indicator / Android display cutouts). Wraps love.window.getSafeArea when
-- the engine provides it; otherwise the full graphics window.
--
-- Desktop and headless stubs return the full window, so callers can always
-- layout against this rect without platform branches. Interactive chrome
-- (touch overlay, launcher) should prefer this over getDimensions; the game
-- canvas may still letterbox into the full framebuffer for immersion.
local SafeArea = {}
function SafeArea.rect()
local ww, wh = 0, 0
if love and love.graphics and love.graphics.getDimensions then
ww, wh = love.graphics.getDimensions()
end
if ww <= 0 then ww = 1 end
if wh <= 0 then wh = 1 end
if not (love and love.window and love.window.getSafeArea) then
return 0, 0, ww, wh
end
local x, y, w, h = love.window.getSafeArea()
if type(x) ~= "number" or type(y) ~= "number"
or type(w) ~= "number" or type(h) ~= "number"
or w <= 0 or h <= 0 then
return 0, 0, ww, wh
end
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
-- push layout outside the surface.
x = math.max(0, math.min(x, ww))
y = math.max(0, math.min(y, wh))
w = math.max(1, math.min(w, ww - x))
h = math.max(1, math.min(h, wh - y))
return x, y, w, h
end
return SafeArea
+24 -1
View File
@@ -241,6 +241,11 @@ function SaveData.defaultOptions()
videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- graphics performance tier: auto | high | balanced | low. "auto"
-- picks a default from the device (ARM handhelds/phones drop the heavy
-- extras); scales TILT / GBC FX / survey ZOOM / FPS but never game
-- logic. See src/core/Performance.lua.
performance = "auto",
-- Per-pipeline display levels, keyed by render_pipelines id (see
-- src/render/Pipelines.lua). A level for a mod that is not installed
-- is kept rather than pruned, so re-enabling the mod restores the mode
@@ -249,6 +254,24 @@ function SaveData.defaultOptions()
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
-- GitHub release checks for mods with a manifest "github" field
-- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours.
modUpdateCache = {},
-- Community mod indexes the player has chosen to browse
-- (src/mods/ModIndex.lua), in the order they added them. Empty by
-- default and never populated automatically: adding an index is how a
-- player says they trust whoever publishes it, so the launcher asks
-- rather than shipping one. Rows are { url, feed, base, fallback,
-- label }.
modIndexes = {},
-- Parsed index listings keyed by feed URL; TTL is 24 hours, matching how
-- often the feeds themselves rebuild.
modIndexCache = {},
-- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua).
-- enabled=false hides it permanently (distinct from auto-hide-on-gamepad).
-- positions are optional normalized centers {x=0..1, y=0..1} per control
-- (dpad/a/b/start/select); nil means the default layout.
touchControls = { enabled = true },
}
end
@@ -1035,7 +1058,7 @@ local function reclaim(save, data, report)
if type(entry) == "table" and known(data.items, entry.id) then
table.remove(orphaned.items, i)
if entry.from == "pcItems" or type(save.inventory) ~= "table"
or not Bag.add(save, entry.id, entry.count or 1) then
or not Bag.add(save, entry.id, entry.count or 1, data) then
save.pcItems = save.pcItems or {}
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
end
+213 -50
View File
@@ -12,12 +12,18 @@
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
-- off everywhere.
--
-- Player preferences (options.touchControls) can permanently disable the
-- overlay and/or override per-control positions as normalized window
-- fractions. The launcher editor (src/ui/TouchControlsEditor.lua) writes
-- those; applyOptions reads them at boot and whenever options change.
--
-- Controls press GB buttons through Input:overlayPressed/Released -- their
-- own input source, not a keyboard alias -- so a held overlay direction
-- merges cleanly with a keyboard key or stick holding the same button,
-- and a player rebind can never detach the overlay.
local Input = require("src.core.Input")
local SafeArea = require("src.core.SafeArea")
local TouchControls = {}
@@ -39,6 +45,7 @@ local DPAD_DEAD = 0.16
local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
local BUTTONS = { "a", "b", "start", "select" }
local CONTROLS = { "dpad", "a", "b", "start", "select" }
local IMAGES = {
dpad = "assets/touch/dpad.png",
@@ -52,6 +59,12 @@ local IMAGES = {
select = "assets/touch/select.png",
}
local function clamp01(v)
if v < 0 then return 0 end
if v > 1 then return 1 end
return v
end
local function wantsOverlay()
local env = os.getenv("POKEPORT_TOUCH")
if env == "1" then return true end
@@ -60,8 +73,62 @@ local function wantsOverlay()
return osName == "Android" or osName == "iOS"
end
-- Normalize a persisted touchControls table into {enabled, positions}.
-- Unknown / garbage keys are dropped so a bad options.lua cannot brick
-- the overlay.
function TouchControls.normalizeConfig(tc)
local out = { enabled = true, positions = nil }
if type(tc) ~= "table" then return out end
if tc.enabled == false then out.enabled = false end
if type(tc.positions) == "table" then
local pos = {}
for _, name in ipairs(CONTROLS) do
local p = tc.positions[name]
if type(p) == "table" and type(p.x) == "number" and type(p.y) == "number" then
pos[name] = { x = clamp01(p.x), y = clamp01(p.y) }
end
end
if next(pos) then out.positions = pos end
end
return out
end
-- Pure default layout in LOVE units for a usable rect of size ww x wh at
-- origin (ox, oy). Shared by layout() and the editor's Reset path so
-- defaults stay in one place. ox/oy default to 0 for the headless tests
-- and for callers that already pass a full-window size.
function TouchControls.defaultLayout(ww, wh, ox, oy)
ox, oy = ox or 0, oy or 0
local short = math.min(ww, wh)
local dpadW = math.min(180, short * 0.34)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
return {
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW },
start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
}
end
local function loadImages()
local img = {}
for name, path in pairs(IMAGES) do
local ok, im = pcall(love.graphics.newImage, path)
if not ok then return nil end
im:setFilter("linear", "linear")
img[name] = im
end
return img
end
function TouchControls:init()
self.active = wantsOverlay()
self.enabled = true
self.positions = nil
self.preview = false
self.controllerHidden = false
self.touches = {}
-- per-GB-button owner count: two fingers on A must not double-press it,
@@ -70,51 +137,94 @@ function TouchControls:init()
self.dpadTouch = nil
self.layoutW, self.layoutH = nil, nil
self.img = nil
if not self.active then return end
-- soft-fail: a missing/corrupt PNG must never block boot; the overlay
-- stays off and keyboard/controller play still works
local img = {}
for name, path in pairs(IMAGES) do
local ok, im = pcall(love.graphics.newImage, path)
if not ok then
img = nil
break
end
-- smooth UI icons; the global default filter is nearest for GB pixels
im:setFilter("linear", "linear")
img[name] = im
-- Images load whenever the platform wants the overlay OR the launcher
-- editor forces a preview (desktop testing of the editor).
if self.active then
self.img = loadImages()
end
end
-- Ensure art is loaded for the launcher editor even when wantsOverlay()
-- is false (desktop without POKEPORT_TOUCH).
function TouchControls:ensureImages()
if self.img then return true end
self.img = loadImages()
return self.img ~= nil
end
-- Apply options.touchControls. Called from Game:applyOptions and from
-- the launcher editor after a save.
function TouchControls:applyOptions(opts)
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
self.enabled = cfg.enabled
self.positions = cfg.positions
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
if not self.enabled then
self.controllerHidden = false
self:reset()
end
end
function TouchControls:config()
return {
enabled = self.enabled ~= false,
positions = self.positions,
}
end
-- Preview mode: force-draw the overlay for the layout editor, ignoring
-- platform / enabled / gamepad gates. Gameplay input still respects
-- enabled via touchpressed.
function TouchControls:setPreview(on)
self.preview = on and true or false
if on then
self:ensureImages()
self.controllerHidden = false
end
self.img = img
end
function TouchControls:visible()
return self.active and self.img ~= nil and not self.controllerHidden
if self.preview then return self.img ~= nil end
return self.active and self.enabled ~= false and self.img ~= nil
and not self.controllerHidden
end
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
local function clampZone(zone, x0, y0, x1, y1)
local half = zone.w * 0.5
zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx))
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
end
-- Layout in LOVE units (density-independent on mobile), recomputed when
-- the window size changes (rotation, resize). D-pad bottom-left, B/A
-- bottom-right with A above B (the Game Boy diagonal), START/SELECT
-- flanking the bottom center.
-- the window or safe area changes (rotation, resize, notch insets).
-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
-- diagonal), START/SELECT flanking the bottom center -- all inside the
-- device safe area so thumbs clear the home indicator / cutouts.
-- Custom positions (normalized 0..1 within the safe rect) override centers
-- while sizes stay derived from the short edge.
function TouchControls:layout()
local ww, wh = love.graphics.getDimensions()
if self.layoutW == ww and self.layoutH == wh then return self.L end
self.layoutW, self.layoutH = ww, wh
local short = math.min(ww, wh)
-- ~a third of the short edge, capped so tablets don't get a dinner plate
local dpadW = math.min(180, short * 0.34)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
-- START/SELECT hug the bottom center: on a narrow portrait phone the
-- d-pad and B leave little room, so a tight pair is what keeps them off
-- the neighboring controls
self.L = {
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
}
local ox, oy, sw, sh = SafeArea.rect()
if self.layoutW == sw and self.layoutH == sh
and self.layoutOx == ox and self.layoutOy == oy and self.L then
return self.L
end
self.layoutW, self.layoutH = sw, sh
self.layoutOx, self.layoutOy = ox, oy
self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
if self.positions then
for _, name in ipairs(CONTROLS) do
local p = self.positions[name]
local zone = self.L[name]
if p and zone then
zone.cx = ox + p.x * sw
zone.cy = oy + p.y * sh
clampZone(zone, ox, oy, ox + sw, oy + sh)
end
end
end
local ssW = self.L.start.w
local fontSize = math.max(8, math.floor(ssW * 0.26))
if not self.labelFont or self.fontSize ~= fontSize then
self.fontSize = fontSize
@@ -123,12 +233,49 @@ function TouchControls:layout()
return self.L
end
-- Move one control to a screen-space point and persist its normalized
-- position within the safe rect. Used by the layout editor while dragging.
function TouchControls:setControlCenter(name, cx, cy)
local ox, oy, sw, sh = SafeArea.rect()
local L = self:layout()
local zone = L[name]
if not zone then return end
zone.cx, zone.cy = cx, cy
clampZone(zone, ox, oy, ox + sw, oy + sh)
self.positions = self.positions or {}
self.positions[name] = {
x = sw > 0 and (zone.cx - ox) / sw or 0,
y = sh > 0 and (zone.cy - oy) / sh or 0,
}
end
function TouchControls:clearPositions()
self.positions = nil
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
end
local function inCircle(zone, x, y, slop)
local r = zone.w * 0.5 * slop
local dx, dy = x - zone.cx, y - zone.cy
return dx * dx + dy * dy <= r * r
end
-- Which control (if any) contains (x, y). Prefer face buttons over the
-- d-pad when they overlap, matching touchpressed's order.
function TouchControls:hitTest(x, y)
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if inCircle(L[btn], x, y, SLOP[btn]) then return btn end
end
local dz = L.dpad
local half = dz.w * 0.65
if math.abs(x - dz.cx) <= half and math.abs(y - dz.cy) <= half then
return "dpad"
end
return nil
end
local function dpadDir(zone, x, y)
local dx, dy = x - zone.cx, y - zone.cy
local dead = zone.w * DPAD_DEAD
@@ -165,7 +312,9 @@ local function setDpad(self, touch, dir)
end
function TouchControls:touchpressed(id, x, y)
if not (self.active and self.img) then return end
-- preview mode is layout-edit only: never press GB buttons
if self.preview then return end
if not (self.active and self.enabled ~= false and self.img) then return end
-- a controller hid the overlay; the first touch only brings it back
if self.controllerHidden then
self.controllerHidden = false
@@ -192,6 +341,7 @@ function TouchControls:touchpressed(id, x, y)
end
function TouchControls:touchmoved(id, x, y)
if self.preview then return end
local touch = self.touches[id]
-- only the d-pad tracks movement (slide between directions without
-- lifting); buttons hold until release wherever the finger wanders
@@ -200,6 +350,7 @@ function TouchControls:touchmoved(id, x, y)
end
function TouchControls:touchreleased(id, x, y)
if self.preview then return end
local touch = self.touches[id]
if not touch then return end
self.touches[id] = nil
@@ -216,7 +367,7 @@ end
-- touchreleased and would strand its button held forever. Called from
-- Game alongside Input:reset() on focus/visibility loss.
function TouchControls:reset()
for btn in pairs(self.held) do
for btn in pairs(self.held or {}) do
Input:overlayReleased(btn)
end
self.held = {}
@@ -225,9 +376,13 @@ function TouchControls:reset()
end
-- a gamepad is being used: hide the overlay (dropping anything it held)
-- until the next screen touch asks for it back
-- until the next screen touch asks for it back. No-op when the player
-- permanently disabled the overlay -- there is nothing to hide, and a
-- later accidental touch must not resurrect it.
function TouchControls:noteGamepad()
if not self.active or self.controllerHidden then return end
if not self.active or self.enabled == false or self.controllerHidden then
return
end
self.controllerHidden = true
self:reset()
end
@@ -242,40 +397,46 @@ function TouchControls:joystickremoved()
end
end
local function drawIcon(img, zone, pressed)
love.graphics.setColor(1, 1, 1, pressed and BACK_PRESSED or BACK)
local function drawIcon(img, zone, pressed, alphaMul)
alphaMul = alphaMul or 1
love.graphics.setColor(1, 1, 1, (pressed and BACK_PRESSED or BACK) * alphaMul)
love.graphics.circle("fill", zone.cx, zone.cy, zone.w * 0.58)
local scale = zone.w / img:getWidth()
love.graphics.setColor(1, 1, 1, pressed and ALPHA_PRESSED or ALPHA)
love.graphics.setColor(1, 1, 1, (pressed and ALPHA_PRESSED or ALPHA) * alphaMul)
love.graphics.draw(img, zone.cx - zone.w / 2,
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end
-- Screen-space, called by Game:draw after Renderer:endFrame so the
-- overlay rides on top of everything (world, UI, CRT/GBC FX included).
-- Also used by the launcher layout editor under preview mode.
function TouchControls:draw()
if not self:visible() then return end
local L = self:layout()
-- when the player disabled the overlay but the editor is previewing,
-- draw dimmed so the layout is still editable
local alphaMul = (self.preview and self.enabled == false) and 0.45 or 1
love.graphics.push("all")
love.graphics.origin()
local dpadTouch = self.dpadTouch and self.touches[self.dpadTouch]
local dir = dpadTouch and dpadTouch.dir
drawIcon(dir and self.img["dpad_" .. dir] or self.img.dpad, L.dpad,
dir ~= nil)
dir ~= nil, alphaMul)
for _, btn in ipairs(BUTTONS) do
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil)
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil, alphaMul)
end
-- the +/- glyphs alone don't say which is which; shadowed so the text
-- reads on both the black letterbox and battle's white one
-- reads on both the black letterbox and battle's white one. Each label
-- tracks its own control's cy/w so dragging START cannot move SELECT.
love.graphics.setFont(self.labelFont)
local ly = L.start.cy + L.start.w * 0.66
local function label(text, zone)
local ly = zone.cy + zone.w * 0.66
local w = self.labelFont:getWidth(text)
love.graphics.setColor(0, 0, 0, 0.6)
love.graphics.setColor(0, 0, 0, 0.6 * alphaMul)
love.graphics.print(text, zone.cx - w / 2 + 1, ly + 1)
love.graphics.setColor(1, 1, 1, ALPHA + 0.2)
love.graphics.setColor(1, 1, 1, (ALPHA + 0.2) * alphaMul)
love.graphics.print(text, zone.cx - w / 2, ly)
end
label("START", L.start)
@@ -284,4 +445,6 @@ function TouchControls:draw()
love.graphics.pop()
end
TouchControls.CONTROLS = CONTROLS
return TouchControls
+1 -1
View File
@@ -214,7 +214,7 @@ function VERBS.give(self, rest)
end
elseif game.data.items and game.data.items[id] then
local n = tonumber(count) or 1
if require("src.inventory.Bag").add(save, id, n) then
if require("src.inventory.Bag").add(save, id, n, game.data) then
self:print(("%s x%d added"):format(id, n))
else
self:print("bag full")
+36
View File
@@ -1179,6 +1179,13 @@ function RomExtractor:extractPokemon()
completed = completed + 1
self:tick("Pokemon", completed, #self.manifest.dexOrder + 10)
end
-- Yellow-only third back pic: LoadPlayerBackPic (engine/battle/core.asm)
-- picks OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for
-- BATTLE_TYPE_PIKACHU, the Pallet Town catch scene (#557). Outside the
-- ticked loop so the progress denominator stays as it was.
if self.symbols["ProfOakPicBack"] then
self:writeCompressedPic("ProfOakPicBack", "battle/profoakb.png")
end
local balls = self:symbol("PokeballTileGraphics")
self:write2bpp(self.rom:bytes(balls.bank, balls.address, 64),
32, 8, "battle/balls.png", true)
@@ -1826,6 +1833,35 @@ function RomExtractor:extractField()
end
end
-- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per
-- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in
-- data/pikachu/pikachu_pic_animation.asm, the pic PikaPicAnimBGFrames_4
-- (PikaAnimTilemap_1, column order) paints. Scripts 18/22/23/24 have no
-- compressed base and take PikaPicAnimBGFrames_5 -> PikaAnimTilemap_9,
-- which is ROW order over a raw 25-tile sheet, hence no columns flag.
-- Script 26 shares script 11's base. The pikaframe overlays each script
-- draws ON TOP of the base are a second full pose out of the same blob and
-- stay unripped: PikachuFollower.picLift stands in for their motion
-- (#561, still on #407's stand-in).
local PIKAPIC_BASE = {
"Pic_e4000", "Pic_e411c", "Pic_e4272", "Pic_e4383", "Pic_e458b",
"Pic_e467b", "Pic_e476e", "Pic_e49d1", "Pic_e4b39", "Pic_e4c3e",
"Pic_e5000", "Pic_e523f", "Pic_e548e", "Pic_e56d1", "Pic_e5924",
"Pic_e5b7d", "Pic_e5ddd", "GFX_e6020", "Pic_e6340", "Pic_e6587",
"Pic_e67d6", "GFX_e6e6f", "GFX_e718f", "GFX_e74af", "Pic_e77cf",
"Pic_e5000", "Pic_f0abf", "Pic_f0cf4",
}
if self.symbols[PIKAPIC_BASE[1]] then
for script, label in ipairs(PIKAPIC_BASE) do
local path = "pikachu/pikapic_" .. script .. ".png"
if label:sub(1, 4) == "GFX_" then
self:raw2bpp(label, 40, 40, path, { matte = true })
else
self:writeCompressedPic(label, path)
end
end
end
self:raw1bpp("LedgeHoppingShadow", 8, 8,
"fx/shadow.png", true); tick()
for _, spec in ipairs({
+1795 -130
View File
File diff suppressed because it is too large Load Diff
+23 -7
View File
@@ -1,11 +1,26 @@
-- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm):
-- a distinct item id occupies one slot regardless of quantity; badges
-- live in the inventory table but are not bag items. save.bagOrder
-- keeps acquisition order like wBagItems (SELECT can reorder it).
-- The bag defaults to 20 slots (BAG_ITEM_CAPACITY,
-- constants/menu_constants.asm), but mods may replace that limit through
-- Data.constants.bagSize. A distinct item id occupies one slot regardless
-- of quantity; badges live in the inventory table but are not bag items.
-- save.bagOrder keeps acquisition order like wBagItems (SELECT can reorder
-- it).
local Bag = {}
Bag.CAPACITY = 20
local DEFAULT_CAPACITY = 20
-- `data` is injectable for the save editor and headless mod tests. Normal
-- gameplay may omit it because the loader merges mods into the Data
-- singleton before any item can be added. The fallback keeps old/stale
-- generated caches and isolated callers at the vanilla limit.
function Bag.capacity(data)
data = data or require("src.core.Data")
local configured = data and data.constants and data.constants.bagSize
if type(configured) == "number" and configured >= 1 then
return math.floor(configured)
end
return DEFAULT_CAPACITY
end
local function isBadge(id)
return id:find("BADGE", 1, true) ~= nil
@@ -55,9 +70,10 @@ end
-- Add qty of an item; returns false (and adds nothing) when a new slot
-- is needed and the bag is full, or when the stack would pass 99
-- (AddItemToInventory's per-slot quantity cap).
function Bag.add(save, id, qty)
function Bag.add(save, id, qty, data)
local inv = save.inventory
if not inv[id] and not isBadge(id) and Bag.slots(save) >= Bag.CAPACITY then
if not inv[id] and not isBadge(id)
and Bag.slots(save) >= Bag.capacity(data) then
return false
end
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
+7 -1
View File
@@ -10,7 +10,6 @@
-- "ball" caller must throw it (battle only)
-- "learn", moveId caller must run the learn-move flow
local Pokemon = require("src.pokemon.Pokemon")
local Flags = require("src.script.Flags")
local Strings = require("src.core.Strings")
@@ -433,6 +432,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if battle then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
-- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2
-- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime
-- on that carry -- surfing refuses the rod with the same OAK text as
-- the mid-battle case above, no rod-specific message (#533)
if ow and ow.player and ow.player.surfing then
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
end
return "fish", itemId
end
+8 -1
View File
@@ -128,7 +128,14 @@ end
-- ------- the link surface
local SPECIES_FIELDS = { "baseStats", "types", "catchRate", "baseExp",
-- catchRate stays out (#511): no link mode ever reads it -- a ball thrown
-- in a link battle is a trainer-battle throw and always refused (pokered
-- engine/items/item_effects.asm ItemUseBall), and the trade rebuild never
-- touches it. Hashing it split Red/Blue from Yellow, whose only link
-- surface delta is the Dragonair/Dragonite catch-rate bytes
-- (data/pokemon/base_stats/dragonair.asm db 45 vs 27, dragonite.asm 45
-- vs 9), when the real cable links R/B/Y freely.
local SPECIES_FIELDS = { "baseStats", "types", "baseExp",
"growthRate", "evolutions" }
local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category",
"priority", "highCrit", "fixedDamage", "multiHit",
+9 -12
View File
@@ -178,19 +178,16 @@ end
-- ------- subset negotiation
-- the species and moves this party actually references, so the exchange
-- stays small (six mons) instead of shipping the whole catalog
-- every record hash, not just this party's slice: the receiver filters
-- its OWN party against these (eligibleParty), so a message limited to the
-- sender's party read "not on the other game" for every species the two
-- parties did not happen to share, and a subset trade between different
-- games showed neither side (#511). The full catalog is ~300 short hash
-- strings -- still one message.
function Protocol.recordsMessage(data, party)
local species = Fingerprint.records(data, "pokemon")
local moves = Fingerprint.records(data, "moves")
local outSpecies, outMoves = {}, {}
for _, mon in ipairs(party or {}) do
if species[mon.species] then outSpecies[mon.species] = species[mon.species] end
for _, mv in ipairs(mon.moves or {}) do
if moves[mv.id] then outMoves[mv.id] = moves[mv.id] end
end
end
return { type = "records", pokemon = outSpecies, moves = outMoves }
return { type = "records",
pokemon = Fingerprint.records(data, "pokemon"),
moves = Fingerprint.records(data, "moves") }
end
-- a mon may cross the wire only if both peers rebuild it identically: the
+10 -3
View File
@@ -384,9 +384,16 @@ function Tournament:update(dt)
else
self.pendingBattleOpts.seed = msg.seed
end
local battle, why = self.isHost
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
-- Split rather than `cond and newHost() or newGuest()`: the and/or
-- idiom truncates a call to its first result, so the second return
-- (the specific reason) was always dropped and every failure showed
-- the generic fallback instead of "same mods on both games" etc.
local battle, why
if self.isHost then
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
else
battle, why = LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
end
if not battle then
self:exitWith(why or Strings("Link battle\ncan't start."))
return
+102 -12
View File
@@ -95,8 +95,15 @@ function LauncherMods.deriveList(manifests, options)
local byId, enabledSet = {}, {}
for _, m in ipairs(ordered) do
byId[m.id] = m
-- missing entry means enabled, matching the loader's default
if mods[m.id] ~= false then enabledSet[m.id] = true end
-- missing entry means enabled, matching the loader -- except experimental
-- mods, which stay off until the player opts in
if mods[m.id] == false then
-- stay off
elseif mods[m.id] == true then
enabledSet[m.id] = true
elseif not m.experimental then
enabledSet[m.id] = true
end
end
local out = {}
@@ -104,16 +111,19 @@ function LauncherMods.deriveList(manifests, options)
local enabled = enabledSet[m.id] == true
local status, detail = statusFor(byId, m.id, enabledSet, enabled)
local raw = m.raw or {}
local badge = tostring(raw.category or m.profile or "MOD"):upper()
if m.experimental then badge = "EXPERIMENTAL" end
out[#out + 1] = {
id = m.id,
name = m.name or m.id,
version = m.version,
-- category, then profile, then a generic fallback -- uppercased
badge = tostring(raw.category or m.profile or "MOD"):upper(),
badge = badge,
description = m.description or "",
enabled = enabled,
status = status,
statusDetail = detail,
github = m.github,
experimental = m.experimental == true,
}
end
return out
@@ -213,7 +223,9 @@ local function discover()
for _, name in ipairs(fs.getDirectoryItems("mods")) do
local path = "mods/" .. name
local info = fs.getInfo(path)
if info and info.type == "directory" then
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
-- setSymlinksEnabled(true); see the matching note in Loader:_discover.
if info and (info.type == "directory" or info.type == "symlink") then
local raw = fs.read(path .. "/manifest.json")
if raw then
local manifest = decodeManifest(raw, path)
@@ -231,8 +243,15 @@ end
-- options.mods enable-state the loader persists, so a toggle here is what the
-- game sees on its next boot.
function LauncherMods.list()
local options = SaveData.loadOptions()
return LauncherMods.deriveList(discover(), options)
local ok, result = pcall(function()
local options = SaveData.loadOptions()
return LauncherMods.deriveList(discover(), options)
end)
if not ok then
-- a single bad options/mod file must not blank the launcher
return {}
end
return result or {}
end
-- setEnabled(id, enabled): persist options.mods[id] in the exact shape
@@ -454,13 +473,22 @@ function LauncherMods.strays() return scanStrays(false) end
-- on the player's behalf is not this function's call to make.
function LauncherMods.adoptStrays() return scanStrays(true) end
-- installZip(source) -> true, id | nil, errString
-- installZip(source [, opts]) -> true, id | nil, errString
-- source is an external path or a love DroppedFile. The archive is validated
-- BEFORE anything is copied; every path unmounts and clears the staged temp
-- file, and a failed copy rolls its partial tree back. A dropped file outside
-- the save dir is staged into a save-dir temp first, because
-- love.filesystem.mount only reaches a save-directory-relative path.
function LauncherMods.installZip(source)
-- opts.replace = true uninstalls an existing same-id mod first (updates /
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
function LauncherMods.installZip(source, opts)
local ok, result, err = pcall(LauncherMods._installZipInner, source, opts)
if not ok then return nil, "import failed: " .. tostring(result) end
return result, err
end
function LauncherMods._installZipInner(source, opts)
opts = opts or {}
if not (love and love.filesystem) then
return nil, "mod install needs LOVE"
end
@@ -501,12 +529,24 @@ function LauncherMods.installZip(source)
cleanup()
return nil, "invalid mod manifest: " .. tostring(manifestErr)
end
if opts.expectId and manifest.id ~= opts.expectId then
cleanup()
return nil, ("zip is for '%s', expected '%s'")
:format(manifest.id, opts.expectId)
end
-- reject a duplicate before touching the mods tree
local dest = "mods/" .. manifest.id
if fs.getInfo(dest) then
cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed"
if not opts.replace then
cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed"
end
-- drop the old tree before copy; enable-flag is preserved (uninstall
-- would clear it, which would surprise an update)
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
removeTree(dest)
CacheFs.prefix = savedPrefix
end
-- CacheFs.prefix steers ROM-cache writes into a version subtree (blue/...);
@@ -529,6 +569,56 @@ function LauncherMods.installZip(source)
return true, manifest.id
end
-- Install (or replace) a mod from a GitHub release zip URL.
-- Returns true, version | nil, errString. Soft-fails: download / install /
-- cleanup errors never throw into the launcher UI.
function LauncherMods.installFromRelease(modId, release)
local ok, result, err = pcall(function()
if type(modId) ~= "string" or modId == "" then
return nil, "missing mod id"
end
if type(release) ~= "table" or not release.zip or not release.zip.url then
return nil, "release has no downloadable .zip"
end
local ModUpdate = require("src.mods.ModUpdate")
local tmpName = ("mod_update_%s_%s.zip"):format(
tostring(modId), tostring(release.version or os.time()))
local localPath, dlErr = ModUpdate.downloadZip(release.zip.url, tmpName)
if not localPath then return nil, dlErr end
local installed, res = LauncherMods.installZip(localPath, {
replace = true, expectId = modId,
})
pcall(love.filesystem.remove, localPath)
if not installed then return nil, res end
return true, release.version or res
end)
if not ok then return nil, "install failed: " .. tostring(result) end
return result, err
end
-- Install a mod listed in a community index (src/mods/ModIndex.lua).
-- The index only ever tells us WHERE the zip is; resolving that URL is
-- ModIndex's job and installing it is installFromRelease's, so this is the
-- seam between them and nothing about the archive is special-cased. expectId
-- comes from the listing, so a feed that points an entry at somebody else's
-- zip fails the manifest check instead of installing the wrong mod.
-- Returns true, version | nil, errString.
function LauncherMods.installFromIndex(entry)
local ok, result, err = pcall(function()
if type(entry) ~= "table" or type(entry.id) ~= "string" then
return nil, "index entry has no mod id"
end
local ModIndex = require("src.mods.ModIndex")
local release, why = ModIndex.releaseFor(entry)
if not release then
return nil, why or "this mod cannot be installed from the index"
end
return LauncherMods.installFromRelease(entry.id, release)
end)
if not ok then return nil, "install failed: " .. tostring(result) end
return result, err
end
-- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from wherever it was installed (the portable game folder
-- or the save directory, CacheFs decides -- #330) and clears options.mods[id]
+17 -1
View File
@@ -206,7 +206,11 @@ function Loader:_discover()
for _, name in ipairs(self.fs.getDirectoryItems(root)) do
local path = root .. "/" .. name
local info = self.fs.getInfo(path)
if info and info.type == "directory" then
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
-- setSymlinksEnabled(true) -- PhysFS never resolves the symlink's
-- own getInfo, only traversal into it. readManifest below still
-- correctly no-ops on a symlink that isn't a directory.
if info and (info.type == "directory" or info.type == "symlink") then
local manifest, err = readManifest(self.fs, path)
if manifest then
if self.mods[manifest.id] then
@@ -834,6 +838,18 @@ function Loader:load(data)
require("src.mods.Builtins").install(self.content, data)
self:_loadState()
self:_discover()
-- Experimental mods stay off until the player opts in: a missing
-- options.mods entry normally means enabled, but experimental flips that.
do
local options = SaveData.loadOptions(self.fs)
local modsOpt = options.mods or {}
for id, mod in pairs(self.mods) do
if not self.disabled[id] and modsOpt[id] == nil
and mod.manifest.experimental then
self.disabled[id] = true
end
end
end
for id, mod in pairs(self.mods) do
mod.enabled = not self.disabled[id]
mod.state = mod.enabled and "pending" or "disabled"
+25 -6
View File
@@ -324,6 +324,12 @@ function ManagerState:detailRows(m)
rows[#rows + 1] = { label = Strings("PERMISSIONS.."),
action = function() self:goTo("permissions") end }
end
if m.github then
rows[#rows + 1] = { inert = true, label = "GH " .. m.github }
end
if m.experimental then
rows[#rows + 1] = { inert = true, label = "EXPERIMENTAL" }
end
if m.error then
rows[#rows + 1] = { label = Strings("VIEW ERROR.."),
action = function() self:goTo("errors") end }
@@ -607,13 +613,26 @@ function ManagerState:beginToggle(m)
r = ManagerState.resolveToggle(self:manifestMap(), m.id, want,
self:enabledSet())
end
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
self:openBlocked(r)
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
self:openCascade(r, m, want)
else
self:commitToggle(r.apply)
local function proceed()
if #r.missing > 0 or #r.conflicts > 0 or #r.badVersion > 0 then
self:openBlocked(r)
elseif #r.alsoEnable > 0 or #r.alsoDisable > 0 then
self:openCascade(r, m, want)
else
self:commitToggle(r.apply)
end
end
-- Experimental mods ask once on enable; disable is silent.
if want and m.experimental then
self:openConfirm({
"EXPERIMENTAL MOD",
"THIS MOD IS MARKED",
"EXPERIMENTAL.",
"ENABLE ANYWAY?",
}, proceed)
return
end
proceed()
end
function ManagerState:commitToggle(apply)
+49 -2
View File
@@ -50,6 +50,42 @@ local function parseSpecs(list, field)
return specs
end
-- Optional GitHub repo for launcher auto-update / other-versions.
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
function Manifest.parseGithub(value)
if value == nil or value == "" then return nil end
assert(type(value) == "string", "github must be a string")
local trimmed = value:match("^%s*(.-)%s*$") or value
if trimmed == "" then return nil end
local owner, repo = trimmed:match(
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)/?$")
if not owner then
owner, repo = trimmed:match(
"^https?://github%.com/([%w%._%-]+)/([%w%._%-]+)%.git/?$")
end
if not owner then
owner, repo = trimmed:match("^([%w%._%-]+)/([%w%._%-]+)$")
end
assert(owner and repo and owner ~= "" and repo ~= "",
"github must be owner/repo or a github.com URL")
repo = repo:gsub("%.git$", "")
return owner .. "/" .. repo
end
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
local function mergeConflictLists(conflicts, incompatible)
local seen, out = {}, {}
for _, list in ipairs({ array(conflicts), array(incompatible) }) do
for _, entry in ipairs(list) do
if not seen[entry] then
seen[entry] = true
out[#out + 1] = entry
end
end
end
return out
end
function Manifest.validate(raw, path)
assert(type(raw) == "table", "manifest must be an object")
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
@@ -86,6 +122,12 @@ function Manifest.validate(raw, path)
assert(gameVersionOk, ("malformed game_version %q: %s")
:format(tostring(raw.game_version), tostring(gameVersionErr)))
local github = Manifest.parseGithub(raw.github)
assert(raw.experimental == nil or type(raw.experimental) == "boolean",
"experimental must be a boolean")
local experimental = raw.experimental == true
-- overhauls and total conversions are assumed to move the link
-- fingerprint unless the manifest says otherwise; content packs are not
local affectsLink = profile ~= "content"
@@ -97,6 +139,8 @@ function Manifest.validate(raw, path)
return value
end
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
return {
id = raw.id,
name = raw.name,
@@ -106,13 +150,16 @@ function Manifest.validate(raw, path)
priority = tonumber(raw.priority) or 0,
dependencies = array(raw.dependencies),
optional_dependencies = array(raw.optional_dependencies),
conflicts = array(raw.conflicts),
conflicts = conflicts,
incompatible = array(raw.incompatible),
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
conflictSpecs = parseSpecs(array(raw.conflicts), "conflicts"),
conflictSpecs = parseSpecs(conflicts, "conflicts"),
category = raw.category or "OTHER",
game_version = raw.game_version,
description = raw.description or "",
github = github,
experimental = experimental,
profile = profile,
affects_link = affectsLink,
permissions = permissions,
+623
View File
@@ -0,0 +1,623 @@
-- Community mod index consumer (the "Find mods" launcher tab).
--
-- An index is metadata only: a published index.json feed listing mods that
-- live in their authors' own repos. Nothing here clones or vendors the index
-- repo -- the feed is the whole contract, and every install still goes through
-- the same LauncherMods.installZip path an "Import mod .zip" does, so a listing
-- buys a mod no trust it would not otherwise have.
--
-- Shape of the split mirrors src/mods/ModUpdate.lua, which this borrows its
-- host I/O from: everything above "host I/O" is pure (no love, no filesystem,
-- no network) so the engine tier can table-drive it, and the fetch/cache half
-- reaches for curl and options.lua.
--
-- Sources are never added automatically. options.modIndexes is a player-built
-- list -- adding an index is a deliberate act of trusting whoever publishes it,
-- so the launcher ships with none and asks.
--
-- schema_version is a hard gate, not a hint: a bumped feed may reuse a field
-- name for something else, so an unknown version is refused outright rather
-- than parsed hopefully.
local ModIndex = {}
-- The feed is rebuilt on every push and refreshed nightly, so a day-old copy
-- is the worst a cached listing can be. ModUpdate's six hours is tuned for a
-- single repo's releases; a whole index is heavier and changes more slowly.
ModIndex.CACHE_TTL = 24 * 60 * 60
ModIndex.SCHEMA_VERSION = 1
-- ------- pure: source resolution
local function trim(s)
return (tostring(s):gsub("^%s+", ""):gsub("%s+$", ""))
end
-- Split "owner/repo" out of the several ways a player can name a GitHub repo.
local function githubSlug(url)
local owner, repo = url:match("^https?://github%.com/([%w%-%.]+)/([%w%-%.]+)")
if not owner then
owner, repo = url:match("^([%w%-%.]+)/([%w%-%.]+)$")
end
if not owner then return nil end
repo = repo:gsub("%.git$", "")
return owner, repo
end
-- resolveSource(input) -> { feed, base, fallback, label } | nil, err
--
-- Players paste whichever URL they happened to have, so all four shapes of the
-- same index resolve to one source: the Pages root, the feed file itself, the
-- GitHub repo page, or a bare "owner/repo". `base` is what relative thumbnail
-- and description_url paths resolve against, and it always keeps its trailing
-- slash so joinUrl can stay a concatenation.
--
-- The raw.githubusercontent fallback exists because Pages deploys lag a push
-- by a minute or two; it is only ever consulted when the feed fetch fails.
function ModIndex.resolveSource(input)
if type(input) ~= "string" then return nil, "missing index URL" end
local url = trim(input)
if url == "" then return nil, "missing index URL" end
local owner, repo = githubSlug(url)
if owner then
return {
feed = ("https://%s.github.io/%s/data/index.json"):format(owner, repo),
base = ("https://%s.github.io/%s/"):format(owner, repo),
fallback = ("https://raw.githubusercontent.com/%s/%s/main/site/data/index.json")
:format(owner, repo),
label = owner .. "/" .. repo,
}
end
if not url:match("^https?://") then
return nil, "index must be an http(s) URL or owner/repo"
end
-- A feed URL names the file; the Pages root is what is left once the
-- "data/index.json" tail comes off (any other .json keeps only its folder).
if url:match("%.json$") then
local base = url:match("^(.*/)data/index%.json$") or url:match("^(.*/)")
return { feed = url, base = base or url, label = ModIndex.labelFor(base or url) }
end
local base = url:match("/$") and url or (url .. "/")
return {
feed = base .. "data/index.json",
base = base,
label = ModIndex.labelFor(base),
}
end
-- A short human name for a source row: "owner/repo" for a Pages host, else the
-- host plus first path segment. Only ever cosmetic.
function ModIndex.labelFor(url)
url = tostring(url or "")
local owner, repo = url:match("^https?://([%w%-%.]+)%.github%.io/([%w%-%.]+)/")
if owner then return owner .. "/" .. repo end
local host, first = url:match("^https?://([^/]+)/([^/]*)")
if host and first and first ~= "" then return host .. "/" .. first end
return host or url
end
-- joinUrl(base, rel) -> absolute URL | nil
-- Relative feed paths resolve against the Pages root; an already-absolute one
-- is handed back untouched (the schema allows either), and anything else --
-- nil, "", a non-string -- is simply absent rather than an error, because a
-- missing thumbnail is not a broken index.
function ModIndex.joinUrl(base, rel)
if type(rel) ~= "string" or rel == "" then return nil end
if rel:match("^https?://") then return rel end
if type(base) ~= "string" or base == "" then return nil end
if not base:match("/$") then base = base .. "/" end
return base .. (rel:gsub("^/", ""))
end
-- ------- pure: feed parsing
local function str(v)
return type(v) == "string" and v or nil
end
local function strArray(v)
local out = {}
if type(v) == "table" then
for _, entry in ipairs(v) do
if type(entry) == "string" then out[#out + 1] = entry end
end
end
return out
end
-- Decode one release blob from the feed's `latest` field into the same shape
-- ModUpdate.parseRelease produces, so LauncherMods.installFromRelease takes it
-- without a translation layer.
local function parseLatest(raw)
if type(raw) ~= "table" then return nil end
local zip = nil
if type(raw.zip) == "table" and str(raw.zip.url) then
zip = { name = str(raw.zip.name), url = raw.zip.url,
size = tonumber(raw.zip.size) }
end
return {
version = str(raw.version),
tag = str(raw.tag),
name = str(raw.name),
prerelease = raw.prerelease == true,
published_at = str(raw.published_at),
zip = zip,
}
end
local function parseEntry(raw)
if type(raw) ~= "table" or not str(raw.id) then return nil end
return {
folder = str(raw.folder),
id = raw.id,
title = str(raw.title) or raw.id,
author = str(raw.author),
version = str(raw.version),
summary = str(raw.summary) or "",
categories = strArray(raw.categories),
tags = strArray(raw.tags),
license = str(raw.license),
repo = str(raw.repo),
github = str(raw.github),
downloadURL = str(raw.downloadURL),
api = tonumber(raw.api),
game_version = str(raw.game_version),
profile = str(raw.profile),
affects_link = raw.affects_link == true,
experimental = raw.experimental == true,
permissions = strArray(raw.permissions),
dependencies = raw.dependencies,
conflicts = raw.conflicts,
thumbnail = str(raw.thumbnail),
description_url = str(raw.description_url),
latest = parseLatest(raw.latest),
update_check = str(raw.update_check) or "pending",
}
end
-- parse(jsonText [, Json]) -> { schemaVersion, generatedAt, categories, mods }
-- | nil, err
-- Never throws: a truncated download, an HTML error page, or a feed from a
-- future schema all come back as a message the panel can print.
function ModIndex.parse(jsonText, Json)
local ok, result, err = pcall(function()
Json = Json or require("src.link.Json")
local doc, decodeErr = Json.decode(jsonText)
if type(doc) ~= "table" then
return nil, decodeErr or "index.json is not an object"
end
local schema = tonumber(doc.schema_version)
if schema == nil then
return nil, "index.json has no schema_version"
end
if schema ~= ModIndex.SCHEMA_VERSION then
return nil, ("index schema %d is not supported (this build reads %d)")
:format(schema, ModIndex.SCHEMA_VERSION)
end
if type(doc.mods) ~= "table" then
return nil, "index.json has no mods array"
end
local mods = {}
for _, raw in ipairs(doc.mods) do
local entry = parseEntry(raw)
if entry then mods[#mods + 1] = entry end
end
return {
schemaVersion = schema,
generatedAt = str(doc.generated_at),
categories = strArray(doc.categories),
mods = mods,
}
end)
if not ok then return nil, "could not read the index: " .. tostring(result) end
return result, err
end
-- ------- pure: install resolution
-- installUrl(entry) -> url, kind | nil, reason
--
-- The same order the engine's zip import already implies: a verified release
-- asset first, then the author's fixed downloadURL. A GitHub source-archive
-- URL is never invented -- codeload gives you the repo, not the built mod, and
-- the folder layout would be wrong even when the download succeeds.
function ModIndex.installUrl(entry)
if type(entry) ~= "table" then return nil, "no entry" end
if entry.update_check == "ok" and entry.latest and entry.latest.zip
and entry.latest.zip.url then
return entry.latest.zip.url, "release"
end
if entry.downloadURL and entry.downloadURL ~= "" then
return entry.downloadURL, "download"
end
if entry.update_check == "off" then
return nil, "the author does not publish installable releases"
end
if entry.update_check == "no installable release" then
return nil, "no release with a .zip asset yet"
end
if type(entry.update_check) == "string"
and entry.update_check:match("^error") then
return nil, entry.update_check
end
return nil, "nothing installable listed"
end
function ModIndex.canInstall(entry)
return ModIndex.installUrl(entry) ~= nil
end
-- The version to show on a card: the release the index resolved when it could
-- reach GitHub, else whatever meta.json declared.
function ModIndex.displayVersion(entry)
if type(entry) ~= "table" then return "?" end
if entry.update_check == "ok" and entry.latest and entry.latest.version then
return entry.latest.version
end
return entry.version or "?"
end
-- The release table LauncherMods.installFromRelease wants. A downloadURL
-- entry has no release behind it, so one is synthesised around the URL; the
-- installer still validates the manifest inside and still refuses a zip whose
-- id is not the one being installed.
function ModIndex.releaseFor(entry)
local url, kind = ModIndex.installUrl(entry)
if not url then return nil, kind end
if kind == "release" then return entry.latest end
return {
version = ModIndex.displayVersion(entry),
zip = { url = url, name = entry.id .. ".zip" },
}
end
-- ------- pure: compatibility
-- compatIssues(entry, ctx) -> array of { level, text }
--
-- Soft gate by design: an index entry is metadata an author wrote, possibly
-- months ago, and hiding a mod because a range looks wrong is how a working
-- mod becomes invisible. Everything here warns; the confirm dialog shows the
-- list and the player decides. ctx carries { modApi, engineVersion,
-- installed = { id -> version }, enabled = { id -> true } }.
function ModIndex.compatIssues(entry, ctx)
local out = {}
if type(entry) ~= "table" then return out end
ctx = ctx or {}
local function warn(text) out[#out + 1] = { level = "warn", text = text } end
local modApi = tonumber(ctx.modApi)
if entry.api and modApi and entry.api > modApi then
warn(("Needs mod API %d; this build provides %d")
:format(entry.api, modApi))
end
if entry.game_version and ctx.engineVersion then
local okSemver, Semver = pcall(require, "src.mods.Semver")
if okSemver and not Semver.satisfies(ctx.engineVersion, entry.game_version) then
warn(("Needs engine %s (have %s)")
:format(entry.game_version, ctx.engineVersion))
end
end
if entry.profile and entry.profile ~= "content" then
warn(("Profile '%s' changes engine behaviour beyond content")
:format(entry.profile))
end
if entry.affects_link then
warn("Changes link play; both sides need the same mods")
end
if entry.experimental then
warn("Marked experimental by its author")
end
for _, name in ipairs(entry.permissions or {}) do
warn("Requests permission: " .. name)
end
-- dependencies / conflicts arrive as the manifest's own vocabulary: either
-- an array of "id" / "id@<range>" strings or an id -> range map.
local installed = ctx.installed or {}
local function eachSpec(spec, fn)
if type(spec) ~= "table" then return end
for k, v in pairs(spec) do
if type(k) == "number" and type(v) == "string" then
local id, range = v:match("^([^@]+)@(.+)$")
fn(id or v, range)
elseif type(k) == "string" then
fn(k, type(v) == "string" and v or nil)
end
end
end
eachSpec(entry.dependencies, function(id, range)
if installed[id] == nil then
warn("Needs " .. id .. (range and (" " .. range) or "") .. " (not installed)")
end
end)
eachSpec(entry.conflicts, function(id)
if installed[id] ~= nil then
warn("Conflicts with installed " .. id)
end
end)
return out
end
-- ------- pure: search / filter
local function haystack(entry)
return (tostring(entry.title or "") .. " " .. tostring(entry.author or "")
.. " " .. tostring(entry.summary or "") .. " " .. tostring(entry.id or ""))
:lower()
end
-- matches(entry, query) -> bool. Every whitespace-separated term must appear
-- somewhere in title / author / summary / id, so typing more narrows.
function ModIndex.matches(entry, query)
if type(query) ~= "string" or trim(query) == "" then return true end
local hay = haystack(entry)
for term in trim(query):lower():gmatch("%S+") do
if not hay:find(term, 1, true) then return false end
end
return true
end
-- filter(mods, opts) -> a new array. opts = { query, category, tag }.
-- Category and tag compare case-insensitively; feed order (already sorted by
-- title) is preserved.
function ModIndex.filter(mods, opts)
opts = opts or {}
local want = opts.category and tostring(opts.category):lower() or nil
local wantTag = opts.tag and tostring(opts.tag):lower() or nil
local out = {}
for _, entry in ipairs(mods or {}) do
local keep = ModIndex.matches(entry, opts.query)
if keep and want then
keep = false
for _, c in ipairs(entry.categories or {}) do
if tostring(c):lower() == want then keep = true; break end
end
end
if keep and wantTag then
keep = false
for _, t in ipairs(entry.tags or {}) do
if tostring(t):lower() == wantTag then keep = true; break end
end
end
if keep then out[#out + 1] = entry end
end
return out
end
-- Every category actually used by a feed, in the feed's declared order, with
-- anything an entry names that the header forgot appended. Drives the filter
-- row without hard-coding the vocabulary.
function ModIndex.categoriesIn(index)
local out, seen = {}, {}
if type(index) ~= "table" then return out end
local used = {}
for _, entry in ipairs(index.mods or {}) do
for _, c in ipairs(entry.categories or {}) do used[c] = true end
end
for _, c in ipairs(index.categories or {}) do
if used[c] and not seen[c] then seen[c] = true; out[#out + 1] = c end
end
for _, entry in ipairs(index.mods or {}) do
for _, c in ipairs(entry.categories or {}) do
if not seen[c] then seen[c] = true; out[#out + 1] = c end
end
end
return out
end
-- ------- sources (options.modIndexes)
local function loadOptions()
return require("src.core.SaveData").loadOptions()
end
-- The player's index list, normalised. Rows are { url, feed, base, fallback,
-- label }; `url` is what they typed, kept so the row reads back the way they
-- entered it.
function ModIndex.sources()
local ok, opts = pcall(loadOptions)
if not ok or type(opts) ~= "table" then return {} end
local out = {}
for _, row in ipairs(opts.modIndexes or {}) do
if type(row) == "table" and type(row.feed) == "string" then
out[#out + 1] = row
end
end
return out
end
-- addSource(input) -> row | nil, err. Idempotent on the resolved feed URL, so
-- pasting the repo page and the Pages root in either order adds one source.
function ModIndex.addSource(input)
local source, err = ModIndex.resolveSource(input)
if not source then return nil, err end
local ok, result, addErr = pcall(function()
local SaveData = require("src.core.SaveData")
local opts = loadOptions()
opts.modIndexes = opts.modIndexes or {}
for _, row in ipairs(opts.modIndexes) do
if row.feed == source.feed then
return nil, "that index is already added"
end
end
source.url = trim(input)
opts.modIndexes[#opts.modIndexes + 1] = source
SaveData.saveOptions(opts)
return source
end)
if not ok then return nil, "could not save the index: " .. tostring(result) end
return result, addErr
end
-- removeSource(feed) -> true | nil, err. Drops the cached listing with it:
-- keeping a feed's mods around after its source is gone is how a stale card
-- outlives the index it came from.
function ModIndex.removeSource(feed)
if type(feed) ~= "string" or feed == "" then return nil, "missing index" end
local ok, result = pcall(function()
local SaveData = require("src.core.SaveData")
local opts = loadOptions()
local kept, found = {}, false
for _, row in ipairs(opts.modIndexes or {}) do
if row.feed == feed then found = true else kept[#kept + 1] = row end
end
if not found then return nil end
opts.modIndexes = kept
if type(opts.modIndexCache) == "table" then opts.modIndexCache[feed] = nil end
SaveData.saveOptions(opts)
return true
end)
if not ok then return nil, tostring(result) end
if not result then return nil, "that index is not in the list" end
return true
end
-- ------- cache (options.modIndexCache[feed])
function ModIndex.readCache(feed)
if type(feed) ~= "string" or feed == "" then return nil end
local ok, opts = pcall(loadOptions)
if not ok or type(opts) ~= "table" then return nil end
local entry = opts.modIndexCache and opts.modIndexCache[feed]
if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then
return nil
end
if type(entry.mods) ~= "table" then return nil end
return entry
end
function ModIndex.cacheFresh(entry, now, ttl)
now = now or os.time()
ttl = ttl or ModIndex.CACHE_TTL
return entry ~= nil and type(entry.checkedAt) == "number"
and (now - entry.checkedAt) < ttl
end
function ModIndex.writeCache(feed, index)
if type(feed) ~= "string" or feed == "" then return false end
local ok = pcall(function()
local SaveData = require("src.core.SaveData")
local opts = loadOptions()
opts.modIndexCache = opts.modIndexCache or {}
opts.modIndexCache[feed] = {
checkedAt = os.time(),
generatedAt = index.generatedAt,
categories = index.categories,
mods = index.mods,
}
SaveData.saveOptions(opts)
end)
return ok
end
-- ------- host I/O (curl, via ModUpdate's shell plumbing)
local function shq(s)
s = tostring(s)
if love and love.system and love.system.getOS
and love.system.getOS() == "Windows" then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
-- Plain GET returning the body. No GitHub Accept header: the feed and the
-- description markdown are static files on Pages, and the public feed is
-- explicitly unauthenticated.
function ModIndex.httpGet(url)
local ModUpdate = require("src.mods.ModUpdate")
if not ModUpdate.haveCurl() then
return nil, "curl is not available on this platform"
end
local HostShell = require("src.core.HostShell")
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
.. "-H " .. shq("User-Agent: gen1recomp-mod-index") .. " "
.. shq(url)
local pipeOk, pipe = pcall(HostShell.popen, cmd)
if not pipeOk or not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
if not readOk then return nil, "fetch failed: " .. tostring(out) end
if not out or out == "" then return nil, "empty response from " .. url end
return out
end
-- fetch(source, opts) -> index, err, meta
-- index is the parse() table; meta is { fromCache, stale }. opts.force skips
-- the 24h cache. A failed live fetch falls back to whatever is cached, marked
-- stale, so going offline degrades the listing rather than emptying it.
function ModIndex.fetch(source, opts)
opts = opts or {}
if type(source) ~= "table" or type(source.feed) ~= "string" then
return nil, "missing index source"
end
local feed = source.feed
local function cached(stale)
local entry = ModIndex.readCache(feed)
if not entry then return nil end
return {
schemaVersion = ModIndex.SCHEMA_VERSION,
generatedAt = entry.generatedAt,
categories = entry.categories or {},
mods = entry.mods or {},
}, nil, { fromCache = true, stale = stale, checkedAt = entry.checkedAt }
end
if not opts.force then
local entry = ModIndex.readCache(feed)
if ModIndex.cacheFresh(entry) then return cached(false) end
end
local body, err = ModIndex.httpGet(feed)
-- Pages deploys trail a push; the raw mirror is the same file, so a feed
-- that 404s right after a release is worth one retry elsewhere before it
-- counts as an outage.
if not body and source.fallback then
body = ModIndex.httpGet(source.fallback)
end
if not body then
local index, _, meta = cached(true)
if index then return index, nil, meta end
return nil, err
end
local index, parseErr = ModIndex.parse(body)
if not index then
local stale, _, meta = cached(true)
if stale then return stale, parseErr, meta end
return nil, parseErr
end
ModIndex.writeCache(feed, index)
return index, nil, { fromCache = false, checkedAt = os.time() }
end
-- Fetch a description_url / any index-relative text file. Returns the raw
-- markdown; callers run it through ModUpdate.cleanBody for display.
function ModIndex.fetchText(url)
if type(url) ~= "string" or url == "" then return nil, "no description" end
return ModIndex.httpGet(url)
end
-- Download a thumbnail into the save directory and return the love.filesystem
-- relative path. Reuses ModUpdate.downloadZip, which is a plain curl -o with
-- a non-empty-file check -- nothing in it is zip-specific.
function ModIndex.downloadThumbnail(url, modId)
if type(url) ~= "string" or url == "" then return nil, "no thumbnail" end
local ModUpdate = require("src.mods.ModUpdate")
local ext = url:match("%.(%a%a%a?%a?)$") or "png"
local name = ("mod_thumb_%s.%s"):format(tostring(modId):gsub("[^%w%-_]", "_"), ext)
return ModUpdate.downloadZip(url, name)
end
return ModIndex
+361
View File
@@ -0,0 +1,361 @@
-- GitHub release helpers for mod auto-update / other-versions.
-- Pure parsing is love-free; fetch/download use HostShell + curl when available.
-- Release lists are cached in options.modUpdateCache for CACHE_TTL seconds
-- (default 6 hours). The launcher owns UI and install.
local ModUpdate = {}
ModUpdate.CACHE_TTL = 6 * 60 * 60 -- six hours
local function stripV(tag)
return (tostring(tag):gsub("^[vV]", ""))
end
-- Prefer "<id>-<version>.zip", then any "<id>*.zip", then the first .zip.
function ModUpdate.pickZipAsset(assets, modId, version)
if type(assets) ~= "table" then return nil end
local prefer = nil
if modId and version then
prefer = tostring(modId) .. "-" .. tostring(version) .. ".zip"
end
local idPrefix, idPrefixZip, anyZip = nil, nil, nil
if modId then idPrefix = tostring(modId):lower() end
for _, a in ipairs(assets) do
if type(a) == "table" and type(a.name) == "string" then
local name = a.name
if name:lower():match("%.zip$") then
local row = {
name = name,
url = a.browser_download_url,
size = tonumber(a.size),
}
if prefer and name == prefer then return row end
if idPrefix and not idPrefixZip
and name:lower():find(idPrefix, 1, true) == 1 then
idPrefixZip = row
end
if not anyZip then anyZip = row end
end
end
end
return idPrefixZip or anyZip
end
-- Byte-length cut that never lands mid UTF-8 codepoint.
local function utf8Cut(s, maxBytes)
if type(s) ~= "string" or maxBytes <= 0 then return "" end
if #s <= maxBytes then return s end
s = s:sub(1, maxBytes)
-- Drop trailing continuation bytes (10xxxxxx).
while #s > 0 do
local b = s:byte(#s)
if b < 0x80 or b >= 0xC0 then break end
s = s:sub(1, #s - 1)
end
-- Drop a lead byte whose continuation was truncated away.
if #s > 0 then
local b = s:byte(#s)
if b >= 0xC0 then
s = s:sub(1, #s - 1)
end
end
return s
end
-- Strip common markdown / HTML noise for the launcher changelog preview.
function ModUpdate.cleanBody(text, maxChars)
if type(text) ~= "string" or text == "" then return "" end
local s = text
s = s:gsub("\r\n", "\n"):gsub("\r", "\n")
s = s:gsub("<!%-%-.-%-%->", "")
s = s:gsub("<[^>]+>", "")
s = s:gsub("%[%!%[[^%]]*%]%([^%)]*%)%]", "") -- images ![alt](url)
s = s:gsub("%[([^%]]+)%]%([^%)]+%)", "%1") -- links [text](url) -> text
s = s:gsub("```[^\n]*\n(.-)```", "%1")
s = s:gsub("`([^`]+)`", "%1")
s = s:gsub("^#+%s*", "", 1)
s = s:gsub("\n#+%s*", "\n")
s = s:gsub("%*%*([^*]+)%*%*", "%1")
s = s:gsub("%*([^*]+)%*", "%1")
s = s:gsub("__([^_]+)__", "%1")
s = s:gsub("_([^_]+)_", "%1")
s = s:gsub("^\n+", ""):gsub("\n+$", "")
s = s:gsub("\n\n\n+", "\n\n")
maxChars = tonumber(maxChars) or 0
if maxChars > 0 and #s > maxChars then
-- ASCII "..." (not U+2026) so later pixel ellipsize stays byte-safe.
s = utf8Cut(s, maxChars):gsub("%s+%S*$", "") .. "..."
end
return s
end
-- One-line preview for tight UI rows: cleaned, newlines collapsed, ellipsized.
function ModUpdate.previewLine(text, maxChars)
local s = ModUpdate.cleanBody(text, 0)
if s == "" then return "" end
s = s:gsub("\n+", " "):gsub("%s+", " ")
s = s:match("^%s*(.-)%s*$") or s
maxChars = tonumber(maxChars) or 80
if #s > maxChars then
s = utf8Cut(s, maxChars):gsub("%s+%S*$", "") .. "..."
end
return s
end
-- Decode one GitHub release object into { version, tag, zip, prerelease, body }.
function ModUpdate.parseRelease(doc, modId)
if type(doc) ~= "table" or not doc.tag_name then
return nil, "no tag_name in release"
end
local version = stripV(doc.tag_name)
if not version:match("^%d+%.%d+%.%d+") then
return nil, "release tag is not semver-like: " .. tostring(doc.tag_name)
end
local triple = version:match("^(%d+%.%d+%.%d+)")
local zip = ModUpdate.pickZipAsset(doc.assets, modId, triple)
local body = type(doc.body) == "string" and doc.body or ""
return {
version = triple,
tag = tostring(doc.tag_name),
zip = zip,
prerelease = doc.prerelease == true,
name = type(doc.name) == "string" and doc.name or triple,
body = body,
}
end
-- Decode a releases array (GET /repos/.../releases) into a sorted list
-- (newest first). Releases without a .zip asset are dropped. Never throws.
function ModUpdate.parseReleases(jsonText, modId, Json)
local ok, result, err = pcall(function()
Json = Json or require("src.link.Json")
local doc, decodeErr = Json.decode(jsonText)
if type(doc) ~= "table" then
return nil, decodeErr or "releases json is not an array"
end
if type(doc.message) == "string" and not doc.tag_name and not doc[1] then
return nil, "GitHub: " .. doc.message
end
if doc.tag_name then
local one, oneErr = ModUpdate.parseRelease(doc, modId)
if not one then return nil, oneErr end
if not one.zip then return nil, "latest release has no .zip asset" end
return { one }
end
local out = {}
for _, entry in ipairs(doc) do
local rel = ModUpdate.parseRelease(entry, modId)
if rel and rel.zip then out[#out + 1] = rel end
end
return out
end)
if not ok then return nil, "could not parse releases: " .. tostring(result) end
return result, err
end
function ModUpdate.apiReleasesUrl(repo)
return "https://api.github.com/repos/" .. repo .. "/releases?per_page=30"
end
function ModUpdate.apiLatestUrl(repo)
return "https://api.github.com/repos/" .. repo .. "/releases/latest"
end
function ModUpdate.isNewer(installed, candidate)
local Semver = require("src.mods.Semver")
if type(installed) ~= "string" or type(candidate) ~= "string" then
return false
end
local a, b = Semver.parse(installed), Semver.parse(candidate)
if not a or not b then return false end
return Semver.compare(candidate, installed) > 0
end
-- Newest non-prerelease, else newest overall.
function ModUpdate.pickBest(releases)
if type(releases) ~= "table" or #releases == 0 then return nil end
for _, rel in ipairs(releases) do
if not rel.prerelease then return rel end
end
return releases[1]
end
-- ------- cache (options.modUpdateCache[repo])
local function cacheStore()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
opts.modUpdateCache = opts.modUpdateCache or {}
return opts
end
function ModUpdate.readCache(repo)
if type(repo) ~= "string" or repo == "" then return nil end
local ok, opts = pcall(function()
return require("src.core.SaveData").loadOptions()
end)
if not ok or type(opts) ~= "table" then return nil end
local entry = opts.modUpdateCache and opts.modUpdateCache[repo]
if type(entry) ~= "table" or type(entry.checkedAt) ~= "number" then
return nil
end
if type(entry.releases) ~= "table" then return nil end
return entry
end
function ModUpdate.cacheFresh(entry, now, ttl)
now = now or os.time()
ttl = ttl or ModUpdate.CACHE_TTL
return entry and type(entry.checkedAt) == "number"
and (now - entry.checkedAt) < ttl
end
function ModUpdate.writeCache(repo, releases)
if type(repo) ~= "string" or repo == "" then return false end
local ok = pcall(function()
local SaveData = require("src.core.SaveData")
local opts = cacheStore()
local best = ModUpdate.pickBest(releases)
-- Persist a lean copy: enough to paint the UI and reinstall without
-- re-fetching within the TTL.
local lean = {}
for i, rel in ipairs(releases or {}) do
lean[i] = {
version = rel.version,
tag = rel.tag,
name = rel.name,
prerelease = rel.prerelease == true,
body = type(rel.body) == "string" and rel.body or "",
zip = rel.zip and {
name = rel.zip.name,
url = rel.zip.url,
size = rel.zip.size,
} or nil,
}
end
opts.modUpdateCache[repo] = {
checkedAt = os.time(),
latest = best and best.version or nil,
releases = lean,
}
SaveData.saveOptions(opts)
end)
return ok
end
-- Status vs an installed version using a cache entry / release list.
-- Returns "available" | "current" | "unknown".
function ModUpdate.statusFor(installedVersion, releases)
local best = ModUpdate.pickBest(releases)
if not best then return "unknown", nil end
if ModUpdate.isNewer(installedVersion, best.version) then
return "available", best
end
return "current", best
end
-- ------- host I/O (curl)
local function shq(s)
s = tostring(s)
if love and love.system and love.system.getOS
and love.system.getOS() == "Windows" then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
local function curlCapture(url)
local HostShell = require("src.core.HostShell")
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
.. "-H " .. shq("User-Agent: gen1recomp-mod-updater") .. " "
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
.. shq(url)
local pipeOk, pipe = pcall(HostShell.popen, cmd)
if not pipeOk or not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
if not readOk then return nil, "curl read failed: " .. tostring(out) end
if not out or out == "" then return nil, "empty response from GitHub" end
return out
end
function ModUpdate.haveCurl()
local HostShell = require("src.core.HostShell")
local pipeOk, pipe = pcall(HostShell.popen, "curl --version")
if not pipeOk or not pipe then return false end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
end
-- Fetch release list. opts.force bypasses the 6h cache.
-- Returns releases, err, meta where meta = { fromCache = bool }.
function ModUpdate.fetchReleases(repo, modId, opts)
opts = opts or {}
if type(repo) ~= "string" or repo == "" then
return nil, "missing github repo"
end
if not opts.force then
local cached = ModUpdate.readCache(repo)
if ModUpdate.cacheFresh(cached) then
return cached.releases, nil, { fromCache = true }
end
end
if not ModUpdate.haveCurl() then
-- Stale cache is better than nothing when offline
local cached = ModUpdate.readCache(repo)
if cached and cached.releases then
return cached.releases, nil, { fromCache = true, stale = true }
end
return nil, "curl is not available on this platform"
end
local body, curlErr = curlCapture(ModUpdate.apiReleasesUrl(repo))
if not body then
local cached = ModUpdate.readCache(repo)
if cached and cached.releases then
return cached.releases, nil, { fromCache = true, stale = true }
end
return nil, curlErr
end
local list, parseErr = ModUpdate.parseReleases(body, modId)
if not list then return nil, parseErr end
ModUpdate.writeCache(repo, list)
return list, nil, { fromCache = false }
end
function ModUpdate.downloadZip(url, destName)
if type(url) ~= "string" or url == "" then
return nil, "missing download url"
end
if not (love and love.filesystem) then
return nil, "download needs LOVE"
end
if not ModUpdate.haveCurl() then
return nil, "curl is not available on this platform"
end
local HostShell = require("src.core.HostShell")
local name = destName or ("mod_update_" .. tostring(os.time()) .. ".zip")
name = tostring(name):gsub("[/\\]", "_")
local saveOk, saveDir = pcall(function()
return love.filesystem.getSaveDirectory()
end)
if not saveOk or not saveDir or saveDir == "" then
return nil, "no save directory"
end
local abs = saveDir .. "/" .. name
local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 -o "
.. shq(abs) .. " " .. shq(url)
local pipeOk, pipe = pcall(HostShell.popen, cmd)
if not pipeOk or not pipe then return nil, "could not start download" end
pcall(function() pipe:read("*a") end)
pcall(function() pipe:close() end)
local infoOk, info = pcall(love.filesystem.getInfo, name)
if not infoOk or not info or (info.size or 0) == 0 then
pcall(love.filesystem.remove, name)
return nil, "download failed"
end
return name
end
return ModUpdate
+6 -4
View File
@@ -1186,11 +1186,13 @@ R.field = {
nest = f.opt(f.any) },
flyOrder = f.list(f.str),
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
-- back pic, the catch tutorial's old man, and the front pic the intro,
-- trainer card and Hall of Fame share. Every key is optional so a
-- conversion can replace one pic and inherit the rest.
-- back pic, the catch tutorial's old man, Yellow's PROF.OAK variant of
-- it (#557), and the front pic the intro, trainer card and Hall of Fame
-- share. Every key is optional so a conversion can replace one pic and
-- inherit the rest.
playerPics = f.rec{
back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) },
back = f.opt(f.str), demoBack = f.opt(f.str),
oakBack = f.opt(f.str), front = f.opt(f.str) },
-- the new-game and boot config a total conversion replaces
boot = f.rec{
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
+13 -1
View File
@@ -46,20 +46,32 @@ end
-- opts.kind: "battle" | "intro" | "trainer_card" | "hof"
-- opts.demo: the catch tutorial, where the old man fights in the player's
-- place and stands in for the back pic
-- opts.oakDemo: the Yellow variant of that demo (BATTLE_TYPE_PIKACHU), where
-- PROF.OAK fights in the player's place behind his own back pic
-- opts.battle: the live battle, for kind == "battle"
-- Returns path, trueColor.
function Sprites.playerPath(data, side, opts)
opts = opts or {}
side = side == "back" and "back" or "front"
-- one key per pic, so a conversion can replace the back and inherit the
-- rest; fieldValue folds data.field over FieldDefaults per key
-- rest; fieldValue folds data.field over FieldDefaults per key. The two
-- demo keys mirror LoadPlayerBackPic's wBattleType branch (#557).
local key = side == "front" and "front"
or (opts.oakDemo and "oakBack")
or (opts.demo and "demoBack" or "back")
local path = FieldDefaults.fieldValue(data, "playerPics", key)
-- ProfOakPicBack is a Yellow-only rip, so a cache imported before it
-- existed has no file there; fall back to the old man rather than hand a
-- missing path to getImage (#557)
if key == "oakBack" and path
and not require("src.render.Assets").exists(path) then
path = FieldDefaults.fieldValue(data, "playerPics", "demoBack")
end
local ctx = {
side = side,
kind = opts.kind or "battle",
demo = opts.demo and true or false,
oakDemo = opts.oakDemo and true or false,
battle = opts.battle,
trueColor = false,
data = data,
+97 -39
View File
@@ -5,11 +5,13 @@
-- then drawn once per zone through a shader that remaps the four DMG
-- shades to that zone's palette.
--
-- Port display option: COLORS (OG RED / SGB / ADVANCED / OG / OG INV /
-- SGB INV / CLASSIC) transforms every zone's palette at send time via
-- effectiveColors. ADVANCED (the `redpp` id below) swaps the named-palette
-- pack for pokered-gbc SuperPalettes (data/palettes_gbc.lua), including
-- per-species mon colors.
-- Port display option: COLORS (OG RED / OG BLUE / OG YELLOW / SGB /
-- ADVANCED / OG / OG INV / SGB INV / CLASSIC) transforms every zone's
-- palette at send time via effectiveColors. ADVANCED (the `redpp` id
-- below) swaps the named-palette pack for pokered-gbc SuperPalettes
-- (data/palettes_gbc.lua), including per-species mon colors. OG YELLOW
-- (Yellow playthrough + `ogred` id) uses pokeyellow CGBBasePalettes
-- (data/palettes_yellow.lua / ROM cgbBase).
local GameVersion = require("src.core.GameVersion")
@@ -17,10 +19,11 @@ local PaletteFX = {}
local shader -- false = unavailable (headless / no shader support)
local gbcPack -- false = missing; nil = not loaded yet
local yellowPack -- false = missing; nil = not loaded yet
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
-- come first (OG RED = GBC hardware, SGB = per-map Super Game Boy, ADVANCED =
-- pokered-gbc per-tile), then the DMG-shade novelty modes.
-- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy,
-- ADVANCED = pokered-gbc per-tile), then the DMG-shade novelty modes.
PaletteFX.MODES = { "ogred", "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
-- `gbc`/`gbc_inv`/`redpp` keep their save-value ids for back-compat while
-- their LABELS say what the mode actually is: "SGB"/"SGB INV" because the old
@@ -117,14 +120,19 @@ PaletteFX.GBC_OBJ_BLUE = {
}
-- The active game's OG boot-ROM background palette: blue for a Blue
-- playthrough, red otherwise. White (index 1) and black (index 4) are
-- identical across versions, so callers that only touch the endpoints
-- (e.g. BattleState's zone white/black snap) need no version branch.
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes) and has no extracted
-- boot-ROM auto-palette here; keep the Red ramp -- never Blue's GBC_BG_BLUE.
-- playthrough, red for Red. White (index 1) and black (index 4) are
-- identical across Red/Blue, so callers that only touch the endpoints
-- (e.g. BattleState's zone white/black snap) need no version branch there.
-- Yellow is CGB-enhanced (pokeyellow CGBBasePalettes): named zones go through
-- pal() / usesYellowCgb(), and ogBg() falls back to CGBBase PAL_ROUTE for any
-- remaining whole-screen callers -- never Blue's GBC_BG_BLUE.
function PaletteFX.ogBg()
if GameVersion.isBlue() then return PaletteFX.GBC_BG_BLUE end
if GameVersion.isYellow() then return PaletteFX.GBC_BG end
if GameVersion.isYellow() then
local y = PaletteFX.yellowPack()
local route = y and y.cgbBase and y.cgbBase.ROUTE
if route then return route end
end
return PaletteFX.GBC_BG
end
@@ -133,14 +141,12 @@ end
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
-- its baked-image cache by (image path, group): a shared group would collide a
-- Red bake with a Blue one and one version would show the other's colors.
-- Yellow: same Red OBJ green as above until Yellow-specific tables land.
-- Yellow OG does not bake a boot-ROM OBJ (usesSpriteObp is false); this path
-- is unused there and kept as Red green only as a safe leftover.
function PaletteFX.ogObj()
if GameVersion.isBlue() then
return PaletteFX.darkObp(PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue")
end
if GameVersion.isYellow() then
return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")
end
return PaletteFX.darkObp(PaletteFX.GBC_OBJ, "gbcobj")
end
@@ -271,13 +277,31 @@ function PaletteFX.gbcPack()
return gbcPack or nil
end
-- pokeyellow SuperPalettes + CGBBasePalettes (committed; not wiped by import).
-- ROM import may also stamp palettes.cgbBase; yellowCgbNamedPal prefers that.
function PaletteFX.yellowPack()
if yellowPack == nil then
local ok, pack = pcall(require, "data.palettes_yellow")
yellowPack = ok and pack or false
end
return yellowPack or nil
end
function PaletteFX.usesGbcPack(mode)
mode = mode or PaletteFX.mode
return mode == "redpp"
end
-- Yellow's authentic GBC look is CGBBasePalettes (per-map), not a boot-ROM
-- auto-palette. The shared `ogred` save id wears that table on a Yellow
-- playthrough and labels itself "OG YELLOW".
function PaletteFX.usesYellowCgb(mode)
mode = mode or PaletteFX.mode
return GameVersion.isYellow() and mode == "ogred"
end
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
-- (the OBP bake + post-zone redraw path). OG RED alone does: the Game Boy
-- (the OBP bake + post-zone redraw path). OG RED / OG BLUE do: the Game Boy
-- Color boot ROM hands the game one global object palette (PaletteFX.ogObj --
-- green over Red's red background, pink over Blue's blue background), so on
-- that machine the player and NPCs carry a fixed object color instead of
@@ -288,6 +312,10 @@ end
-- regression. Terrain is unaffected -- pal() below still hands SGB its per-map
-- BG palette; only OG RED short-circuits BG to the one global red palette.
--
-- OG YELLOW does NOT. Yellow ships CGB code and colors regions via
-- CGBBasePalettes the same way SGB mode colors SuperPalettes, so sprites tint
-- with the map zone (usesSpriteObp stays off).
--
-- SGB does NOT. The Super Game Boy colorizes the composited DMG picture it is
-- handed and cannot tell an OBJ pixel from a BG one; pokered never sends the
-- OBJ_TRN packet that would enable SGB sprite mode (data/sgb/sgb_packets.asm
@@ -306,7 +334,7 @@ end
-- usesGbcPack() path in SpriteRenderer instead.
function PaletteFX.usesSpriteObp(mode)
mode = mode or PaletteFX.mode
return mode == "ogred"
return mode == "ogred" and not GameVersion.isYellow()
end
-- ------- post-zone sprite redraw (OG RED)
@@ -363,8 +391,8 @@ end
-- read these from the ROM-imported table or the title ribbon stays red
-- and the Game Corner reels keep Red's pink (issue #128).
-- Yellow is intentionally NOT in BLUE_VERSIONED: skip Blue LOGO1/SLOTS*
-- recolors. When CGBBasePalettes were imported (palettes.cgbBase), Yellow
-- prefers those over SGB SuperPalettes for named zones.
-- recolors. OG YELLOW (usesYellowCgb) reads CGBBasePalettes; SGB mode on
-- Yellow keeps SuperPalettes.
local BLUE_VERSIONED = {
LOGO1 = true, SLOTS2 = true, SLOTS3 = true, SLOTS4 = true,
}
@@ -376,31 +404,42 @@ end
local function yellowCgbNamedPal(data, name)
local p = data and data.palettes
return p and p.cgbBase and p.cgbBase[name]
local fromRom = p and p.cgbBase and p.cgbBase[name]
if fromRom then return fromRom end
local y = PaletteFX.yellowPack()
return y and y.cgbBase and y.cgbBase[name] or nil
end
-- named palette from the active pack (nil on stale builds / missing name).
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
-- OG RED short-circuits EVERY name to the one global GBC boot-ROM BG palette
-- (the hardware had a single BGP for the whole game), so terrain zones,
-- battle HP bars / text, and menu boxes all come out red -- everything a
-- background tile drew. Objects do not come through here (they bake
-- GBC_OBJ green), so this stays a BG-only hook.
-- OG RED / OG BLUE short-circuit EVERY name to the one global GBC boot-ROM
-- BG palette (the hardware had a single BGP for the whole game), so terrain
-- zones, battle HP bars / text, and menu boxes all come out red/blue --
-- everything a background tile drew. Objects do not come through here
-- (they bake GBC_OBJ), so this stays a BG-only hook.
-- OG YELLOW instead resolves each name through CGBBasePalettes.
function PaletteFX.pal(data, name)
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
if PaletteFX.mode == "ogred" and not GameVersion.isYellow() then
return PaletteFX.ogBg()
end
-- Blue-only ROM override for versioned SuperPals. Yellow (isYellow) and
-- Red keep the active pack / Red-like path -- do not apply Blue recolors.
if GameVersion.isBlue() and BLUE_VERSIONED[name] then
local fromRom = romNamedPal(data, name)
if fromRom then return fromRom end
end
if GameVersion.isYellow() then
if PaletteFX.usesYellowCgb() then
local fromCgb = yellowCgbNamedPal(data, name)
if fromCgb then return fromCgb end
end
local p = PaletteFX.pack(data)
local c = p and p.palettes[name]
if c then return c end
if GameVersion.isYellow() then
local y = PaletteFX.yellowPack()
local yc = y and y.palettes and y.palettes[name]
if yc then return yc end
end
if PaletteFX.usesGbcPack() then
return romNamedPal(data, name)
end
@@ -413,14 +452,20 @@ end
-- Transformed mon's pic is tinted gray, not the copied species' own
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
function PaletteFX.monPal(data, species, transformed)
-- OG RED: a battle mon pic is a BG tile on the Game Boy Color (drawn into
-- the tilemap, colored by BGP), so it wears the global red BG palette, not
-- a per-species one -- matching the hardware capture where both mons are
-- red/pink on the white field.
if PaletteFX.mode == "ogred" then return PaletteFX.ogBg() end
-- OG RED / OG BLUE: a battle mon pic is a BG tile on the Game Boy Color
-- (drawn into the tilemap, colored by BGP), so it wears the global boot-ROM
-- BG palette, not a per-species one -- matching the hardware capture where
-- both mons are red/pink (or blue/pink) on the white field.
-- OG YELLOW keeps per-species CGBBasePalettes (Yellow had real CGB code).
if PaletteFX.mode == "ogred" and not GameVersion.isYellow() then
return PaletteFX.ogBg()
end
local p = PaletteFX.pack(data)
if not p then return nil end
if transformed then
if PaletteFX.usesYellowCgb() then
return PaletteFX.pal(data, "GRAYMON")
end
return p.palettes.GRAYMON
or (data and data.palettes and data.palettes.palettes.GRAYMON)
end
@@ -430,17 +475,32 @@ function PaletteFX.monPal(data, species, transformed)
-- so fall back to it when the pack itself doesn't carry the name.
local def = data and data.pokemon and data.pokemon[species]
if def and def.palette then
if PaletteFX.usesYellowCgb() then
local yc = PaletteFX.pal(data, def.palette)
if yc then return yc end
end
local pal = p.palettes[def.palette]
or (data and data.palettes and data.palettes.palettes[def.palette])
if pal then return pal end
end
local name = p.pokemon[species] or "MEWMON"
if PaletteFX.usesYellowCgb() then
local yc = PaletteFX.pal(data, name)
if yc then return yc end
end
local c = p.palettes[name]
if c then return c end
if PaletteFX.usesGbcPack() and data and data.palettes then
name = data.palettes.pokemon[species] or "MEWMON"
return data.palettes.palettes[name]
end
if GameVersion.isYellow() then
local y = PaletteFX.yellowPack()
if y and y.pokemon then
name = y.pokemon[species] or "MEWMON"
return y.palettes and y.palettes[name]
end
end
return nil
end
@@ -707,12 +767,10 @@ end
function PaletteFX.modeLabel(mode)
mode = mode or PaletteFX.mode
-- The GBC boot-ROM mode wears the running game's name: it is red for Red and
-- blue for Blue (see ogBg), so a Blue playthrough shows "OG BLUE".
-- Yellow still uses the Red boot-ROM ramp (no Yellow table yet), so keep
-- the "OG RED" label rather than inventing an "OG YELLOW" without colors.
-- The GBC hardware mode wears the running game's name: OG RED / OG BLUE
-- (boot-ROM auto-palette) or OG YELLOW (pokeyellow CGBBasePalettes).
if mode == "ogred" and GameVersion.isBlue() then return "OG BLUE" end
if mode == "ogred" and GameVersion.isYellow() then return "OG RED" end
if mode == "ogred" and GameVersion.isYellow() then return "OG YELLOW" end
return PaletteFX.MODE_LABELS[mode] or "GBC"
end
+76 -30
View File
@@ -474,6 +474,41 @@ local function withTrueColor(zoneList, pass)
return merged
end
-- Palette-correct blit of `canvas` at (sx, sy) LOVE-unit scales into origin
-- (bx, by), scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/
-- zoneSy convert zone coords (canvas-space) into screen units. Public so a
-- render.compose mod can composite the world/UI canvases into its own layout.
function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor()
return
end
love.graphics.setShader(shader)
-- a colors == false zone is the trueColor opt-out: its rect draws with
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
-- list never toggles and issues exactly the calls it always did.
local bare = false
for _, z in ipairs(zoneList) do
local plain = z.colors == false
if plain ~= bare then
bare = plain
love.graphics.setShader(not plain and shader or nil)
end
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH, dpiX, dpiY) then
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
love.graphics.setScissor()
love.graphics.setShader()
end
-- zones: optional list of SGB palette regions (see PaletteFX) in
-- 160x144 UI space, applied to the UI pass. worldZones: optional
-- regions in world-canvas pixels (overworld survey zoom colors each
@@ -506,6 +541,36 @@ function Renderer:endFrame(zones, worldZones)
zones = withTrueColor(zones, "ui")
worldZones = withTrueColor(worldZones, "world")
-- render.compose: hand a mod the finished world + UI canvases (and their
-- SGB zones), the frame metrics, Renderer:blitCanvas and the SecondScreen
-- bridge, letting it lay the two passes out however it likes -- e.g. as two
-- stacked Game Boy screens, or driving one onto a second physical display.
-- The mod returns true to take over the whole window; anything else (or no
-- mod wrapping the hook) falls through to the normal composite below.
if Runtime.wantsHook("render.compose") then
local ctx = {
renderer = self,
worldCanvas = self.worldCanvas, uiCanvas = self.canvas,
worldOverride = self.worldOverride,
worldActive = self.worldActive and true or false,
zones = zones, worldZones = worldZones,
ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy,
vpw = vpw, vph = vph, uiw = uiw, uih = uih,
scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY,
secondScreen = require("src.render.SecondScreen"),
}
if Runtime.call("render.compose", function() return false end, self, ctx) == true then
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
PaletteFX.setPass(nil)
return {
width = ww, height = wh, gameX = ox, gameY = oy,
gameWidth = vpw, gameHeight = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY,
}
end
end
-- A post-process pipeline needs the whole composite in a canvas for the
-- same reason GBC FX does, so either one alone is enough to take the
-- present path; with neither, the frame draws straight to the screen
@@ -560,38 +625,12 @@ function Renderer:endFrame(zones, worldZones)
})
end
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
-- convert zone coords (canvas-space) into screen units.
-- see Renderer:blitCanvas; bound here to the frame's dpi so the composite
-- call sites below stay unchanged.
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor()
return
end
love.graphics.setShader(shader)
-- a colors == false zone is the trueColor opt-out: its rect draws with
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
-- list never toggles and issues exactly the calls it always did.
local bare = false
for _, z in ipairs(zoneList) do
local plain = z.colors == false
if plain ~= bare then
bare = plain
love.graphics.setShader(not plain and shader or nil)
end
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH, dpiX, dpiY) then
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
love.graphics.setScissor()
love.graphics.setShader()
return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
end
if self.worldOverride then
@@ -720,6 +759,13 @@ function Renderer:endFrame(zones, worldZones)
self.uprightActive = false
self.worldOverride = nil
PaletteFX.setPass(nil)
return {
width = ww, height = wh,
gameX = ox, gameY = oy,
gameWidth = vpw, gameHeight = vph,
scale = Sp,
dpiX = dpiX, dpiY = dpiY,
}
end
return Renderer
+59
View File
@@ -0,0 +1,59 @@
-- Bridge to native secondary-display output (Android Presentation). The C
-- functions live in mobile/android/love/src/jni/love/src/common/android.cpp.
-- Everything is guarded: off Android, or if the symbols cannot be resolved,
-- this stays inert and the renderer keeps the in-window stacked layout.
local SecondScreen = {}
local C = nil
local function log(msg)
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
end
do
local ok, ffi = pcall(require, "ffi")
if not (ok and ffi) then
log("ffi unavailable (not LuaJIT); second display disabled")
else
pcall(ffi.cdef, [[
int love_android_secondary_ready();
void love_android_push_secondary(const void *rgba, int w, int h);
void love_android_secondary_enable(int on);
]])
local okLib, lib = pcall(ffi.load, "love")
if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then
C = lib
log("bridge linked via ffi.load('love')")
elseif pcall(function() return ffi.C.love_android_secondary_ready end) then
C = ffi.C
log("bridge linked via default namespace")
else
log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
:format(tostring(okLib)))
end
end
end
function SecondScreen.usable()
return C ~= nil
end
function SecondScreen.available()
if not C then return false end
local ok, r = pcall(C.love_android_secondary_ready)
return ok and r ~= 0
end
function SecondScreen.push(imageData, w, h)
if not C or not imageData then return false end
return pcall(function()
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
end)
end
function SecondScreen.setEnabled(on)
if not C then return end
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
end
return SecondScreen
+10
View File
@@ -10,6 +10,13 @@ local Zoom = {}
Zoom.offset = 0
-- Survey zoom (zooming out past FIT, which renders connected neighbor maps)
-- is the port's most expensive optional extra. The performance tier sets
-- this false on LOW hardware (Game:applyOptions); offsetRange then floors
-- the range at FIT so the option row, hotkey, and mouse wheel all stop at
-- close-up. Nil/true keeps the historical full range.
Zoom.allowSurvey = true
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
-- through 2× fit). zoom.range may widen or shrink the window.
function Zoom.offsetRange(S)
@@ -21,6 +28,9 @@ function Zoom.offsetRange(S)
hi = math.floor(tonumber(hi) or S)
if lo > hi then lo, hi = hi, lo end
end
-- LOW performance tier: no survey (negative offsets), even if a mod's
-- zoom.range widened it. == false so nil/true stays permissive.
if Zoom.allowSurvey == false and lo < 0 then lo = 0 end
return lo, hi
end
+21 -5
View File
@@ -210,11 +210,12 @@ end
-- text (label or literal; {RAM:wStringBuffer} becomes the item name);
-- pass false when the script shows its own received-text row.
function Commands.give_item(ctx, itemId, count, gotText)
-- the 20-slot bag can refuse (BAG_ITEM_CAPACITY): say so and halt
-- the bag can refuse at its configured capacity (20 in vanilla): halt
-- the script, so later set_flag rows don't burn the gift -- make
-- room and talk again, like the original (pokered's `jr nc, .bag_full`
-- skips the received text entirely when AddItemToInventory refuses)
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
if not require("src.inventory.Bag").add(
ctx.save, itemId, count or 1, ctx.game.data) then
Commands.show_text(ctx, ctx.game.data.text
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
return math.huge
@@ -265,7 +266,18 @@ function Commands.start_battle(ctx, kind, a, b)
ctx.lastBattleResult = result
ctx.lastCheck = result == "win"
if ctx.overworld then
ctx.overworld:afterBattle(result, battle)
-- A map script often follows a trainer battle with its own text.
-- Keep a level evolution behind that text: otherwise afterBattle
-- pushes the evolution screen, then this runner resumes and pushes
-- the trainer's text on top of it.
if result == "win" then
ctx.afterScript = ctx.afterScript or {}
table.insert(ctx.afterScript, function()
ctx.overworld:afterBattle(result, battle)
end)
else
ctx.overworld:afterBattle(result, battle)
end
end
runner:resume()
end
@@ -723,11 +735,15 @@ function Commands.record_hall_of_fame(ctx)
if game.overworld then
game.overworld.lastOutdoor = ctx.save.lastOutdoor
end
if game.writeSave then game:writeSave() end
local saveAllowed = true
if game.writeSave then saveAllowed = game:writeSave() ~= false end
-- writeSave's captureSave re-stamps the live HALL_OF_FAME coords;
-- re-apply home and persist so CONTINUE resumes in the bedroom.
SaveData.applyPostGameHome(ctx.save, boot)
SaveData.save(ctx.save)
-- A tool session may veto Game:writeSave to keep its in-memory run
-- isolated from the player's real save. The relocation write is part
-- of that same save operation and must honor the same decision.
if saveAllowed then SaveData.save(ctx.save) end
end)
end)
runner:yield()
+4
View File
@@ -125,6 +125,10 @@ function ScriptRunner:run(script, extra)
end
self.co = coroutine.create(function()
self:exec(script, ctx)
-- Commands can defer a game action until the script's own dialogue is
-- done. start_battle uses this for win-path evolutions, which must not
-- be covered by post-battle trainer text.
for _, callback in ipairs(ctx.afterScript or {}) do callback() end
if ctx.onDone then ctx.onDone() end
if Runtime.wants("script.ended") then
Runtime.emit("script.ended", { ctx = ctx, completed = true })
+11
View File
@@ -93,6 +93,17 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
end
if result == "bicycle" then
-- StartMenu_Item .useOrTossItem (engine/menus/start_sub_menus.asm):
-- while BIT_ALWAYS_ON_BIKE of wStatusFlags6 is set -- the Cycling Road,
-- armed by the forced-bike tiles and cleared by the Route 16/18 gate
-- scripts -- the BICYCLE refuses with _CannotGetOffHereText and jumps
-- back to ItemMenuLoop, so the bag stays open and no dismount happens
-- (#513). The gate sits ahead of UseItem, before ItemUseBicycle ever
-- runs, which is why it precedes list:close() here.
if game.save.forcedBike then
showMessages(game, { Strings("You can't get off\nhere.") })
return
end
list:close()
local ow = game.overworld
local Music = require("src.core.Music")
+22 -2
View File
@@ -64,9 +64,24 @@ function BindingsMenu.new(game)
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
BindingsMenu)
self.onChoose = function(item) self:beginCapture(item) end
-- A rebind reaches Input only when this screen closes (#510). The menu
-- steers by the live map, so applying "B = Z" the instant it was captured
-- turned the player's next confirm press into a cancel and shut the
-- screen mid-swap. options.bindings is still written immediately, and
-- Game:applyOptions re-applies it on load, so a close that skips this
-- hook still ends up with the saved map.
self.onCancel = function() self:commitBindings() end
return self
end
-- Cross-file contract with src/core/Input.lua: the saved overlay reaches
-- the live map here, on close, and nowhere else in this screen.
function BindingsMenu:commitBindings()
local game = self.game
local opts = game and game.save and game.save.options
if opts then Input:applyBindings(opts.bindings) end
end
-- the capture handlers are per-instance slots, so Game's raw-input
-- routing only ever sees this screen while a capture is armed
function BindingsMenu:beginCapture(item)
@@ -75,7 +90,12 @@ function BindingsMenu:beginCapture(item)
self.onGamepadPressed = BindingsMenu.capturePad
end
-- Escape is the capture's way out, so it is never captured: every other
-- key is bindable, which otherwise leaves an armed row with no exit but
-- to bind something (#510). Escape stays START in Input's default map,
-- which no rebind removes, so reserving it costs the player nothing.
function BindingsMenu:captureKey(key)
if key == "escape" then return self:storeBinding("key", nil) end
self:storeBinding("key", key)
end
@@ -100,7 +120,6 @@ function BindingsMenu:storeBinding(slot, value)
b[slot] = value
opts.bindings[item.button.id] = b
item.right = boundRight(opts.bindings, item.button)
Input:applyBindings(opts.bindings)
if game.writeOptions then game:writeOptions() end
end
@@ -112,9 +131,10 @@ end
function BindingsMenu:draw()
ListMenu.draw(self)
if self.capture then
Font.drawBox(1, 6, 18, 4)
Font.drawBox(1, 6, 18, 5)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
Font.draw(Strings("ESC TO CANCEL"), 24, 72)
love.graphics.setColor(1, 1, 1, 1)
end
end
+4
View File
@@ -66,6 +66,7 @@ local function withdraw(game)
end
game.stack:push(ListMenu.new(game,
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
local mon = box[item.value]
if not mon then return end
@@ -112,6 +113,7 @@ local function deposit(game)
table.insert(items, { label = monLabel(game, mon), value = i })
end
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
local mon = game.save.party[item.value]
if not mon then return end
@@ -157,6 +159,7 @@ local function release(game)
end
game.stack:push(ListMenu.new(game,
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(_, list)
local mon = box[list.index]
if not mon then return end
@@ -189,6 +192,7 @@ local function changeBox(game)
})
end
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
-- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change
+23 -11
View File
@@ -48,11 +48,11 @@ end
local FLASH_FRAMES = 220
local function frontSprite(game, species, mon)
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
{ mon = mon, kind = "evolution" })
if not path then return nil end
local path, trueColor = require("src.pokemon.Sprites").path(
game.data, species, "front", { mon = mon, kind = "evolution" })
if not path then return nil, false end
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
return ok and img or nil, ok and trueColor or false
end
function EvolutionState.new(game, mon, newSpecies, onDone, via)
@@ -69,8 +69,8 @@ function EvolutionState.new(game, mon, newSpecies, onDone, via)
-- wForceEvolution clear and so honour B (#290, #213).
self.cancelable = (via ~= "TRADE" and via ~= "ITEM")
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
self.oldSprite = frontSprite(game, mon.species, mon)
self.newSprite = frontSprite(game, newSpecies, mon)
self.oldSprite, self.oldSpriteTrueColor = frontSprite(game, mon.species, mon)
self.newSprite, self.newSpriteTrueColor = frontSprite(game, newSpecies, mon)
self.t = 0
self.done = false
self.canceled = false
@@ -127,18 +127,30 @@ function EvolutionState:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144)
-- accelerating flash between the two forms
local sprite
local sprite, spriteTrueColor
if self.done then
-- a cancelled evolution settles back on the original form
sprite = self.canceled and self.oldSprite or self.newSprite
if self.canceled then
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
else
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
end
else
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
local showNew = math.floor(self.t / period) % 2 == 1
sprite = showNew and self.newSprite or self.oldSprite
if showNew then
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
else
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
end
end
if sprite then
love.graphics.draw(sprite, math.floor((160 - sprite:getWidth()) / 2),
math.max(8, 64 - sprite:getHeight()))
local x = math.floor((160 - sprite:getWidth()) / 2)
local y = math.max(8, 64 - sprite:getHeight())
love.graphics.draw(sprite, x, y)
if spriteTrueColor then
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
end
end
love.graphics.setColor(0, 0, 0, 1)
+18
View File
@@ -78,6 +78,10 @@ function ListMenu.new(game, title, items, opts)
-- bottom text box -- same row budget as the mart, without the money box.
self.messageBox = opts.messageBox
self.money = opts.money -- () -> current money for the box
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): both PC sessions hold the flag
-- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm),
-- so their lists opt out of the A/B beep the same way Menu's noSound does
self.noSound = opts.noSound or false
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
return self
end
@@ -101,6 +105,17 @@ local function syncScroll(self)
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
end
-- HandleMenuInput_ (home/window.asm) replays SFX_PRESS_AB for any watched
-- A or B press; DisplayListMenuID's mask is PAD_A | PAD_B | PAD_SELECT
-- (home/list_menu.asm), so the SELECT swap key stays silent (#570)
local function beep(self)
-- game.data is nil under the UI harnesses that drive a list with a stub
-- game (tests/engine/rebind_capture_bug510.lua), where there is no audio
-- cache to play out of
if self.noSound or not (self.game and self.game.data) then return end
require("src.core.Sound").play(self.game.data, "Press_AB")
end
-- edge press or key-repeat tick for a held direction
local function navPressed(self, dir)
if dir == "up" then
@@ -126,6 +141,7 @@ function ListMenu:update(dt)
local input = self.game.input
if #self.items == 0 then
if input:wasPressed("a") or input:wasPressed("b") then
beep(self)
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
@@ -148,10 +164,12 @@ function ListMenu:update(dt)
elseif self.onSelectKey and input:wasPressed("select") then
self.onSelectKey(self.items[self.index], self)
elseif input:wasPressed("b") then
beep(self)
self.game.stack:pop()
if self.onCancel then self.onCancel() end
return
elseif input:wasPressed("a") then
beep(self)
local item = self.items[self.index]
if self.onChoose then
self.onChoose(item, self)
+18 -4
View File
@@ -108,20 +108,34 @@ function Menu:draw()
love.graphics.setColor(0, 0, 0, 1)
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
or #self.items
-- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior
-- row and let any slack fall as a blank row under the top edge --
-- draw_start_menu.asm (TextBoxBorder 10,0, then hlcoord 12,2 /
-- wTopMenuItemY 2), players_pc.asm and bills_pc.asm all do the same. The
-- bottom border is ty + th - 1, so the last choice sits on ty + th - 2 and
-- row r counts back up from there. Anchoring from the top instead only
-- agrees when th is exactly visible * rowStep + 2, which is Menu.new's
-- default but NOT what a caller sizing its own box passes: BagMenu's
-- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's
-- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto
-- the bottom border (#564, #572).
for row = 1, visible do
local item = self.items[self.scroll + row]
if not item then break end
Font.draw(item.label, (self.tx + 2) * 8,
(self.ty + row * self.rowStep - (self.rowStep - 1)) * 8)
(self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8)
end
local cursorRow = self.index - self.scroll
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
(self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8)
(self.ty + self.th - 2 - (visible - cursorRow) * self.rowStep) * 8)
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
-- use, sat on the bottom border like TextBox's page-advance cursor
-- use, sat on the bottom border like TextBox's page-advance cursor. It
-- has to be the border row, not ty + th - 2: that is the last interior
-- row, which the last choice now occupies, and Menu.new widens the box to
-- widest + 3 so tx + tw - 2 is exactly that label's final glyph (#564).
if self.maxVisible and self.scroll + self.maxVisible < #self.items then
Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8,
(self.ty + self.th - 2) * 8)
(self.ty + self.th - 1) * 8)
end
love.graphics.setColor(1, 1, 1, 1)
end
+61
View File
@@ -20,6 +20,7 @@ local GameSpeed = require("src.core.GameSpeed")
local GameVersion = require("src.core.GameVersion")
local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local Performance = require("src.core.Performance")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows")
@@ -202,6 +203,21 @@ local function buildRows(game)
require("src.core.Music").setFilterLevel(o.musicFilter)
return true
end },
-- Heads the port's display group: one tier that scales the heavy extras
-- (TILT / GBC FX / survey ZOOM) and the FPS ceiling for weaker devices.
-- AUTO picks a default from the hardware; every tier is overridable.
-- Re-applies live so the extras clamp (or, on a higher tier, restore to
-- the player's stored TILT / GBC FX / ZOOM) the moment the row changes.
{ id = "performance", label = Strings("PERFORMANCE"),
value = function(g)
return Strings(Performance.label(g.save.options.performance))
end,
step = function(g, dir)
local o = g.save.options
o.performance = Performance.cycle(o.performance, dir)
g:applyOptions(o)
return true
end },
{ id = "colors", label = Strings("COLORS"),
value = function(g)
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
@@ -322,6 +338,25 @@ local function buildRows(game)
activate = function(g)
require("src.ui.Screens").push(g, "BindingsMenu")
end },
-- permanent on-screen pad toggle (#327); layout editing stays in the
-- launcher. Hidden where the overlay never appears (desktop without
-- POKEPORT_TOUCH), so the row costs a non-mobile install nothing.
{ id = "touchControls", label = Strings("TOUCH PAD"),
value = function(g)
local tc = g.save.options.touchControls
local on = not (type(tc) == "table" and tc.enabled == false)
return on and Strings("ON") or Strings("OFF")
end,
step = function(g)
local o = g.save.options
local tc = type(o.touchControls) == "table" and o.touchControls or {}
local on = not (tc.enabled == false)
tc.enabled = not on
-- keep any saved positions when toggling
o.touchControls = tc
require("src.core.TouchControls"):applyOptions(o)
return true
end },
}
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
if not GBCFX.isSupported() then
@@ -331,6 +366,21 @@ local function buildRows(game)
end
rows = filtered
end
-- TOUCH PAD only where the overlay can appear (mobile, or desktop with
-- POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off everywhere.
do
local env = os.getenv("POKEPORT_TOUCH")
local osName = love.system and love.system.getOS and love.system.getOS()
local show = env == "1"
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
if not show then
local filtered = {}
for _, row in ipairs(rows) do
if row.id ~= "touchControls" then filtered[#filtered + 1] = row end
end
rows = filtered
end
end
-- PIKACHU VOL only means something where the voice clips exist: Yellow
-- (data.audio.pikaCries is the clip count the importer wrote). Red/Blue
-- keep the row list they always had.
@@ -398,10 +448,21 @@ function OptionsMenu:update(dt)
elseif row and row.step then
changed = row.step(self.game, dir) and true or false
elseif input:wasPressed("a") then -- CANCEL
-- DisplayOptionMenu .exitMenu (engine/menus/main_menu.asm) is the
-- only spot in this menu that plays SFX_PRESS_AB: A on a setting row
-- and the Left/Right toggles stay silent (#570). game.data is nil
-- under the stub games the UI harnesses drive.
if self.game.data then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
elseif input:wasPressed("b") or input:wasPressed("start") then
-- .exitMenu again: B and START leave the same way, sound and all
if self.game.data then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
self.game.stack:pop()
if self.onCancel then self.onCancel() end
end
+8
View File
@@ -310,6 +310,14 @@ function PartyMenu:update(dt)
local input = self.game.input
local party = self.party or self.game.save.party
-- HandlePartyMenuInput (home/pokemon.asm) and the field-move submenu
-- (engine/menus/start_sub_menus.asm .chosePokemon) both run through
-- HandleMenuInput_, which beeps SFX_PRESS_AB on any A or B press (#570).
-- game.data is nil under the stub games the UI harnesses drive.
if self.game.data and (input:wasPressed("a") or input:wasPressed("b")) then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
if self.submenu then
local n = #self.subItems
if input:wasPressed("up") then
+4 -1
View File
@@ -73,10 +73,11 @@ local function withdraw(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag")
if not Bag.add(game.save, item.value, qty) then
if not Bag.add(game.save, item.value, qty, game.data) then
list.footer = Strings("You can't carry\nany more items.")
return
end
@@ -110,6 +111,7 @@ local function deposit(game)
end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then
@@ -130,6 +132,7 @@ local function toss(game)
local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list)
local def = game.data.items[item.value]
if (def and def.keyItem) or item.value:find("^HM_") then
+11 -3
View File
@@ -50,11 +50,13 @@ function PokedexMenu.new(game, opts)
footer = Strings("SEEN %d OWNED %d", seen, owned),
pageJump = true, -- Left/Right page jumps like the original
onCancel = opts.onCancel, -- B returns to the start menu when opened from it
onChoose = function(item)
onChoose = function(item, dexList)
if not item.value then return end
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
-- PokedexMenuItemsText); CRY keeps the side menu open like the
-- original, QUIT returns to the list
-- original. B is what returns to the list: HandlePokedexSideMenu
-- hands back b=2 for B and b=1 for QUIT, and ShowPokedexMenu sends
-- b=1 to .exitPokedex, so QUIT closes the whole Pokédex (#571)
local Menu = require("src.ui.Menu")
local Screens = require("src.ui.Screens")
local entries = {
@@ -91,7 +93,13 @@ function PokedexMenu.new(game, opts)
or Strings("Printer error!\n%s", tostring(err))))
end }
end
entries[#entries + 1] = { label = Strings("QUIT") }
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
-- .exitPokedex: drop the dex list too and hand back to whoever
-- opened it (the start menu, whose saved cursor is still on
-- POKéDEX -- wBattleAndStartSavedMenuItem)
dexList:close()
if opts.onCancel then opts.onCancel() end
end }
game.stack:push(Menu.new(game, entries,
{ tx = 12, ty = 8, tw = 8,
th = #entries * 2 + 2 }))
+1 -1
View File
@@ -67,7 +67,7 @@ local function buy(game, stock)
list.footer = notEnough
return
end
if not Bag.add(game.save, item.value, qty) then
if not Bag.add(game.save, item.value, qty, game.data) then
list.footer = txt(game, "_PokemartItemBagFullText",
Strings("You can't carry\nany more items."))
return
+11
View File
@@ -7,6 +7,8 @@ local Font = require("src.render.Font")
local Music = require("src.core.Music")
local GameVersion = require("src.core.GameVersion")
local Strings = require("src.core.Strings")
local Runtime = require("src.mods.Runtime")
local Logger = require("src.core.Logger")
local TitleState = {}
TitleState.__index = TitleState
@@ -300,6 +302,8 @@ local function hasSave()
return ok and info ~= nil
end
local function sameItems(_, items) return items end
-- The CONTINUE info window (main_menu.asm DisplayContinueGameInfo):
-- PLAYER / BADGES / POKéDEX / TIME over the title, shown after choosing
-- CONTINUE. A confirms and loads the game, B returns to the main menu.
@@ -375,6 +379,13 @@ function TitleState:openMenu()
love.event.quit()
end
end })
local hooked = Runtime.call("ui.title_menu.items", sameItems, game, items)
if type(hooked) == "table" then
items = hooked
else
Logger.error("ui.title_menu.items returned %s; keeping the vanilla items",
type(hooked))
end
local th = #items * 2 + 2
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
-- full-width title LOGO zones would recolor this box; see sgbPalettes
+277
View File
@@ -0,0 +1,277 @@
-- Launcher touch-controls editor (#327): drag each on-screen button to a
-- new spot, toggle the overlay off entirely, Reset to defaults, Done to
-- persist into options.lua. Opened from the game panel's "Touch Controls"
-- button; main.lua suspends the launcher the same way it does for the
-- save editor.
--
-- Draws in full window LOVE units -- the same space TouchControls uses
-- after Renderer:endFrame -- so what you drag here is what you get in play.
local SaveData = require("src.core.SaveData")
local TouchControls = require("src.core.TouchControls")
local Editor = {}
local PAL = {
bgTop = { 8, 14, 36 },
bgBot = { 4, 8, 22 },
white = { 245, 248, 255 },
label = { 160, 175, 210 },
green = { 80, 220, 140 },
red = { 240, 90, 110 },
card = { 18, 28, 58 },
stroke = { 120, 150, 220 },
}
local function col(c, a)
love.graphics.setColor(c[1] / 255, c[2] / 255, c[3] / 255, a or 1)
end
local function inside(r, x, y)
return r and x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h
end
local function roundRect(mode, x, y, w, h, r)
love.graphics.rectangle(mode, x, y, w, h, r, r)
end
function Editor.load(opts)
opts = opts or {}
Editor.onClose = opts.onClose
Editor.drag = nil
Editor.rects = {}
Editor.fonts = {
title = love.graphics.newFont(28),
body = love.graphics.newFont(16),
btn = love.graphics.newFont(18),
}
local optsTbl = SaveData.loadOptions()
TouchControls:init()
TouchControls:ensureImages()
TouchControls:applyOptions(optsTbl)
TouchControls:setPreview(true)
Editor.enabled = TouchControls.enabled ~= false
end
function Editor.unload()
TouchControls:setPreview(false)
TouchControls:reset()
Editor.drag = nil
Editor.onClose = nil
end
local function persist()
local opts = SaveData.loadOptions()
local cfg = TouchControls:config()
opts.touchControls = {
enabled = cfg.enabled,
positions = cfg.positions,
}
SaveData.saveOptions(opts)
end
local function close()
persist()
local cb = Editor.onClose
Editor.unload()
if cb then cb() end
end
local function resetLayout()
TouchControls:clearPositions()
end
local function toggleEnabled()
Editor.enabled = not Editor.enabled
TouchControls.enabled = Editor.enabled
if not Editor.enabled then TouchControls:reset() end
end
function Editor.update(_dt)
-- drag follows the live pointer when love.touch / mouse is available;
-- touchmoved / mousemoved also update, so this is a belt-and-suspenders
-- path for Android where move events can be thin
if not Editor.drag then return end
local x, y
if love.touch and love.touch.getPosition and Editor.drag.touchId then
local ok, tx, ty = pcall(love.touch.getPosition, Editor.drag.touchId)
if ok and tx then x, y = tx, ty end
end
if not x and love.mouse then
x, y = love.mouse.getPosition()
end
if x then
TouchControls:setControlCenter(
Editor.drag.name,
x - Editor.drag.offX,
y - Editor.drag.offY)
end
end
function Editor.draw()
local SafeArea = require("src.core.SafeArea")
local fullW, fullH = love.graphics.getDimensions()
local ox, oy, ww, wh = SafeArea.rect()
local s = math.max(0.75, math.min(1.4, wh / 768))
Editor.rects = {}
-- radial-ish navy field (two stacked fills; matches launcher atmosphere)
col(PAL.bgBot)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
col(PAL.bgTop, 0.85)
love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
local pad = 18 * s
local barH = 56 * s
local btnH = 40 * s
local btnW = 100 * s
-- top bar (inside the safe area so it clears the notch / status bar)
col(PAL.card, 0.92)
love.graphics.rectangle("fill", 0, 0, fullW, oy + barH + pad)
col(PAL.stroke, 0.35)
love.graphics.setLineWidth(1)
love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
love.graphics.setFont(Editor.fonts.title)
col(PAL.white)
love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
-- Done / Reset
local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
w = btnW, h = btnH }
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
Editor.rects.done, Editor.rects.reset = done, reset
local function chromeBtn(r, label, fill)
col(fill, 0.9)
roundRect("fill", r.x, r.y, r.w, r.h, 8 * s)
col(PAL.white, 0.2)
roundRect("line", r.x, r.y, r.w, r.h, 8 * s)
love.graphics.setFont(Editor.fonts.btn)
col(PAL.white)
local tw = Editor.fonts.btn:getWidth(label)
love.graphics.print(label, r.x + (r.w - tw) / 2,
r.y + (r.h - Editor.fonts.btn:getHeight()) / 2)
end
chromeBtn(reset, "Reset", { 60, 70, 110 })
chromeBtn(done, "Done", PAL.green)
-- enable toggle card
local cardY = oy + barH + pad + 14 * s
local cardH = 64 * s
local cardX, cardW = ox + pad, ww - 2 * pad
col(PAL.card, 0.88)
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4)
roundRect("line", cardX, cardY, cardW, cardH, 12 * s)
love.graphics.setFont(Editor.fonts.body)
col(PAL.label)
love.graphics.print("On-screen controls", cardX + 16 * s,
cardY + 12 * s)
love.graphics.setFont(Editor.fonts.btn)
local on = Editor.enabled
col(on and PAL.green or PAL.red)
love.graphics.print(on and "ON" or "OFF", cardX + 16 * s,
cardY + 34 * s)
local toggleW = 110 * s
local toggle = {
x = cardX + cardW - 16 * s - toggleW,
y = cardY + (cardH - btnH) / 2,
w = toggleW, h = btnH,
}
Editor.rects.toggle = toggle
chromeBtn(toggle, on and "Disable" or "Enable",
on and PAL.red or PAL.green)
-- hint
love.graphics.setFont(Editor.fonts.body)
col(PAL.label, 0.9)
local hint = on
and "Drag each button to reposition. Layout is saved when you tap Done."
or "Controls are hidden in-game. Enable them to show and edit the layout."
love.graphics.printf(hint, ox + pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
-- the overlay itself (preview mode; dimmed when disabled)
TouchControls:draw()
-- highlight the control under drag
if Editor.drag then
local L = TouchControls:layout()
local zone = L[Editor.drag.name]
if zone then
love.graphics.setLineWidth(3 * s)
col(PAL.green, 0.85)
love.graphics.circle("line", zone.cx, zone.cy, zone.w * 0.62)
end
end
end
local function beginDrag(id, x, y)
-- chrome takes priority over controls
if inside(Editor.rects.done, x, y) then close(); return end
if inside(Editor.rects.reset, x, y) then resetLayout(); return end
if inside(Editor.rects.toggle, x, y) then toggleEnabled(); return end
local name = TouchControls:hitTest(x, y)
if not name then return end
local zone = TouchControls:layout()[name]
Editor.drag = {
name = name,
touchId = id,
offX = x - zone.cx,
offY = y - zone.cy,
}
end
local function moveDrag(id, x, y)
local d = Editor.drag
if not d then return end
if d.touchId ~= nil and id ~= nil and d.touchId ~= id then return end
TouchControls:setControlCenter(d.name, x - d.offX, y - d.offY)
end
local function endDrag(id)
local d = Editor.drag
if not d then return end
if d.touchId ~= nil and id ~= nil and d.touchId ~= id then return end
Editor.drag = nil
end
function Editor.mousepressed(x, y, button)
if button ~= 1 then return end
beginDrag("mouse", x, y)
end
function Editor.mousemoved(x, y)
if love.mouse.isDown(1) then moveDrag("mouse", x, y) end
end
function Editor.mousereleased(x, y, button)
if button ~= 1 then return end
endDrag("mouse")
end
function Editor.touchpressed(id, x, y)
beginDrag(id, x, y)
end
function Editor.touchmoved(id, x, y)
moveDrag(id, x, y)
end
function Editor.touchreleased(id, x, y)
endDrag(id)
end
function Editor.keypressed(key)
if key == "escape" or key == "return" or key == "space" then
close()
elseif key == "r" then
resetLayout()
end
end
return Editor
+23 -9
View File
@@ -49,9 +49,10 @@ local function dexOf(game, mon)
end
local function spriteOf(game, mon)
local path = require("src.pokemon.Sprites").path(game.data, mon.species, "front",
{ mon = mon, kind = "trade" })
return tryImage(path)
local path, trueColor = require("src.pokemon.Sprites").path(
game.data, mon.species, "front", { mon = mon, kind = "trade" })
local image = tryImage(path)
return image, image and trueColor or false
end
local function expand(game, key, subs)
@@ -110,8 +111,8 @@ function TradeAnim.new(game, opts)
cableBallAlt = tryImage(art.cableBallAlt or DEFAULT_ART.cableBallAlt),
bubble = tryImage(art.bubble or DEFAULT_ART.bubble),
}
self.sentSprite = spriteOf(game, self.sent)
self.recvSprite = spriteOf(game, self.received)
self.sentSprite, self.sentSpriteTrueColor = spriteOf(game, self.sent)
self.recvSprite, self.recvSpriteTrueColor = spriteOf(game, self.received)
self.seq = 1
self.phase = SEQ[1]
@@ -349,8 +350,8 @@ function TradeAnim:drawMonInfo(mon, ot, otId, boxTy)
love.graphics.setColor(1, 1, 1, 1)
end
function TradeAnim:drawIconInBubble(mon, x, y)
local spr = (mon.species == self.received.species) and self.recvSprite or self.sentSprite
function TradeAnim:drawIconInBubble(sprite, x, y)
local spr = sprite
if spr then
local sw, sh = spr:getDimensions()
local s = 16 / math.max(sw, sh)
@@ -425,6 +426,10 @@ function TradeAnim:draw()
-- hWY $50, so it sits in the bottom half of the screen
if self.monVisible and self.sentSprite then
love.graphics.draw(self.sentSprite, 56, 16)
if self.sentSpriteTrueColor then
require("src.render.PaletteFX").markTrueColor(
56 - self.scx, 16, self.sentSprite:getDimensions())
end
end
self:drawMonInfo(self.sent, self.playerOt, self.playerOtId, 10)
love.graphics.pop()
@@ -466,8 +471,13 @@ function TradeAnim:draw()
love.graphics.translate(160, 0)
self:drawRightGB()
love.graphics.pop()
local mon = (p == "transfer_lr") and self.sent or self.received
self:drawIconInBubble(mon, self.monX, self.monY)
local sprite
if p == "transfer_lr" then
sprite = self.sentSprite
else
sprite = self.recvSprite
end
self:drawIconInBubble(sprite, self.monX, self.monY)
if self.cableFlash then
love.graphics.setColor(1, 1, 1, 0.15)
love.graphics.rectangle("fill", 0, 32, 160, 8)
@@ -477,6 +487,10 @@ function TradeAnim:draw()
elseif p == "show_enemy" then
if self.monVisible and self.recvSprite then
love.graphics.draw(self.recvSprite, 56, 16)
if self.recvSpriteTrueColor then
require("src.render.PaletteFX").markTrueColor(
56, 16, self.recvSprite:getDimensions())
end
end
self:drawMonInfo(self.received, self.enemyName, self.enemyOtId, 10)
end
+99 -12
View File
@@ -148,6 +148,37 @@ local SPEED_BARS = {
{ 0xE0, 0x80, 4 }, { 0xF0, 0x90, 2 },
}
-- PlayIntroScene's .loop (pokeyellow engine/movie/intro_yellow.asm) ends
-- every iteration with its own `call DelayFrame`, so one scene handler costs
-- one frame plus whatever DelayFrame calls it makes itself. The port runs a
-- handler inside a single update() tick, so every one of those internal
-- DelayFrame calls has to be spent explicitly or the movie runs short of
-- hardware. Three places owe frames:
--
-- SETUP_SCENE_DELAY scenes 2/4/6/8/10/12 open with
-- YellowIntro_BlankPalsDelay2AndDisableLCD: rBGP/rOBP0/
-- rOBP1 to $00 (color 0 in every shade -- the lightest,
-- white on DMG), two DelayFrame calls, then DisableLCD.
-- Two internal plus the loop's own is a 3-frame
-- iteration, after which the next wait scene takes its
-- first timer decrement. Func_f9e9a restores rBGP $e4
-- at the end of the same handler.
-- HEAD_FRAMES PlayIntroScene spends a DelayFrame between
-- InitYellowIntroGFXAndMusic (which ends in PlayMusic)
-- and the first .loop pass, and YellowIntroScene0 then
-- owns a whole iteration of its own.
-- EXIT_FRAMES .go_to_title_screen blanks the palettes and spends
-- four DelayFrame calls clearing the tilemap and OAM
-- before the title screen is built.
--
-- These restore the movie's real elapsed length; they do NOT lengthen the
-- intro song. title.asm's StopAllMusic before MUSIC_TITLE_SCREEN is
-- unconditional (only PikachuCry1 is waited on), so hardware cuts
-- Music_YellowIntro off mid-phrase too (#523).
local SETUP_SCENE_DELAY = 3
local HEAD_FRAMES = 2
local EXIT_FRAMES = 4
-- scene-6 sine (YellowIntro_Copy8BitSineWave.SineWave), signed SCY deltas
local WAVE = { 0, 0, 1, 2, 2, 3, 3, 3, 4, 3, 3, 3, 2, 2, 1, 0,
0, 0, -1, -2, -2, -3, -3, -3, -4, -3, -3, -3, -2, -2, -1, 0 }
@@ -208,6 +239,8 @@ function YellowIntro.new(game, onDone)
self.palName = "MEWMON" -- PalPacket_Generic
self.objects = {}
self.cloudFrame = 0
self.pendingThen = nil -- what enterDelay runs once pendingDelay hits 0 (#523)
self.pendingDelay = 0
self.atlas1 = tryImage("assets/generated/intro/yellow_intro_1.png")
self.atlas2 = tryImage("assets/generated/intro/yellow_intro_2.png")
@@ -559,6 +592,24 @@ function YellowIntro:startScene(scene)
end
end
-- Spend `frames` real frames, then run fn: the port's stand-in for the
-- DelayFrame calls a pokeyellow scene handler makes inside its own
-- PlayIntroScene .loop iteration (#523). fn may be nil to just burn time.
function YellowIntro:enterDelay(frames, fn)
self.pendingDelay = frames
self.pendingThen = fn
end
-- YellowIntro_BlankPalsDelay2AndDisableLCD blanks the palettes for the
-- delay; Func_f9e9a puts rBGP back to $e4 at the tail of the same handler.
function YellowIntro:enterSetupScene(scene)
self.bgp = 0x00
self:enterDelay(SETUP_SCENE_DELAY, function()
self.bgp = 0xE4
self:startScene(scene)
end)
end
function YellowIntro:enter()
if self.pre then return end -- pre-roll first; beginScenes takes over
self:beginScenes()
@@ -571,7 +622,11 @@ function YellowIntro:beginScenes()
local song = songs and (songs.Music_YellowIntro and "Music_YellowIntro"
or songs.Music_IntroBattle and "Music_IntroBattle")
if song then pcall(Music.play, data, song, false) end
self:startScene(0)
-- HEAD_FRAMES: the DelayFrame between PlayMusic and the first .loop pass
-- plus YellowIntroScene0's own iteration. new() already laid down the
-- Func_f9e5f letterbox that InitYellowIntroGFXAndMusic writes, so these
-- frames show what hardware shows: the letterbox with no pika yet.
self:enterDelay(HEAD_FRAMES, function() self:startScene(0) end)
end
-- The movie's exit never stops the song (intro_yellow.asm PlayIntroScene
@@ -586,16 +641,44 @@ function YellowIntro:finish()
if self.onDone then self.onDone() end
end
-- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land
-- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then
-- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM
-- buffers before the title screen is built (#523). The pre-roll's own skip
-- is IntroMovie's path, not this one, and still finishes immediately.
function YellowIntro:exitToTitle()
if self.finished or self.exiting then return end
self.exiting = true
self.bgp = 0x00
self:clearObjects()
self:enterDelay(EXIT_FRAMES, function() self:finish() end)
end
function YellowIntro:update(dt)
if self.finished then return end
if self.pre then
self.pre:update(dt)
return
end
-- Ahead of the skip poll on purpose: JoypadLowSensitivity runs once at the
-- top of each .loop iteration, never inside a handler, so hardware is deaf
-- for the DelayFrame calls these ticks stand in for (#523).
if self.pendingDelay > 0 then
self.pendingDelay = self.pendingDelay - 1
if self.pendingDelay == 0 then
local fn = self.pendingThen
self.pendingThen = nil
if fn then fn() end
end
self:updateObjects()
if self.bgDirty then self:rebuildBgCanvas() end
return
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b")
or input:wasPressed("start") then
self:finish()
self:exitToTitle()
return
end
@@ -605,7 +688,7 @@ function YellowIntro:update(dt)
self.timer = self.timer - 1
else
self:clearObjects()
self:startScene(scene + 1)
self:enterSetupScene(scene + 1) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 3 then
if self.timer > 0 then
@@ -613,7 +696,7 @@ function YellowIntro:update(dt)
if self.scx ~= 0x68 then self.scx = self.scx + 4 end
else
self:clearObjects()
self:startScene(4)
self:enterSetupScene(4) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 7 then
if self.timer > 0 then
@@ -625,7 +708,7 @@ function YellowIntro:update(dt)
self.wave[255] = first
else
self:clearObjects()
self:startScene(8)
self:enterSetupScene(8) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 11 then
if self.timer > 0 then
@@ -640,7 +723,7 @@ function YellowIntro:update(dt)
self.timer = self.timer - 1
else
self:clearObjects()
self:startScene(12)
self:enterSetupScene(12) -- BlankPalsDelay2AndDisableLCD (#523)
end
elseif scene == 13 then
if self.timer > 0 then
@@ -656,13 +739,17 @@ function YellowIntro:update(dt)
if v then
self.bgp = v
else
-- .expired: everything despawns, letterbox returns, logo/face
-- object $7 appears for the strobe scene
-- .expired: everything despawns and the letterbox returns, then the
-- handler spends three DelayFrame calls with hAutoBGTransferEnabled
-- pushing that rebuilt tilemap to VRAM before it restores rBGP $e4
-- and spawns the logo/face object $7 for the strobe scene (#523).
self:clearObjects()
self:bgLetterbox()
self.bgp = 0xE4
self:spawn(7, 0x58, 0x58)
self:startScene(15)
self:enterDelay(3, function()
self.bgp = 0xE4
self:spawn(7, 0x58, 0x58)
self:startScene(15)
end)
end
elseif scene == 15 then
if self.timer > 0 then
@@ -687,7 +774,7 @@ function YellowIntro:update(dt)
if self.timer > 0 then
self.timer = self.timer - 1
else
self:finish()
self:exitToTitle()
return
end
end
+6 -1
View File
@@ -89,6 +89,11 @@ local PLAYER_SPRITES = {
local PLAYER_PICS = {
back = "assets/generated/battle/redb.png",
demoBack = "assets/generated/battle/oldmanb.png",
-- LoadPlayerBackPic keys the demo pic off wBattleType, not off "is a
-- demo": BATTLE_TYPE_PIKACHU (Yellow's Pallet catch scene) puts PROF.OAK
-- in the player's place with his own back pic, not the old man's (#557).
-- Red/Blue never reach it -- only the Yellow script names that thrower.
oakBack = "assets/generated/battle/profoakb.png",
front = "assets/generated/trainer_card/red.png",
}
@@ -185,7 +190,7 @@ FieldDefaults.CONSTANTS = {
neighborHops = 2, -- connection hops drawn around the current map
stepFrames = 16, -- 1px per frame, 16 frames per tile
bikeStepFrames = 8, -- the bicycle doubles walking speed
turnFrames = 2, -- the extra OverworldLoop pass after a turn
turnFrames = 4, -- tap window before a turn commits to a step
},
-- cumulative slot thresholds out of 256 (engine/battle/wild_encounters.asm)
encounterBuckets = { 51, 102, 141, 166, 191, 216, 229, 242, 253, 256 },
+147 -21
View File
@@ -3,6 +3,7 @@
-- surfing, Cut trees, trainer sight lines, and dispatches interactions to
-- map scripts (data/scripts/), marts, nurses or extracted text.
local Assets = require("src.render.Assets")
local Camera = require("src.render.Camera")
local Collision = require("src.world.Collision")
local Encounter = require("src.world.Encounter")
@@ -359,6 +360,36 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
else
self.player = Player.new(Game.data, x, y, facing)
end
-- boot only: the original persists the surf state. wWalkBikeSurfState
-- (ram/wram.asm) lives inside wMainDataStart..wMainDataEnd, which
-- engine/menus/save.asm block-copies into sMainData on save and back out
-- on load (sram.asm declares sMainData as `ds wMainDataEnd -
-- wMainDataStart`), and Continue never clears it -- the only `xor a /
-- ld [wWalkBikeSurfState], a` on that path is the cable club's. Restore
-- it here, before Music.playMap reads it below and before
-- PikachuFollower.onMapEntered, matching LoadMapData calling
-- LoadPlayerSpriteGraphics ahead of PlayDefaultMusic (home/overworld.asm,
-- home/audio.asm). Without this the player resumed on foot on a water
-- cell, which softlocks here: Collision.canMove (src/world/Collision.lua)
-- picks land tile-pairs whenever mover.surfing is falsy, and land
-- tile-pairs never permit stepping off a water cell (#536). Same
-- boot-only shape as the refreshStandingOnWarp door-mat restore (#378).
if opts and opts.via == "boot" then
local ps = Game.save and Game.save.player
if ps and ps.surfing ~= nil then
self.player.surfing = ps.surfing and true or false
else
-- saves written before #536 carry no flag. Map:isWaterCell alone is
-- not self-sufficient (see src/world/Map.lua: water and shore share
-- one lookup, and no tileset stamps waterTiles, so tile $14 -- a
-- walkable floor in HOUSE/GATE/LOBBY/MANSION/MUSEUM -- reads as
-- water), so gate it the way facingIsShoreOrWater does and require a
-- cell you could not be standing on upright.
self.player.surfing = self:tilesetHasWater()
and not self.map:isWalkableCell(x, y)
and self.map:isWaterCell(x, y)
end
end
-- crossConnection re-arms this after setMap; clear so a warp/reload
-- cannot leave a stale deferred PlayMapMusic pending
self.pendingSeamMusic = nil
@@ -550,9 +581,26 @@ end
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
-- resolves every name to the one global red BG palette inside PaletteFX.pal,
-- so this needs no mode-specific branch.
--
-- TalkToPikachu's framed frontpic is the one exception: pokeyellow
-- LoadOverworldPikachuFrontpicPalettes loads the map pal as slot 0 and
-- PAL_PIKACHU_PORTRAIT as slot 1, then ATTR_BLK's the 5x5 pic at
-- (7,6)-(11,10) onto slot 1 (engine/gfx/palettes.asm:345-391). Without
-- that zone the pic wears the route/town palette and looks washed out.
function OverworldState:sgbPalettes()
local PaletteFX = require("src.render.PaletteFX")
return PaletteFX.wholeNamed(Game.data, self:paletteNameFor(self.map))
local mapName = self:paletteNameFor(self.map)
if self.emote and self.emote.pikaPic then
local base = PaletteFX.pal(Game.data, mapName)
if not base then return nil end
local zones = { PaletteFX.whole(base) }
local portrait = PaletteFX.pal(Game.data, "PIKACHU_PORTRAIT")
if portrait then
zones[#zones + 1] = PaletteFX.zone(portrait, 7, 6, 11, 10)
end
return zones
end
return PaletteFX.wholeNamed(Game.data, mapName)
end
-- World-pass palette zones in world-canvas pixels: each visible map
@@ -1040,19 +1088,38 @@ function OverworldState:handleInput()
-- OverworldLoop (home/overworld.asm) gates ALL of JoypadOverworld on
-- wWalkCounter == 0 ("if the player sprite has not yet completed the
-- walking animation" it jumps straight to .moveAhead): A, START and
-- direction initiation are only ever looked at while the player stands
-- on a tile, and a button pressed mid-step is simply never seen.
-- Without this gate a mid-step A/START pushed its TextBox/StartMenu
-- right there and froze Red between tiles, mid-animation (#286). Held
-- directions need no buffering -- isDown below picks them up on the
-- landing frame.
if self.player.moving then return end
-- direction initiation are only ever ACTED ON while the player stands on
-- a tile. Without this gate a mid-step A/START pushed its TextBox/
-- StartMenu right there and froze Red between tiles, mid-animation
-- (#286). Held directions need no buffering -- isDown below picks them
-- up on the landing frame.
--
-- The original defers the poll rather than discarding it, though. Joypad
-- (engine/joypad.asm _Joypad) computes hJoyPressed against hJoyLast and
-- advances hJoyLast only when something calls it; the mid-step path never
-- does, and vblank's per-frame ReadJoypad refreshes hJoyInput alone.
-- hJoyLast is frozen for the whole animation, so a button pressed
-- mid-step and STILL HELD when the step lands reads as a fresh press at
-- the next poll -- one released before then is genuinely lost. Dropping
-- the edge outright made START a coin flip on the Cycling Road roll,
-- where the pull below re-arms a step on the single idle frame in
-- bikeStepFrames (#525).
if self.player.moving then
local held = self.joyLatch
if not held then held = {}; self.joyLatch = held end
if input:wasPressed("a") then held.a = true end
if input:wasPressed("start") then held.start = true end
return
end
local latch = self.joyLatch
self.joyLatch = nil
if input:wasPressed("a") then
if input:wasPressed("a") or (latch and latch.a and input:isDown("a")) then
self:interact()
return
end
if input:wasPressed("start") then
if input:wasPressed("start")
or (latch and latch.start and input:isDown("start")) then
require("src.core.Sound").play(Game.data, "Start_Menu")
Screens.push(Game, "StartMenu")
return
@@ -1088,6 +1155,13 @@ function OverworldState:handleInput()
end
end
-- .noDirectionButtonsPressed (home/overworld.asm) is the only place that
-- sets wCheckFor180DegreeTurn, so reaching this line -- a poll that found
-- no direction held -- is what re-arms the next turn in place. The early
-- returns above (mid-step, A, START) skip it exactly as the original's
-- jumps to .moveAhead and .displayDialogue do (#415).
self.player.turnArmed = true
-- Cycling Road's downhill pull: with no d-pad held the bike rolls
-- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN).
-- The mask there is PAD_CTRL_PAD | PAD_B | PAD_A, so HOLDING A or B
@@ -1736,7 +1810,7 @@ function OverworldState:tryHiddenObject(fx, fy)
if h.x == fx and h.y == fy then
save.hiddenTaken = save.hiddenTaken or {}
if save.hiddenTaken[key] then return false end
if not require("src.inventory.Bag").add(save, h.item, 1) then
if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
return true
end
@@ -1824,9 +1898,20 @@ function OverworldState:tryHiddenObject(fx, fy)
end
end
-- bench guys (data/events/bench_guys.asm)
-- Bench guys (data/events/bench_guys.asm). A hidden_event's fourth byte
-- is wHiddenEventFunctionArgument, not a facing gate -- pokered's own
-- macro comment says the SPRITE_FACING_* values parked there "do not
-- actually prevent the player from interacting with them in any
-- direction" (data/events/hidden_events.asm). The facing that does decide
-- a bench guy is PrintBenchGuyText's own test against BenchGuyTextPointers,
-- SPRITE_FACING_LEFT for all twelve seats, which the manifest carries as
-- `textFacing`. Gating on the hidden_event byte instead silenced the four
-- seats that store SPRITE_FACING_UP (Vermilion, Saffron, Fuchsia,
-- Cinnabar): (0,4) is the bench wall cell and can only ever be faced from
-- the right (#488).
for _, h in ipairs(extras.benchGuys[self.map.id] or {}) do
if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then
local want = h.textFacing or h.facing
if h.x == fx and h.y == fy and (not want or want == facing) then
local text = OverworldState.benchGuyText(Game.data, save, h.text)
if text then
Game.stack:push(TextBox.new(Game, text))
@@ -2093,6 +2178,7 @@ function OverworldState:billsHouseBillExits()
local Commands = require("src.script.Commands")
local ctx = { game = Game, save = Game.save, overworld = self }
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
require("src.world.PikachuFollower").onBillExitedMachine(Game, self)
local function done()
Game.save.flags.EVENT_MET_BILL = true
Game.save.flags.EVENT_MET_BILL_2 = true
@@ -2355,7 +2441,7 @@ function OverworldState:talkTo(npc)
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
-- the string "0" as truthy, so screen it out and fall through to text.
if d.item and d.item ~= "0" and d.item ~= 0 then
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
return
end
@@ -3761,7 +3847,15 @@ end
-- dev-mode hot reload). The neighbors go too: their strips render the
-- same tileset. When the active map is the one that changed, the player
-- is clamped back in bounds, the NPC pool is reused so runtime handles
-- survive, and the tile-pair table is re-read.
-- survive, and the tile-pair table is re-read. keepMusic: a reload is not a
-- map entry. Its counterpart ReloadMapData (home/reload_tiles.asm) only
-- re-reads the map view and the tileset tile patterns after the Pokedex /
-- start menu / PC clobbered VRAM; map music starts from LoadMapData alone
-- (home/overworld.asm, gated on BIT_NO_MAP_MUSIC). Whatever is playing
-- belongs to the state on top, so a COLORS cycle during a battle
-- (PaletteFX.setMode reloads the live map to rebuild its baked atlas) must
-- not drop the route theme over the battle song (#484). The out-of-bounds
-- fallback below is a real map change and keeps its map music.
function OverworldState:reloadMap(mapId, reason)
MapLoader.invalidate(mapId)
for _, nb in ipairs(self.neighbors or {}) do MapLoader.invalidate(nb.map.id) end
@@ -3769,7 +3863,8 @@ function OverworldState:reloadMap(mapId, reason)
local p = self.player
local x, y, facing = p.cellX, p.cellY, p.facing
Collision.load(Game.data)
self:setMap(mapId, x, y, facing, { seamless = true, via = "reload" })
self:setMap(mapId, x, y, facing,
{ seamless = true, via = "reload", keepMusic = true })
if not self.map:inBounds(x, y) then
local heal = self:healPoint()
Logger.warn("map %s reloaded out from under the player; sending to %s",
@@ -3914,6 +4009,31 @@ function OverworldState:draw()
self:drawUI()
end
-- The emote sheet is OBJ art (engine/overworld/emotion_bubbles.asm builds the
-- bubble out of shadow OAM), so it renders through OBP0, and GBPalNormal
-- (home/palettes.asm:20-26 `ld a, %11010000 ; 3100 / ldh [rOBP0], a`) holds
-- OBP0 at "3100": OBJ color 1 shows as shade 0, color 2 as shade 1, color 3
-- as shade 3. Blitting the raw sheet skipped that lift and left the "!"
-- bubble's interior (color 1) at DMG shade 1 grey instead of white (#505).
-- Same CPU-remap bake as SpriteRenderer.getObpImage and PartyMenu's obpIcon,
-- and it resolves through Assets so a mod's emotes.png override still wins.
-- Color 0's alpha (a tRNS entry on the extracted png) is what keys the
-- bubble's corners out, so carry it through untouched.
local function obpEmoteImage(path)
if not (love.image and love.image.newImageData) then
return love.graphics.newImage(Assets.resolve(path)) -- headless stub
end
local id = Assets.imageData(path)
id:mapPixel(function(_, _, r, _, _, a)
local v = 0
if r > 0.5 then v = 1 -- OBJ colors 0 and 1 -> shade 0
elseif r > 0.17 then v = 170 / 255 -- OBJ color 2 -> shade 1
end -- OBJ color 3 -> shade 3
return v, v, v, a
end)
return love.graphics.newImage(id)
end
-- The SGB palette a tilt-mode billboard at flat foot (fx, fy) sits under.
-- World zones are rectangles in flat world-canvas space (the current map's
-- base fills the view; neighbour maps stack on top), so the last zone that
@@ -4153,7 +4273,7 @@ function OverworldState:drawWorld()
local drawn = false
if bubble and bubble.path then
local ok, img = pcall(function()
self.emoteImg = self.emoteImg or love.graphics.newImage(bubble.path)
self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path)
return self.emoteImg
end)
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
@@ -4475,10 +4595,11 @@ function OverworldState:drawUI()
-- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm
-- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a
-- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so the
-- front pic stands in for every frame of the script; PikachuFollower
-- .picLift lifts it on the runs that draw the alternate pose, and the
-- script's own duration times the beat (#407, #424).
-- script's base frame is ripped as pikachu/pikapic_N.png (#561) but the
-- pikaframe overlays on top of it are not, so PikachuFollower
-- .picLift lifts the base on the runs that draw the alternate pose, and the
-- script's own duration times the beat (#407, #424). Palette zone
-- PAL_PIKACHU_PORTRAIT covers (7,6)-(11,10) via sgbPalettes above.
if self.emote and self.emote.pikaPic then
require("src.render.Font").drawBox(6, 5, 7, 7)
-- one image per path, cached: this draws every frame of the hold, and
@@ -4516,6 +4637,11 @@ function OverworldState:captureSave(save)
save.player.x = self.player.cellX
save.player.y = self.player.cellY
save.player.facing = self.player.facing
-- wWalkBikeSurfState (ram/wram.asm) sits inside the wMainDataStart..
-- wMainDataEnd range engine/menus/save.asm block-copies into sMainData,
-- so the original saves and restores the surf state; setMap's boot path
-- reads this back (#536).
save.player.surfing = self.player.surfing and true or false
end
return OverworldState
+95 -6
View File
@@ -189,6 +189,9 @@ function PikachuFollower.current(ow)
end
function PikachuFollower.onMapEntered(game, ow, opts)
-- Bill's House owns a short scripted scene that deliberately keeps
-- Pikachu off the normal trailing loop. A new map instance ends it.
ow.pikachuBillsScene = nil
remove(ow)
if not shouldSpawn(game, ow) then return end
-- opts.keepPikachu is the follower a connection crossing kept alive:
@@ -393,6 +396,7 @@ end
-- (pikachu_follow.asm keeps it one walk step behind)
function PikachuFollower.update(game, ow)
if ow.pikaHop then return end -- the counter hop owns the follower (#417)
if ow.pikachuBillsScene then return end
local npc = findFollower(ow)
if not npc then
if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end
@@ -514,9 +518,12 @@ end
-- pikachu_emotions.asm): pick a scripted emotion, then play its bubble
-- and voiced PCM clip, and raise the framed Pikachu picture the original
-- puts over the map (pikaemotion_pikapic -> pikachu_pic_animation.asm
-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. The
-- per-emotion animation frames (gfx/pikachu/unknown_*) are not extracted,
-- so the front pic stands in for all twenty of them (#407).
-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. Each
-- script's BASE 5x5 frame is ripped as pikachu/pikapic_N.png (#561); the
-- pikaframe overlays it alternates with are a second full-body pose out of
-- the same blob and are still unported, so picLift below stands in for
-- their motion and the battle front pic covers caches built before the
-- rip (#407).
-- ---------------------------------------------------------------------
-- PikachuEmotionTable, reduced to each entry's bubble + pikaemotion_pcm
@@ -711,9 +718,17 @@ function PikachuFollower.talk(game, ow, npc, done)
-- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame;
-- Sprites.path keeps a mod's replacement skin in play.
local Sprites = require("src.pokemon.Sprites")
local pic = Sprites.path(game.data, "PIKACHU", "front",
{ kind = "overworld" })
local anim = PIKAPIC[PIKAPIC_SCRIPT[emotion] or emotion] or PIKAPIC[1]
-- The chosen script's own base frame (its first pikapic_loadgfx, ripped as
-- pikachu/pikapic_N.png). Red/Blue have no such art and Yellow caches
-- built before the rip do not carry it, so both fall back to the battle
-- front pic that stood in for every script before (#561).
local script = PIKAPIC_SCRIPT[emotion] or emotion
local pic = "assets/generated/pikachu/pikapic_" .. script .. ".png"
if not require("src.render.Assets").exists(pic) then
pic = Sprites.path(game.data, "PIKACHU", "front",
{ kind = "overworld" })
end
local anim = PIKAPIC[script] or PIKAPIC[1]
local hold = anim.dur * PIKAPIC_TICK
ow.emote = {
npc = npc, frames = hold, bubble = bi or false, pikaPic = pic,
@@ -741,6 +756,80 @@ function PikachuFollower.picLift(emote)
return 0
end
-- Bill's House has three map-scripted Yellow companion beats
-- (BillsHouseScript0/2/5): Pikachu walks over to investigate Bill, waits at
-- the cell separator, then reacts when Bill reappears. Keep it at the
-- machine until this map instance is discarded, just like the cartridge's
-- disabled following state.
local function billsHouseEmotion(game, ow, npc, bubble)
local Sprites = require("src.pokemon.Sprites")
ow.emote = {
npc = npc, frames = 50, bubble = bubbleIndex(game, bubble) or false,
pikaPic = Sprites.path(game.data, "PIKACHU", "front",
{ kind = "overworld" }),
}
end
local function movePikachu(ow, npc, steps, onDone)
npc.goalX, npc.goalY = nil, nil
idleReset(npc)
local function nextStep(i)
local step = steps[i]
if not step then
if onDone then onDone() end
return
end
ow:scriptMove(npc, step[1], step[2], function() nextStep(i + 1) end)
end
nextStep(1)
end
function PikachuFollower.onBillsHouseEnter(game, ow)
if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then
return
end
if game.save.flags.EVENT_MET_BILL_2 then return end
local npc = findFollower(ow)
if not npc then return end
ow.pikachuBillsScene = true
movePikachu(ow, npc, { { "right", 3 }, { "up", 1 } }, function()
billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE")
end)
end
function PikachuFollower.onBillEnteredMachine(game, ow)
if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end
local npc = findFollower(ow)
if not npc then return end
-- BillsHouseScript3 (pokeyellow scripts/BillsHouse.asm:100-115). The two
-- movement tables are named the wrong way round in the cartridge source:
-- hl is seeded with ..._EnterCellSeparatorDown, then
-- and a ; cp SPRITE_FACING_DOWN (SPRITE_FACING_DOWN is 0)
-- jr nz, .applyPikachuMovement
-- keeps that seeded table when the player is NOT facing down, and only the
-- fallthrough -- facing down -- swaps in ..._EnterCellSeparatorNotDown.
-- So facing down takes the long way round the cell separator and every
-- other facing walks straight up. Following the label names instead of the
-- branch inverts the scene (#455).
local steps = ow.player.facing == "down"
and { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } }
or { { "up", 3 } }
movePikachu(ow, npc, steps, function()
-- PIKAMOVEMENT_LOOK_UP closes the detour table before the bubble
npc.facing = "up"
billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE")
end)
end
function PikachuFollower.onBillExitedMachine(game, ow)
if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end
local npc = findFollower(ow)
if not npc then return end
idleReset(npc)
npc.facing = "left"
billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE")
end
-- ---------------------------------------------------------------------
-- PikachuWalksToNurseJoy (engine/pikachu/pikachu_emotions.asm, run by
-- engine/events/pokecenter.asm once the heal is accepted): the companion
+46 -6
View File
@@ -12,10 +12,24 @@ local Player = {}
Player.__index = Player
local STEP_FRAMES = 16
-- a turn in place holds for the ~2 frames the original spends on the
-- extra OverworldLoop pass (home/overworld.asm .handleDirectionButtonPress
-- returns to the loop without moving after a direction change)
local TURN_FRAMES = 2
-- a turn in place blocks movement for the one extra OverworldLoop pass the
-- original spends after a direction change: .handleDirectionButtonPress ends
-- `jp OverworldLoop` (home/overworld.asm), and OverworldLoop burns two
-- DelayFrame calls before the next JoypadOverworld, so the step can only
-- commit at the following poll -- the same 2-frames-per-iteration cadence
-- that makes STEP_FRAMES 16 above (wWalkCounter = 8, 2px per
-- AdvancePlayerSprite).
-- Those polls sit on a 2-frame grid, so the hardware samples a press 0 or 1
-- frames after the d-pad physically goes down and the release deadline lands
-- 2 or 3 frames after that. We sample on the frame the button goes down
-- with none of that poll latency, so a flat 2 handed every tap the tightest
-- case the original could produce; 4 covers the grid instead of
-- undercutting it (#415).
local TURN_FRAMES = 4
-- The on-screen d-pad cannot produce a 60ms tap: a finger press and release
-- run well past it even before the OS batches the touch events, so the
-- overlay gets a longer window than a physical pad (#415).
local TOUCH_TURN_FRAMES = 8
function Player.new(data, cx, cy, facing)
local self = setmetatable({}, Player)
@@ -63,6 +77,11 @@ function Player.new(data, cx, cy, facing)
self.progress = 0
self.stepFlip = false
self.turnTimer = 0
-- wCheckFor180DegreeTurn (home/overworld.asm): the original only lets a
-- turn in place happen on a poll whose previous pass found no direction
-- held. It starts armed, tryMove spends it, and OverworldState:handleInput
-- re-arms it from a standstill.
self.turnArmed = true
self.inputLocked = false
return self
end
@@ -71,14 +90,35 @@ function Player:position()
return self.cellX, self.cellY
end
-- How long a fresh turn holds the step off for; see TURN_FRAMES. The
-- overlay is detected per source rather than by whether the touch controls
-- are on screen, so a phone with a controller attached still gets the
-- physical pad's window (Input:isTouchDown, src/core/Input.lua).
function Player:turnWindow()
local frames = self.turnFrames or TURN_FRAMES
local input = require("src.core.Game").input
if input and input.isTouchDown and input:isTouchDown(self.facing) then
return math.max(frames, TOUCH_TURN_FRAMES)
end
return frames
end
-- Attempt to start a step; returns "moved"|"turned"|"blocked"|nil.
function Player:tryMove(dir, map, entities)
if self.moving or self.inputLocked then return nil end
if self.facing ~= dir then
self.facing = dir
self.turnTimer = self.turnFrames or TURN_FRAMES
self.bumpFrames = nil -- turning to a new facing ends any wall-bonk cycle
return "turned"
-- .handleDirectionButtonPress only reaches the turn while
-- wCheckFor180DegreeTurn is still set, and .noDirectionButtonsPressed is
-- the one place that sets it (home/overworld.asm), so a facing change
-- made without the d-pad ever coming up steps straight away rather than
-- paying the turn delay at every corner (#415)
if self.turnArmed then
self.turnArmed = false
self.turnTimer = self:turnWindow()
return "turned"
end
end
if self.turnTimer > 0 then return nil end
local ok, why = Collision.canMove(map, entities, self, dir)

Some files were not shown because too many files have changed in this diff Show More