mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
902f0d73d4
Makes the iOS build a first-class citizen: ROM/mod/save import through
the system document picker (the README's missing "UIDocumentPicker
handoff"), Files-app drop-in support, and an opt-in Apple Health
step-sync seam consumed by a new gallery mod (Pokewalker).
Native layer (mobile/ios/native/, wired by mobile/ios/patch_love_src.py
on every build, so the fetched love-src tree stays pristine + re-patchable):
- GRPickerBridge.swift: love.system.pickFile("rom"|"mod"|"sav") and
love.system.createFile on iOS with the same contract as love-android's
SAF picker (picked_rom.gb / picked_mod.zip / picked_save.sav /
export_done.flag in the save dir). Reached from wrap_System.cpp via the
ObjC runtime, so liblove needs no Swift interop.
- GRBootstrap.m: sweeps .gb/.gbc/.zip/.sav dropped in Documents (Files
app / Finder sharing) into the save dir on every activation;
UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace in the plist
overlay. Drop a ROM, open the app, it imports with zero taps.
- GRHealthBridge.swift: love.system.syncHealthSteps() -> read-only
HealthKit step query anchored to the last sync, delivered as
steps_pending.json (merge-not-overwrite). HealthKit entitlement +
usage description included.
Lua:
- RomImporter: iOS rides the Android mobile flows; a 0.5s poll consumes
picker deliveries (iOS pickers are in-process modals, so the Android
refocus rescan never fires); failed pick copies surface as an
on-screen notice via pick_error.txt.
- main.lua: on iOS, stop forwarding touchpressed to the Importer - LOVE
already synthesizes a mousepressed for the primary touch, and the
resulting same-frame double-present made the document picker
auto-dismiss with zero documents (silent import failure).
- mods/pokewalker: opt-in Pokewalker mod (manifest v2, MECHANIC,
permissions declared, mod.card, CHANGELOG, headless test suite 9/9,
modkit validate --base imported + lint clean). Fused into iOS
game.love only; loads dormant anywhere without the bridge.
Build (scripts/build_ios.sh):
- Fix Xcode 26: the global PRODUCT_NAME override also renamed liblove.a
and broke the app link; the app bundle is renamed after the build
instead.
- Fix nondeterministic pack failures: grep -q + pipefail races SIGPIPE
on the game.love content checks.
- Simulator builds sign ad-hoc so entitlements embed (HealthKit works in
the simulator).
- Device builds: signing team auto-detected from the keychain,
CODE_SIGN_STYLE=Automatic + -allowProvisioningUpdates for CLI-only
provisioning, per-team derived bundle ID (explicit App IDs are
globally unique, so third parties can't sign the project default),
gitignored mobile/ios/bundle_id.local pin, and --install to push to a
connected iPhone.
- docs/ios-install.md: a zero-knowledge walkthrough from bare Mac to
playing on an iPhone.
Backward compatibility: no behavior change on desktop or Android. The
new love.system functions exist only under LOVE_IOS; RomImporter's
mobile flag simply includes iOS alongside Android; the main.lua change
is iOS-gated; the Pokewalker mod is packed only by the iOS build script
and its option defaults off.
Verified on an iPhone 17 Pro simulator and an iPhone 16 Pro device:
scripted ROM import to title screen, Files-drop zero-tap import,
picker-driven mod install and save import/export, HealthKit permission
sheet + step credit (4000 steps -> +200 EXP at the default rate through
the engine growth curve).
145 lines
5.8 KiB
Lua
145 lines
5.8 KiB
Lua
-- 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
|