Compare commits

..

61 Commits

Author SHA1 Message Date
bryanthaboi 28f741f72f Merge pull request #1489 from bryanthaboi/dev
Update LauncherView.lua
2026-08-17 14:04:35 -04:00
bryanthaboi 22bcd95da1 Update LauncherView.lua 2026-08-17 14:03:07 -04:00
github-actions ea28f886f3 chore(ios): update app-repo.json [skip ci] 2026-08-17 13:20:07 -04:00
bryanthaboi 6cd8f0ddea Merge pull request #1487 from bryanthaboi/dev
[release 0.2.0]
2026-08-17 13:10:08 -04:00
bryanthaboi 7e25da70f0 CLOSES #1467 2026-08-17 13:06:09 -04:00
bryanthaboi df3d3e7600 [release 0.2.0] 2026-08-17 10:35:40 -04:00
bryanthaboi 8c9af95598 CLOSES #1396, CLOSES #1398, CLOSES #1400, CLOSES #1401, CLOSES #1406, CLOSES #1407, CLOSES #1411, CLOSES #1413, CLOSES #1415, CLOSES #1416, CLOSES #1417, CLOSES #1419, CLOSES #1421, CLOSES #1422, CLOSES #1423, CLOSES #1424, CLOSES #1425, CLOSES #1427, CLOSES #1428, CLOSES #1429, CLOSES #1431, CLOSES #1432, CLOSES #1433, CLOSES #1435, CLOSES #1437, CLOSES #1440, CLOSES #1441, CLOSES #1442, CLOSES #1443, CLOSES #1444, CLOSES #1447, CLOSES #1449, CLOSES #1456, CLOSES #1461, CLOSES #1464, CLOSES #1465, CLOSES #1466, CLOSES #1468, CLOSES #1469, CLOSES #1470 2026-08-17 10:27:14 -04:00
bryanthaboi 45519ad550 CLOSES #1396, CLOSES #1398, CLOSES #1400, CLOSES #1401, CLOSES #1406, CLOSES #1407, CLOSES #1411, CLOSES #1413, CLOSES #1415, CLOSES #1416, CLOSES #1417, CLOSES #1419, CLOSES #1421, CLOSES #1422, CLOSES #1423, CLOSES #1424, CLOSES #1425, CLOSES #1427, CLOSES #1428, CLOSES #1429, CLOSES #1431, CLOSES #1432, CLOSES #1433, CLOSES #1435, CLOSES #1437, CLOSES #1440, CLOSES #1441, CLOSES #1442, CLOSES #1443, CLOSES #1447, CLOSES #1449, CLOSES #1456, CLOSES #1464, CLOSES #1465, CLOSES #1468, CLOSES #1469, CLOSES #1470 2026-08-17 10:15:06 -04:00
bryanthaboi 3cca70608f skins and skin studio 2026-08-17 06:47:33 -04:00
github-actions fb4eaeda10 chore(ios): update app-repo.json [skip ci] 2026-08-16 22:37:21 -04:00
bryanthaboi 69100301a1 Merge pull request #1460 from bryanthaboi/dev 2026-08-16 22:28:20 -04:00
bryanthaboi a5b674f9da Merge pull request #1454 from ShaneMcGovernIE/feat/mod-compute-permission 2026-08-16 22:05:10 -04:00
bryanthaboi 4356b94483 Merge pull request #1404 from emre155/fix/save-editor-cycle-move-safety 2026-08-16 22:04:49 -04:00
bryanthaboi c8bd205d0c Merge pull request #1378 from syybott/experiment/fixed-extended-world-alignment 2026-08-16 22:04:28 -04:00
bryanthaboi 995444774b Merge pull request #1458 from AverageConsumer/codex/android-asymmetric-display-routing 2026-08-16 22:00:33 -04:00
AverageConsumer 360b692963 android: route asymmetric companion displays 2026-08-17 03:00:05 +02:00
bryanthaboi 051040371f Merge pull request #1439 from thibautbus/fix/stat-rise-message-translation 2026-08-16 20:43:54 -04:00
bryanthaboi c5ff95edcf Merge pull request #1450 from thibautbus/fix/translate-clock-and-day-of-week 2026-08-16 20:43:36 -04:00
bryanthaboi d6627eda4c Merge pull request #1426 from thibautbus/fix/manager-state-draw-color 2026-08-16 20:36:13 -04:00
bryanthaboi 6e28cd5dca Merge pull request #1434 from sanjinpepic/upstream-fixes 2026-08-16 20:34:55 -04:00
bryanthaboi 08ddb882af Merge pull request #1448 from AverageConsumer/codex/android-companion-contract 2026-08-16 20:33:22 -04:00
bryanthaboi 2f6f094559 Merge pull request #1446 from 1Jamie/feat/launcher-fixes-and-patch-notes 2026-08-16 20:33:11 -04:00
bryanthaboi 6ac425144d Merge pull request #1436 from ShaneMcGovernIE/feat/android-post-bridge 2026-08-16 20:32:47 -04:00
James Hall 73f561e256 Merge branch 'bryanthaboi:dev' into experiment/fixed-extended-world-alignment 2026-08-16 19:30:19 -05:00
Shane McGovern b739fa76c0 mods: gate love.thread behind a compute permission
Threads open a fresh Lua state with a full standard library, so they
stayed blocked wholesale. PotatoVoxel's prebuilder wants to run its
pure geometry phase on worker threads; the mod declares the new
"compute" permission and the sandbox hands out love.thread only
then. The worker runs the mod's own source (source-only, like every
mod file) and receives data only through channels.
2026-08-16 23:45:09 +01:00
Shane McGovern 0b00faf38e feat(android): add httpPost bridge for mod.postLog log sends
Android ships no curl and the JNI bridge was GET-only, so mod.postLog
failed there with 'no POST transport on this platform' (HostShell.lua).
Add the mirror of httpDownload: GameActivity.httpPost (https-only,
hand-followed redirects re-POSTing the body, one-way), the JNI bridge
with the same old-APK-skew tolerance, the love.system.httpPost binding,
and the HostShell arm that rides it when curl is absent. The body
crosses the JNI as raw bytes (jbyteArray) so a log ring with arbitrary
UTF-8 cannot corrupt through modified-UTF-8 jstring conversion.
2026-08-16 22:22:38 +01:00
thibautbus b20b1370ab Reset ManagerState's draw color after Font.drawBox
Font.drawBox leaves the caller's color at white; every other screen
resets to black right after calling it, but ManagerState.lua's draw()
and drawOverlay() never did. That's invisible on the vanilla tile font
(tile glyphs are black-on-transparent regardless of color) but renders
fully invisible white-on-white text once a mod's TTF font is active.
2026-08-16 23:15:59 +02:00
thibautbus 467566c799 Cover Gold's Light Screen / Reflect fix in the same test
The targeted test only covered the RBY side (ItemEffects.lua,
TrainerAI.lua); src/battle/gen2/Battle.lua's EFFECT_LIGHT_SCREEN/
EFFECT_REFLECT fix had no test at all, spotted when asked whether the
Gold changes were covered.

Adds a minimal MACHOP/TACKLE gen2 fixture (same shape as
tests/gen2_move_effects_test.lua's), calls both MOVE_EFFECTS directly,
and checks a loaded catalog reaches the whole message template (Gold
wraps the full sentence, not just the stat name). Confirmed it catches
the regression: reverting Battle.lua to its pre-fix state fails 2 of
the suite's now 8 checks.
2026-08-16 23:15:18 +02:00
thibautbus d6ddf23f97 Add targeted coverage for the stat-rise message translation fix
No existing test could tell a translated stat name apart from a raw
stat:upper() that never went through Strings() at all: every rose!
message assertion in the suite runs with no catalog loaded, where
Strings() is an identity function either way.

Loads a real catalog (Strings.load) that translates one stat name at a
time and checks it actually reaches the X-item, vitamin, and AI-trainer
X-item messages -- ROM-free, over tests/fixture_data. Confirmed this
catches the regression: reverting src/inventory/ItemEffects.lua and
src/battle/TrainerAI.lua to their pre-fix state fails 5 of 6 checks.
2026-08-16 23:15:18 +02:00
thibautbus 9423337bcc Make the rose! messages' stat name harvestable by the mod catalog tool
Strings(stat:upper()) is a dynamic argument -- tools/modkit.py's
STRINGS_CALL harvester only matches a literal string right after
Strings(/Strings.source(, so it can't discover "ATTACK"/"DEFENSE"/etc.
from these call sites. Translation coverage happened to still work
only because the same literals are independently harvested from
unrelated call sites (MoveEffects.lua's STAT_LABEL, BattleState.lua's
literal Strings("ATTACK") calls) -- real but fragile, found in review.

Reuse the codebase's existing pattern for exactly this situation
(MoveEffects.lua's STAT_LABEL): a local table built at require time
with Strings.source(...), which the harvester can see, resolved to a
translated label at use time with Strings(TABLE[key]). Adds one such
table to ItemEffects.lua (covering its X-item and vitamin call sites,
including "hp") and one to TrainerAI.lua.
2026-08-16 23:15:18 +02:00
thibautbus c280119d03 Translate Gold's Light Screen / Reflect rose! messages
Same theme as the RBY fix, found while checking whether Gold had the
same gap: EFFECT_LIGHT_SCREEN and EFFECT_REFLECT built their "'s
SPCL.DEF/DEFENSE rose!" message by raw string concatenation, bypassing
Strings() entirely -- unlike most other messages in this file (e.g.
"%s\nused %s!" a few lines up), which already go through it.

Wrap the whole message template in Strings(), matching that existing
pattern; the substituted name still comes from monName() as before.

Gold's gen2/Battle.lua has many more messages built the same
unwrapped way (fainted!, learned..., missed!, and so on) -- that is
the much larger "Battle messages" gap already tracked separately and
deliberately left out of this change.
2026-08-16 23:15:18 +02:00
thibautbus 24d0c6528d Translate the stat name in RBY's X-item and vitamin rose! messages
Both the player-side and AI-trainer stat-rise messages (X ATTACK/
DEFENSE/etc. and the vitamins) passed the raised stat's name as a raw
uppercase Lua string (stat:upper()), bypassing Strings() entirely, so
it always rendered in English regardless of the active language even
though the surrounding sentence template was already translated.

Wrap the substituted stat name in Strings() at every call site
(src/inventory/ItemEffects.lua's two player-side messages and
src/battle/TrainerAI.lua's AI-trainer X-item message, found in
review), reusing the same "ATTACK"/"DEFENSE"/"SPEED"/"SPECIAL"/"HP"
keys SummaryMenu.lua's stat labels already look up the same way.
2026-08-16 23:15:18 +02:00
AverageConsumer 1fc34da2a9 android: extend secondary display presentation 2026-08-16 22:57:10 +02:00
sanjinpepic 70b9def0b0 Make Mon.syncIdentity's shiny recompute monotonic
syncIdentity unconditionally recomputed mon.shiny from the mon's DVs,
overwriting whatever was there. It is wired into refreshStats, which
SummaryMenu.new calls on every menu open, so a forced shiny -- Mon.new's
opts.shiny path, DVs that do not themselves read as shiny -- got
un-shinied the moment the summary screen opened, even though opts.shiny
already wins over shiny.roll at construction for exactly this case (a
scripted shiny is the cart overriding the roll, not a roll to be
hooked).

mon.shiny now only ever gets PROMOTED by the DV check, never demoted:
`mon.shiny or Mon.isShiny(...)`. A naturally shiny mon and a plain one
are unaffected -- the DV check still runs and still decides the first
time -- and a mon whose DVs are edited to justify shininess later still
promotes normally; only an already-true shiny stops being able to flip
back to false on a later refresh.
2026-08-16 22:48:34 +02:00
sanjinpepic d03d2af5f8 Pass data through to ItemEffects.partyAction for Gen 2 pack items
Both call sites -- Game2:usePartyItem (the field pack) and
BattleState:useItem (the battle pack) -- asked ItemEffects.partyAction
for an item's family with no `data` argument, even though every other
call in the same functions (useOnMon, usePpItem, applyPartyItem) passed
it through correctly. partyAction resolves through recordFor, which
reads data.gen2ItemEffects when given a dataset and falls back to the
module's own built-in RECORDS table when not -- so with no data, a
mod's own item_effects record was invisible and every mod-defined Gen 2
field or battle item resolved to a nil action, falling straight through
to "isn't going to help here" / "isn't going to help here" without ever
opening the party picker.

Both now pass the live dataset (self.data on Game2, self.game.data on
the battle screen) the same way their sibling calls already did.
2026-08-16 22:48:34 +02:00
sanjinpepic c23f85cba9 Bind gen2Constants in the save editor's Gold bootstrap
bindGoldData points gen2Palettes, gen2Icons, gen2Pokedex, gen2Landmarks,
gen2Roofs and gen2Sprites at the extractor's own Gold tables through
loadGen, but never gen2Constants -- despite Schemas.GEN2 routing
`constants` to that same namespaced-and-differently-shaped category
palettes and icons are in. A save editor boot left data.gen2Constants
unset, so mod.content.constants:get(...) read an empty table instead
of the cart's ordered name lists, misreading the generation and
rejecting every record a mod shaped off it.

data.gen2Constants now goes through the same loadGen("constants") path
the other five already use, falling back the same way they do when no
ROM cache is active.
2026-08-16 22:48:33 +02:00
sanjinpepic 455ff21aff Validate a map object's pokemon field against the pokemon registry
R.maps.objects was f.opt(f.list(f.any)): a static wild encounter's
species (OverworldController.lua's d.pokemon, handed straight to
BattleState.newWild) went completely unchecked at load time, unlike an
encounter slot's species. A typo'd or removed id sat in a loaded mod
and only surfaced as a crash the moment a player reached that object.

Objects share one array across every kind -- NPCs, signs, warps and
static encounters all coexist with no field the loader could use to
tell them apart ahead of time -- so a strict f.rec covering the whole
shape would reject every kind this schema does not enumerate. Added
f.partial, an open counterpart to f.rec: it type-checks (and, through
collectRefs, cross-reference-checks) only the fields it is given and
leaves everything else on the value alone, the same extensibility
f.rec already grants at a record's top level but nowhere further in.
R.maps.objects now types just `pokemon` through it, so a bad species
id is a load-time "unresolved reference" error instead of a runtime
crash, while an NPC object's sprite/movement/range/... fields -- never
named in this schema -- still pass through untouched.
2026-08-16 22:48:33 +02:00
sanjinpepic 2da2168dac Refuse a TM/HM on a species with no tmhm list instead of crashing
ItemEffects.use walked speciesDef.tmhm with a bare ipairs() to check
whether the species could learn the machine's move. A record with no
tmhm field at all -- a mod species that never set one, or any record
missing it for whatever reason -- hit ipairs(nil) and took the whole
game down on the first TM/HM use, rather than reaching the ordinary
"can't learn that move" refusal a species whose list simply omits the
move already gets.

An absent list now reads the same as an empty one: nothing to learn,
same refusal, same sound, same text.
2026-08-16 22:48:33 +02:00
sanjinpepic f62b1268c8 Add an item.use hook around BagMenu's item-use dispatch
useOn was a plain Lua local: every result ItemEffects.use returned fell
through to one unconditional showMessages with no seam a mod could
reach, unlike menu.lua/boxmark.lua/formview.lua's screens, which wrap
their own default behavior as a table field or a Runtime hook. A mod
could not suppress a message, delay it behind a screen of its own, or
substitute a different outcome for one item id -- exactly the gap noted
against Ultra Burst's item-driven fusion, which had nowhere left to
attach a bespoke animation once TextBox.new turned out to be the only
other reachable seam.

This wraps the whole dispatch in a Runtime.call("item.use", ...) hook,
the same mechanism "battle.overlay", "ui.party.submenu" and the rest of
src/ui already use, rather than exporting BagMenu.useOn as a table
field. A hook is the smaller commitment: it is additive (a fresh
Runtime.call site needs no schema or manifest change and costs nothing
unsubscribed -- see tests/engine/gate_hooks.lua's null-object case) and
a mod can still run the vanilla flow unchanged by calling the handed-in
vanilla function, whereas a table field would fix useOn's exact
signature as public API the moment it shipped. If the maintainer would
rather match the sibling screens' convention directly, exporting
BagMenu.useOn is the alternative and does not conflict with this hook
existing alongside it.

vanillaUseOn keeps the original function body; useOn is now the thin
wrapper mods observe through, and every internal caller in this file
still goes through useOn so the hook fires on every path into it.
2026-08-16 22:48:33 +02:00
sanjinpepic 82ae667611 Update the crossValidate comment for growth_rates / evolution_methods
The comment above the gatedFor skip in Schemas.crossValidate still
described growth_rates and evolution_methods as unconfirmable Gen 2
namespaces, the way they were before each got a real Gen 2 id space:
growth_rates keeps its Gen 1 target and is seeded from the extractor's
data.pokemon.growthRates (src/battle/gen2/Mon.lua), and
evolution_methods routes to gen2EvolutionMethods, a fixed literal set
(src/core/gen2/Evolution.lua) that exists with or without a ROM
import. Schemas.GEN2 does not gate either name -- gate_gen2_mod_api.lua
pins that directly, including a case that a bad evolution method on a
Gold species is still caught -- so the two are validated like any other
reference today, not skipped.  Nothing here changes that behavior;
only the comment, which was describing an earlier state of the code,
is corrected.
2026-08-16 22:48:33 +02:00
sanjinpepic 2b5229e73f Deny require("jit.util") in the mod sandbox
DENIED_PREFIX blocked love.* and ffi.* submodule requires but had no
entry for jit, so require("jit.util") walked straight through to the
real module.  jit.util is LuaJIT's own equivalent of the debug library
this file already denies by name: funcbc, funck and the rest read the
bytecode and constants of any function a chunk can reach, which is
enough to recover upvalues -- the real _G, love, io -- that the sandbox
exists to keep out of a mod's hands.

Adding "jit" to DENIED_PREFIX blocks jit.* submodule requires the same
way love.* and ffi.* already are, while leaving the bare jit global
(env.jit, handed over directly for jit.on/off/flush) and a bare
require("jit") untouched -- jit.util is not a field of that table
without its own require, so neither route was ever a way to reach it.
2026-08-16 22:48:33 +02:00
sanjinpepic 881670db91 Clear drainHold once the HP-bar drain actually finishes
stepHPDrain counts drainHold down to 0 as the last step of every phase
(pixel slide, HP-number step, closing frames) but never let go of the
field afterward, so it sat at 0 -- not nil -- for the rest of the
battle.  BattleSafety.inspect uses drainHold ~= nil as its
settled-presentation gate for checkpoint capture, so the very first HP
change in a battle permanently refused every checkpoint after it with
battle_phase_busy, even once the bar had long since caught up.

Only nil the field when the whole drain is actually over (bar pixel,
HP number and the closing-frame hold all settled), not on every
mid-sequence 0 -- a fresh HP change still needs drainHold to read as
busy so BattleSafety keeps refusing captures until that one settles
too.
2026-08-16 22:48:33 +02:00
1jamie a542ed90ba refactor: consolidate loose constants into tables in World.lua and add GameViewport module dependency
and updated the behavior of the patch notes
also fixed manual update checking
added test to make sure no prs or build tasks are able to pass if the luajit limits ar exceeded.
2026-08-16 15:47:56 -05:00
1jamie e9a4a592a4 feat(update): cache fetched release notes and strict-match patch notes version 2026-08-16 15:47:56 -05:00
thibautbus 70f7d5c028 Translate the clock-setting screens' day names and time-of-day word
DAYS (SUNDAY..SATURDAY), the MORN/DAY/NITE word PrintHour prints, and the
"o'clock"/"min." suffixes bypassed src/core/Strings.lua entirely -- they
were plain Lua literals with no lookup, so a translation mod's `strings`
registry had nothing to catch and Oak's clock screens, the day-of-week
wheel, the main menu clock box and the Pokegear's clock card kept printing
English regardless of the loaded language (reported from a real Spanish
Gold build).

Both live in src/core/gen2/Clock.lua, which already owns weekday/hour
arithmetic and is already required by InitClock.lua, MainMenu.lua and
Pokegear.lua: Clock.DAY_NAMES + Clock.weekdayName(day) is the one place the
three screens read a weekday's name from, so a fix to it cannot land on one
screen and silently miss the other two. Clock.daytimeLabel(hour) is the
translated counterpart to Palettes.clockDaytime, which keeps answering the
untranslated MORN/DAY/NITE key every FORCED_DAYTIME lookup in Palettes.lua
compares against -- src/world/gen2/Palettes.lua itself is untouched, so
that module stays pure table/color math with no Strings coupling.
2026-08-16 22:45:03 +02:00
bryanthaboi 46f73b7bb3 Merge pull request #1445 from AverageConsumer/codex/desktop-companion-display 2026-08-16 16:40:10 -04:00
AverageConsumer 99d9908017 render: add cross-platform desktop companion display 2026-08-16 22:24:12 +02:00
bryanthaboi b72d1d34b5 Merge pull request #1438 from AverageConsumer/codex/fix-gen2-viewport-local-limit 2026-08-16 16:15:05 -04:00
AverageConsumer f3619a00c2 fix(gen2): stay below LuaJIT local limit 2026-08-16 20:43:38 +02:00
syybott 524138ff27 Fix wide battle shake test fixture 2026-08-16 11:52:00 -05:00
bryanthaboi fdffb12571 Merge pull request #1399 from AverageConsumer/codex/mod-battle-special-intents 2026-08-16 12:09:03 -04:00
bryanthaboi 0c941cecd4 Merge pull request #1402 from AverageConsumer/codex/mod-field-advanced-actions 2026-08-16 12:08:47 -04:00
bryanthaboi dd59175c71 Merge pull request #1405 from AverageConsumer/codex/mod-render-viewport 2026-08-16 12:08:03 -04:00
github-actions 114352b75f chore(ios): update app-repo.json [skip ci] 2026-08-16 10:23:08 -04:00
AverageConsumer 1f3d13adaf mods: add OS-independent game viewport composition 2026-08-16 15:17:35 +02:00
Yunus Emre Umar 393a1013e4 fix(save-editor): guard cycleMove against undefined moves in catalog
Closes #1403
2026-08-16 16:11:32 +03:00
AverageConsumer a3a20a07e1 feat(mods): expose Fly and Softboiled field actions 2026-08-16 14:57:46 +02:00
AverageConsumer b39e11b7cd feat(mods): add special battle intents 2026-08-16 14:43:08 +02:00
syybott 90eb53b00c Keep WIDE battle visible beneath opaque menus 2026-08-15 23:29:04 -05:00
syybott 6780393f45 Add extended battle HUD visual coverage 2026-08-15 19:39:48 -05:00
syybott 530f2bdd15 Add optional extended widescreen battle HUD 2026-08-15 19:39:48 -05:00
179 changed files with 10872 additions and 855 deletions
+118
View File
@@ -0,0 +1,118 @@
# AI Disclosure
This is a disclosure of the use of AI in this project.
## AI Use
Anyone who demands the dislosure of how AI was used in an engineering project,
has no idea what AI is, or how it works.
AI was used in this project as a tool. Several contributors used AI in their
commits, and so you will see like 7 commits by Claude or Codex or Cursor.
However those commits were reviewed by human beings, and it was declared that
the exact same fix would have been done by a human, so they were accepted.
AI was not used to make decisions, or to create the project.
If you would like to read more, well then continue reading:
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque ante leo, luctus in semper a, maximus ut est. Vivamus nec magna vitae quam luctus suscipit nec eu orci. Vestibulum ut felis a dolor cursus vulputate. Phasellus pharetra elementum sollicitudin. Aenean elementum imperdiet ultrices. In risus mauris, scelerisque sed viverra in, iaculis non eros. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin quam justo, maximus sit amet fringilla in, tristique eget sapien.
Pellentesque rhoncus, erat nec elementum ultricies, augue purus suscipit augue, in condimentum nisl enim vel velit. Etiam at semper turpis. Vestibulum ipsum magna, ultrices non sem ut, hendrerit bibendum mi. Curabitur in varius sapien. Morbi posuere bibendum ex, at ultrices orci. Fusce feugiat ultrices varius. Suspendisse sed ante ligula. Sed dignissim lorem est, nec tristique arcu commodo sed. Cras consectetur sapien dolor, vitae finibus enim lacinia id.
Donec quis magna est. Maecenas dui arcu, venenatis sit amet libero nec, lacinia eleifend leo. Quisque lobortis vulputate lacus a elementum. Proin nec metus lectus. Donec eu auctor sem, at finibus ipsum. Curabitur eget dignissim justo. Donec lobortis leo eu arcu tristique, in volutpat augue eleifend. Morbi lacinia a risus in suscipit. Maecenas suscipit est eu interdum dictum. Cras in nulla imperdiet, dapibus mauris posuere, facilisis velit. Nunc dapibus, leo quis interdum tempor, elit mi mattis dolor, sagittis dictum mi urna sed lectus. Maecenas elementum, mauris id molestie dapibus, diam arcu egestas erat, at tempor justo orci vitae nibh. Etiam sagittis facilisis erat a vulputate. Praesent condimentum ac odio quis sollicitudin.
Fusce vitae orci vestibulum, sagittis dolor non, cursus urna. Morbi eleifend pretium pellentesque. Pellentesque ornare elementum sem in imperdiet. Maecenas dapibus, erat et lobortis porttitor, velit magna auctor odio, quis interdum elit est eu justo. In posuere euismod odio, in porttitor magna iaculis eget. In id quam pulvinar, ultrices dolor in, pellentesque dolor. Nunc varius ante at felis dictum, id porttitor sem efficitur. Integer pretium dignissim commodo. Suspendisse in est a arcu blandit faucibus. Donec quis lacus mollis, tincidunt nunc quis, suscipit nunc. Nunc non arcu dignissim, dignissim sem in, finibus neque. Aliquam non porta eros. Donec et pretium augue, non cursus eros.
Nunc at dignissim nisi. Nam nec metus augue. Proin nulla sapien, tristique a purus vel, vulputate commodo mi. Sed id erat leo. Quisque ullamcorper a nisl id molestie. Aliquam erat volutpat. Donec eget hendrerit mauris. Fusce tincidunt nisl a lorem tincidunt dapibus. Nam volutpat rhoncus tortor.
Nunc et sapien enim. Proin at nunc a nulla maximus consectetur nec eget tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Mauris orci odio, sodales et elementum laoreet, porta at lacus. Maecenas vestibulum lectus risus, pulvinar scelerisque dolor posuere viverra. Proin gravida tellus vitae accumsan dignissim. Nunc non sapien aliquet ex cursus ultricies ac quis diam. Sed luctus feugiat risus eu tincidunt. Duis auctor lacinia fringilla. Donec pretium cursus magna a feugiat. Duis tristique, leo vulputate semper iaculis, est ipsum dapibus lacus, et molestie nulla enim nec ex. Nunc non feugiat neque.
Fusce euismod egestas elit ut pretium. Nulla eros quam, auctor sit amet faucibus eu, scelerisque eu neque. Sed nisi felis, lobortis in sapien a, tempor efficitur nunc. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Pellentesque ut justo est. Sed maximus, tellus in bibendum posuere, quam augue finibus eros, sed gravida arcu diam quis lectus. Donec eu placerat ligula. Ut quis imperdiet lorem. Maecenas a mi ac augue semper sodales. Curabitur in justo velit. Praesent et felis quis enim porttitor sagittis.
Nulla sed sagittis felis, sit amet placerat tortor. Ut metus est, sollicitudin ac turpis quis, aliquet congue lorem. Fusce auctor erat non convallis aliquet. Nullam sodales rutrum tellus ac malesuada. Quisque sem diam, iaculis in ultricies sit amet, fermentum quis sem. Integer condimentum placerat purus non lacinia. Integer hendrerit ultricies tellus, at dignissim nibh. Suspendisse accumsan eget tortor nec cursus. Proin accumsan rhoncus leo, eget pretium est tristique ac.
Sed feugiat sed diam a porta. Nullam varius lacus at fermentum fringilla. Morbi pharetra scelerisque pharetra. Nulla placerat vitae ligula non efficitur. Suspendisse quam dui, rutrum eget nulla eu, semper eleifend ante. Aenean ut condimentum arcu. Suspendisse auctor metus non sem ornare, vel tincidunt odio vehicula. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
Donec vel metus ut libero sollicitudin posuere a nec nulla. Vivamus a scelerisque nisi. Aliquam eu sollicitudin tortor. Aliquam purus eros, blandit et quam et, pretium porttitor sem. Nunc iaculis arcu enim, et maximus sem malesuada in. Morbi nec nunc volutpat, semper diam sit amet, gravida elit. Vestibulum eu turpis vel lacus imperdiet congue. Donec rhoncus auctor sem.
Suspendisse eu lorem non dolor pretium finibus euismod quis dolor. Cras finibus egestas velit, commodo rutrum est placerat sit amet. Pellentesque vitae semper diam, sit amet auctor metus. Sed porttitor porttitor nunc, vel imperdiet neque volutpat quis. Sed hendrerit sapien et lacus imperdiet, nec hendrerit lorem sodales. Integer lobortis rutrum odio at ullamcorper. Aliquam tincidunt magna a tellus suscipit, at porta turpis dapibus. Ut ultricies auctor felis eu feugiat. Sed tempus sem et dictum fringilla. Nunc non pellentesque tortor. Suspendisse pulvinar, arcu ut imperdiet gravida, eros ex mattis mauris, vel ultricies est erat et dui. Praesent porttitor tortor et erat interdum efficitur. Phasellus et luctus lectus, et egestas ante. Praesent ex ipsum, rutrum id efficitur et, vulputate non tortor. Aenean maximus nunc ac purus sodales, et venenatis lacus laoreet.
Cras egestas ultrices dui, at tempor leo varius vitae. Donec porta, nisl nec ornare maximus, est arcu auctor mauris, varius elementum nisl arcu venenatis neque. Aenean metus quam, vestibulum eget justo non, hendrerit dapibus nunc. Vivamus diam ante, mattis sed nulla at, iaculis elementum magna. Sed massa diam, efficitur vel nunc sed, malesuada interdum tortor. Aliquam non neque aliquet ex imperdiet finibus eget ac neque. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae;
Pellentesque fringilla tortor metus, luctus commodo leo gravida et. Fusce nec turpis at lorem rutrum porta vel in justo. Curabitur mattis suscipit felis, id dapibus arcu ornare ut. Proin sapien felis, pulvinar ut tristique vitae, aliquet ac dui. Vestibulum a erat tellus. Vestibulum sagittis dolor eget augue egestas fringilla. Fusce et purus a nunc auctor dapibus vel sed dui. Fusce interdum, libero vel pellentesque rutrum, urna massa iaculis tellus, et aliquam lectus diam vel sem. Donec a nunc et dui semper gravida. Donec posuere, eros eu consequat efficitur, justo metus ullamcorper lectus, et molestie massa ipsum eu sapien. Mauris eu suscipit neque. Morbi convallis sit amet leo a scelerisque. Cras ultrices libero ac mattis accumsan. Nam gravida ligula id erat semper ornare. Duis consequat ut ipsum eu volutpat. Quisque egestas sollicitudin ullamcorper.
Cras pellentesque quam non neque porta fringilla. Integer elementum, augue mattis blandit consequat, enim ipsum finibus ex, quis finibus neque eros eu ex. Fusce at urna justo. Donec erat eros, maximus id mauris vel, rutrum rutrum sapien. Morbi sed rutrum ex. Suspendisse lacus velit, varius ut elementum vitae, finibus non enim. Suspendisse vehicula euismod ipsum, id consequat nulla sodales vel. Morbi eu sem id leo congue dapibus a nec velit. Sed nec neque quam. Etiam rhoncus id nulla id volutpat. Nulla facilisi. Donec non maximus enim. Aenean consequat, sapien sit amet malesuada rutrum, erat sem euismod sapien, et feugiat lectus mauris id velit.
Fusce sodales porttitor gravida. Proin placerat ante nec nibh tempor aliquam. Sed ut diam eu sem fringilla malesuada. Maecenas aliquam risus vel quam dictum, at iaculis nibh pretium. Mauris convallis quam vitae dolor varius suscipit. Etiam nec fermentum dui. Aliquam in magna tincidunt, consectetur quam eget, aliquam purus. Ut dictum aliquet finibus. Pellentesque vel lacinia felis. Nulla malesuada vestibulum varius. Sed quam diam, efficitur id felis in, volutpat bibendum erat. Praesent luctus vulputate urna at interdum. Aenean ac aliquam eros. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Morbi mollis, nisl vel consequat vehicula, massa tellus porta arcu, vel dictum sapien mauris sit amet nunc. Integer sed maximus neque, ac iaculis urna.
Integer non erat a leo euismod convallis quis eget magna. Morbi gravida ac urna sed ornare. Nunc vehicula mauris accumsan, ornare sem in, egestas mauris. Vestibulum vel vulputate felis. Nulla eu scelerisque diam. Suspendisse ac odio tempor nunc pellentesque hendrerit at a magna. Vivamus ultrices nunc ut orci fermentum pharetra. Nullam laoreet hendrerit ligula ut gravida. Proin scelerisque magna sit amet arcu malesuada, pharetra ultrices est molestie. Nullam pulvinar placerat dui, vitae hendrerit tortor luctus sed. Pellentesque elementum tellus eget arcu pulvinar varius.
Ut placerat, magna vitae tincidunt ultricies, est orci aliquet urna, at luctus augue erat vel ipsum. Fusce odio sem, venenatis vel consequat nec, bibendum sed dolor. Cras a sodales eros. Nullam eget dui congue, vehicula purus ut, condimentum dui. Maecenas libero ipsum, condimentum tincidunt nisi in, sodales lacinia ex. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.
Duis gravida velit ac euismod suscipit. Proin sed ligula erat. Nullam eu ornare massa, non fringilla felis. Curabitur eu erat ex. Quisque sit amet dolor id arcu mattis scelerisque at at eros. In arcu nulla, fermentum non maximus eget, rhoncus in lacus. Nulla sit amet augue eu tortor vulputate congue. Vivamus laoreet condimentum tempus. Maecenas tempor, diam sed laoreet venenatis, mauris arcu lacinia enim, quis facilisis nunc turpis a ligula. Pellentesque quis placerat nisi, sit amet ullamcorper diam. Suspendisse a elementum elit, vel tristique dolor. Nam pretium ante tortor, vel tristique ipsum ultricies ut. Quisque non lectus imperdiet, placerat erat ac, pharetra tellus. In condimentum at magna a posuere. Aliquam et fringilla ipsum. Sed facilisis, nulla a finibus gravida, elit elit vulputate velit, dictum ornare est sapien a nisl.
Mauris eleifend vulputate felis sed mattis. Praesent id velit vitae ex porta pretium. Cras mollis malesuada justo, ut ornare quam placerat ac. Donec lobortis arcu tellus, luctus tempor mi malesuada quis. Maecenas condimentum libero vitae finibus malesuada. Vestibulum sollicitudin fringilla diam eget egestas. Sed vulputate urna nec ipsum maximus hendrerit. Maecenas blandit ex ut massa sodales, vitae tincidunt lorem ullamcorper. Phasellus vitae nisl ornare, cursus sem a, pulvinar arcu. Vestibulum faucibus risus nec tincidunt pellentesque. Pellentesque vel porttitor ex. Vivamus sollicitudin gravida lacus in suscipit. Aliquam urna neque, sodales quis quam ac, suscipit condimentum ante.
Morbi id arcu sit amet sapien ornare gravida eget quis sapien. In hac habitasse platea dictumst. In quis interdum ligula. Donec sed mi vulputate, scelerisque turpis vitae, interdum odio. Proin tristique condimentum arcu, et malesuada tellus convallis in. Fusce egestas maximus magna, sit amet convallis velit porttitor ut. Curabitur venenatis lacus ut blandit convallis. Phasellus scelerisque congue turpis eget vehicula. Nam venenatis mi sit amet rhoncus pretium. Nulla sed odio purus. Phasellus cursus id sapien ut feugiat. Duis et ipsum vel dui tempus porta. Curabitur non tortor consectetur, sollicitudin tellus mollis, sollicitudin lorem.
Ut quis ornare justo. Nunc aliquam, leo sit amet placerat placerat, dui nulla luctus dui, ac iaculis nisl orci id metus. Vestibulum nunc nunc, porta nec dictum id, feugiat et ante. Quisque lobortis, lacus tristique vestibulum rhoncus, massa nulla dignissim massa, at scelerisque massa nunc at velit. Donec eu ipsum nec dui luctus pulvinar et et turpis. Quisque eu neque erat. Donec varius egestas nunc, ut pretium libero tempor ac. Vestibulum pellentesque mi erat, et semper dolor semper vitae. Morbi enim dui, laoreet non venenatis sit amet, dignissim a orci. Sed id odio turpis. Phasellus non rutrum magna. Maecenas placerat arcu ultricies ultrices congue. Nulla quis neque ligula. Etiam in diam commodo, pharetra mauris ac, pretium nisi. Praesent sed nibh nec odio condimentum commodo vel vel lacus.
Pellentesque id libero vitae ex egestas pharetra placerat nec augue. Ut eget lobortis lorem, at vehicula sapien. Aliquam eu tincidunt ligula. Aenean et vestibulum dui, quis porttitor dui. Nullam quis dolor libero. Sed accumsan eros vitae nisi ornare congue. Aliquam nisi sapien, sollicitudin quis odio vel, pharetra maximus urna.
Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Morbi accumsan felis id urna malesuada, non vulputate velit maximus. Proin urna velit, viverra a metus vel, sollicitudin faucibus nulla. Vivamus ipsum lectus, pharetra sit amet varius vel, ullamcorper nec neque. Suspendisse potenti. Vivamus sit amet justo ac augue placerat hendrerit in eu felis. Integer luctus ex quam, in pellentesque eros interdum sed. Fusce finibus quis neque vitae efficitur. Pellentesque vulputate consectetur egestas. Integer a neque fermentum, tincidunt ligula id, gravida urna. Pellentesque ultrices, leo et suscipit accumsan, lorem nunc porta dui, non congue ligula leo ut urna. Duis vehicula risus in mi eleifend luctus. Duis convallis, mi faucibus pellentesque cursus, libero mauris varius sem, sit amet fermentum massa metus nec tellus. In tortor ligula, faucibus eu nibh id, lobortis viverra erat. Morbi non nisi suscipit, mollis enim at, convallis velit.
Pellentesque consequat imperdiet felis quis scelerisque. Duis aliquam mollis nibh quis tincidunt. Vivamus elit odio, blandit quis volutpat at, blandit nec tortor. Cras maximus ex at odio maximus, dapibus condimentum risus malesuada. Pellentesque viverra orci at ante commodo, quis posuere sapien efficitur. Nunc tristique imperdiet diam elementum lobortis. Fusce velit dui, ultrices id ante pharetra, fermentum egestas augue. Curabitur ante augue, vestibulum non magna quis, feugiat pulvinar diam. Suspendisse sagittis dui a tellus scelerisque, ut tincidunt neque accumsan. Cras pharetra metus vel eros tincidunt, vel tincidunt lacus egestas. Donec eget pellentesque sapien. Cras condimentum in justo pulvinar feugiat. Quisque malesuada ac odio eget rhoncus. Fusce posuere justo sed finibus ornare.
Duis a auctor tortor. Pellentesque lobortis auctor risus, ultrices varius mi cursus quis. Sed dui nulla, mattis sit amet justo mattis, condimentum commodo justo. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Pellentesque hendrerit diam vel lacinia volutpat. Sed et luctus nunc, vitae congue arcu. Aenean placerat tincidunt ipsum. Ut molestie orci eu dapibus viverra. Cras sodales ullamcorper augue, at aliquam ex. Sed et justo augue.
Nullam feugiat risus et turpis faucibus, vel tincidunt nulla consequat. Aliquam libero erat, pellentesque sit amet tellus in, tempor ornare nisl. Donec viverra eget magna non pharetra. Cras sollicitudin, justo ut porttitor venenatis, risus nulla auctor est, at commodo sem urna in mi. Vestibulum mattis sapien vel nibh pulvinar, vel dignissim lacus cursus. Maecenas vel dictum tortor, ac ultricies justo. Praesent quis venenatis nisl. Morbi a diam fringilla, auctor lectus sed, varius est. Praesent faucibus auctor dolor, a commodo nisi mattis id. Fusce porta molestie ultrices. Sed pulvinar, leo ac consectetur hendrerit, erat velit gravida enim, eu blandit ligula justo sit amet neque.
Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean libero risus, porttitor in congue vel, consequat sed felis. Vivamus eget blandit ante. Vivamus vitae mattis massa. In ut dolor sit amet tellus sollicitudin mattis. Mauris iaculis nisl neque, in gravida lectus ultricies nec. Fusce vehicula vehicula lacinia. Fusce viverra sed nisl id rhoncus. Donec sed porta mi. Nam tempus purus non massa tincidunt iaculis. Morbi viverra massa ut gravida vestibulum.
Proin dapibus mi a libero sagittis, id vehicula nulla iaculis. Proin a enim in tortor tincidunt egestas. Integer finibus neque eu nibh pretium, et pellentesque urna finibus. Integer a sollicitudin mauris, at convallis erat. Nullam sit amet lectus sed turpis commodo efficitur. Nulla nec turpis dapibus, suscipit diam quis, vulputate urna. Aenean sed posuere justo.
Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Sed luctus purus nibh, id imperdiet risus pulvinar in. Quisque auctor sem lacinia turpis mollis, nec pretium ipsum suscipit. Aliquam sed metus sagittis, mollis nisi eget, hendrerit libero. Nulla sodales erat semper nisl condimentum, ultricies rhoncus lacus commodo. Ut suscipit libero augue, non vulputate dui tristique vel. Praesent convallis efficitur est, sed tincidunt mauris aliquet in. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
Etiam a vehicula purus. Curabitur lacus erat, ultrices et dictum id, posuere ut risus. Etiam at auctor leo. Pellentesque eleifend est a metus lacinia rutrum. Nullam justo ligula, tempor non facilisis vel, volutpat sed nulla. Integer non dolor consequat, dapibus lectus eu, luctus turpis. Aenean enim erat, sagittis vitae ornare bibendum, faucibus sit amet magna. Phasellus suscipit ultrices faucibus. Sed at risus molestie, viverra ipsum vel, bibendum lectus. Pellentesque molestie vitae risus non viverra. Phasellus eleifend massa id odio sagittis mattis. Nullam velit mauris, viverra quis fermentum sit amet, vestibulum ut ex. Suspendisse imperdiet, sapien sed sagittis pharetra, nisi nibh vulputate metus, quis mattis dolor nisi ut lorem. Praesent vestibulum nibh vulputate lacus pulvinar tempor. Vestibulum vulputate diam ligula, vitae efficitur enim dapibus non. Etiam at ornare enim.
Curabitur aliquet velit enim, euismod faucibus urna euismod sit amet. Vivamus viverra vulputate nulla, ut gravida neque rutrum in. Suspendisse potenti. Nulla vitae neque felis. Etiam eu erat ac nulla ornare volutpat. Quisque ut diam dui. Sed ut massa quis dolor volutpat eleifend. Duis posuere dolor sit amet varius auctor. Donec mollis malesuada erat, eu luctus libero viverra feugiat. Curabitur fermentum velit eu purus fringilla, consequat tincidunt diam rutrum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc volutpat vel est in vehicula. Morbi euismod, tortor ut posuere ullamcorper, velit justo ultricies lorem, vitae tincidunt erat ante vitae sem. Praesent semper, quam eget condimentum finibus, metus leo imperdiet augue, nec fringilla sem nunc id sapien.
Aliquam vestibulum ante porta sem finibus, ut rhoncus elit sodales. Phasellus a lacus congue, sagittis nulla eget, cursus libero. Duis laoreet fringilla faucibus. Aenean gravida lorem sed fringilla facilisis. Pellentesque sodales urna lorem, non rutrum tortor vulputate eget. Duis a enim semper, iaculis sem ac, facilisis urna. Donec iaculis nulla sit amet dignissim volutpat. Mauris cursus dui id feugiat suscipit. Fusce tempor placerat nulla vitae vestibulum. Vivamus imperdiet blandit nulla, in aliquet justo viverra in. Cras malesuada molestie ligula sit amet volutpat. Praesent ornare orci sit amet rutrum eleifend. Ut placerat metus felis, id malesuada justo mollis eget. Etiam mi turpis, pulvinar in pulvinar in, tincidunt in neque.
In venenatis euismod neque, eu convallis diam semper ac. Mauris auctor mi non massa vestibulum viverra. Aenean non turpis sapien. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Praesent gravida feugiat interdum. Vivamus sit amet consequat ligula. Praesent dictum nunc ac sapien ullamcorper consectetur. Ut malesuada blandit neque.
Duis tincidunt mauris sit amet odio dictum accumsan. Suspendisse efficitur nibh magna, ac dapibus augue vulputate nec. Etiam lectus neque, sollicitudin quis sapien vitae, aliquet fermentum mi. Aenean interdum interdum rhoncus. Donec eu libero urna. Donec semper lacus eu nunc scelerisque, vitae viverra quam consectetur. Integer sagittis, nulla congue venenatis auctor, purus justo mollis metus, at rutrum magna arcu nec est. Maecenas felis lorem, consequat non cursus vitae, lobortis vitae nisi. Cras eget magna justo. Nullam sagittis tellus id luctus ultricies. Etiam a arcu efficitur, consectetur libero non, imperdiet turpis. Donec ac velit et nisl semper semper. Duis iaculis interdum nunc sed tempor.
Sed diam odio, sagittis non dignissim nec, accumsan ac diam. Fusce sit amet dui sit amet justo ultrices viverra. Sed vel massa suscipit nibh porttitor laoreet in id nunc. Nam quis libero vitae nunc blandit sollicitudin et a lorem. Duis urna arcu, accumsan sed dignissim sit amet, vulputate at ex. Mauris porttitor libero mauris, vel fringilla diam euismod quis. Sed varius placerat tellus vel efficitur. Phasellus pulvinar gravida magna. Nulla dignissim consectetur finibus.
Nunc quis aliquet nisi. Cras luctus bibendum eros ac dignissim. Aenean suscipit felis vitae elementum eleifend. Proin commodo nunc non diam dignissim, in tincidunt nulla ultrices. Vivamus faucibus quam scelerisque interdum finibus. Sed porttitor vehicula urna, in laoreet arcu condimentum non. Praesent ac lacus diam. Vivamus aliquam euismod risus, luctus dictum lectus sodales et. Proin quis velit ac massa tristique scelerisque.
Sed non dolor efficitur, tincidunt mi eget, sagittis tortor. Quisque at varius felis, at finibus sem. Vestibulum vel lectus tincidunt, pharetra diam sit amet, interdum nulla. Sed ut elit tortor. Nam tincidunt tempus aliquam. Vivamus rhoncus faucibus sapien eget facilisis. Aliquam erat volutpat. Phasellus placerat aliquam lacus, eget ultrices orci pretium hendrerit. Fusce vitae dolor sit amet ante condimentum placerat. Nunc varius risus id tellus mollis, euismod luctus sapien viverra. Donec sodales est vel massa suscipit, eu sollicitudin ante convallis. Curabitur eu condimentum velit. Sed pharetra euismod tincidunt.
Nam at libero eros. Quisque bibendum, ligula quis sagittis ullamcorper, eros leo consectetur ex, quis elementum dolor justo vitae mi. Maecenas et elementum erat, et auctor enim. Nunc at nibh fermentum, ullamcorper mi elementum, facilisis erat. Vestibulum vestibulum leo ut pellentesque placerat. Suspendisse imperdiet nisl vitae justo sodales pellentesque. Interdum et malesuada fames ac ante ipsum primis in faucibus. In faucibus pretium nunc, sed interdum lectus vestibulum quis. Vestibulum luctus viverra ex at efficitur. Etiam ac est lorem. Maecenas mollis, orci at rhoncus congue, nulla leo rutrum dui, et pellentesque orci ligula eget ipsum. Suspendisse fermentum nisi turpis, ut sollicitudin purus imperdiet non. Maecenas vitae quam ornare, porta sem quis, rhoncus neque. Donec mattis purus a erat tristique, ac mollis est convallis. Duis vitae ipsum viverra, condimentum ante vel, sagittis ex. Maecenas placerat odio libero, id interdum turpis fermentum at.
Praesent faucibus nulla eget vehicula accumsan. Nulla elementum ante a nibh venenatis hendrerit. Proin nec nunc mattis, imperdiet nisl rutrum, sollicitudin libero. Nullam bibendum dignissim faucibus. Sed eget tortor vitae sapien cursus faucibus nec et lectus. Duis pharetra non odio id consectetur. Suspendisse at est sem. Nunc mauris ligula, ultrices id ante non, venenatis mattis erat. Vivamus sit amet viverra nisl.
Sed cursus vel nisi in mattis. Nunc porttitor dictum leo ac euismod. Sed blandit ornare nunc id lobortis. Aenean convallis ligula at volutpat commodo. Vestibulum sit amet laoreet urna. Donec et pellentesque orci, ac egestas nulla. In accumsan venenatis porta. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Donec scelerisque, metus nec viverra pharetra, dolor libero dictum velit, id ullamcorper enim nisi eu nibh. Aliquam nec dapibus quam. Curabitur vulputate, libero sit amet tempor ullamcorper, libero purus congue quam, nec sollicitudin orci erat non massa. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.
Aenean faucibus mollis placerat. Praesent lacinia venenatis turpis eu scelerisque. Nam tempus tortor a varius posuere. Cras ut viverra tortor. Cras facilisis mauris ut ante imperdiet, a malesuada justo luctus. Ut eu enim sit amet arcu porttitor pulvinar ac ac odio. Ut odio neque, molestie vitae ligula quis, dignissim viverra erat. In ullamcorper erat sed elementum varius. Sed in lacus maximus, euismod nisi vitae, tempus mi. Nunc tellus justo, auctor at luctus ac, feugiat sit amet dui.
Donec imperdiet purus lorem, sed venenatis dolor finibus non. Aenean lacus nunc, elementum nec arcu eget, faucibus elementum turpis. Aliquam lacinia massa ac quam efficitur, et tincidunt eros pretium. Fusce condimentum mi vel pharetra egestas. Quisque consectetur nibh vel leo dignissim sollicitudin. Duis ultrices felis ipsum, sed maximus arcu ornare vitae. Curabitur porttitor ligula in turpis facilisis, id venenatis augue ultricies. Phasellus vel dolor id tellus finibus sodales ut quis nisi. Integer id orci cursus erat tincidunt sagittis non in nunc. Pellentesque ligula lacus, vestibulum eu ante vel, facilisis viverra massa. Sed ut tincidunt metus, vel tristique est. Ut et cursus justo. Ut ac porttitor eros, at dictum felis. Phasellus ornare nisi sit amet risus varius, sed sollicitudin nulla ornare. Donec aliquam ipsum urna. Aliquam id bibendum magna, quis venenatis diam.
Duis tempor odio id iaculis egestas. Cras consequat neque ac posuere iaculis. Nulla tempus et nisi eu auctor. Vestibulum metus massa, dignissim ut metus eget, ullamcorper consectetur turpis. Integer vel est tellus. Ut ac vestibulum massa. Pellentesque nec venenatis erat. Nam vel pellentesque lectus.
Morbi a placerat est. Ut eleifend ante ut placerat porta. Donec sagittis semper leo, ut scelerisque nisi imperdiet feugiat. Mauris purus turpis, consequat ut fringilla ac, cursus eget augue. Fusce arcu dolor, sagittis et facilisis ut, scelerisque non lacus. Aliquam sit amet eleifend tellus. Mauris id est luctus, iaculis tortor eget, gravida justo. Suspendisse at tellus nisl. Nullam felis erat, vehicula eu porttitor bibendum, pulvinar et dui. Sed molestie lacus nec sagittis rutrum. Aliquam erat volutpat. Nullam ut aliquet eros. Sed feugiat, massa id pharetra auctor, leo turpis condimentum purus, sit amet volutpat sem nunc sed nisi.
Pellentesque feugiat ipsum at accumsan iaculis. Morbi et dui in lorem commodo hendrerit. Mauris tempor ex mollis mollis blandit. Cras eu turpis feugiat, suscipit velit quis, volutpat magna. Vestibulum varius ligula ut quam mollis, a volutpat nibh lobortis. Sed sodales euismod leo non suscipit. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nullam ultricies sagittis justo, sit amet lacinia eros tincidunt tristique. Etiam faucibus turpis in lacus efficitur, vitae rutrum magna porttitor. Cras finibus eros vel ante semper, facilisis vestibulum erat accumsan. Ut mollis dui ut commodo varius. In et sem malesuada erat egestas pellentesque. Maecenas pulvinar sodales risus, at euismod ligula aliquet in. Quisque fringilla malesuada dui vel cursus. Curabitur eu ex vulputate, pretium elit non, sodales sapien. Integer egestas facilisis odio et pretium.
Integer eleifend, felis vitae faucibus tempus, tellus lectus placerat nunc, eu efficitur lacus mi vel nisl. Mauris commodo pretium feugiat. In aliquet nibh diam, ac egestas mauris consequat ut. Integer cursus, tortor pharetra pellentesque pulvinar, neque risus ultricies felis, et consequat felis eros at eros. Pellentesque fermentum velit ac sodales facilisis. Suspendisse vestibulum metus quis convallis lacinia. Donec in pharetra magna. Proin gravida dolor eget ligula lobortis sagittis.
Nullam consectetur ut massa id ultrices. Fusce consectetur at eros at mollis. Donec nec nibh fringilla, porttitor ipsum eget, aliquam neque. Quisque suscipit tortor in dui commodo, sed venenatis augue cursus. Etiam feugiat purus id justo elementum placerat. Sed interdum dictum nibh at sodales. Maecenas lobortis, metus ac sagittis lacinia, elit arcu varius felis, quis facilisis magna elit in leo. Proin condimentum orci sit amet dignissim imperdiet. Fusce sed iaculis felis. Maecenas sodales non magna vitae rutrum.
Nulla id ex massa. Sed vehicula sed quam non elementum. Aliquam luctus, enim vel molestie posuere, sem arcu laoreet justo, quis finibus nisl justo et magna. Vivamus malesuada elit in aliquam dignissim. Sed at tellus in orci vulputate ullamcorper. Integer magna sem, mattis id hendrerit non, tincidunt in est. Praesent posuere aliquet lobortis. Quisque euismod leo ut nisl pellentesque, et imperdiet dui dapibus.
Sed a erat nec risus pulvinar venenatis. Integer ultrices eros at aliquet efficitur. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Quisque ac maximus ante. Sed sodales, nisi et sagittis accumsan, diam odio consequat elit, et mollis dolor turpis tincidunt metus. Proin justo quam, tincidunt id convallis a, molestie hendrerit massa. Etiam pharetra turpis eu ultrices mollis.
Proin fermentum libero in purus cursus molestie. In varius magna eu ante maximus, eget rutrum felis iaculis. Maecenas hendrerit, diam eget vestibulum vehicula, est quam porta magna, a dictum urna mi ut libero. Vestibulum dictum quis lacus vitae eleifend. Integer sapien libero, pretium vitae euismod eget, semper eget ante. Vivamus mollis elementum odio vel hendrerit. Phasellus tristique, metus eget luctus tincidunt, ex enim faucibus ipsum, at dictum eros urna ac mi. Aenean imperdiet felis eu ultricies egestas. Vivamus fermentum convallis nisi, non sollicitudin felis posuere nec. Ut commodo sit amet felis semper dictum. Aenean accumsan, tellus id blandit aliquet, lorem nibh pellentesque mi, sit amet volutpat erat ligula ut est. Vivamus non posuere velit. Sed vel rutrum diam, non pulvinar nulla. Suspendisse quis gravida lectus, ac accumsan justo. Sed lobortis neque ante, a imperdiet nisl iaculis nec.
+4
View File
@@ -4,6 +4,10 @@ A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player.
And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. ***Reverse Engineering Causes Obsessive Mental Problems***
[Click Here for the AI Use Disclosure!](AIDisclosure.md)
> [!CAUTION]
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project.
+10
View File
@@ -0,0 +1,10 @@
# gb_anim -- bundled touch skin
Bezel art and overlay layout from libretro's `common-overlays`
(`gamepads/gb_anim_portrait`), licensed CC-BY-4.0:
https://github.com/libretro/common-overlays
`overlay.cfg` is the upstream `gb_big.cfg`, unmodified. It ships as the
reference skin for the RetroArch-overlay loader in
`src/core/TouchSkin.lua`: a full-device bezel, per-button press art, a
screen viewport, and page switching between the DMG and Color shells.
Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+89
View File
@@ -0,0 +1,89 @@
overlays = 2
overlay0_name = "GameBoy"
overlay0_overlay = img/gb_back.png
overlay0_full_screen = true
overlay0_normalized = true
overlay0_range_mod = 1.0
overlay0_alpha_mod = 0.001
overlay0_viewport = "0.0,0.0,1.0,0.505"
overlay0_viewport_fill = true
overlay1_name = "GameBoyColor"
overlay1_overlay = img/gbc_back.png
overlay1_full_screen = true
overlay1_normalized = true
overlay1_range_mod = 1.0
overlay1_alpha_mod = 0.001
overlay1_viewport = "0.0,0.0,1.0,0.505"
overlay1_viewport_fill = true
# GameBoy
overlay0_descs = 18
overlay0_desc0 = "left,0.12778,0.73417,radial,0.09630,0.04635"
overlay0_desc0_overlay = img/gb_left.png
overlay0_desc1 = "right,0.35370,0.73417,radial,0.09630,0.04635"
overlay0_desc1_overlay = img/gb_right.png
overlay0_desc2 = "up,0.24074,0.67063,radial,0.08241,0.05417"
overlay0_desc2_overlay = img/gb_up.png
overlay0_desc3 = "down,0.24074,0.79771,radial,0.08241,0.05417"
overlay0_desc3_overlay = img/gb_down.png
overlay0_desc4 = "left|up,0.09259,0.65188,rect,0.06481,0.03646"
overlay0_desc5 = "right|up,0.38704,0.65188,rect,0.06481,0.03646"
overlay0_desc6 = "left|down,0.09259,0.81750,rect,0.06481,0.03646"
overlay0_desc7 = "right|down,0.38704,0.81750,rect,0.06481,0.03646"
overlay0_desc8 = "a,0.87407,0.72417,radial,0.08889,0.05000"
overlay0_desc8_overlay = img/gb_a_b.png
overlay0_desc9 = "b,0.68148,0.76584,radial,0.08889,0.05000"
overlay0_desc9_overlay = img/gb_a_b.png
overlay0_desc10 = "a|b,0.77037,0.73417,radial,0.02963,0.01667"
overlay0_desc11 = "a|b,0.78518,0.75584,radial,0.02963,0.01667"
overlay0_desc12 = "start,0.66666,0.93000,radial,0.07037,0.03958"
overlay0_desc12_overlay = img/gb_start_select.png
overlay0_desc13 = "select,0.33333,0.93000,radial,0.07037,0.03958"
overlay0_desc13_overlay = img/gb_start_select.png
overlay0_desc14 = "menu_toggle,0.05000,0.52800,radial,0.041296,0.02323"
overlay0_desc14_overlay = img/menu.png
overlay0_desc15 = "overlay_next,0.95000,0.52800,radial,0.041296,0.02323"
overlay0_desc15_overlay = img/rotate.png
overlay0_desc15_next_target = "GameBoyColor"
overlay0_desc16 = "rewind,0.05000,0.97500,radial,0.041296,0.02323"
overlay0_desc16_overlay =
overlay0_desc17 = "hold_fast_forward,0.95000,0.97500,radial,0.041296,0.02323"
overlay0_desc17_overlay =
# GameBoyColor
overlay1_descs = 18
overlay1_desc0 = "left,0.14078,0.73417,radial,0.08530,0.04635"
overlay1_desc0_overlay = img/gbc_left.png
overlay1_desc1 = "right,0.34270,0.73417,radial,0.08530,0.04635"
overlay1_desc1_overlay = img/gbc_right.png
overlay1_desc2 = "up,0.24074,0.67863,radial,0.08241,0.04617"
overlay1_desc2_overlay = img/gbc_up.png
overlay1_desc3 = "down,0.24074,0.78971,radial,0.08241,0.04617"
overlay1_desc3_overlay = img/gbc_down.png
overlay1_desc4 = "left|up,0.09259,0.65188,rect,0.06481,0.03646"
overlay1_desc5 = "right|up,0.38704,0.65188,rect,0.06481,0.03646"
overlay1_desc6 = "left|down,0.09259,0.81750,rect,0.06481,0.03646"
overlay1_desc7 = "right|down,0.38704,0.81750,rect,0.06481,0.03646"
overlay1_desc8 = "a,0.87407,0.72417,radial,0.08889,0.05000"
overlay1_desc8_overlay = img/gbc_a.png
overlay1_desc9 = "b,0.68148,0.76584,radial,0.08889,0.05000"
overlay1_desc9_overlay = img/gbc_b.png
overlay1_desc10 = "a|b,0.77037,0.73417,radial,0.02963,0.01667"
overlay1_desc11 = "a|b,0.78518,0.75584,radial,0.02963,0.01667"
overlay1_desc12 = "start,0.66666,0.93000,radial,0.07037,0.03958"
overlay1_desc12_overlay = img/gbc_start_select.png
overlay1_desc13 = "select,0.33333,0.93000,radial,0.07037,0.03958"
overlay1_desc13_overlay = img/gbc_start_select.png
overlay1_desc14 = "menu_toggle,0.05000,0.52800,radial,0.041296,0.02323"
overlay1_desc14_overlay = img/menu.png
overlay1_desc15 = "overlay_next,0.95000,0.52800,radial,0.041296,0.02323"
overlay1_desc15_overlay = img/rotate.png
overlay1_desc15_next_target = "GameBoy"
overlay1_desc16 = "rewind,0.05000,0.97500,radial,0.041296,0.02323"
overlay1_desc16_overlay =
overlay1_desc17 = "hold_fast_forward,0.95000,0.97500,radial,0.041296,0.02323"
overlay1_desc17_overlay =
+10
View File
@@ -0,0 +1,10 @@
# tv_crt -- bundled desktop bezel
CRT television border from libretro's `common-overlays`
(`borders/tv-integer.cfg` + `borders/img/tv-integer.png`), licensed
CC-BY-4.0: https://github.com/libretro/common-overlays
`overlay.cfg` is the upstream file, unmodified. It is the reference
DESKTOP skin: 1920x1080, `descs = 0` (pure decoration, no touch buttons),
and a `viewport` naming the transparent screen hole, so the Game Boy
picture is fitted into the TV's tube instead of the whole window.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+6
View File
@@ -0,0 +1,6 @@
overlays = 1
overlay0_overlay = img/tv-integer.png
overlay0_full_screen = true
overlay0_descs = 0
overlay0_viewport = "0.2335,0.0855,0.5335,0.830"
overlay0_viewport_fill = true
+15 -2
View File
@@ -6,18 +6,30 @@ function love.conf(t)
local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1"
local companion = nil
if arg then
for _, a in ipairs(arg) do
if a == "--editor" then editor = true end
if a == "--developer" then developer = true end
local port, token = a:match("^%-%-display%-companion=(%d+),([%w]+)$")
if port then companion = { port = tonumber(port), token = token } end
end
end
-- main.lua runs in the same Lua state right after conf.lua; stash the
-- decision in a global so it doesn't need to reparse `arg`.
_G.POKEPORT_EDITOR_MODE = editor
_G.POKEPORT_DEV_MODE = developer
_G.POKEPORT_DISPLAY_COMPANION = companion
if editor then
if companion then
t.identity = "pokemon-love2d-companion"
t.window.title = "gen1recomp Secondary Display"
t.window.width = 640
t.window.height = 576
t.window.minwidth = 160
t.window.minheight = 144
t.window.resizable = true
elseif editor then
-- Same identity as the game, deliberately: the editor edits the game's
-- saves and reads the game's ROM cache, both of which live under this
-- folder. A private editor identity would point love.filesystem at an
@@ -51,7 +63,8 @@ function love.conf(t)
end
t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1
t.modules.joystick = true
t.modules.audio = not companion
t.modules.joystick = not companion
t.modules.physics = false
-- love.system is not loaded during love.conf; love._os is set by the
+17
View File
@@ -702,6 +702,23 @@ M.MT_MOON_B2F = {
return false
end,
talk = {
-- MtMoonB2FSuperNerdText: once beaten his line turns on the fossils
-- (scripts/MtMoonB2F.asm:187), which the header's flat `after` can't hold
TEXT_MTMOONB2F_SUPER_NERD = function(game, ow, npc, done)
if not superNerdBeaten(ow) then
engageSuperNerd(game, ow, done)
return
end
local TextBox = require("src.render.TextBox")
local t = game.data.text
local flags = game.save.flags
local line = (flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL)
and (t._MtMoonB2FSuperNerdTheresAPokemonLabText
or "Far away, on\nCINNABAR ISLAND,\nthere's a POKéMON\nLAB.")
or (t._MtMoonB2fSuperNerdEachTakeOneText
or "We'll each take\none!\nNo being greedy!")
game.stack:push(TextBox.new(game, line, done))
end,
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
+30 -27
View File
@@ -118,33 +118,36 @@ M.ROUTE_15_GATE_2F = {
M.MT_MOON_POKECENTER = {
talk = {
TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = function(game, ow, npc, done)
local t = text(game)
if game.save.flags.EVENT_BOUGHT_MAGIKARP then
push(game, t._MtMoonPokecenterMagikarpSalesmanNoRefundsText
or "Well, I don't\ngive refunds!", done)
return
end
ask(game, t._MtMoonPokecenterMagikarpSalesmanOfferText
or "MAGIKARP! A\nsteal at ¥500!\nWant one?", function(yes)
if not yes then
push(game, t._MtMoonPokecenterMagikarpSalesmanNoText
or "No? I'm only\nselling today!", done)
return
end
if game.save.money < 500 then
push(game, t._MtMoonPokecenterMagikarpSalesmanNoMoneyText
or "You'll need more\nmoney than that!", done)
return
end
game.save.money = game.save.money - 500
game.save.flags.EVENT_BOUGHT_MAGIKARP = true
local Commands = require("src.script.Commands")
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
"MAGIKARP", 5)
push(game, t._GotMonText or "{PLAYER} got\n{RAM:wNameBuffer}!", done)
end)
end,
-- command rows, not a Lua handler: give_pokemon needs a runner to AskName (#1407)
TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = {
{ "check_flag", "EVENT_BOUGHT_MAGIKARP" },
{ "jump_if_true", "no_refunds" },
-- MONEY_BOX goes up between the offer and YesNoChoice -- MtMoonPokecenter.asm:31
{ "text_opts", { money = true } },
{ "ask", "_MtMoonPokecenterMagikarpSalesmanIGotADealText" },
{ "jump_if_false", "declined" },
{ "check_money", 500 },
{ "jump_if_false", "no_money" },
{ "give_pokemon", "MAGIKARP", 5 },
-- MtMoonPokecenter.asm:49 `jr nc, .done`: a refused gift is never charged
{ "jump_if_false", "box_full" },
{ "take_money", 500 },
{ "set_flag", "EVENT_BOUGHT_MAGIKARP" },
{ "text_sound", "Get_Item1" },
{ "show_text", "_GotMonText", { RAM = "MAGIKARP" } },
{ "jump", "end" },
{ "label", "box_full" },
{ "show_text", "_BoxIsFullText" },
{ "jump", "end" },
{ "label", "declined" },
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoText" },
{ "jump", "end" },
{ "label", "no_money" },
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoMoneyText" },
{ "jump", "end" },
{ "label", "no_refunds" },
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoRefundsText" },
},
},
}
+1 -1
View File
@@ -554,7 +554,7 @@ gains a field instead of the name gaining a prefix.
passes `game`; positions 2-4 (mon, row, trigger) match.
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
`render.letterbox`, `render.hud`. Each sits
`render.letterbox`, `render.hud`, `render.viewport`, `render.window`. Each sits
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
-- the logic tick before the pad is read, a pointer the touch overlay gets
first refusal on, the palette zone list handed to the present pass, the
+61 -14
View File
@@ -223,20 +223,29 @@ scripts, battles, and transitions leave the party untouched.
start at the player's current position. Both games expose `bicycle`, `fish`,
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
contextual `squirtbottle` key item. Fishing rows include the owned rods that
are valid choices. The list is empty while the world is busy, and omits an
action whenever its item, move, badge, terrain, or engine state forbids it.
contextual `squirtbottle` key item. Red additionally exposes `softboiled` with
eligible `sources`; each source contains its eligible `targets`. Fishing rows
include the owned rods that are valid choices. The list is empty while the
world is busy, and omits an action whenever its item, move, badge, terrain, or
engine state forbids it.
The optional second return is `"world is busy"` during transient input locks
or `"no overworld"` before a playable world exists.
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
and chooses automatically when only one rod is available. Invalid, stale, and
busy requests return `nil` plus a reason without changing game state. Mods do
not need generation-specific badge, terrain, bike, fishing, or field-move
and chooses automatically when only one rod is available. Red's `softboiled`
accepts one-based `{ sourceSlot, targetSlot }` values copied from its action
record. Invalid, stale, and busy requests return `nil` plus a reason without
changing game state. Mods do not need generation-specific badge, terrain,
bike, fishing, or field-move
logic. Action lists are extensible; callers should render the records they
understand and ignore unknown ids rather than assuming a fixed list length.
Red exposes FLY separately because it requires a destination picker:
`mod.world:canFly()` reports whether FLY is eligible at the current location,
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
Fly town list. Gold does not expose these two methods yet.
## Read-only battle snapshots
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
@@ -278,10 +287,16 @@ The shared Red, Blue, Yellow, and Gold intents are:
- `{ kind = "move", slot = 1..4 }`
- `{ kind = "back" }` while the move menu is active
Red, Blue, and Yellow also expose their generation-specific choices:
- `{ kind = "safari", action = "ball" }` (`bait`, `rock`, and `run` are the
other accepted actions)
- `{ kind = "mimic", index = 1 }` using an entry's snapshot `index`
Menu choices and moves use the same engine methods as the native controls;
`party` and `item` open the native screens rather than exposing or duplicating
their mutable logic. Tutorial, link, Safari, forced, stale, and covered battle
states refuse these core intents. Use `mod.input` for ordinary text advance.
their mutable logic. Tutorial, link, forced, stale, and covered battle states
refuse core intents. Use `mod.input` for ordinary text advance.
## Rendering pipelines
@@ -654,11 +669,14 @@ the wrapper is visible during that same fixed step. The callback receives
`input.pointer` delivers uncaptured gameplay pointer events -- touches and
real mouse input alike. The callback receives `(next, game, ev)` where `ev`
is `{ phase, source, id, x, y, dx, dy, pressure, button }`: `phase` is
is `{ phase, source, id, x, y, gameX, gameY, insideGame, dx, dy, pressure,
button }`: `phase` is
`"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"`
or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates
are LOVE window units, the same space `render.hud`'s viewport and the touch
overlay lay out in. The on-screen touch controls keep first refusal: a
`x` / `y` are LOVE window units, while `gameX` / `gameY` are local to the
active game viewport and `insideGame` says whether the pointer is inside it.
Without a custom viewport both coordinate pairs are identical. The on-screen
touch controls keep first refusal: a
pointer that begins on a virtual control belongs to the pad for its whole
lifecycle and never reaches the hook, while one that begins outside stays
visible even if it later crosses a control. A real mouse reaches the hook
@@ -691,6 +709,22 @@ composited and before touch controls draw. The window-space viewport contains
and `dpiY`, so a tool can use the letterbox margins without drawing over the
playfield or pushing an updating game state.
`render.viewport` lets a layout mod reserve the window-space rectangle in which
the game renders. It receives `(next, ctx)` with the full window's `width`,
`height`, `pixelWidth`, `pixelHeight`, `dpiX`, `dpiY`, and `generation`, and
returns `{ x, y, width, height }`. The engine clamps that rectangle to the
window and makes game layout, safe-area calculations, and rendering use it as
their display. Set `capture = true` to request a composition canvas even when
the rectangle fills the window. With no subscriber, no canvas is allocated and
the normal presentation path is unchanged.
When a viewport is active, `render.window` receives `(next, game, ctx)` after
the game frame has been captured. `ctx` contains its `canvas`, `x`, `y`,
`width`, `height`, the full `windowWidth` / `windowHeight`, `dpiX`, `dpiY`, and
`generation`. Calling `next(game, ctx)` draws the game at the requested origin;
a wrapper may instead compose that canvas with its own UI. Touch controls remain
full-size OS-window chrome and draw after this hook.
`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
@@ -699,11 +733,24 @@ 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)` / `pollTouch()` /
`setEnabled`) for driving a second physical display. `pollTouch()` returns the
oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
`secondScreen` bridge (`available()` / `detected()` / `push(...)` /
`pollTouch()` / `setEnabled`) for driving a second physical display.
`detected()` reports a connected target even while its output is being created;
`available()` means it can accept a frame now. `push(imageData, w, h)` retains
the original contract. Its optional `background` (`0xRRGGBB`) and `preference`
arguments request an extended presentation; a preference ending in `:cover`
fills and crops the target, while other values preserve the whole frame.
Android also accepts `handheld` or `secondary` (with an optional `:cover`
suffix) as routing hints; unsupported or unavailable targets fall back to the
other connected display.
`pollTouch()` returns the oldest queued event as `"action,x,y"` in submitted-frame
coordinates, or `nil`.
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
or push one onto a second screen, without the engine knowing the layout.
On process-capable Windows, Linux and macOS hosts without a native display
bridge, enabling this facade opens a second resizable app window instead. It
uses the same `available`, `detected`, `push`, `pollTouch` and `setEnabled`
contract, so a mod does not need a desktop-specific rendering path.
`render.output_enabled` and `render.output` are the later, whole-window seam
for mods that need the engine's normal composite rather than its separate
+2 -20
View File
@@ -11,28 +11,10 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Persistent custom options** stored separately from game saves
* **Optional widescreen battle layout**
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
* **Translation and custom font support**
* **Built-in save editor** for parties, boxes, items, events, maps, and Pokédex data
* **Tiled map editing tools** for mod authors
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
* **Pokédex diploma and printer image exports**
* **Community mod browser**
* **Soft reset button combination**
* **Keyboard and controller rebinding**
* **Mod profiles** with separate mod settings and save slots
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage
* **Improved launcher and save editor UI**, including background downloads and update checks
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
## Pokémon Gold (Gen 2)
## Gen 2 Specifics
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
* **UI that stays fixed while the overworld zooms**
* **Border-block surrounds** for maps smaller than the screen
* **Gold-specific launcher options**
* **Optional widescreen battle layout**
* **Skippable trade animation** with B or START
* **QUIT and EXIT GAME** from the menus
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
* **Followers** for mods, plus Gen 2-only registries and hooks
* **On-screen touch pad** and controller SELECT for registered items
* **Older mods keep loading** after the sandbox change, through per-mod compat stand-ins for the pre-sandbox globals
+179
View File
@@ -0,0 +1,179 @@
# Touch skins and the Skin Studio
A **skin** replaces the on-screen controls wholesale: a bezel image, a
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
(draw and input), `src/render/Renderer.lua` (the screen viewport),
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
`tests/engine/launcher_skins_tab.lua`.
Skins are picked in the launcher's **Skins** tab, which also imports them and
opens the studio. `options.touchControls.skin` holds the folder name.
## Formats
Two load. `skin.lua` wins when a folder has both.
**RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads
as-is. Supported keys:
| Key | Meaning |
| --- | --- |
| `overlays` | page count |
| `overlayN_name` | page name, the target of `next_target` |
| `overlayN_overlay` | bezel image |
| `overlayN_full_screen` | stretch the page to the window |
| `overlayN_rect` | page placement, default `0,0,1,1` |
| `overlayN_aspect_ratio` | fallback aspect when not full screen |
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
| `overlayN_viewport` | `x,y,w,h`, the screen cutout |
| `overlayN_viewport_fill` | parsed; the engine always fits, see below |
| `overlayN_descM` | `binds,x,y,shape,range_x,range_y` |
| `overlayN_descM_overlay` | control art |
| `overlayN_descM_next_target` | page to switch to |
| `overlayN_descM_range_mod`, `_alpha_mod` | per-control overrides |
| `overlayN_descM_reach_x/_y/_up/_down/_left/_right` | hitbox reach |
`x,y` is the centre and `range_x,range_y` are half extents, both normalized.
Hitboxes are `radial` or `rect`. Pipe-separated binds (`left|down`) are one
control that holds both. A `nul` desc is decoration: it draws and never
captures a touch.
Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every
image sits at the overlay opacity, and a pressed control's image swaps to
`opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1
fades it out, and both directions read as a press animation.
**Native `skin.lua`.** This module's own model written back out: one Lua
table, no flat key space, and a separate `imagePressed` per control that a
`.cfg` cannot express. Loaded with an empty environment, so a skin authored by
a stranger cannot reach `love` or `io`. Sizes here are full width and height
rather than RetroArch's half extents, because that is what an editor's numeric
fields mean.
```lua
return {
name = "my_skin",
pages = {
{
name = "main",
image = "img/bezel.png",
fullScreen = true,
viewport = { x = 0.0, y = 0.0, w = 1.0, h = 0.5, fill = false },
controls = {
{ bind = "a", x = 0.87, y = 0.72, w = 0.18, h = 0.10,
shape = "radial", image = "img/a.png", imagePressed = "img/a_down.png" },
},
},
},
}
```
## Bindable actions
The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`,
`left`, `right`.
Engine hotkeys, handled in `Game:touchSkinHotkey`:
| Bind | Effect |
| --- | --- |
| `overlay_next`, `overlay_previous` | switch page, honouring `next_target` |
| `hold_fast_forward`, `fast_forward` | fast forward while held |
| `toggle_fast_forward` | step the speed option |
| `reset` | soft reset to the title |
| `menu_toggle` | open OPTIONS |
`screenshot`, `pause_toggle` and `exit_emulator` are recognised but have no
handler yet: a control bound to them draws and does nothing. Anything else,
`rewind` included, is not in the bind table at all, so the control falls back
to decoration and never captures a touch.
As an extension to the format, `key:<name>` presses any keyboard key, which is
how a skin button reaches a mod hotkey.
## The screen viewport
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
stretching to it, so a bezel gets an exact 160x144 picture; `viewport_fill` is
parsed but does not stretch. `overlayN_viewport_expand = true` is an extension
that lets a widescreen bezel take the filling survey-zoom world view instead.
A viewport also implies the faithful-ratio lock. Without it the world pass
expands to fill the cutout and you get more map instead of a Game Boy screen.
Border art often ships with a transparent hole and no `viewport` key. **Detect
screen from bezel** in the studio measures the hole out of the art's alpha
channel and writes the rect.
## Bezels versus pads
A skin whose active page binds nothing is a frame rather than a pad: a TV
surround, a handheld shell, a Super Game Boy border. Those draw on **desktop**
as well, where the touch overlay itself does not, and a gamepad does not hide
them. Anything that binds a button still follows the usual mobile /
`POKEPORT_TOUCH` rule.
## Installing
Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on
the launcher window while the Skins tab is open. A zip is mounted in place, so
there is nothing to unpack. The folder needs one `skin.lua` or `.cfg`
(`overlay.cfg` is preferred when there are several) and the images it names.
Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0:
| Skin | Source | Shape |
| --- | --- | --- |
| `gb_anim` | `gamepads/gb_anim_portrait` | handheld shell, working buttons, two pages |
| `tv_crt` | `borders/tv-integer` | CRT television frame, no buttons |
Attribution lives in each folder's `README.md`. `tv_crt` is a photograph of a
real television: CC-BY-4.0 upstream, but treat it as a test asset rather than
shipping branding.
## The studio
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open
that skin. Desktop only: the launcher does not offer it on Android or iOS,
because it wants a mouse, typed coordinates and room for an inspector.
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
phone proportions on a desktop monitor.
| Preset | Size |
| --- | --- |
| Phone portrait / landscape | 1080x1920, 1920x1080 |
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
| Steam Deck | 1280x800 |
| Desktop 1080p | 1920x1080 |
| Ultrawide 21:9 | 2560x1080 |
| Super Game Boy border | 256x224 |
The Super Game Boy preset locks the viewport to the real screen window,
160x144 at (48,40), so an SGB border cannot be drawn out of register.
**Editing.** Click a control to select it, drag to move, eight handles to
resize. X / Y / W / H are in canvas pixels, so a control can be typed to the
coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and
pressed images are per control; the bezel, the pages and the screen cutout are
per page. The cutout is itself a draggable element with a 10:9 lock. Drop a PNG
or JPG on the window to import art into the skin.
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy
buttons and the footer reports what is held. **Play** saves the skin, selects
it, and boots the game with it.
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
skin names, so the folder stands alone. **Export** packs it as one zip
(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the
images, and the original `.cfg` when it came from one. An exported skin drops
straight back into `skins/` and still opens in RetroArch.
## Not implemented
RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types.
Image assignment cycles through art already in the skin folder; there is no
file browser, so new art arrives by drag and drop.
+1
View File
@@ -120,6 +120,7 @@ bundled game, in that case.
## Known limitations
- **`love.run` persists across handoff.** By the time `chainload` runs, the
bundled `love.run` has already returned its stepper to LOVE; redefining the
global `love.run` from the payload's `main.lua` does not affect the loop
+68 -4
View File
@@ -8,6 +8,11 @@
-- opens the editor on that slot's file, and restores the launcher when
-- the editor's Close button is pressed (openEditor / closeEditor below)
if POKEPORT_DISPLAY_COMPANION then
return require("src.render.DesktopCompanion").install(
POKEPORT_DISPLAY_COMPANION)
end
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
@@ -15,6 +20,7 @@ local LaunchOptions = require("src.core.LaunchOptions")
local NxDisplay = require("src.core.NxDisplay")
local PlatformHooks = require("src.core.PlatformHooks")
local HostDisplay = require("src.core.HostDisplay")
local GameViewport = require("src.render.GameViewport")
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
do
@@ -30,7 +36,7 @@ do
end
end
local Game, EditorApp, Importer, TouchEditor
local Game, EditorApp, Importer, TouchEditor, Studio
-- #887: quit-to-launcher state, shared by love.load and love.quit (both need
-- it, so it is declared here rather than next to love.quit).
@@ -249,7 +255,43 @@ function closeTouchControlsEditor()
end
end
local function bootGame(version)
-- ------------------------------------------------------------ skin studio
local studioHost
local closeSkinStudio
local bootGame
local function openSkinStudio(version, skinId)
local SkinStudio = require("src.ui.SkinStudio")
if not SkinStudio.available_desktop() then return end
studioHost = Importer
if Importer and Importer.prepareOverlayHandoff then
Importer:prepareOverlayHandoff()
end
Importer = nil
Studio = SkinStudio
Studio.load({
version = version,
skinId = skinId,
onClose = function() closeSkinStudio() end,
onPlay = function(v)
closeSkinStudio()
Importer = nil
bootGame(v or version)
end,
})
end
function closeSkinStudio()
if Studio and Studio.unload then Studio.unload() end
Studio = nil
Importer = studioHost
studioHost = nil
if Importer and Importer.resumeAfterOverlay then
Importer:resumeAfterOverlay()
end
end
function bootGame(version)
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
-- Set the active version and overlay its extracted cache BEFORE anything
@@ -450,6 +492,8 @@ function love.load(args)
forceImport = forceImport,
onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor,
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
and openSkinStudio or nil,
})
end
@@ -460,6 +504,7 @@ function love.update(dt)
NxDisplay.sync()
if editorMode then return EditorApp.update(dt) end
if TouchEditor then return TouchEditor.update(dt) end
if Studio then return Studio.update(dt) end
if Importer then return Importer:update(dt) end
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
@@ -503,24 +548,36 @@ end
function love.draw()
if editorMode then
GameViewport.reset()
HostDisplay.beginFrame("editor", EditorApp)
local result = EditorApp.draw()
HostDisplay.endFrame("editor", EditorApp)
return result
end
if TouchEditor then
GameViewport.reset()
HostDisplay.beginFrame("touch_editor", TouchEditor)
local result = TouchEditor.draw()
HostDisplay.endFrame("touch_editor", TouchEditor)
return result
end
if Studio then
HostDisplay.beginFrame("skin_studio", Studio)
local result = Studio.draw()
HostDisplay.endFrame("skin_studio", Studio)
return result
end
if Importer then
GameViewport.reset()
HostDisplay.beginFrame("launcher", Importer)
local result = Importer:draw()
HostDisplay.endFrame("launcher", Importer)
return result
end
if not Game then return end
if not Game then
GameViewport.reset()
return
end
HostDisplay.beginFrame("game", Game)
Game:draw()
@@ -543,12 +600,13 @@ end
function love.keypressed(key, scancode, isrepeat)
if editorMode then return EditorApp.keypressed(key) end
if TouchEditor then return TouchEditor.keypressed(key) end
if Studio then return Studio.keypressed(key) end
if Importer then return Importer:keypressed(key) end
Game:keypressed(key)
end
function love.keyreleased(key)
if editorMode or TouchEditor then return end
if editorMode or TouchEditor or Studio then return end
if Importer then return end
Game:keyreleased(key)
end
@@ -777,6 +835,7 @@ function love.wheelmoved(x, y)
return
end
if TouchEditor then return end
if Studio then return Studio.wheelmoved(x, y) end
if Importer then return end
Game:wheelmoved(x, y)
end
@@ -814,6 +873,7 @@ function love.mousepressed(x, y, button, istouch)
if love.system.getOS() == "Android" then return end
return TouchEditor.mousepressed(x, y, button)
end
if Studio then return Studio.mousepressed(x, y, button) end
if Importer then
-- love.touchpressed already forwards the primary touch into FlexLove for
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
@@ -848,6 +908,7 @@ function love.mousereleased(x, y, button, istouch)
if love.system.getOS() == "Android" then return end
return TouchEditor.mousereleased(x, y, button)
end
if Studio then return Studio.mousereleased(x, y, button) end
if Importer then return end
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
@@ -865,6 +926,7 @@ function love.mousemoved(x, y, dx, dy, istouch)
if love.system.getOS() == "Android" then return end
return TouchEditor.mousemoved(x, y)
end
if Studio then return Studio.mousemoved(x, y) end
if editorMode or Importer then return end
if mouseTouch then
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
@@ -875,6 +937,7 @@ end
function love.textinput(text)
if TouchEditor then return end
if Studio then return Studio.textinput(text) end
if Importer then return Importer:textinput(text) end
if editorMode and EditorApp.textinput then
return EditorApp.textinput(text)
@@ -952,6 +1015,7 @@ function love.filedropped(file)
if editorMode and EditorApp and EditorApp.filedropped then
return EditorApp.filedropped(file)
end
if Studio then return Studio.filedropped(file) end
if Importer then Importer:filedropped(file) end
end
@@ -31,6 +31,9 @@
android:allowBackup="true"
android:icon="@drawable/love"
android:label="${NAME}" >
<meta-data
android:name="android.allow_multiple_resumed_activities"
android:value="true" />
<activity
android:name="org.love2d.android.GameActivity"
android:exported="true"
@@ -49,5 +52,15 @@
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
</intent-filter>
</activity>
<activity
android:name="org.love2d.android.GameActivity$SecondaryActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
android:excludeFromRecents="true"
android:exported="false"
android:launchMode="singleTask"
android:resizeableActivity="false"
android:screenOrientation="${ORIENTATION}"
android:taskAffinity="${applicationId}.secondary"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" />
</application>
</manifest>
@@ -325,6 +325,55 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
return result;
}
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent)
{
if (url == nullptr || body == nullptr || bodyLen < 0)
return false;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
// Same resolution rule as httpDownload: the activity's own class via
// SDL_AndroidGetActivity, never FindClass -- this bridge is called off
// the main thread (love.thread workers), whose class loader cannot see
// app classes.
jobject activityObj = (jobject) SDL_AndroidGetActivity();
if (activityObj == nullptr)
return false;
jclass activity = env->GetObjectClass(activityObj);
env->DeleteLocalRef(activityObj);
// Old APK / new liblove skew: report "no transport" the same way a
// missing curl does, instead of aborting on a missing method (#597).
jmethodID method = env->GetStaticMethodID(activity, "httpPost",
"(Ljava/lang/String;[BLjava/lang/String;Ljava/lang/String;)Z");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jstring jurl = env->NewStringUTF(url);
// raw bytes across the bridge: a log ring can carry arbitrary UTF-8,
// and a jstring would run it through modified UTF-8
jbyteArray jbody = env->NewByteArray(bodyLen);
if (jbody != nullptr)
env->SetByteArrayRegion(jbody, 0, bodyLen, (const jbyte*) body);
jstring jct = contentType != nullptr ? env->NewStringUTF(contentType) : nullptr;
jstring jua = userAgent != nullptr ? env->NewStringUTF(userAgent) : nullptr;
jboolean result = env->CallStaticBooleanMethod(activity, method, jurl, jbody, jct, jua);
env->DeleteLocalRef(jurl);
if (jbody != nullptr)
env->DeleteLocalRef(jbody);
if (jct != nullptr)
env->DeleteLocalRef(jct);
if (jua != nullptr)
env->DeleteLocalRef(jua);
env->DeleteLocalRef(activity);
return result;
}
/*
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
* own class, never FindClass -- and the same tolerance for an old APK: a
@@ -1180,6 +1229,68 @@ void love_android_secondary_enable(int on)
env->DeleteLocalRef(activity);
}
extern "C" __attribute__((visibility("default")))
void love_android_secondary_target(int target)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity,
"setSecondaryDisplayTarget", "(I)V");
if (method)
env->CallStaticVoidMethod(activity, method, target);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
}
extern "C" __attribute__((visibility("default")))
int love_android_secondary_detected()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity,
"hasSecondaryDisplayCandidate", "()Z");
jboolean detected = JNI_FALSE;
if (method)
detected = env->CallStaticBooleanMethod(activity, method);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
return detected ? 1 : 0;
}
extern "C" __attribute__((visibility("default")))
int love_android_present_secondary(const void *rgba, int width, int height,
unsigned int background, int cover)
{
if (!rgba || width <= 0 || height <= 0)
return 0;
jlong size = (jlong) width * (jlong) height * 4;
if (size <= 0)
return 0;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "presentSecondaryFrame",
"(Ljava/nio/ByteBuffer;IIIZ)Z");
if (!method)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return 0;
}
jobject frame = env->NewDirectByteBuffer((void *) rgba, size);
if (!frame)
{
env->DeleteLocalRef(activity);
return 0;
}
jboolean shown = env->CallStaticBooleanMethod(activity, method, frame,
width, height, (jint) background, cover ? JNI_TRUE : JNI_FALSE);
env->DeleteLocalRef(frame);
env->DeleteLocalRef(activity);
return shown ? 1 : 0;
}
extern "C" __attribute__((visibility("default")))
const char *love_android_poll_secondary_touch()
{
@@ -98,6 +98,14 @@ bool restartApp();
**/
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
/**
* Blocking HTTPS POST of a raw byte body (GameActivity.httpPost). The
* mirror of httpDownload for mod.postLog log sends, which need POST and
* have no curl on Android. contentType / userAgent may be null. Returns
* whether the server accepted the send (2xx).
**/
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
/**
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
@@ -259,6 +259,21 @@ bool System::httpDownload(const char *url, const char *destPath,
#endif
}
bool System::httpPost(const char *url, const char *body, int bodyLen,
const char *contentType, const char *userAgent) const
{
#ifdef LOVE_ANDROID
return love::android::httpPost(url, body, bodyLen, contentType, userAgent);
#else
LOVE_UNUSED(url);
LOVE_UNUSED(body);
LOVE_UNUSED(bodyLen);
LOVE_UNUSED(contentType);
LOVE_UNUSED(userAgent);
return false;
#endif
}
int System::tlsOpen(const char *host, int port) const
{
#ifdef LOVE_ANDROID
@@ -151,6 +151,14 @@ public:
virtual bool httpDownload(const char *url, const char *destPath,
const char *userAgent = nullptr, const char *accept = nullptr) const;
/**
* Blocking HTTPS POST of a raw byte body (Android only; false
* elsewhere). The mirror of httpDownload for mod.postLog log sends,
* which need POST and have no curl on Android (#597).
**/
virtual bool httpPost(const char *url, const char *body, int bodyLen,
const char *contentType = nullptr, const char *userAgent = nullptr) const;
/**
* TLS client sockets (Android only; every call fails elsewhere, where
* LuaSec or another provider is the answer). Non-blocking by contract:
@@ -139,6 +139,17 @@ int w_httpDownload(lua_State *L)
return 1;
}
int w_httpPost(lua_State *L)
{
const char *url = luaL_checkstring(L, 1);
size_t bodyLen = 0;
const char *body = luaL_checklstring(L, 2, &bodyLen);
const char *ct = luaL_optstring(L, 3, nullptr);
const char *ua = luaL_optstring(L, 4, nullptr);
luax_pushboolean(L, instance()->httpPost(url, body, (int) bodyLen, ct, ua));
return 1;
}
int w_hasBackgroundMusic(lua_State *L)
{
lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -233,6 +244,7 @@ static const luaL_Reg functions[] =
{ "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp },
{ "httpDownload", w_httpDownload },
{ "httpPost", w_httpPost },
{ "tlsOpen", w_tlsOpen },
{ "tlsStatus", w_tlsStatus },
{ "tlsSend", w_tlsSend },
@@ -60,6 +60,7 @@ import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.os.Vibrator;
import android.provider.Settings;
import android.util.Log;
import android.util.DisplayMetrics;
import android.view.*;
@@ -387,10 +388,23 @@ public class GameActivity extends SDLActivity {
public void onResume() {
super.onResume();
onHostResume();
refreshDualScreenDisplayMode();
if (secondaryEnabled) registerSecondaryDisplayListener();
setupSecondaryDisplay();
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
// AYN's panel toggle emits virtual Right Shift, which SDL maps to a
// gameplay button. The setting is absent on other Android devices.
if (secondaryEnabled && dualScreenDisplayMode != -1
&& event.getKeyCode() == KeyEvent.KEYCODE_SHIFT_RIGHT
&& event.getDeviceId() == KeyCharacterMap.VIRTUAL_KEYBOARD) {
return true;
}
return super.dispatchKeyEvent(event);
}
/**
* SDL decides the activity's requested orientation at window creation
* (SDLActivity.setOrientationBis). With a resizable window and no
@@ -741,6 +755,76 @@ public class GameActivity extends SDLActivity {
}
}
/**
* Blocking HTTPS POST, exposed as love.system.httpPost and used by
* src/core/HostShell.lua for mod.postLog. The GET bridge above covers
* downloads; log sends need POST, and Android ships no curl, so this is
* the only POST transport the platform has. Strictly one-way, matching
* the curl branch it mirrors: the response body is drained and
* discarded, and only the 2xx verdict comes back.
*
* Same rules as httpDownload: https only, redirects followed by hand
* (re-POSTing the body on each hop, the way curl -X POST behaves), and
* the call is blocking on the Lua/worker thread -- never the UI thread.
* The body arrives as raw bytes (a jbyteArray across the JNI) because a
* log ring can carry arbitrary UTF-8; a String would risk modified-UTF-8
* corruption on characters outside the BMP.
*/
@Keep
public static boolean httpPost(String url, byte[] body, String contentType, String userAgent) {
if (url == null || body == null) return false;
HttpURLConnection conn = null;
try {
String current = url;
for (int hop = 0; hop < 5; hop++) {
URL parsed = new URL(current);
if (!"https".equalsIgnoreCase(parsed.getProtocol())) return false;
conn = (HttpURLConnection) parsed.openConnection();
conn.setInstanceFollowRedirects(false);
conn.setConnectTimeout(15000);
conn.setReadTimeout(60000);
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("User-Agent",
userAgent == null ? "gen1recomp" : userAgent);
conn.setRequestProperty("Content-Type",
contentType == null ? "text/plain" : contentType);
OutputStream out = new BufferedOutputStream(conn.getOutputStream());
try {
out.write(body);
} finally {
try { out.close(); } catch (IOException ignored) {}
}
int code = conn.getResponseCode();
if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
String next = conn.getHeaderField("Location");
conn.disconnect();
conn = null;
if (next == null) return false;
current = new URL(parsed, next).toString();
continue;
}
if (code < 200 || code > 299) return false;
// drain and discard, so a slow server cannot wedge the
// worker on a full socket buffer
InputStream in = new BufferedInputStream(conn.getInputStream());
try {
byte[] buf = new byte[16384];
while (in.read(buf) > 0) {}
} finally {
try { in.close(); } catch (IOException ignored) {}
}
return true;
}
return false;
} catch (Exception e) {
Log.d("GameActivity", "httpPost failed: " + e.getMessage());
return false;
} finally {
if (conn != null) conn.disconnect();
}
}
/**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive /
@@ -1406,9 +1490,38 @@ public class GameActivity extends SDLActivity {
// 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 final int SECONDARY_TARGET_AUTO = 0;
private static final int SECONDARY_TARGET_HANDHELD = 1;
private static final int SECONDARY_TARGET_EXTERNAL = 2;
// AYN keeps disabled panels registered as ON. This optional setting is the
// usable-state signal: 0 = both, 1 = main only, 2 = second only.
private static final String DUAL_SCREEN_DISPLAY_MODE = "dual_screen_display_mode";
private static final String AYN_SECOND_SCREEN = "Screen-2";
private static volatile SecondaryPresentation secondaryPresentation;
private static volatile SecondaryActivity secondaryActivity;
private static volatile boolean secondaryActivityPending;
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
private static volatile long secondaryRetryAfter;
private static volatile boolean secondaryEnabled = false;
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
private static volatile int dualScreenDisplayMode = -1;
private static volatile byte[] secondaryFrame;
private static volatile int secondaryFrameWidth;
private static volatile int secondaryFrameHeight;
private static volatile int secondaryBackground;
private static volatile boolean secondaryFrameCover;
private static final Object secondaryFrameLock = new Object();
private static volatile long secondaryDetectionAt;
private static volatile boolean secondaryDetected;
private SecondaryDisplayMonitor secondaryDisplayMonitor;
private boolean dualScreenModeObserverRegistered;
private final android.database.ContentObserver dualScreenModeObserver =
new android.database.ContentObserver(new Handler(Looper.getMainLooper())) {
@Override public void onChange(boolean selfChange, Uri uri) {
refreshDualScreenDisplayMode();
rebindSecondaryDisplay();
}
};
private static final int MAX_SECONDARY_TOUCHES = 32;
private static final java.util.ArrayDeque<String> secondaryTouches =
new java.util.ArrayDeque<>();
@@ -1421,86 +1534,206 @@ public class GameActivity extends SDLActivity {
self.runOnUiThread(new Runnable() {
@Override public void run() {
if (on) {
self.refreshDualScreenDisplayMode();
self.registerSecondaryDisplayListener();
setupSecondaryDisplay();
rebindSecondaryDisplay();
} else {
self.unregisterSecondaryDisplayListener();
teardownSecondaryDisplay();
secondaryRetryAfter = 0;
synchronized (secondaryFrameLock) { secondaryFrame = null; }
}
}
});
}
@Keep
public static void setSecondaryDisplayTarget(int target) {
int normalized = target == SECONDARY_TARGET_HANDHELD
|| target == SECONDARY_TARGET_EXTERNAL ? target : SECONDARY_TARGET_AUTO;
if (secondaryTarget == normalized) return;
secondaryTarget = normalized;
secondaryDetectionAt = 0;
rebindSecondaryDisplay();
}
private void refreshDualScreenDisplayMode() {
int mode = Settings.System.getInt(
getContentResolver(), DUAL_SCREEN_DISPLAY_MODE, -1);
if (dualScreenDisplayMode != mode) secondaryDetectionAt = 0;
dualScreenDisplayMode = mode;
}
private void registerSecondaryDisplayListener() {
if (secondaryDisplayMonitor != null || android.os.Build.VERSION.SDK_INT < 17) return;
SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
if (monitor.register()) secondaryDisplayMonitor = monitor;
if (secondaryDisplayMonitor == null && android.os.Build.VERSION.SDK_INT >= 17) {
SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
if (monitor.register()) secondaryDisplayMonitor = monitor;
}
if (dualScreenDisplayMode != -1 && !dualScreenModeObserverRegistered) {
getContentResolver().registerContentObserver(
Settings.System.getUriFor(DUAL_SCREEN_DISPLAY_MODE), false,
dualScreenModeObserver);
dualScreenModeObserverRegistered = true;
}
}
private void unregisterSecondaryDisplayListener() {
SecondaryDisplayMonitor monitor = secondaryDisplayMonitor;
secondaryDisplayMonitor = null;
if (monitor != null) monitor.unregister();
if (dualScreenModeObserverRegistered) {
getContentResolver().unregisterContentObserver(dualScreenModeObserver);
dualScreenModeObserverRegistered = false;
}
}
private static void refreshSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled) return;
SecondaryPresentation current = secondaryPresentation;
Display display = current == null ? null : current.getDisplay();
private static boolean secondaryOutputIsPreferred(GameActivity self) {
Display preferred = findSecondaryDisplay(self, false);
if (preferred == null) return false;
SecondaryPresentation presentation = secondaryPresentation;
Display display = presentation == null
? null : presentation.getDisplay();
if (display == null) {
SecondaryActivity activity = secondaryActivity;
display = activity == null ? null : getActivityDisplay(activity);
}
SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor;
if (current == null) {
setupSecondaryDisplay();
} else if (display == null || monitor == null
|| !monitor.hasDisplay(display.getDisplayId())) {
if (display == null || (monitor != null
&& !monitor.hasDisplay(display.getDisplayId()))) return false;
return display.getDisplayId() == preferred.getDisplayId();
}
private static void rebindSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
self.runOnUiThread(() -> {
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
teardownSecondaryDisplay();
setupSecondaryDisplay();
}
});
}
private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
if (self == null || !secondaryEnabled || secondaryPresentation != null
|| secondaryActivity != null || secondaryActivityPending
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) 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];
}
Display chosen = findSecondaryDisplay(self, true);
if (chosen == null) {
Log.d("GameActivity", "no secondary display found");
return;
}
if (!isPresentationDisplay(chosen)) {
if (android.os.Build.VERSION.SDK_INT < 29) return;
secondaryActivityPending = true;
secondaryActivityTarget = chosen.getDisplayId();
Intent intent = new Intent(self, SecondaryActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_ANIMATION);
android.app.ActivityOptions options = android.app.ActivityOptions.makeBasic();
options.setLaunchDisplayId(secondaryActivityTarget);
self.startActivity(intent, options.toBundle());
final int requestedDisplay = secondaryActivityTarget;
new Handler(Looper.getMainLooper()).postDelayed(() -> {
if (secondaryActivityPending
&& secondaryActivityTarget == requestedDisplay) {
secondaryActivityPending = false;
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
}
}, 1000);
return;
}
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
p.setOnDismissListener(dialog -> {
if (secondaryPresentation == p) {
secondaryPresentation = null;
rebindSecondaryDisplay();
}
});
p.show();
secondaryPresentation = p;
secondaryRetryAfter = 0;
synchronized (secondaryFrameLock) {
if (secondaryFrame != null) {
p.setBackground(secondaryBackground);
p.updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame),
secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover);
}
}
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
} catch (Throwable t) {
Log.d("GameActivity", "secondary display setup failed: " + t);
secondaryPresentation = null;
secondaryActivityPending = false;
secondaryActivityTarget = Display.INVALID_DISPLAY;
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
teardownSecondaryDisplay();
}
}
private static Display findSecondaryDisplay(GameActivity self, boolean logDisplays) {
android.hardware.display.DisplayManager dm =
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
if (dm == null || android.os.Build.VERSION.SDK_INT < 17) return null;
Display gameDisplay = getActivityDisplay(self);
int gameDisplayId = gameDisplay == null
? Display.DEFAULT_DISPLAY : gameDisplay.getDisplayId();
Display handheld = dm.getDisplay(Display.DEFAULT_DISPLAY);
boolean handheldAvailable = android.os.Build.VERSION.SDK_INT >= 29
&& gameDisplayId != Display.DEFAULT_DISPLAY && isDisplayUsable(handheld);
Display external = null;
Display[] presentations = dm.getDisplays(
android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
for (Display d : presentations) {
if (logDisplays) {
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 (external == null && d.getDisplayId() != gameDisplayId
&& isDisplayUsable(d)) external = d;
}
if (secondaryTarget == SECONDARY_TARGET_HANDHELD && handheldAvailable) return handheld;
if (secondaryTarget == SECONDARY_TARGET_EXTERNAL && external != null) return external;
return handheldAvailable ? handheld : external;
}
private static Display getActivityDisplay(android.app.Activity activity) {
return android.os.Build.VERSION.SDK_INT >= 30
? activity.getDisplay() : activity.getWindowManager().getDefaultDisplay();
}
private static boolean isPresentationDisplay(Display display) {
if (display == null || display.getDisplayId() == Display.DEFAULT_DISPLAY) return false;
return android.os.Build.VERSION.SDK_INT < 20
|| (display.getFlags() & Display.FLAG_PRESENTATION) != 0;
}
private static boolean isDisplayUsable(Display display) {
if (display == null) return false;
if (android.os.Build.VERSION.SDK_INT >= 20
&& display.getState() == Display.STATE_OFF) return false;
if (dualScreenDisplayMode == 1 && AYN_SECOND_SCREEN.equals(display.getName())) {
return false;
}
return dualScreenDisplayMode != 2
|| display.getDisplayId() != Display.DEFAULT_DISPLAY;
}
private static void teardownSecondaryDisplay() {
SecondaryPresentation p = secondaryPresentation;
secondaryPresentation = null;
SecondaryActivity a = secondaryActivity;
secondaryActivity = null;
secondaryActivityPending = false;
secondaryActivityTarget = Display.INVALID_DISPLAY;
synchronized (secondaryTouches) { secondaryTouches.clear(); }
if (p != null) {
try { p.dismiss(); } catch (Throwable t) {}
}
if (a != null) {
try { a.finish(); } catch (Throwable t) {}
}
}
@android.annotation.TargetApi(17)
@@ -1527,21 +1760,85 @@ public class GameActivity extends SDLActivity {
return manager.getDisplay(displayId) != null;
}
@Override public void onDisplayAdded(int displayId) { refreshSecondaryDisplay(); }
@Override public void onDisplayRemoved(int displayId) { refreshSecondaryDisplay(); }
@Override public void onDisplayChanged(int displayId) { refreshSecondaryDisplay(); }
private void changed() {
secondaryDetectionAt = 0;
rebindSecondaryDisplay();
}
@Override public void onDisplayAdded(int displayId) { changed(); }
@Override public void onDisplayRemoved(int displayId) { changed(); }
@Override public void onDisplayChanged(int displayId) { changed(); }
}
@Keep
public static boolean hasSecondaryDisplay() {
return secondaryPresentation != null;
return secondaryPresentation != null || secondaryActivity != null;
}
@Keep
public static boolean hasSecondaryDisplayCandidate() {
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (secondaryPresentation != null || secondaryActivity != null) return true;
self.refreshDualScreenDisplayMode();
long now = android.os.SystemClock.uptimeMillis();
if (secondaryDetectionAt != 0 && now - secondaryDetectionAt < 500) {
return secondaryDetected;
}
secondaryDetected = findSecondaryDisplay(self, false) != null;
secondaryDetectionAt = now;
return secondaryDetected;
}
@Keep
public static boolean presentSecondaryFrame(
java.nio.ByteBuffer rgba, int width, int height,
int backgroundColor, boolean cover) {
long bytes = (long) width * height * 4;
if (rgba == null || width <= 0 || height <= 0
|| bytes <= 0 || bytes > Integer.MAX_VALUE
|| rgba.capacity() < bytes) return false;
synchronized (secondaryFrameLock) {
if (secondaryFrame == null || secondaryFrame.length != (int) bytes) {
secondaryFrame = new byte[(int) bytes];
}
rgba.rewind();
rgba.get(secondaryFrame, 0, (int) bytes);
rgba.rewind();
secondaryFrameWidth = width;
secondaryFrameHeight = height;
secondaryBackground = backgroundColor;
secondaryFrameCover = cover;
SecondaryPresentation p = secondaryPresentation;
SecondaryActivity a = secondaryActivity;
if (p == null && a == null) return false;
try {
if (p != null) {
p.setBackground(backgroundColor);
p.updateFrame(rgba, width, height, cover);
} else {
a.setBackground(backgroundColor);
a.updateFrame(rgba, width, height, cover);
}
return true;
} catch (Throwable t) {
GameActivity self = (GameActivity) mSingleton;
if (self != null) self.runOnUiThread(() -> {
teardownSecondaryDisplay();
setupSecondaryDisplay();
});
return false;
}
}
}
@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);
SecondaryActivity a = secondaryActivity;
if ((p != null || a != null) && buf != null && w > 0 && h > 0) {
if (p != null) p.updateFrame(buf, w, h);
else a.updateFrame(buf, w, h);
}
}
@@ -1552,6 +1849,102 @@ public class GameActivity extends SDLActivity {
}
}
private static void applySecondaryImmersive(android.view.Window w) {
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);
}
}
public static class SecondaryActivity extends android.app.Activity {
private FrameView frameView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Display display = getActivityDisplay(this);
if (!secondaryEnabled || display == null
|| display.getDisplayId() != secondaryActivityTarget) {
secondaryActivityPending = false;
secondaryActivityTarget = Display.INVALID_DISPLAY;
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
finish();
return;
}
frameView = new FrameView(this);
android.view.Window w = getWindow();
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
setContentView(frameView);
applySecondaryImmersive(w);
secondaryActivity = this;
secondaryActivityPending = false;
secondaryRetryAfter = 0;
synchronized (secondaryFrameLock) {
if (secondaryFrame != null) {
setBackground(secondaryBackground);
updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame),
secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover);
}
}
}
@Override
protected void onDestroy() {
if (secondaryActivity == this) secondaryActivity = null;
super.onDestroy();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) applySecondaryImmersive(getWindow());
}
@Override
public boolean dispatchKeyEvent(android.view.KeyEvent event) {
GameActivity activity = (GameActivity) mSingleton;
return activity != null
? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
}
@Override
public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
GameActivity activity = (GameActivity) mSingleton;
return activity != null
? activity.dispatchGenericMotionEvent(event)
: super.dispatchGenericMotionEvent(event);
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
frameView.updateFrame(buf, w, h);
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
frameView.updateFrame(buf, w, h, cover);
}
void setBackground(int color) {
frameView.setFrameBackground(color);
}
}
private static class SecondaryPresentation extends android.app.Presentation {
private final FrameView frameView;
@@ -1585,31 +1978,36 @@ public class GameActivity extends SDLActivity {
if (hasFocus) applyImmersive();
}
@Override
public boolean dispatchKeyEvent(android.view.KeyEvent event) {
GameActivity activity = (GameActivity) mSingleton;
return activity != null
? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
}
@Override
public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
GameActivity activity = (GameActivity) mSingleton;
return activity != null
? activity.dispatchGenericMotionEvent(event)
: super.dispatchGenericMotionEvent(event);
}
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);
}
applySecondaryImmersive(getWindow());
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
frameView.updateFrame(buf, w, h);
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
frameView.updateFrame(buf, w, h, cover);
}
void setBackground(int color) {
frameView.setFrameBackground(color);
}
}
private static class FrameView extends View {
@@ -1618,7 +2016,9 @@ public class GameActivity extends SDLActivity {
private final android.graphics.Paint paint = new android.graphics.Paint();
private final Object lock = new Object();
private int fw, fh;
private int backgroundColor = 0xFF000000;
private int activePointer = -1;
private boolean cover;
FrameView(Context context) {
super(context);
@@ -1628,7 +2028,12 @@ public class GameActivity extends SDLActivity {
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
updateFrame(buf, w, h, false);
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
synchronized (lock) {
this.cover = cover;
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);
@@ -1640,6 +2045,13 @@ public class GameActivity extends SDLActivity {
postInvalidate();
}
void setFrameBackground(int color) {
synchronized (lock) {
backgroundColor = 0xFF000000 | (color & 0x00FFFFFF);
}
postInvalidate();
}
private void enqueueTouch(String event) {
synchronized (secondaryTouches) {
if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) {
@@ -1691,12 +2103,15 @@ public class GameActivity extends SDLActivity {
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;
float fit = Math.min((float) vw / fw, (float) vh / fh);
if (fit <= 0) return;
float scale = cover
? Math.max((float) vw / fw, (float) vh / fh)
: fit >= 2f ? (float) Math.floor(fit) : fit;
int dw = Math.round(fw * scale), dh = Math.round(fh * scale);
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
dst.set(dx, dy, dx + dw, dy + dh);
canvas.drawColor(0xFF000000);
canvas.drawColor(backgroundColor);
canvas.drawBitmap(bitmap, null, dst, paint);
}
}
+21
View File
@@ -12,6 +12,27 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.2.0",
"date": "2026-08-17",
"size": 13575299,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.0/gen1recomp++-0.2.0-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1396 Nurse dialogue & options\n- #1398 Alignment of options for changing Pokemon\n- #1400 Flying Bug\n- #1401 [Gold] battlergfx $d9/$da load the wrong row count (jumptable crossed vs macro names)\n- #1406 Magikarp salesman dialogue issues\n- #1407 Not able to nickname Magikarp\n- #1411 No indication for stone evolutions\n- #1413 Using a stone closes menu\n- #1415 Super Nerd dialogue issues\n- #1416 (Pokémon Gold) Pokédex doesn't register other trainers' pokémon as seen\n- #1417 (Pokémon Gold) Pokémon you get in trade aren't being registered as caught\n- #1419 (Pokémon Gold) Deposited pokémon don't get healed\n- #1421 (Pokémon Gold) Bad status and catch state appears on the HUD before they should\n- #1422 (Pokémon Gold) Impossible to have the pokédex register Ditto as caught after it transforms\n- #1423 (Pokémon Gold) No save prompt before changing boxes in the PC\n- #1424 (Pokémon Gold) Quantity for owned TMs not being displayed\n- #1425 (Pokémon Gold) Items quantity in your bag should be alligned to the right\n- #1427 (Pokémon Gold) Can't switch items' position in your bag\n- #1428 (Pokémon Gold) Game doesn't show how many pokémon other trainers have\n- #1429 Pikachu not sliding in before its cry. Stuck on standard pokeball release animation.\n- #1431 Shiny sparkle does not play on your sent out shiny pokemon\n- #1432 Experimental marked mods don't install Android\n- #1433 (Pokémon Gold) Missing prompt for depositing pokémon\n- #1435 When npcs stop you to talk or when you walk up to npcs to talk to them sometimes the player has the wrong sprite\n- #1437 Issues with player sprite on map\n- #1440 hold a direction during cutscene and face the wrong way\n- #1441 Magnet Train missing animation\n- #1442 Radio dial is missing in PokeGear radio\n- #1443 Skipping production logo also skips battle scene\n- #1444 Pokemon lack type immunity to status moves\n- #1447 Soft-lock on Cinnabar Island\n- #1449 Visual error on Route 28\n- #1456 Activating all mods doesn't work properly\n- #1461 #1265 didnt got fixed.\n- #1464 Experiance shared in battle\n- #1465 Changing Touch Layout crashes launcher\n- #1466 #1403 Still Happens\n- #1467 A clearer definition of the use of AI for this reconstruction\n- #1468 [Gold] BICYCLE is broken and some pokegear bug\n- #1469 [Gold] status effects aren't shown in the party overlay or the summary screen of the pokemon\n- #1470 Mod updater doesn't work properly when AppImage is running through Steam or Game Mode (Steam Deck)\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.99",
"date": "2026-08-17",
"size": 11391467,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.99/gen1recomp++-0.1.99-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #597 Pulling mod index fails on Android\n- #1403 Save editor not allowing moves to go past ZAP_CANNON\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @emre155\n- @sanjinpepic\n- @ShaneMcGovernIE\n- @syybott\n- @thibautbus"
},
{
"version": "0.1.98",
"date": "2026-08-16",
"size": 11380117,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.98/gen1recomp++-0.1.98-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1181 Poison seems to trigger twice during the poisoned pokemon's turn\n- #1211 S.S. Anne Visual bug when it's sailing away\n- #1212 the move payday does not grant money in gen 2\n- #1214 Title Screen with OG Red is the wrong color\n- #1224 Windowed and borderless toggle in Gold\n- #1228 No option to nickname starter\n- #1229 Encounter rate grace period not working\n- #1230 Couple of sound effects missing\n- #1231 Using Tackle partially distorts battle sprites\n- #1232 Wild pokemon's sprite disappears early when using a pokeball\n- #1249 Cant use stat items IE HP UP PP UP PROTIEN\n- #1251 You don't have a COIN CASE\n- #1265 Major: Regression from #984 (probably?)\n- #1267 [GOLD] POKEDEX didn't show pokemon appear area\n- #1269 [Gold] shadow ball should be invert the screen\n- #1271 [Gold] substitute image broken/not shown\n- #1272 [Gold] swift still checks accuracy and/or evasion\n- #1273 S.S. Anne Issues\n- #1276 Nurse back to not bowing (and turning)\n- #1279 Rival still not looking at player when initiating first fight\n- #1282 PKMN league PC option missing\n- #1293 Dig animation is bugged in-battle\n- #1296 Opponent's moves failing\n- #1298 Gen1 sound tracks have a fade in period, if you enter a route and immediately exit it while this transition is going on it will land on the wrong music\n- #1301 Pixels aren't square\n- #1303 Animation speed of walking NPCs too slow\n- #1305 Wrong Pikachu cry when getting defeated\n- #1307 Rival theme broken after initial fight in Yellow\n- #1318 Thunder Wave works on Ground-types\n- #1328 Message for turning on the PC missing\n- #1329 Name Select Background\n- #1330 Message before looking at map missing\n- #1331 Messages in Oak's lab missing\n- #1333 E-mail in Oak's lab missing\n- #1334 Missing message after picking starter\n- #1335 No Money Box\n- #1338 Rival's sister missing dialogue and roaming\n- #1340 Color palett doesn't affect attack animations\n- #1341 Pokedex entries look wrong\n- #1343 No dashes in empty attack slots during fights\n- #1344 Town Map not showing player sprite\n- #1345 Wrong health color on OG palett\n- #1346 Health still black when viewing stats\n- #1360 No Surfing Music\n- #1362 Poison damage does not flash the screen\n- #1368 Fishing Rods behaving irregularly\n- #1385 Team Rocket Hideouts missing music\n- #1388 Safeguard targets opponent, not user\n- #1389 Gastly unobtainable\n- #1391 NPC not escorting player to museum\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.97",
"date": "2026-08-16",
+9 -2
View File
@@ -218,13 +218,20 @@ function BattleAPI:submit(intent)
return nil, "stale battle context"
end
local kind = battle:battleKind()
if kind == "oldman" or kind == "link" or kind == "safari" then
if kind == "oldman" or kind == "link" then
return nil, "battle kind is not controllable"
end
if top ~= battle then return nil, "battle menu is covered" end
local ok, err
if intent.kind == "menu" then
if intent.kind == "safari" then
if kind ~= "safari" then return nil, "safari menu is not active" end
ok, err = battle:chooseSafari(intent.action)
elseif kind == "safari" then
return nil, "battle kind is not controllable"
elseif intent.kind == "mimic" then
ok, err = battle:chooseMimic(intent.index)
elseif intent.kind == "menu" then
if battle.phase ~= "menu" then return nil, "battle menu is not active" end
if not MENU_CHOICES[intent.choice] then
return nil, "unknown battle menu choice"
+148 -52
View File
@@ -24,6 +24,7 @@ local Runtime = require("src.mods.Runtime")
local BattleSafety = require("src.battle.BattleSafety")
local Screens = require("src.ui.Screens")
local Status = require("src.battle.Status")
local Theme = require("src.ui.Theme")
local Timing = require("src.core.Timing")
local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder")
@@ -88,6 +89,33 @@ function BattleState:wantsFillScale()
return options and options.battleFit == "fill" or false
end
-- EXTENDED HUD configurations are admitted one at a time after their own
-- placement and screenshot review. FIXED supports the three authored battle
-- backgrounds; FILL uses one adaptive presentation stored as WHITE: stock
-- battles retain the paper field required by Gen 1 back sprites, while arena
-- providers may replace it with their own scene. Only the HUD moves to window
-- space.
function BattleState:extendedHUD()
local options = self.game and self.game.save and self.game.save.options
local bg = options and options.battleBg
return self:wideLayout()
and options and options.battleHud == "extended"
and ((options.battleFit == "fixed"
and (bg == "world" or bg == "white" or bg == "black"))
or (options.battleFit == "fill" and bg == "white"))
end
function BattleState:extendedWorldHUD()
local options = self.game and self.game.save and self.game.save.options
return self:extendedHUD() and options
and options.battleFit == "fixed" and options.battleBg == "world"
end
function BattleState:extendedBlackHUD()
local options = self.game and self.game.save and self.game.save.options
return self:extendedHUD() and options and options.battleBg == "black"
end
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
-- that grow as the window gets bigger or the view is zoomed out. The battle
-- screen itself is untouched: it keeps its white paper field in every mode.
@@ -956,8 +984,8 @@ end
-- Message that opens YES/NO once typed out, keeping the text visible
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
function BattleState:sayChoice(text, onChoose)
table.insert(self.queue, { text = text, choice = onChoose })
function BattleState:sayChoice(text, onChoose, opts)
table.insert(self.queue, { text = text, choice = onChoose, choiceOpts = opts })
end
function BattleState:act(fn)
@@ -1103,6 +1131,15 @@ function BattleState:stepHPDrain()
if not b.shownPx then b.shownPx = targetPx end
if (b.drainHold or 0) > 0 then
b.drainHold = b.drainHold - 1
-- Once the count runs out with nothing left pending (bar and
-- number already on the final total), the drain is over, not just
-- between steps: leave the field at 0 and BattleSafety.inspect
-- reads it as still mid-animation for the rest of the battle,
-- since drainHold ~= nil is its settled-presentation gate.
if b.drainHold <= 0 and b.shownPx == targetPx and b.shownHP == goal
and not b.draining then
b.drainHold = nil
end
busy = true
elseif b.shownPx ~= targetPx then
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
@@ -1449,7 +1486,7 @@ function BattleState:updateQueue()
local fn = item.choice
battle.current = nil
fn(yes)
end))
end, item.choiceOpts))
return true
end
if item and item.auto then
@@ -1807,14 +1844,7 @@ function BattleState:enter()
self:say(self:sendOutText(self.player.name))
-- then the POOF plays and the mon appears with its cry
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false })
self:act(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
self:startGrowIn(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
self:queueSendOutAnim(true)
self:markParticipant()
end
self.phase = "messages"
@@ -2019,6 +2049,38 @@ function BattleState:cancelMove()
return true
end
local SAFARI_ACTION_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 }
function BattleState:chooseSafari(action)
if self.phase ~= "menu" or not self.safari then
return nil, "safari menu is not active"
end
if self.safari.balls <= 0 then return nil, "no safari balls remain" end
local index = SAFARI_ACTION_INDEX[action]
if not index then return nil, "invalid safari action" end
self.menuIndex = index
self:safariAction(action)
return true
end
function BattleState:chooseMimic(index)
if self.phase ~= "mimicSelect" then
return nil, "mimic menu is not active"
end
if type(index) ~= "number" or index % 1 ~= 0 then
return nil, "invalid mimic slot"
end
local pick = self.mimicMoves and self.mimicMoves[index]
local ctx = self.mimicCtx
if not pick or not ctx then return nil, "invalid mimic slot" end
self.mimicIndex = index
self.mimicMoves, self.mimicCtx = nil, nil
self.phase = "messages"
self.nextInsert = 0 -- the copy's anim + text go to the queue head
self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
return true
end
function BattleState:swapMoves(i, j)
if i == j then return end
local moves = self.player.curMoves
@@ -2140,7 +2202,7 @@ function BattleState:update(dt)
self.menuIndex = row * 2 + col + 1
if input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex])
end
return
end
@@ -2248,12 +2310,7 @@ function BattleState:update(dt)
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
elseif input:wasPressed("a") then
require("src.core.Sound").play(self.data, "Press_AB")
local pick = moves[self.mimicIndex]
local ctx = self.mimicCtx
self.mimicMoves, self.mimicCtx = nil, nil
self.phase = "messages"
self.nextInsert = 0 -- the copy's anim + text go to the queue head
self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
self:chooseMimic(self.mimicIndex)
end
return
end
@@ -2617,13 +2674,7 @@ function BattleState:resolveSwitch(newMon)
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
self:queueSendOutAnim(false)
end)
self:act(function()
self:executeAction(self.enemy, self.player, self:enemyAction())
@@ -3168,6 +3219,50 @@ function BattleState:startGrowIn(battler)
table.insert(self.queue, self.nextInsert, { wait = 12 })
end
-- SendOutMon branches on IsThisPartyMonStarterPikachu before the animation:
-- the starter gets no ball and no grow-in -- pokeyellow core.asm:1798-1819
function BattleState:starterPikachuSendOut()
if not require("src.core.GameVersion").isYellow() then return false end
local mon = self.player and self.player.mon
return require("src.world.PikachuFollower")
.isStarterPikachu(self.game.save, mon)
end
-- StarterPikachuBattleEntranceAnimation: the back pic walks in from hlcoord
-- 0,5, one column every 2 frames -- engine/battle/pikachu_entrance_anim.asm:1
function BattleState:startPikachuEntrance()
self:slidePic("playerMon", -56, 0, 8, 2)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 16 })
self:actNext(function()
self:slidePic("playerMon")
self:waitSfxNext(self:playEntranceCry(self.player))
end)
end
-- Player send-out tail: POOF_ANIM + AnimateSendingOutMon (core.asm:1757-1762),
-- or the starter Pikachu entrance instead (pokeyellow core.asm:1798-1819)
function BattleState:queueSendOutAnim(append)
local pikachu = self:starterPikachuSendOut()
if not pikachu then
if append then
table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false })
else
self:animNext("POOF_ANIM", false)
end
end
local fn = function()
self.sendingOut = false
if pikachu then
self:startPikachuEntrance()
else
self:startGrowIn(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end
end
if append then self:act(fn) else self:actNext(fn) end
end
-- Should the low-health alarm sound this frame? pokered keys it off
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
@@ -3226,17 +3321,18 @@ end
-- _ScrollTrainerPicAfterBattle (engine/battle/scroll_draw_trainer_pic.asm)
-- brings the beaten foe back in from the right one column every 4 frames.
-- picOff holds the live programs by slot -- "foe" = the enemy trainer pic,
-- "back" = the player's back pic -- as a screen-pixel x offset stepped
-- toward `to`; updateFx advances them, drawPicsLayer adds them, and the
-- queue rows that start them park a { wait } of the matching length. Call
-- with no target to clear a slot (#317, #282).
function BattleState:slidePic(slot, from, to, step)
-- "back" = the player's back pic, "playerMon" = the player's mon back pic --
-- as a screen-pixel x offset stepped toward `to`; updateFx advances them,
-- drawPicsLayer adds them, and the queue rows that start them park a
-- { wait } of the matching length. Call with no target to clear
-- a slot (#317, #282).
function BattleState:slidePic(slot, from, to, step, hold)
self.picOff = self.picOff or {}
if to == nil then
self.picOff[slot] = nil
return
end
self.picOff[slot] = { x = from or 0, to = to, step = step or 4 }
self.picOff[slot] = { x = from or 0, to = to, step = step or 4, hold = hold }
end
-- the live x offset for a pic slot, 0 when nothing is sliding
@@ -3254,10 +3350,18 @@ function BattleState:updateFx()
-- the battle slot) until its owner clears the slot
if self.picOff then
for _, p in pairs(self.picOff) do
if p.x < p.to then
p.x = math.min(p.to, p.x + p.step)
elseif p.x > p.to then
p.x = math.max(p.to, p.x - p.step)
local move = true
if p.hold then
p.held = (p.held or 0) + 1
move = p.held >= p.hold
if move then p.held = 0 end
end
if move then
if p.x < p.to then
p.x = math.min(p.to, p.x + p.step)
elseif p.x > p.to then
p.x = math.max(p.to, p.x - p.step)
end
end
end
end
@@ -4007,8 +4111,8 @@ function BattleState:awardExp()
end
local function applyShare(mon, split, announce)
local playerId = self.game.save.player and self.game.save.player.id
local traded = mon.otId ~= nil and playerId ~= nil
and mon.otId ~= playerId or mon.traded == true and mon.otId == nil
local traded = mon.traded == true
or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId)
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
self.enemy.mon.level, self.kind == "trainer",
split, traded)
@@ -4168,7 +4272,7 @@ function BattleState:enemyMonFainted()
end
end,
})
end)
end, { box = Theme.trainerSwitchBox })
end
self:act(function()
local previous = self.enemy
@@ -4232,12 +4336,7 @@ function BattleState:enemyMonFainted()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
self:startGrowIn(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
self:queueSendOutAnim(false)
end)
return
end
@@ -4428,13 +4527,7 @@ function BattleState:openReplacementMenu()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
self:actNext(function()
self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player)
self:waitSfxNext(self:playEntranceCry(self.player))
end)
self:queueSendOutAnim(false)
end,
})
end)
@@ -5686,7 +5779,10 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
else
local dx, dy = BattleState.backPlacement(img:getWidth(),
img:getHeight(), pad, padL, s)
self:drawBattlerPic(self.player, dx + sx, dy + sy, s)
-- picOffset: StarterPikachuBattleEntranceAnimation walking the pic in
-- from the left -- engine/battle/pikachu_entrance_anim.asm:1
self:drawBattlerPic(self.player, dx + sx + self:picOffset("playerMon"),
dy + sy, s)
end
end
if clipped then
+10 -1
View File
@@ -33,6 +33,15 @@ end
local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 }
local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
-- Strings.source, not Strings: harvested at require time so the catalog
-- generator can see the literal, same pattern as MoveEffects.lua's
-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument
-- the harvester can't discover.
local STAT_LABEL = {
attack = Strings.source("ATTACK"), defense = Strings.source("DEFENSE"),
speed = Strings.source("SPEED"),
}
-- The trainer's ai_classes record from the merged registry; the direct
-- require covers battles built without a loader. A trainer record's
-- aiClass field picks a record other than its own id.
@@ -124,7 +133,7 @@ function TrainerAI.useItem(battle, item)
elseif X_STAT[item] then
local stat = X_STAT[item]
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), stat:upper()))
table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), Strings(STAT_LABEL[stat])))
elseif item == "GUARD_SPEC" then
enemy.mist = true
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy)))
+51 -8
View File
@@ -92,6 +92,26 @@ local function levelAt(battle, battler, x, y)
end
end
local function battleIsTopState(battle)
local stack = battle.game and battle.game.stack
return not (stack and stack.top) or stack:top() == battle
end
local function anchorHUD(battle, x, y, w, h, anchor)
if not battle:extendedHUD() or not battleIsTopState(battle) then return end
local renderer = battle.game and battle.game.renderer
if not (renderer and renderer.setBattleUIAnchor) then return end
x = x + (battle.extendedHUDOffsetX or 0)
y = y + (battle.extendedHUDOffsetY or 0)
local x2 = math.min(WideBattle.WIDTH, x + w)
local y2 = math.min(WideBattle.HEIGHT, y + h)
x, y = math.max(0, x), math.max(0, y)
w, h = x2 - x, y2 - y
if w > 0 and h > 0 then
renderer:setBattleUIAnchor(x, y, w, h, anchor)
end
end
-- One side's status box: name and level on the first line, a long HP bar
-- under it, and the numeric HP on the player's box only (the foe's exact
-- HP is never shown, like the original).
@@ -114,6 +134,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
x + tw * 8 - 64, y + 24)
end
anchorHUD(battle, x, y, tw * 8, th * 8, player and "bottom" or "top")
end
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
@@ -144,7 +165,6 @@ local function drawHUDs(battle, slide)
and not battle.showPlayerBack and slide == 0 then
drawStatusPanel(battle, battle.player, 184, 56, true)
end
drawIntroBalls(battle)
end
local function drawMessageBox(battle)
@@ -268,6 +288,8 @@ local function drawTextArea(battle)
else
Font.drawBox(0, 13, 38, 5)
end
anchorHUD(battle, 0, WideBattle.FIELD_BOTTOM,
WideBattle.WIDTH, WideBattle.HEIGHT - WideBattle.FIELD_BOTTOM, "bottom")
end
-- Battle animations are authored in the original 160px coordinate space.
@@ -309,17 +331,23 @@ end
-- The whole 304x144 composition for one frame.
function WideBattle.draw(battle)
local g = love.graphics
local renderer = battle.game and battle.game.renderer
local extendedHUD = battle:extendedHUD() and renderer
and renderer.beginBattleHUDPass
and renderer.endBattleHUDPass
-- The field is the display mode's paper. Under a forced-mono mode the
-- whole surface is remapped downstream (WideBattle.zones), so the field
-- goes down as DMG white and comes out of that pass as the mode's paper;
-- painting the resolved shade there would run it through the remap twice
-- and land a shade off the letterbox the renderer fills around it.
if monoMode() then
g.setColor(1, 1, 1, 1)
else
g.setColor(PaletteFX.paperShade(battle.data))
if not (extendedHUD and battle:extendedWorldHUD()) then
if monoMode() then
g.setColor(1, 1, 1, 1)
else
g.setColor(PaletteFX.paperShade(battle.data))
end
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
end
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
-- AskName clears the field the same way the classic layout does
if battle.blankForAskName then return end
@@ -346,6 +374,7 @@ function WideBattle.draw(battle)
inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
battle.wideRegion = nil
drawIntroBalls(battle)
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the
-- shakes move IS the whole screen: PredefShakeScreenHorizontally,
@@ -359,12 +388,26 @@ function WideBattle.draw(battle)
if sx == 0 and sy == 0 then return fn() end
g.push()
g.translate(sx, sy)
battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = sx, sy
fn()
battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = nil, nil
g.pop()
end
shaken(function() drawHUDs(battle, slide) end)
drawAnimationLayer(battle)
shaken(function() drawTextArea(battle) end)
if extendedHUD then
local previous = renderer:beginBattleHUDPass()
shaken(function() drawHUDs(battle, slide) end)
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)
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
end
renderer:endBattleHUDPass(previous)
else
shaken(function() drawHUDs(battle, slide) end)
shaken(function() drawTextArea(battle) end)
end
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
g.setColor(1, 1, 1, 0.85)
+3 -2
View File
@@ -243,8 +243,9 @@ end
C.incbgeffect = function(self, row) self.bg:incEffect(row[2]) end
C.battlergfx_1row = function(self) self:loadBattlerGfx(1) end
C.battlergfx_2row = function(self) self:loadBattlerGfx(2) end
-- anim_commands.asm:317: $d9 (anim_battlergfx_2row) dispatches to _1Row
C.battlergfx_1row = function(self) self:loadBattlerGfx(2) end
C.battlergfx_2row = function(self) self:loadBattlerGfx(1) end
-- GetPokeBallWobble's answer, which the ball's own script then branches on.
C.checkpokeball = function(self)
+55 -20
View File
@@ -1868,7 +1868,11 @@ function Battle:useMove(attacker, defender, moveId)
if status and (def.power or 0) == 0 then
-- A refused primary status is a failed move (effect_commands.asm:3748,
-- :6656); a refused secondary already animated and stays unmarked (:3752).
if Battle.AI_FAIL_STATUSES[status]
if self:statusRefusedByType(defender, def.type, status) then
self:markMissed()
self:emit({ kind = "message",
text = "It doesn't affect " .. self:monName(defender) .. "..." })
elseif Battle.AI_FAIL_STATUSES[status]
and self:aiRandomFail(attacker, defender) then
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
@@ -1880,7 +1884,8 @@ function Battle:useMove(attacker, defender, moveId)
and record.status or nil
-- engine/battle/effect_commands.asm:6325
if secondary and (defender.hp or 0) > 0
and not self:safeguarded(defender) then
and not self:safeguarded(defender)
and not self:statusRefusedByType(defender, def.type, secondary) then
local chance = def.effectChance or 0
if chance > 0 and rand(self.random, 100) < chance then
self:applyStatus(defender, secondary, attacker)
@@ -2429,7 +2434,7 @@ Battle.MOVE_EFFECTS.EFFECT_LIGHT_SCREEN = function(self, attacker)
if (side.lightScreen or 0) > 0 then return fail(self) end
side.lightScreen = Battle.SCREEN_TURNS
self:emit({ kind = "message",
text = self:monName(attacker) .. "'s SPCL.DEF rose!" })
text = Strings("%s's SPCL.DEF rose!", self:monName(attacker)) })
end
Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
@@ -2437,7 +2442,7 @@ Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
if (side.reflect or 0) > 0 then return fail(self) end
side.reflect = Battle.SCREEN_TURNS
self:emit({ kind = "message",
text = self:monName(attacker) .. "'s DEFENSE rose!" })
text = Strings("%s's DEFENSE rose!", self:monName(attacker)) })
end
-- engine/battle/move_effects/safeguard.asm:1
@@ -2941,6 +2946,27 @@ end
-- `source` is the battler that inflicted it, carried only so
-- battle.status_inflicted can name it the way Gen 1's does.
-- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup,
-- and the poison pair also refuses a POISON-type target: effect_commands.asm
-- :5788 (paralyze), :3671 (poison), :3646 / :4019 (the secondary arms).
-- Sleep, confusion and stat changes are deliberately not gated.
function Battle:statusRefusedByType(defender, moveType, status)
if not (status == "paralyze" or status == "poison" or status == "toxic") then
return false
end
local types = (self:speciesDef(defender) or {}).types or defender.types or {}
if moveType then
local matchups = self.data.type_chart and self.data.type_chart.matchups
if Damage.typeMultiplier(moveType, types, matchups) == 0 then return true end
end
if status == "poison" or status == "toxic" then
for _, t in ipairs(types) do
if t == "POISON" then return true end
end
end
return false
end
function Battle:applyStatus(mon, status, source)
if (mon.hp or 0) <= 0 then return false end
-- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives
@@ -3202,6 +3228,7 @@ end
-- player's own.
function Battle:isOutsider(mon)
local playerId = self.save and self.save.player and self.save.player.id
if mon.traded == true then return true end
if mon.otId == nil or playerId == nil then return false end
return mon.otId ~= playerId
end
@@ -3363,25 +3390,33 @@ function Battle:awardExperience(loser)
-- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is
-- accepted and ignored rather than changing what is printed. `recipients`,
-- `holders` and `halved` are the Gen 2 additions.
if not Runtime.wantsHook("battle.exp_award") then return vanillaAward() end
local alive = {}
for _, index in ipairs(participants) do
local mon = self.party[index]
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
end
local function applyShare(mon, split)
for index, candidate in ipairs(self.party) do
if candidate == mon then
return self:giveExperiencePass(loser, def, { index },
math.max(1, split or 1), halved)
if Runtime.wantsHook("battle.exp_award") then
local alive = {}
for _, index in ipairs(participants) do
local mon = self.party[index]
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
end
local function applyShare(mon, split)
for index, candidate in ipairs(self.party) do
if candidate == mon then
return self:giveExperiencePass(loser, def, { index },
math.max(1, split or 1), halved)
end
end
end
Runtime.call("battle.exp_award", vanillaAward, {
battle = self, participants = #participants, alive = alive,
applyShare = applyShare, recipients = participants, holders = holders,
halved = halved, loser = loser,
})
else
vanillaAward()
end
Runtime.call("battle.exp_award", vanillaAward, {
battle = self, participants = #participants, alive = alive,
applyShare = applyShare, recipients = participants, holders = holders,
halved = halved, loser = loser,
})
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
self.participants = {}
if self.playerIndex then self.participants[self.playerIndex] = true end
end
-- The answer to a `choose-forget`: drop the move in `slot` and put the
+7 -8
View File
@@ -347,14 +347,13 @@ function Catching.attempt(opts)
-- that edits o.catchRate or o.status changes the roll exactly as it does
-- on Red, and one that returns `caught, rate` replaces it outright.
--
-- `mon` and `def` are whatever the catch site supplied. Gold's battle
-- screen (src/ui/gen2/BattleState.lua) hands this module a FLAT opts table
-- -- hp, maxHp, catchRate, status, species -- rather than the mon and its
-- record, so both are nil there until it passes them; nil, not a stand-in
-- a mod would read as the real mon. Passing `battle = self.battle` and
-- `mon = enemy` with them is what puts the capture tail below on the real
-- catch: the Transform reload and the battle.catch_exp hook both hang off
-- the battle, and neither can be reached from a flat table.
-- `mon` and `def` are whatever the catch site supplied, and nil rather
-- than a stand-in a mod would read as the real mon when it supplied
-- neither. Gold's battle screen (src/ui/gen2/BattleState.lua) passes them
-- with `battle` alongside the flat hp/maxHp/catchRate/status fields, which
-- is what puts the capture tail below on the real catch: the Transform
-- reload and the battle.catch_exp hook both hang off the battle, and
-- neither can be reached from a flat table.
caught, rate = Runtime.call("catch.rate", function(_, _, _, o)
return Catching.vanillaAttempt(o)
end, opts.ball or "POKE_BALL", opts.mon, opts.def, opts)
+9 -2
View File
@@ -112,7 +112,13 @@ function Mon.syncIdentity(mon, data)
if mon.dvs then
mon.gender = Mon.gender(def, mon.dvs,
{ species = mon.species, level = mon.level })
mon.shiny = Mon.isShiny(mon.dvs,
-- shiny is monotonic once true, the same as opts.shiny winning over
-- shiny.roll at Mon.new: a forced shiny (the scripted-shiny path, DVs
-- that do not themselves read as shiny) must not un-shiny the moment
-- this runs again, and it runs on every SummaryMenu open via
-- refreshStats. A mon not already shiny still promotes normally if
-- its DVs justify it, e.g. after an edit.
mon.shiny = mon.shiny or Mon.isShiny(mon.dvs,
{ species = mon.species, def = def, level = mon.level })
if mon.species == Unown.SPECIES then
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
@@ -382,7 +388,8 @@ function Mon.stampOT(save, mon)
mon.ot = mon.ot or player.name
-- NpcTrade.lua:150: `ot` is what Breeding reads, `otName` what the summary prints.
mon.otName = mon.otName or mon.ot
mon.otId = mon.otId or player.id
-- engine/battle/experience.asm:69: a traded mon keeps its own OT id
if not mon.traded then mon.otId = mon.otId or player.id end
return mon
end
+89 -11
View File
@@ -6,6 +6,7 @@ local FixedStep = require("src.core.FixedStep")
local Input = require("src.core.Input")
local Logger = require("src.core.Logger")
local Renderer = require("src.render.Renderer")
local GameViewport = require("src.render.GameViewport")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TouchControls = require("src.core.TouchControls")
@@ -58,6 +59,9 @@ function Game:load()
self.touchControls = TouchControls
TouchControls:init()
TouchControls:setHotkeyHandler(function(action, pressed)
self:touchSkinHotkey(action, pressed)
end)
self.renderer = Renderer
Renderer:init()
@@ -184,6 +188,40 @@ function Game:returnToTitle()
self.stack:push(self:makeTitleState())
end
Game.SKIN_FAST_FORWARD = 4
function Game:touchSkinHotkey(action, pressed)
if action == "fast_forward_hold" then
if pressed then
self.skinSpeedSaved = self.speedOverride
self.speedOverride = Game.SKIN_FAST_FORWARD
else
self.speedOverride = self.skinSpeedSaved
self.skinSpeedSaved = nil
end
elseif action == "fast_forward_toggle" then
if pressed then self:_cycleSpeed(1) end
elseif action == "soft_reset" then
if pressed then
Input:reset()
TouchControls:reset()
self:returnToTitle()
end
elseif action == "menu" then
if pressed then
local Screens = require("src.ui.Screens")
local top = self.stack and self.stack:top()
if top and not top.isTouchSkinMenu then
local state = Screens.build(self, "OptionsMenu")
if state then
state.isTouchSkinMenu = true
self.stack:push(state)
end
end
end
end
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
@@ -305,12 +343,32 @@ function Game.worldBgBattleDim(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
if state and state.bgMode and state:bgMode() == "world" then
-- The extended fixed HUD intentionally exposes the live world across
-- the whole physical window. Keep this as a world-backed battle (zero
-- is non-nil, so scaling and overlay holds remain active), but do not
-- paint the standard dim veil around the native battle rectangle.
if state.extendedWorldHUD and state:extendedWorldHUD() then
return 0
end
return state.BG_WORLD_DIM or 0.55
end
end
return nil
end
-- Does the stack contain the opt-in fixed Extended WORLD battle? Renderer
-- uses this separately from battleDim: the world remains the surround, while
-- the native-width battle field receives a paper backing from top to bottom.
function Game.extendedWorldHUDInStack(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
if state and state.extendedWorldHUD and state:extendedWorldHUD() then
return true
end
end
return false
end
-- Is a BATTLE BG "world" battle composing itself over the live map right now?
-- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the
-- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is
@@ -403,13 +461,13 @@ function Game.uiAnchorsHeldInStack(stack)
end
-- Where Game:draw starts drawing this frame. Normally the topmost opaque
-- state (StateStack:visibleBase) -- but BATTLE BG "world" composes the battle
-- over the LIVE map, and an opaque state pushed on top of it (the party menu,
-- the bag) becomes that base, cutting the overworld -- and with it the world
-- pass -- out of the frame entirely. The backdrop the battle established
-- then collapses to endFrame's flat black clear for as long as the menu is
-- up. So a world-bg battle keeps the frame starting from underneath itself
-- until it leaves the stack, the same hold uiFill and the dim already use.
-- state (StateStack:visibleBase) -- but an opaque menu pushed over a WIDE
-- battle must not prevent that battle from drawing. Native WIDE battles own
-- the 304x144 surround around a centred classic menu, while external arena
-- providers establish their window-sized scene from BattleState:draw. If the
-- menu becomes the draw base, neither owner runs and the menu's white field
-- replaces the whole presentation. BATTLE BG "world" additionally needs the
-- overworld below the battle, as before.
--
-- Only the START of the draw moves. The clear stays keyed to the real
-- visibleBase, so the menu still gets its opaque canvas and draws exactly as
@@ -420,9 +478,13 @@ function Game.drawBaseInStack(stack, visibleBase)
local states = stack and stack.states or {}
for i = visibleBase - 1, 1, -1 do
local state = states[i]
if state and state.bgMode and state:bgMode() == "world" then
local worldBattle = state and state.bgMode and state:bgMode() == "world"
local wideBattle = state and state.isWideBattleLayout
and state:isWideBattleLayout()
if worldBattle or wideBattle then
-- restart the search from under the battle: the highest opaque state at
-- or below it (the overworld), not the menu sitting over it
-- or below it (the battle itself for white/black WIDE, the overworld for
-- a non-opaque world-backed battle), not the menu sitting over it
for j = i, 1, -1 do
if states[j].isOpaque then return j end
end
@@ -432,6 +494,14 @@ function Game.drawBaseInStack(stack, visibleBase)
return visibleBase
end
-- A classic overlay above the approved world-backed extended HUD paints only
-- its centred area. Keep the wider owner surface transparent so its margins
-- continue to reveal the world instead of becoming an opaque white sheet.
function Game.uiCanvasTransparent(worldBelow, worldDrawn, wideBattle)
return worldBelow or (worldDrawn and wideBattle ~= nil
and wideBattle.extendedHUD and wideBattle:extendedHUD())
end
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
-- into both margins, keeping the canvas' paper color continuous; narrower
-- sprite and status zones move with the classic UI content.
@@ -452,6 +522,7 @@ local function centerClassicZones(zones, offset)
end
function Game:draw()
GameViewport.begin(1)
-- the UI canvas clears transparent when the overworld's world pass
-- shows through beneath it; opaque full-screen states get the classic
-- white clear
@@ -485,6 +556,7 @@ function Game:draw()
-- the stack for the same reason as uiFill above -- a prompt opened during
-- the battle must not drop the dim for a frame.
Renderer.battleDim = Game.worldBgBattleDim(self.stack)
Renderer.extendedWorldBand = Game.extendedWorldHUDInStack(self.stack)
-- ...and for the same reason the UI's own scale has to know the world is
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
-- steps the UI down with the survey zoom only while a world is behind it,
@@ -502,7 +574,8 @@ function Game:draw()
-- menu to its top right, and the whole UI steps down with the zoom.
Renderer.uiCentered = not Game.dynamicUI(self.save)
Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack)
Renderer:beginFrame(worldBelow)
Renderer:beginFrame(Game.uiCanvasTransparent(
worldBelow, worldDrawn, wideBattle))
for i = drawFrom, #self.stack.states do
local state = self.stack.states[i]
local wideState = state and state.isWideBattleLayout
@@ -563,7 +636,9 @@ function Game:draw()
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
GameViewport.finish(self)
-- OS-window chrome: keep the pad full-size and above any composed companion
-- view instead of capturing and shrinking it with the game viewport.
TouchControls:draw()
end
@@ -939,8 +1014,10 @@ local function pointerUnclaimed() return false end
-- coordinates are LOVE window units, the same space render.hud's viewport
-- and the touch overlay lay out in
function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
phase = phase, source = source, id = id, x = x, y = y,
gameX = gameX, gameY = gameY, insideGame = insideGame,
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
})
end
@@ -1156,6 +1233,7 @@ function Game:restoreSave(loaded, recovered, opts)
self:applyOptions(loaded.options)
-- saves from before OT/ID stamping: backfill with the player's (after
-- the scrub, so every mon the stamp loop sees is known)
SaveData.repairTradedOtIds(loaded)
local stamp = require("src.battle.BattleState").stampOT
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
for _, box in ipairs(loaded.boxes or {}) do
+31 -19
View File
@@ -35,6 +35,7 @@ local World = require("src.world.gen2.World")
-- The mod event/hook buses. Gold reaches them through Runtime like every
-- other engine file, so a call site here is the same call site Gen 1 has.
local ModRuntime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport")
-- Only for the mod-supplied save migrations and the mods-changed report, which
-- are keyed off save.meta and know nothing about a generation; Gold's own save
-- IO is src/core/gen2/Save.lua.
@@ -73,8 +74,8 @@ end
--
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
-- (the FixedStep callback in Game2:load), so none of it goes through
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight
-- hooks below never used to fire here; it is not a reason they should not. A
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the hooks
-- below never used to fire here; it is not a reason they should not. A
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
-- PAYLOAD, at the Gen 1 point in the order:
@@ -86,6 +87,8 @@ end
-- render.output* the normal composed frame (Renderer.lua:1063)
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
-- render.viewport the game's OS-window rectangle (GameViewport.lua:52)
-- render.window final OS-window composition (GameViewport.lua:145)
--
-- Where Gold genuinely cannot tell two Gen 1 things apart -- it composites the
-- world pass and the UI into ONE canvas, not two -- the call site says so and
@@ -680,7 +683,10 @@ end
-- .SelectMon / PPRestoreItem_Cancel carry path: nothing spent.
function Game2:usePartyItem(itemId)
local ItemEffects = require("src.core.gen2.ItemEffects")
local action = ItemEffects.partyAction(itemId)
-- without the merged dataset this can only ever see RECORDS, the
-- module's own built-ins, so a mod's field item resolves to no action
-- at all and never gets past the .Oak refusal below
local action = ItemEffects.partyAction(itemId, self.data)
if not action then return end
local party = (self.save and self.save.party) or {}
if #party == 0 then
@@ -933,6 +939,8 @@ function Game2:load()
self.data.gen2Maps = loadGenerated("data/generated/maps.lua")
self.data.gen2Tilesets = loadGenerated("data/generated/tilesets.lua")
self.data.gen2Roofs = loadGenerated("data/generated/roofs.lua")
-- engine/events/magnet_train.asm:165 DrawMagnetTrain
self.data.gen2Field = loadGenerated("data/generated/field.lua")
self.data.gen2Marts = loadGenerated("data/generated/marts.lua")
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
@@ -1197,9 +1205,7 @@ function Game2:frameFit(w, h)
dpi = tonumber(love.window.getDPIScale()) or 1
end
local pw, ph = w * dpi, h * dpi
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
pw, ph = GameViewport.pixelDimensions()
return scale, ox, oy, dpi, pw, ph
end
@@ -1217,18 +1223,15 @@ function Game2:viewport(w, h)
}
end
-- The screen-space layer, in the Gen 1 order: render.hud and then the
-- on-screen pad (src/core/Game.lua:521 and :524, either side of
-- Renderer:endFrame). Both are window-space, both sit over the finished
-- frame -- post passes, letterbox and all -- and neither ever enters the game
-- canvas. Every exit path of Game2:draw ends here, which is what makes that
-- true of the composed frame a mod owns as well as of the plain one.
-- The render.hud layer, in Gen 1's order over the finished game frame. The
-- on-screen pad is drawn separately after GameViewport.finish, because it is
-- OS-window chrome and must not be captured or scaled with this canvas.
--
-- render.hud: persistent tool status. The call is fenced with
-- push("all")/pop for the reason src/render/Pipelines.lua:guardRender fences a
-- mod render callback: a subscriber that returns cleanly but leaves a shader
-- bound, the canvas redirected or the colour changed must not corrupt the next
-- frame -- or, now, the pad drawn immediately after it.
-- frame.
function Game2:drawHud(w, h)
if ModRuntime.wantsHook("render.hud") then
local G = love.graphics
@@ -1236,10 +1239,6 @@ function Game2:drawHud(w, h)
ModRuntime.call("render.hud", noop, self, self:viewport(w, h))
G.pop()
end
-- The pad LAST, so a HUD mod cannot draw over the controls the player is
-- pressing. It draws nothing at all off Android/iOS unless POKEPORT_TOUCH=1
-- forces it, and nothing ever while a controller is in use.
TouchControls:draw()
end
-- render.letterbox: SGB borders and custom void art in the bars around the
@@ -1363,9 +1362,9 @@ end
-- is being shown on. Mod post-processes fold in between the two, where
-- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid
-- is then drawn over, rather than something that smears the grid itself.
function Game2:draw()
function Game2:drawViewportFrame()
local G = love.graphics
local w, h = G.getDimensions()
local w, h = GameViewport.dimensions()
local GBCFX = require("src.render.GBCFX")
local GbcPalette = require("src.render.GbcPalette")
local Pipelines = require("src.render.Pipelines")
@@ -1477,6 +1476,16 @@ function Game2:draw()
self:drawHud(w, h)
end
function Game2:draw()
GameViewport.begin(2)
GameViewport.setTarget()
self:drawViewportFrame()
GameViewport.finish(self)
-- OS-window chrome: draw after companion composition so viewport layouts
-- neither shrink nor cover the touch pad.
TouchControls:draw()
end
-- The paper a pushed TextBox has to sit on. A textbox is built entirely from
-- font-page tiles ($79-$7e frame, ' ' $7f interior), so it takes BG palette 0
-- colour 0 from the screen UNDER it (pokegold engine/pokegear/pokegear.asm
@@ -1803,8 +1812,10 @@ end
-- coordinates are LOVE window units, the same space render.hud's viewport is in
function Game2:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
phase = phase, source = source, id = id, x = x, y = y,
gameX = gameX, gameY = gameY, insideGame = insideGame,
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
})
end
@@ -1956,6 +1967,7 @@ function Game2:applyOptions()
haptics = options.haptics,
})
require("src.core.VideoMode").applyOptions(options)
require("src.core.FrameCap").applyOptions(options)
local GBCFX = require("src.render.GBCFX")
if GBCFX.applyOptions(options) and self.save then
-- applyOptions returns true when it had to clear an unsupported level.
+64 -8
View File
@@ -6,11 +6,14 @@ local HostShell = {}
-- spawn tries to link against the libraries we're shipping instead of the
-- system ones. We want to unset the var so that any system tools can find
-- their proper libraries. Only needed when running in an AppImage.
-- LD_PRELOAD is Steam's overlay (#1470): its 32-bit half cannot load into a
-- 64-bit child, so ld.so prints an error into that child's output.
function HostShell.envPrefix()
if os.getenv("APPIMAGE") then
return "env -u LD_LIBRARY_PATH "
end
return ""
local unset = ""
if os.getenv("APPIMAGE") then unset = unset .. "-u LD_LIBRARY_PATH " end
if os.getenv("LD_PRELOAD") then unset = unset .. "-u LD_PRELOAD " end
if unset == "" then return "" end
return "env " .. unset
end
-- Windows: every host tool we shell out to (curl for the update and mod-index
@@ -222,11 +225,20 @@ end
local HTTP_MARK = "\n__gen1recomp_http__"
local HTTP_MARK_FMT = "\\n__gen1recomp_http__%{http_code}"
local function stripLoaderNoise(out)
while out:find("^ERROR: ld%.so:") do
local nl = out:find("\n", 1, true)
if not nl then return "" end
out = out:sub(nl + 1)
end
return out
end
-- Split a curl pipe's output into (body, status, noise). `status` is nil
-- when curl never got far enough to have one (DNS failure, no route, a
-- timeout), in which case `noise` carries curl's own complaint.
local function splitCurlOutput(out)
out = tostring(out or "")
out = stripLoaderNoise(tostring(out or ""))
local at = nil
local from = 1
while true do
@@ -272,6 +284,38 @@ function HostShell.quote(s)
return "'" .. s:gsub("'", "'\\''") .. "'"
end
-- Launch another instance of this packaged app without waiting for it. The
-- same path works on all process-capable desktop hosts; only the shell's
-- background spelling differs. Source checkouts include their game folder,
-- while fused releases and AppImages already carry it in the executable.
function HostShell.spawnSelfDetached(args)
if not require("src.core.Platform").canSpawnProcess() then return false end
local fs = love and love.filesystem
if not (fs and fs.getExecutablePath) then return false end
local executable = os.getenv("APPIMAGE") or fs.getExecutablePath()
if type(executable) ~= "string" or executable == "" then return false end
local argv = {}
local fused = fs.isFused and fs.isFused()
if not os.getenv("APPIMAGE") and not fused and fs.getSource then
argv[#argv + 1] = fs.getSource()
end
for _, value in ipairs(args or {}) do argv[#argv + 1] = tostring(value) end
local command = HostShell.quote(executable)
for _, value in ipairs(argv) do
command = command .. " " .. HostShell.quote(value)
end
local osName = love.system and love.system.getOS and love.system.getOS()
if osName == "Windows" then
command = 'start "" /b ' .. command .. " >NUL 2>&1"
else
command = HostShell.envPrefix() .. command .. " >/dev/null 2>&1 &"
end
local ok, _, code = os.execute(command)
return ok == true or ok == 0 or code == 0
end
-- MEMOISED per Lua state (so once per thread). This used to spawn a whole
-- `curl --version` process on every single fetch -- twice for a GET through
-- the Android-bridge fallback -- which doubled the number of spawns the lock
@@ -420,9 +464,11 @@ end
-- POST returning success/failure. Strictly one-way: the response body is
-- discarded, only the HTTP status class is surfaced (postLog callers never
-- trust the reply). curl --data-binary reads the payload from a pipe, so a
-- large body never lands in the command line; the Android bridge has no POST
-- transport, and httpPost reports that instead of half-working through
-- httpDownload (a GET round-trip to a POST endpoint would be a lie).
-- large body never lands in the command line; where curl is absent (Android
-- and the other bridge-only platforms) the POST rides the JNI bridge --
-- love.system.httpPost, the dedicated POST arm added beside httpDownload --
-- instead of half-working through httpDownload (a GET round-trip to a POST
-- endpoint would be a lie).
function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
if type(url) ~= "string" or url == "" then return nil, "missing url" end
if type(body) ~= "string" then return nil, "missing body" end
@@ -498,6 +544,16 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
if not haveBridge() then
return nil, "no network transport on this platform"
end
-- The GET bridge has no POST; the dedicated love.system.httpPost arm
-- (GameActivity.httpPost) is the transport where curl is missing. A
-- build without it reports the same "no POST transport" a missing curl
-- would -- the old-APK skew path in the JNI bridge returns false.
if love.system and type(love.system.httpPost) == "function" then
local ok, sent = pcall(love.system.httpPost, url, body, contentType,
userAgent)
if ok and sent then return true end
return nil, "log post rejected"
end
return nil, "no POST transport on this platform"
end
+8 -2
View File
@@ -7,12 +7,14 @@
-- (touch overlay, launcher) should prefer this over getDimensions; the game
-- canvas may still letterbox into the full framebuffer for immersion.
local GameViewport = require("src.render.GameViewport")
local SafeArea = {}
function SafeArea.rect()
function SafeArea.windowRect()
local ww, wh = 0, 0
if love and love.graphics and love.graphics.getDimensions then
ww, wh = love.graphics.getDimensions()
ww, wh = GameViewport.fullDimensions()
end
if ww <= 0 then ww = 1 end
if wh <= 0 then wh = 1 end
@@ -55,4 +57,8 @@ function SafeArea.rect()
return x, y, w, h
end
function SafeArea.rect()
return GameViewport.localSafeRect(SafeArea.windowRect())
end
return SafeArea
+28 -4
View File
@@ -239,6 +239,11 @@ function SaveData.defaultOptions()
-- scale the battle surface to the window so it fills vertically. See
-- BattleState:wantsFillScale.
battleFit = "fixed",
-- BATTLE HUD: STANDARD keeps every wide-battle element inside the native
-- 304x144 surface. EXTENDED is opt-in window-space placement for selected
-- wide layouts; unsupported combinations deliberately fall back to the
-- standard composition.
battleHud = "standard",
-- BATTLE BG: what fills the screen behind and around the battle.
-- "white" = the display mode's paper shade (the classic look),
-- "black" = plain black bars, "world" = the frozen overworld showing
@@ -661,11 +666,10 @@ function SaveData.setModEnabled(options, id, enabled, version)
options.modsByVersion = options.modsByVersion or {}
local bucket = options.modsByVersion[version] or {}
options.modsByVersion[version] = bucket
-- no shared flag reads as enabled, the same default the loader applies to a
-- missing entry, so a fresh install never fills the overlay with agreement
-- with no shared flag the default is the caller's (experimental mods read as
-- disabled), so the answer is stored outright rather than judged against one
local shared = options.mods and options.mods[id]
if type(shared) ~= "boolean" then shared = true end
if shared == enabled then
if type(shared) == "boolean" and shared == enabled then
bucket[id] = nil
else
bucket[id] = enabled
@@ -1919,6 +1923,26 @@ function SaveData.applyPostGameHome(save, boot)
return heal
end
-- 0.1.82-0.1.9x loads stamped the player's own id onto traded mons and saved
-- it, which reads back as home-caught. A caught mon can never carry
-- traded=true, so clearing that pair is safe on every load. #1461
function SaveData.repairTradedOtIds(save)
local playerId = save and save.player and save.player.id
if playerId == nil then return 0 end
local fixed = 0
local function scrub(mon)
if mon and mon.traded == true and mon.otId == playerId then
mon.otId = nil
fixed = fixed + 1
end
end
for _, mon in ipairs(save.party or {}) do scrub(mon) end
for _, box in ipairs(save.boxes or {}) do
for _, mon in ipairs(box) do scrub(mon) end
end
return fixed
end
-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits,
-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE.
function SaveData.needsPostGameRescue(save)
+92
View File
@@ -0,0 +1,92 @@
local SkinZip = {}
local bxor
do
local ok, bit = pcall(require, "bit")
if ok and bit and bit.bxor then
bxor = function(a, b) return bit.bxor(a, b) % 0x100000000 end
else
local byteXor = {}
local function xor8(a, b)
local key = a * 256 + b
local memo = byteXor[key]
if memo then return memo end
local r, bitv = 0, 1
local x, y = a, b
for _ = 1, 8 do
if (x % 2) ~= (y % 2) then r = r + bitv end
x, y, bitv = math.floor(x / 2), math.floor(y / 2), bitv * 2
end
byteXor[key] = r
return r
end
bxor = function(a, b)
local r, mul = 0, 1
for _ = 1, 4 do
r = r + xor8(a % 256, b % 256) * mul
a, b, mul = math.floor(a / 256), math.floor(b / 256), mul * 256
end
return r
end
end
end
local crcTable
local function crc32(s)
if not crcTable then
crcTable = {}
for i = 0, 255 do
local c = i
for _ = 1, 8 do
if c % 2 == 1 then
c = bxor(math.floor(c / 2), 0xEDB88320)
else
c = math.floor(c / 2)
end
end
crcTable[i] = c
end
end
local crc = 0xFFFFFFFF
for i = 1, #s do
crc = bxor(crcTable[bxor(crc, s:byte(i)) % 256], math.floor(crc / 256))
end
return bxor(crc, 0xFFFFFFFF) % 0x100000000
end
local function le16(n)
n = math.floor(n) % 0x10000
return string.char(n % 256, math.floor(n / 256) % 256)
end
local function le32(n)
n = math.floor(n) % 0x100000000
return string.char(n % 256, math.floor(n / 256) % 256,
math.floor(n / 65536) % 256, math.floor(n / 16777216) % 256)
end
function SkinZip.encode(entries)
local out, central, offset = {}, {}, 0
for _, entry in ipairs(entries) do
local name, data = entry.name, entry.data or ""
local crc, size = crc32(data), #data
local local_ = "PK\3\4" .. le16(20) .. le16(0) .. le16(0)
.. le16(0) .. le16(0)
.. le32(crc) .. le32(size) .. le32(size)
.. le16(#name) .. le16(0) .. name
out[#out + 1] = local_
out[#out + 1] = data
central[#central + 1] = "PK\1\2" .. le16(20) .. le16(20) .. le16(0) .. le16(0)
.. le16(0) .. le16(0)
.. le32(crc) .. le32(size) .. le32(size)
.. le16(#name) .. le16(0) .. le16(0) .. le16(0) .. le16(0)
.. le32(0) .. le32(offset) .. name
offset = offset + #local_ + #data
end
local dir = table.concat(central)
return table.concat(out) .. dir
.. "PK\5\6" .. le16(0) .. le16(0) .. le16(#entries) .. le16(#entries)
.. le32(#dir) .. le32(offset) .. le16(0)
end
return SkinZip
+226 -14
View File
@@ -39,6 +39,7 @@
local Input = require("src.core.Input")
local SafeArea = require("src.core.SafeArea")
local TouchSkin = require("src.core.TouchSkin")
local TouchControls = {}
@@ -189,6 +190,7 @@ function TouchControls.normalizeConfig(tc)
-- no caller ever has to nil-check a bucket's scale
if type(tc) ~= "table" then tc = {} end
if tc.enabled == false then out.enabled = false end
if type(tc.skin) == "string" and tc.skin ~= "" then out.skin = tc.skin end
local saved = type(tc.layouts) == "table" and tc.layouts or nil
for _, o in ipairs(ORIENTATIONS) do
local b = saved and saved[o]
@@ -241,6 +243,7 @@ end
function TouchControls:init()
self.active = wantsOverlay()
TouchSkin.setOverlayLive(self.active)
self.enabled = true
-- vibration level for presses (#806); applyOptions overwrites it from
-- options.haptics, this is the value a harness that never applies options
@@ -261,6 +264,10 @@ function TouchControls:init()
self.held = {}
self.dpadTouch = nil
self.layoutW, self.layoutH = nil, nil
self.skinId = nil
self.skinError = nil
self.hotkeysHeld = {}
TouchSkin.setActive(nil)
self.img = nil
-- Images load whenever the platform wants the overlay OR the launcher
-- editor forces a preview (desktop testing of the editor).
@@ -285,6 +292,8 @@ function TouchControls:applyOptions(opts)
-- haptics is a plain top-level option, not part of the layout config the
-- launcher editor round-trips through config() (#806)
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
TouchSkin.setOverlayLive(self.active)
self:selectSkin(cfg.skin)
self.layouts = cfg.layouts
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
@@ -300,7 +309,7 @@ end
-- Snapshot for the editor's save path: enabled plus both orientation
-- buckets, matching what options.lua stores (#633).
function TouchControls:config()
local out = { enabled = self.enabled ~= false, layouts = {} }
local out = { enabled = self.enabled ~= false, skin = self.skinId, layouts = {} }
for _, o in ipairs(ORIENTATIONS) do
local b = self.layouts and self.layouts[o] or nil
out.layouts[o] = {
@@ -323,11 +332,56 @@ function TouchControls:setPreview(on)
end
function TouchControls:visible()
if self.preview then return self.img ~= nil end
return self.active and self.enabled ~= false and self.img ~= nil
and not self.controllerHidden
local art = TouchSkin.active ~= nil or self.img ~= nil
if self.preview then return art end
if self.enabled == false or not art then return false end
if TouchSkin.active and TouchSkin.decorativeOnly() then return true end
return self.active and not self.controllerHidden
end
local function surfaceRect()
local r = TouchSkin.surfaceRect
if r then return r.w, r.h, r.x, r.y end
if love and love.graphics and love.graphics.getDimensions then
local w, h = love.graphics.getDimensions()
return w, h, 0, 0
end
return 0, 0, 0, 0
end
TouchControls.surfaceRect = surfaceRect
function TouchControls:selectSkin(id)
if id == self.skinId and TouchSkin.active then return TouchSkin.active end
self:reset()
self.skinError = nil
if not id or id == "" then
self.skinId = nil
TouchSkin.setActive(nil)
return nil
end
local skin, err = TouchSkin.select(id)
if not skin then
self.skinId = nil
self.skinError = err
TouchSkin.setActive(nil)
return nil, err
end
self.skinId = id
return skin
end
function TouchControls:skin()
if not self:visible() then return nil end
return TouchSkin.active
end
function TouchControls:setHotkeyHandler(fn)
self.hotkeyHandler = type(fn) == "function" and fn or nil
end
local skinHitSet, applySkinSet
-- 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
@@ -339,7 +393,7 @@ end
-- demand. Mirrors it into self.orientation / self.positions / self.scale,
-- which layout(), the editor chrome and the tests read.
function TouchControls:currentBucket()
local _, _, sw, sh = SafeArea.rect()
local _, _, sw, sh = SafeArea.windowRect()
local o = orientationFor(sw, sh)
self.layouts = self.layouts or { portrait = {}, landscape = {} }
local b = self.layouts[o]
@@ -363,7 +417,7 @@ end
-- while sizes stay derived from the short edge, times the orientation's
-- size setting (#633).
function TouchControls:layout()
local ox, oy, sw, sh = SafeArea.rect()
local ox, oy, sw, sh = SafeArea.windowRect()
if self.layoutW == sw and self.layoutH == sh
and self.layoutOx == ox and self.layoutOy == oy and self.L then
return self.L
@@ -397,7 +451,7 @@ 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 ox, oy, sw, sh = SafeArea.windowRect()
local L = self:layout()
local zone = L[name]
if not zone then return end
@@ -451,6 +505,15 @@ 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 page = TouchSkin.page()
if page then
local ww, wh, ox, oy = surfaceRect()
for i = #page.controls, 1, -1 do
local ctl = page.controls[i]
if TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then return ctl.spec, ctl end
end
return nil
end
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if inCircle(L[btn], x, y, SLOP[btn]) then return btn end
@@ -505,6 +568,86 @@ local function setDpad(self, touch, dir)
if dir then pressBtn(self, dir) end
end
local function pressKey(key, down)
local fn = down and love.keypressed or love.keyreleased
if type(fn) == "function" then pcall(fn, key, key, false) end
end
local function fireHotkey(self, action, pressed, ctl)
if action == "overlay_next" then
if pressed then TouchSkin.nextPage(ctl and ctl.nextTarget) end
return pressed
end
if action == "overlay_prev" then
if pressed then TouchSkin.setPage(TouchSkin.pageIndex - 1) end
return pressed
end
self.hotkeysHeld = self.hotkeysHeld or {}
local n = (self.hotkeysHeld[action] or 0) + (pressed and 1 or -1)
if n < 0 then n = 0 end
self.hotkeysHeld[action] = n > 0 and n or nil
local edge = (pressed and n == 1) or (not pressed and n == 0)
if edge and self.hotkeyHandler then
pcall(self.hotkeyHandler, action, pressed)
end
return false
end
local function enterControl(self, ctl)
local buzzed = false
for _, btn in ipairs(ctl.buttons) do
if not self.held[btn] then buzzed = true end
pressBtn(self, btn)
end
for _, key in ipairs(ctl.keys) do pressKey(key, true) end
local switched = false
for _, action in ipairs(ctl.hotkeys) do
if fireHotkey(self, action, true, ctl) then switched = true end
end
if not buzzed and (ctl.keys[1] or ctl.hotkeys[1]) then
TouchControls.buzz(self.haptics)
end
return switched
end
local function exitControl(self, ctl)
for _, btn in ipairs(ctl.buttons) do releaseBtn(self, btn) end
for _, key in ipairs(ctl.keys) do pressKey(key, false) end
for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end
end
function skinHitSet(self, x, y)
local page = TouchSkin.page()
if not page then return nil end
local ww, wh, ox, oy = surfaceRect()
local set = nil
for _, ctl in ipairs(page.controls) do
if not ctl.decorative and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then
set = set or {}
set[ctl] = true
end
end
return set
end
function applySkinSet(self, touch, set)
local prev = touch.set or {}
local switched = false
for ctl in pairs(prev) do
if not (set and set[ctl]) then exitControl(self, ctl) end
end
for ctl in pairs(set or {}) do
if not prev[ctl] then
if enterControl(self, ctl) then switched = true end
end
end
touch.set = set
if switched then
for ctl in pairs(touch.set or {}) do exitControl(self, ctl) end
touch.set = nil
end
end
-- Returns true when this touch was captured by a virtual control -- the
-- pad's first refusal on the gameplay pointer seam (#807). Capture is
-- decided here, at press, and rides self.touches[id] for the touch's
@@ -513,13 +656,22 @@ end
function TouchControls:touchpressed(id, x, y)
-- 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
if not (self.active and self.enabled ~= false
and (TouchSkin.active or self.img)) then return end
-- a controller hid the overlay; the first touch only brings it back
-- (uncaptured: it began on no control, so mods may still see it)
if self.controllerHidden then
self.controllerHidden = false
return
end
if TouchSkin.active then
local set = skinHitSet(self, x, y)
if not set then return end
local touch = { control = "skin" }
self.touches[id] = touch
applySkinSet(self, touch, set)
return true
end
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if inCircle(L[btn], x, y, SLOP[btn]) then
@@ -544,9 +696,14 @@ end
function TouchControls:touchmoved(id, x, y)
if self.preview then return end
local touch = self.touches[id]
if not touch then return end
if touch.control == "skin" then
applySkinSet(self, touch, skinHitSet(self, x, y))
return
end
-- only the d-pad tracks movement (slide between directions without
-- lifting); buttons hold until release wherever the finger wanders
if not touch or touch.control ~= "dpad" then return end
if touch.control ~= "dpad" then return end
setDpad(self, touch, dpadDir(self:layout().dpad, x, y))
end
@@ -555,7 +712,9 @@ function TouchControls:touchreleased(id, x, y)
local touch = self.touches[id]
if not touch then return end
self.touches[id] = nil
if touch.control == "dpad" then
if touch.control == "skin" then
applySkinSet(self, touch, nil)
elseif touch.control == "dpad" then
setDpad(self, touch, nil)
self.dpadTouch = nil
else
@@ -568,6 +727,15 @@ end
-- touchreleased and would strand its button held forever. Called from
-- Game alongside Input:reset() on focus/visibility loss.
function TouchControls:reset()
for _, touch in pairs(self.touches or {}) do
if touch.control == "skin" and touch.set then
for ctl in pairs(touch.set) do exitControl(self, ctl) end
end
end
for action in pairs(self.hotkeysHeld or {}) do
if self.hotkeyHandler then pcall(self.hotkeyHandler, action, false) end
end
self.hotkeysHeld = {}
for btn in pairs(self.held or {}) do
Input:overlayReleased(btn)
end
@@ -608,12 +776,56 @@ local function drawIcon(img, zone, pressed, alphaMul)
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end
-- Screen-space, called by Game:draw after Renderer:endFrame -- and by
-- Game2:drawHud after Gold's own present pass -- so the overlay rides on top
-- of everything (world, UI, CRT/GBC FX included). Also used by the launcher
-- layout editor under preview mode.
local function drawStretched(img, x, y, w, h, alpha)
if not img or alpha <= 0 then return end
local iw, ih = img:getWidth(), img:getHeight()
if iw <= 0 or ih <= 0 then return end
love.graphics.setColor(1, 1, 1, math.min(1, alpha))
love.graphics.draw(img, x, y, 0, w / iw, h / ih)
end
function TouchControls:drawSkin(alphaMul)
local page = TouchSkin.page()
if not page then return false end
local ww, wh, sox, soy = surfaceRect()
local bx, by, bw, bh = TouchSkin.pageBox(page, ww, wh, sox, soy)
local opacity = (self.skinOpacity or 1) * alphaMul
love.graphics.push("all")
love.graphics.origin()
drawStretched(page.image, bx, by, bw, bh, opacity)
local pressed = {}
for _, touch in pairs(self.touches or {}) do
for ctl in pairs(touch.set or {}) do pressed[ctl] = true end
end
for _, ctl in ipairs(page.controls) do
local down = pressed[ctl] == true
local img = (down and ctl.pressedImage) or ctl.image
if img then
local cx, cy, halfW, halfH =
TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy)
local alpha = opacity
if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end
drawStretched(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
end
end
love.graphics.pop()
return true
end
-- OS-window space, called after GameViewport.finish so the overlay rides on
-- top of the game, companion composition and post-processing without being
-- captured or scaled with any game viewport. Also used by the launcher layout
-- editor under preview mode.
function TouchControls:draw()
if not self:visible() then return end
if TouchSkin.active then
local mul = (self.preview and self.enabled == false) and 0.45 or 1
if self:drawSkin(mul) then return end
end
local L = self:layout()
-- when the player disabled the overlay but the editor is previewing,
-- draw dimmed so the layout is still editable
+842
View File
@@ -0,0 +1,842 @@
local TouchSkin = {}
TouchSkin.BUNDLED_ROOT = "assets/skins"
TouchSkin.USER_ROOT = "skins"
TouchSkin.GB_BUTTONS = {
a = "a", b = "b", start = "start", select = "select",
up = "up", down = "down", left = "left", right = "right",
}
TouchSkin.HOTKEYS = {
overlay_next = "overlay_next",
overlay_previous = "overlay_prev",
menu_toggle = "menu",
reset = "soft_reset",
hold_fast_forward = "fast_forward_hold",
fast_forward = "fast_forward_hold",
toggle_fast_forward = "fast_forward_toggle",
screenshot = "screenshot",
pause_toggle = "pause",
exit_emulator = "quit",
}
local DEFAULT_ASPECT = 16 / 9
local PORTRAIT_ASPECT = 9 / 16
local function trim(s)
return (tostring(s or ""):match("^%s*(.-)%s*$"))
end
local function unquote(s)
s = trim(s)
local inner = s:match('^"(.*)"$')
return inner or s
end
local function toBool(v)
v = trim(v):lower()
return v == "true" or v == "1"
end
local function tokens(s)
local out = {}
for tok in tostring(s or ""):gmatch("[^,%s]+") do out[#out + 1] = tok end
return out
end
local function num(v, fallback)
local n = tonumber(trim(v))
if not n or n ~= n then return fallback end
return n
end
function TouchSkin.parseConfig(text)
local kv = {}
for line in tostring(text or ""):gmatch("[^\r\n]+") do
if not line:match("^%s*#") then
local key, value = line:match("^%s*([%w_]+)%s*=%s*(.-)%s*$")
if key then kv[key] = unquote(value) end
end
end
return kv
end
local function parseBinds(spec)
local buttons, hotkeys, keys, decorative = {}, {}, {}, true
for raw in tostring(spec or ""):gmatch("[^|]+") do
local name = trim(raw):lower()
local key = name:match("^key:(.+)$") or name:match("^retrok_(.+)$")
if name == "nul" or name == "" then
-- decoration
elseif key then
keys[#keys + 1] = key
decorative = false
elseif TouchSkin.GB_BUTTONS[name] then
buttons[#buttons + 1] = TouchSkin.GB_BUTTONS[name]
decorative = false
elseif TouchSkin.HOTKEYS[name] then
hotkeys[#hotkeys + 1] = TouchSkin.HOTKEYS[name]
decorative = false
end
end
return buttons, hotkeys, keys, decorative
end
local function parseDesc(kv, prefix, page)
local spec = kv[prefix]
if not spec then return nil end
local t = tokens(spec)
if #t < 6 then return nil end
local shape = trim(t[4]):lower()
if shape ~= "radial" and shape ~= "rect" then shape = "rect" end
local buttons, hotkeys, keys, decorative = parseBinds(t[1])
local reachX = num(kv[prefix .. "_reach_x"], 1)
local reachY = num(kv[prefix .. "_reach_y"], 1)
local ctl = {
spec = t[1],
buttons = buttons,
hotkeys = hotkeys,
keys = keys,
decorative = decorative,
x = num(t[2], 0.5),
y = num(t[3], 0.5),
shape = shape,
rangeX = math.abs(num(t[5], 0.05)),
rangeY = math.abs(num(t[6], 0.05)),
rangeMod = num(kv[prefix .. "_range_mod"], page.rangeMod),
alphaMod = num(kv[prefix .. "_alpha_mod"], page.alphaMod),
reachUp = num(kv[prefix .. "_reach_up"], reachY),
reachDown = num(kv[prefix .. "_reach_down"], reachY),
reachLeft = num(kv[prefix .. "_reach_left"], reachX),
reachRight = num(kv[prefix .. "_reach_right"], reachX),
imagePath = kv[prefix .. "_overlay"],
pressedImagePath = kv[prefix .. "_overlay_pressed"],
nextTarget = kv[prefix .. "_next_target"],
}
if ctl.imagePath == "" then ctl.imagePath = nil end
if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end
return ctl
end
function TouchSkin.parse(text)
local kv = TouchSkin.parseConfig(text)
local count = math.floor(num(kv.overlays, 0))
if count <= 0 then return nil, "no overlays" end
local pages = {}
for i = 0, count - 1 do
local p = "overlay" .. i
local page = {
index = i + 1,
name = kv[p .. "_name"] or ("overlay" .. i),
imagePath = kv[p .. "_overlay"],
fullScreen = toBool(kv[p .. "_full_screen"]),
normalized = toBool(kv[p .. "_normalized"]),
rangeMod = num(kv[p .. "_range_mod"], 1),
alphaMod = num(kv[p .. "_alpha_mod"], 1),
aspect = num(kv[p .. "_aspect_ratio"], nil),
controls = {},
}
if page.imagePath == "" then page.imagePath = nil end
if not page.aspect or page.aspect <= 0 then
page.aspect = page.name:lower():find("portrait", 1, true)
and PORTRAIT_ASPECT or DEFAULT_ASPECT
end
local rect = tokens(kv[p .. "_rect"])
page.rect = { x = 0, y = 0, w = 1, h = 1 }
if #rect >= 4 then
page.rect = { x = num(rect[1], 0), y = num(rect[2], 0),
w = num(rect[3], 1), h = num(rect[4], 1) }
end
local vp = tokens(kv[p .. "_viewport"])
if #vp >= 4 then
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
w = num(vp[3], 1), h = num(vp[4], 1) }
page.viewportFill = toBool(kv[p .. "_viewport_fill"])
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
end
local descs = math.floor(num(kv[p .. "_descs"], 0))
for d = 0, descs - 1 do
local ctl = parseDesc(kv, p .. "_desc" .. d, page)
if ctl then page.controls[#page.controls + 1] = ctl end
end
pages[#pages + 1] = page
end
return { pages = pages }
end
local function readFile(path)
if love and love.filesystem and love.filesystem.read then
local ok, data = pcall(love.filesystem.read, path)
if ok and data then return data end
end
local handle = io.open(path, "rb")
if not handle then return nil end
local data = handle:read("*a")
handle:close()
return data
end
local function listDir(path)
if love and love.filesystem and love.filesystem.getDirectoryItems then
local ok, items = pcall(love.filesystem.getDirectoryItems, path)
if ok and items then return items end
end
return {}
end
local function isDir(path)
if love and love.filesystem and love.filesystem.getInfo then
local ok, info = pcall(love.filesystem.getInfo, path)
if ok and info then return info.type == "directory" end
end
return false
end
local function joinPath(root, rel)
rel = tostring(rel or ""):gsub("^%./", ""):gsub("\\", "/")
if rel:sub(1, 1) == "/" then return rel:sub(2) end
return root .. "/" .. rel
end
TouchSkin.NATIVE_NAME = "skin.lua"
local function findConfig(root)
if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then
return root .. "/" .. TouchSkin.NATIVE_NAME, "native"
end
local named = { "overlay.cfg", "skin.cfg", "layout.cfg" }
for _, name in ipairs(named) do
if readFile(root .. "/" .. name) then return root .. "/" .. name, "retroarch" end
end
local items = listDir(root)
table.sort(items)
for _, name in ipairs(items) do
if name:match("%.cfg$") then return root .. "/" .. name, "retroarch" end
end
return nil
end
local function loadDataChunk(text, name)
if loadstring then
local chunk, err = loadstring(text, name)
if not chunk then return nil, err end
return setfenv(chunk, {})
end
return load(text, name, "t", {})
end
function TouchSkin.parseNative(text)
local chunk, err = loadDataChunk(tostring(text or ""), "skin")
if not chunk then return nil, "skin.lua does not parse: " .. tostring(err) end
local ok, data = pcall(chunk)
if not ok or type(data) ~= "table" then return nil, "skin.lua returned no table" end
if type(data.pages) ~= "table" or not data.pages[1] then
return nil, "skin.lua has no pages"
end
local pages = {}
for i, raw in ipairs(data.pages) do
if type(raw) ~= "table" then return nil, "page " .. i .. " is not a table" end
local page = {
index = i,
name = tostring(raw.name or ("page" .. i)),
imagePath = raw.image,
fullScreen = raw.fullScreen ~= false,
normalized = true,
rangeMod = num(raw.rangeMod, 1),
alphaMod = num(raw.alphaMod, 1),
aspect = num(raw.aspect, DEFAULT_ASPECT),
rect = { x = 0, y = 0, w = 1, h = 1 },
controls = {},
}
if type(raw.rect) == "table" then
page.rect = { x = num(raw.rect.x, 0), y = num(raw.rect.y, 0),
w = num(raw.rect.w, 1), h = num(raw.rect.h, 1) }
end
if type(raw.viewport) == "table" then
page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0),
w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
page.viewportFill = raw.viewport.fill == true
page.viewportExpand = raw.viewport.expand == true
end
for _, c in ipairs(raw.controls or {}) do
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
page.controls[#page.controls + 1] = {
spec = tostring(c.bind or "nul"),
buttons = buttons, hotkeys = hotkeys, keys = keys,
decorative = decorative,
x = num(c.x, 0.5), y = num(c.y, 0.5),
shape = c.shape == "radial" and "radial" or "rect",
rangeX = math.abs(num(c.w, 0.1)) * 0.5,
rangeY = math.abs(num(c.h, 0.1)) * 0.5,
rangeMod = num(c.rangeMod, page.rangeMod),
alphaMod = num(c.alphaMod, page.alphaMod),
reachUp = num(c.reachUp, 1), reachDown = num(c.reachDown, 1),
reachLeft = num(c.reachLeft, 1), reachRight = num(c.reachRight, 1),
imagePath = c.image,
pressedImagePath = c.imagePressed,
nextTarget = c.nextTarget,
}
end
pages[#pages + 1] = page
end
return { pages = pages, name = data.name, author = data.author,
notes = data.notes, format = "native" }
end
function TouchSkin.toNative(skin)
local out = {
name = skin.name or skin.id,
author = skin.author,
notes = skin.notes,
format = 1,
pages = {},
}
for _, page in ipairs(skin.pages or {}) do
local p = {
name = page.name,
image = page.imagePath,
fullScreen = page.fullScreen ~= false,
rangeMod = page.rangeMod,
alphaMod = page.alphaMod,
aspect = page.aspect,
controls = {},
}
if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0
or page.rect.w ~= 1 or page.rect.h ~= 1) then
p.rect = { x = page.rect.x, y = page.rect.y, w = page.rect.w, h = page.rect.h }
end
if page.viewport then
p.viewport = {
x = page.viewport.x, y = page.viewport.y,
w = page.viewport.w, h = page.viewport.h,
fill = page.viewportFill or nil,
expand = page.viewportExpand or nil,
}
end
for _, ctl in ipairs(page.controls or {}) do
p.controls[#p.controls + 1] = {
bind = ctl.spec,
x = ctl.x, y = ctl.y,
w = ctl.rangeX * 2, h = ctl.rangeY * 2,
shape = ctl.shape,
rangeMod = ctl.rangeMod ~= p.rangeMod and ctl.rangeMod or nil,
alphaMod = ctl.alphaMod ~= p.alphaMod and ctl.alphaMod or nil,
reachUp = ctl.reachUp ~= 1 and ctl.reachUp or nil,
reachDown = ctl.reachDown ~= 1 and ctl.reachDown or nil,
reachLeft = ctl.reachLeft ~= 1 and ctl.reachLeft or nil,
reachRight = ctl.reachRight ~= 1 and ctl.reachRight or nil,
image = ctl.imagePath,
imagePressed = ctl.pressedImagePath,
nextTarget = ctl.nextTarget,
}
end
out.pages[#out.pages + 1] = p
end
return out
end
function TouchSkin.serialize(skin)
return require("src.import.LuaWriter").encode(TouchSkin.toNative(skin))
end
local imageCache = setmetatable({}, { __mode = "v" })
local function loadImage(path)
if not (love and love.graphics and love.graphics.newImage) then return nil end
local cached = imageCache[path]
if cached then return cached end
local ok, img = pcall(love.graphics.newImage, path)
if not ok or not img then return nil end
if img.setFilter then img:setFilter("linear", "linear") end
imageCache[path] = img
return img
end
function TouchSkin.load(root, id)
local cfgPath, format = findConfig(root)
if not cfgPath then return nil, "no skin.lua or .cfg in " .. root end
local text = readFile(cfgPath)
if not text then return nil, "unreadable " .. cfgPath end
local skin, err
if format == "native" then
skin, err = TouchSkin.parseNative(text)
else
skin, err = TouchSkin.parse(text)
end
if not skin then return nil, err end
skin.id = id or root:match("([^/]+)$") or root
skin.root = root
skin.configPath = cfgPath
skin.format = format
skin.name = skin.name or skin.pages[1] and skin.pages[1].name or skin.id
for _, page in ipairs(skin.pages) do
if page.imagePath then
page.image = loadImage(joinPath(root, page.imagePath))
end
for _, ctl in ipairs(page.controls) do
if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end
if ctl.pressedImagePath then
ctl.pressedImage = loadImage(joinPath(root, ctl.pressedImagePath))
end
end
end
return skin
end
local function mountZip(archive, point)
if not (love and love.filesystem and love.filesystem.mount) then return false end
local ok, mounted = pcall(love.filesystem.mount, archive, point)
return ok and mounted == true
end
function TouchSkin.list()
local out, seen = {}, {}
local function scan(root, source)
for _, name in ipairs(listDir(root)) do
local id = name:gsub("%.zip$", "")
if not seen[id] then
local path = root .. "/" .. name
if name:match("%.zip$") then
local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id
if mountZip(path, point) and findConfig(point) then
seen[id] = true
out[#out + 1] = { id = id, root = point, source = source, archive = path }
end
elseif isDir(path) and findConfig(path) then
seen[id] = true
out[#out + 1] = { id = id, root = path, source = source }
end
end
end
end
if love and love.filesystem and love.filesystem.createDirectory then
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
end
scan(TouchSkin.USER_ROOT, "user")
scan(TouchSkin.BUNDLED_ROOT, "bundled")
table.sort(out, function(a, b) return a.id < b.id end)
return out
end
-- Drop a .zip into <save>/skins and report the id it will list under.
function TouchSkin.installArchive(name, data)
if not data or data == "" then return nil, "empty archive" end
if not (love and love.filesystem and love.filesystem.write) then
return nil, "no writable filesystem"
end
name = tostring(name or ""):match("([^/\\]+)$") or ""
name = name:gsub("[^%w%._%-]", "_")
if not name:lower():match("%.zip$") then return nil, "not a .zip" end
local id = name:gsub("%.[Zz][Ii][Pp]$", "")
if id == "" then return nil, "bad archive name" end
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
local dest = TouchSkin.USER_ROOT .. "/" .. name
local ok, err = love.filesystem.write(dest, data)
if not ok then return nil, tostring(err) end
local entry = TouchSkin.find(id)
if not entry then
love.filesystem.remove(dest)
return nil, "no skin.lua or .cfg inside " .. name
end
return id
end
function TouchSkin.find(id)
if not id or id == "" then return nil end
for _, entry in ipairs(TouchSkin.list()) do
if entry.id == id then return entry end
end
return nil
end
function TouchSkin.assetPaths(skin)
local out, seen = {}, {}
local function add(rel)
if rel and rel ~= "" and not seen[rel] then
seen[rel] = true
out[#out + 1] = rel
end
end
for _, page in ipairs(skin.pages or {}) do
add(page.imagePath)
for _, ctl in ipairs(page.controls or {}) do
add(ctl.imagePath)
add(ctl.pressedImagePath)
end
end
return out
end
function TouchSkin.export(skin, destPath)
if not skin then return nil, "no skin" end
local SkinZip = require("src.core.SkinZip")
local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } }
local missing = {}
for _, rel in ipairs(TouchSkin.assetPaths(skin)) do
local data = readFile(joinPath(skin.root, rel))
if data then
entries[#entries + 1] = { name = rel, data = data }
else
missing[#missing + 1] = rel
end
end
if skin.configPath and skin.format == "retroarch" then
local cfg = readFile(skin.configPath)
if cfg then
entries[#entries + 1] =
{ name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg }
end
end
local blob = SkinZip.encode(entries)
destPath = destPath or (TouchSkin.USER_ROOT .. "/" .. skin.id .. "-export.zip")
local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil
if not absolute and love and love.filesystem and love.filesystem.write then
local ok, err = love.filesystem.write(destPath, blob)
if not ok then return nil, tostring(err) end
else
local handle = io.open(destPath, "wb")
if not handle then return nil, "cannot write " .. destPath end
handle:write(blob)
handle:close()
end
return destPath, missing
end
TouchSkin.BINDS = {
"nul",
"up", "down", "left", "right",
"a", "b", "start", "select",
"left|up", "right|up", "left|down", "right|down",
"hold_fast_forward", "toggle_fast_forward", "reset", "menu_toggle",
"overlay_next",
}
function TouchSkin.describeBind(spec)
local buttons, hotkeys, keys, decorative = parseBinds(spec)
if decorative then return "decoration" end
local parts = {}
for _, b in ipairs(buttons) do parts[#parts + 1] = "GB " .. b:upper() end
for _, h in ipairs(hotkeys) do parts[#parts + 1] = h end
for _, k in ipairs(keys) do parts[#parts + 1] = "key " .. k end
return table.concat(parts, " + ")
end
function TouchSkin.newControl(spec, x, y, w, h, shape)
local buttons, hotkeys, keys, decorative = parseBinds(spec)
return {
spec = spec, buttons = buttons, hotkeys = hotkeys, keys = keys,
decorative = decorative,
x = x, y = y, rangeX = w * 0.5, rangeY = h * 0.5,
shape = shape == "radial" and "radial" or "rect",
rangeMod = 1, alphaMod = 1,
reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1,
}
end
function TouchSkin.setBind(ctl, spec)
ctl.spec = spec
ctl.buttons, ctl.hotkeys, ctl.keys, ctl.decorative = parseBinds(spec)
return ctl
end
function TouchSkin.newSkin(id)
local page = {
index = 1, name = "main", fullScreen = true, normalized = true,
rangeMod = 1, alphaMod = 1, aspect = PORTRAIT_ASPECT,
rect = { x = 0, y = 0, w = 1, h = 1 },
viewport = { x = 0, y = 0, w = 1, h = 0.5 },
viewportFill = false,
controls = {},
}
return { id = id or "new_skin", name = id or "new_skin",
root = TouchSkin.USER_ROOT .. "/" .. (id or "new_skin"),
format = "native", pages = { page } }
end
local function copyTable(src)
if type(src) ~= "table" then return src end
local out = {}
for k, v in pairs(src) do out[k] = copyTable(v) end
return out
end
function TouchSkin.clone(skin)
if not skin then return nil end
local out = {
id = skin.id, name = skin.name, root = skin.root, format = skin.format,
author = skin.author, notes = skin.notes, configPath = skin.configPath,
source = skin.source, pages = {},
}
for i, page in ipairs(skin.pages or {}) do
local p = copyTable(page)
p.image = page.image
p.controls = {}
for j, ctl in ipairs(page.controls or {}) do
local c = copyTable(ctl)
c.image = ctl.image
c.pressedImage = ctl.pressedImage
p.controls[j] = c
end
out.pages[i] = p
end
return out
end
function TouchSkin.resolveImage(root, rel)
if not rel or rel == "" then return nil end
return loadImage(joinPath(root, rel))
end
function TouchSkin.detectViewport(root, imagePath)
if not (love and love.image and love.image.newImageData) then return nil end
if not imagePath or imagePath == "" then return nil end
local ok, data = pcall(love.image.newImageData, joinPath(root, imagePath))
if not ok or not data then return nil end
local w, h = data:getWidth(), data:getHeight()
if w < 2 or h < 2 then return nil end
local function clear(x, y)
if x < 0 or y < 0 or x >= w or y >= h then return false end
local okp, _, _, _, a = pcall(data.getPixel, data, x, y)
return okp and a ~= nil and a < 0.05
end
local cx, cy = math.floor(w / 2), math.floor(h / 2)
if not clear(cx, cy) then return nil end
local left, right = cx, cx
while left > 0 and clear(left - 1, cy) do left = left - 1 end
while right < w - 1 and clear(right + 1, cy) do right = right + 1 end
local top, bottom = cy, cy
while top > 0 and clear(cx, top - 1) do top = top - 1 end
while bottom < h - 1 and clear(cx, bottom + 1) do bottom = bottom + 1 end
local rw, rh = right - left + 1, bottom - top + 1
if rw < 8 or rh < 8 then return nil end
return { x = left / w, y = top / h, w = rw / w, h = rh / h }, rw, rh
end
-- Bring outside art into the skin's own folder. A skin still living in
-- assets/ or a mounted zip is saved out to <save>/skins/<id> first, because
-- that is the only root the engine can write to.
function TouchSkin.importImage(skin, name, data)
if not skin then return nil, "no skin" end
if not data or data == "" then return nil, "no image data" end
if not (love and love.filesystem and love.filesystem.write) then
return nil, "no writable filesystem"
end
name = tostring(name):gsub("[^%w%._%-]", "_")
if name == "" then return nil, "bad file name" end
local dest = TouchSkin.USER_ROOT .. "/" .. skin.id
if skin.root ~= dest then
local saved, err = TouchSkin.saveTo(skin, skin.id)
if not saved then return nil, tostring(err) end
end
pcall(love.filesystem.createDirectory, dest .. "/img")
local rel = "img/" .. name
local ok, err = love.filesystem.write(dest .. "/" .. rel, data)
if not ok then return nil, tostring(err) end
return rel
end
function TouchSkin.listImages(root)
local out = {}
local function scan(dir, prefix)
for _, name in ipairs(listDir(dir)) do
local path = dir .. "/" .. name
if name:lower():match("%.png$") or name:lower():match("%.jpg$") then
out[#out + 1] = prefix .. name
elseif isDir(path) and prefix == "" then
scan(path, name .. "/")
end
end
end
scan(root, "")
table.sort(out)
return out
end
function TouchSkin.saveTo(skin, id)
id = id or skin.id
if not id or id == "" then return nil, "no skin id" end
if not (love and love.filesystem and love.filesystem.write) then
return nil, "no writable filesystem"
end
local dest = TouchSkin.USER_ROOT .. "/" .. id
pcall(love.filesystem.createDirectory, dest)
local copied, failed = 0, {}
for _, rel in ipairs(TouchSkin.assetPaths(skin)) do
local target = dest .. "/" .. rel
local dir = target:match("^(.*)/[^/]+$")
if dir then pcall(love.filesystem.createDirectory, dir) end
if skin.root ~= dest then
local data = readFile(joinPath(skin.root, rel))
if data then
if love.filesystem.write(target, data) then copied = copied + 1 end
else
failed[#failed + 1] = rel
end
end
end
local ok, err = love.filesystem.write(dest .. "/" .. TouchSkin.NATIVE_NAME,
TouchSkin.serialize(skin))
if not ok then return nil, tostring(err) end
skin.id, skin.root, skin.format = id, dest, "native"
return dest, failed, copied
end
TouchSkin.active = nil
TouchSkin.pageIndex = 1
TouchSkin.surfaceRect = nil
function TouchSkin.setSurface(x, y, w, h)
if not w or w <= 0 or not h or h <= 0 then
TouchSkin.surfaceRect = nil
else
TouchSkin.surfaceRect = { x = x, y = y, w = w, h = h }
end
return TouchSkin.surfaceRect
end
function TouchSkin.setActive(skin)
TouchSkin.active = skin or nil
TouchSkin.pageIndex = 1
return TouchSkin.active
end
function TouchSkin.select(id)
if not id or id == "" then return TouchSkin.setActive(nil) end
local entry = TouchSkin.find(id)
if not entry then return nil, "no skin " .. tostring(id) end
local skin, err = TouchSkin.load(entry.root, entry.id)
if not skin then return nil, err end
skin.source = entry.source
return TouchSkin.setActive(skin)
end
function TouchSkin.page()
local skin = TouchSkin.active
if not skin then return nil end
return skin.pages[TouchSkin.pageIndex] or skin.pages[1]
end
function TouchSkin.setPage(target)
local skin = TouchSkin.active
if not skin then return nil end
if type(target) == "number" then
local n = #skin.pages
TouchSkin.pageIndex = ((math.floor(target) - 1) % n) + 1
return TouchSkin.page()
end
for i, page in ipairs(skin.pages) do
if page.name == target then
TouchSkin.pageIndex = i
return page
end
end
return TouchSkin.page()
end
function TouchSkin.nextPage(target)
local skin = TouchSkin.active
if not skin then return nil end
if target and target ~= "" then return TouchSkin.setPage(target) end
return TouchSkin.setPage(TouchSkin.pageIndex + 1)
end
function TouchSkin.pageBox(page, w, h, ox, oy)
ox, oy = ox or 0, oy or 0
if not page then return ox, oy, w, h end
local bx, by, bw, bh = ox, oy, w, h
if not page.fullScreen and h > 0 then
local displayAspect = w / h
if displayAspect > page.aspect then
bw = h * page.aspect
bx = ox + (w - bw) * 0.5
else
bh = w / page.aspect
by = oy + (h - bh) * 0.5
end
end
local r = page.rect
return bx + r.x * bw, by + r.y * bh, r.w * bw, r.h * bh
end
function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
local cx, cy = bx + ctl.x * bw, by + ctl.y * bh
local halfW, halfH = ctl.rangeX * bw, ctl.rangeY * bh
return cx, cy, halfW, halfH
end
function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy)
local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
local left = halfW * ctl.reachLeft * ctl.rangeMod
local right = halfW * ctl.reachRight * ctl.rangeMod
local up = halfH * ctl.reachUp * ctl.rangeMod
local down = halfH * ctl.reachDown * ctl.rangeMod
local dx = px - cx
local dy = py - cy
local rx = dx < 0 and left or right
local ry = dy < 0 and up or down
if rx <= 0 or ry <= 0 then return false end
if ctl.shape == "radial" then
return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1
end
return math.abs(dx) <= rx and math.abs(dy) <= ry
end
TouchSkin.overlayLive = false
function TouchSkin.setOverlayLive(on)
TouchSkin.overlayLive = on and true or false
end
function TouchSkin.decorativeOnly()
local page = TouchSkin.page()
if not page then return false end
for _, ctl in ipairs(page.controls) do
if not ctl.decorative then return false end
end
return true
end
function TouchSkin.drawable()
if not TouchSkin.active then return false end
return TouchSkin.overlayLive or TouchSkin.decorativeOnly()
end
function TouchSkin.hasViewport()
local page = TouchSkin.page()
return page ~= nil and page.viewport ~= nil and TouchSkin.drawable()
end
function TouchSkin.viewport(w, h, ox, oy)
local page = TouchSkin.page()
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
local v = page.viewport
local x, y = (ox or 0) + v.x * w, (oy or 0) + v.y * h
local vw, vh = v.w * w, v.h * h
if vw <= 0 or vh <= 0 then return nil end
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
end
return TouchSkin
+30
View File
@@ -101,6 +101,14 @@ function Boxes.canDeposit(save, partyIndex, boxIndex)
return true
end
-- RestorePPOfDepositedPokemon (engine/pokemon/move_mon.asm:711-773): every
-- slot back to GetMaxPPOfMove, which already carries its own PP Up count.
function Boxes.restorePP(mon)
for _, move in ipairs((mon and mon.moves) or {}) do
if type(move) == "table" then move.pp = move.maxPp or move.pp end
end
end
function Boxes.deposit(save, partyIndex, boxIndex)
local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex)
if not ok then return false, reason end
@@ -112,6 +120,9 @@ function Boxes.deposit(save, partyIndex, boxIndex)
Mail.removeSlot(save, partyIndex)
local box = Boxes.box(save, boxIndex)
box[#box + 1] = mon
-- SendGetMonIntoFromBox's PC_DEPOSIT arm ends in RestorePPOfDepositedPokemon
-- (engine/pokemon/move_mon.asm:633-635, :696-700).
Boxes.restorePP(mon)
return true, mon
end
@@ -129,6 +140,15 @@ function Boxes.withdraw(save, boxIndex, slot)
local ok, reason = Boxes.canWithdraw(save, boxIndex, slot)
if not ok then return false, reason end
local mon = table.remove(Boxes.box(save, boxIndex), slot)
-- The `get mon into Party` arm alone heals: status cleared and MON_MAXHP
-- copied over MON_HP, an egg's staying 0 (move_mon.asm:666-693).
mon.status = nil
mon.statusTurns = nil
if mon.isEgg then
mon.hp = 0
else
mon.hp = mon.maxHp or mon.hp
end
save.party = save.party or {}
save.party[#save.party + 1] = mon
return true, mon
@@ -142,6 +162,16 @@ function Boxes.release(save, boxIndex, slot)
return true, table.remove(box, slot)
end
-- RELEASE off the DEPOSIT screen, whose list is the party: it is
-- RemoveMonFromPartyOrBox's REMOVE_PARTY arm (bills_pc.asm:204-207).
function Boxes.releaseFromParty(save, slot)
local party = (save and save.party) or {}
if not party[slot] then return false, "There is no POKéMON there." end
local mon = table.remove(party, slot)
Mail.removeSlot(save, slot)
return true, mon
end
-- Move a boxed mon to another box (MOVE PKMN W/O MAIL's box-to-box case).
function Boxes.move(save, fromBox, slot, toBox)
if fromBox == toBox then return false, "It's already there." end
+43
View File
@@ -16,13 +16,56 @@
-- it (src/ui/gen2/InitClock.lua, src/script/gen2/Specials.lua SetDayOfWeek)
-- and World only ever reads it.
local Palettes = require("src.world.gen2.Palettes")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local Clock = {}
Clock.MINUTES_PER_DAY = 24 * 60
Clock.DAYS = 7
-- data/text/day_of_week.asm order, which is wCurDay's own: SUNDAY is 0, so
-- index 1 is SUNDAY -- matching both InitClock's `self.day + 1` and
-- `Clock.weekday(save) + 1`. Strings.source, not Strings: built at require
-- time, before Strings.load has a catalog, so Clock.weekdayName looks each
-- name up at display time instead (src/battle/MoveEffects.lua's STAT_LABEL
-- is the same pattern). One shared table and one lookup function, so
-- InitClock's screens, the main menu clock box and the Pokegear clock card
-- cannot drift apart on what a weekday is called.
Clock.DAY_NAMES = {
Strings.source("SUNDAY"), Strings.source("MONDAY"), Strings.source("TUESDAY"),
Strings.source("WEDNESDAY"), Strings.source("THURSDAY"), Strings.source("FRIDAY"),
Strings.source("SATURDAY"),
}
-- The translated name for a 1-based weekday (SUNDAY = 1), or nil if `day` is
-- out of range.
function Clock.weekdayName(day)
local name = Clock.DAY_NAMES[day]
return name and Strings(name)
end
-- The three words Palettes.clockDaytime can hand back, translated (never
-- DARK: that one only comes out of Palettes.daytimeFor, for a PALETTE_DARK
-- map, and is never printed as text). Strings.source, not Strings:
-- clockDaytime's return value is also an internal key every
-- FORCED_DAYTIME/palette lookup in Palettes.lua compares against, so THAT
-- stays untranslated -- only this table, and Clock.daytimeLabel below, look
-- a word up, at the UI call sites that actually print it.
local DAYTIME_LABEL = {
MORN = Strings.source("MORN"), DAY = Strings.source("DAY"),
NITE = Strings.source("NITE"),
}
-- clockDaytime's word, translated -- the one InitClock's clock-setting
-- screen and the Pokegear's clock card print (DisplayHourOClock /
-- Pokegear_UpdateClock).
function Clock.daytimeLabel(hour)
local daytime = Palettes.clockDaytime(hour)
return Strings(DAYTIME_LABEL[daytime] or daytime)
end
-- InitClock's own default: `ld a, 10 ; default hour = 10 AM`, with the minute
-- buffer left at the zero ByteFill put there.
Clock.DEFAULT_HOUR = 10
+7
View File
@@ -159,6 +159,13 @@ function NpcTrade.perform(data, save, row, index)
-- that slot inheriting it (src/core/gen2/Mail.lua).
Mail.removeSlot(save, index)
party[#party + 1] = received
-- TryAddMonToParty's SetSeenAndCaughtMon (engine/pokemon/move_mon.asm:196),
-- which the `predef` at engine/events/npc_trade.asm:168 runs like any other.
save.pokedex = save.pokedex or {}
save.pokedex.seen = save.pokedex.seen or {}
save.pokedex.caught = save.pokedex.caught or {}
save.pokedex.seen[received.species] = true
save.pokedex.caught[received.species] = true
return given, received
end
+1
View File
@@ -273,6 +273,7 @@ Save.DEFAULT_OPTIONS = {
-- means something different, hence the different name.
color = "gbc",
videoMode = "windowed",
fpsCap = 60,
musicVol = 7, -- 0-7, like the GB's NR50 master volume
sfxVol = 7, -- 0-7
musicFilter = 0, -- low-pass steps, 0 = off
+86 -7
View File
@@ -122,6 +122,17 @@ local function addTouchRows(rows, add, opts, hooks)
end,
}
end
if hooks and hooks.openSkinStudio then
rows[#rows + 1] = {
label = Strings("SKIN STUDIO"),
actionLabel = Strings("Open"),
action = function()
hooks.openSkinStudio()
return false
end,
}
end
end
local function coreRows(opts, hooks)
@@ -138,15 +149,72 @@ local function coreRows(opts, hooks)
ladder(opts, "battleStyle",
{ { "shift", "SHIFT" }, { "set", "SET" } }, "shift"))
add(Strings("BATTLE LAYOUT"),
ladder(opts, "battleLayout",
{ { "og", "OG" }, { "wide", "WIDE" } }, "og"))
function()
return opts.battleLayout == "wide" and Strings("WIDE") or Strings("OG")
end,
function()
opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide"
if opts.battleLayout ~= "wide" then
opts.battleHud = "standard"
elseif opts.battleFit == "fill" and opts.battleHud == "extended" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE SIZE"),
ladder(opts, "battleFit",
{ { "fixed", "FIXED" }, { "fill", "FILL" } }, "fixed"))
function()
return opts.battleFit == "fill" and Strings("FILL") or Strings("FIXED")
end,
function()
opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill"
if opts.battleFit == "fill" and opts.battleLayout == "wide"
and opts.battleHud == "extended" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE HUD"),
function()
return opts.battleLayout == "wide" and opts.battleHud == "extended"
and Strings("EXTENDED")
or Strings("STANDARD")
end,
function()
if opts.battleLayout ~= "wide" then
opts.battleHud = "standard"
return false
end
opts.battleHud = opts.battleHud == "extended" and "standard" or "extended"
if opts.battleHud == "extended" and opts.battleFit == "fill" then
opts.battleBg = "white"
end
return true
end)
add(Strings("BATTLE BG"),
ladder(opts, "battleBg",
{ { "white", "WHITE" }, { "black", "BLACK" }, { "world", "WORLD" } },
"white"))
function()
if opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended" then
opts.battleBg = "white"
return Strings("AUTO")
end
if opts.battleBg == "black" then return Strings("BLACK") end
if opts.battleBg == "world" then return Strings("WORLD") end
return Strings("WHITE")
end,
function(dir)
if opts.battleLayout == "wide" and opts.battleFit == "fill"
and opts.battleHud == "extended" then
opts.battleBg = "white"
return false
end
local order = { "white", "black", "world" }
local cur = 1
for i, mode in ipairs(order) do
if opts.battleBg == mode then cur = i break end
end
opts.battleBg = order[wrapIndex(cur - 1 + (dir or 1), #order) + 1]
return true
end)
add(Strings("UI LAYOUT"),
ladder(opts, "uiLayout",
{ { "centered", "CENTERED" }, { "dynamic", "DYNAMIC" } }, "centered"))
@@ -570,6 +638,16 @@ local function gen2Rows(opts, hooks)
end)
end
local okCap, FrameCap = pcall(require, "src.core.FrameCap")
if okCap then
add(Strings("MAX FPS"),
function() return FrameCap.label(opts.fpsCap) end,
function(dir)
opts.fpsCap = FrameCap.cycle(opts.fpsCap, dir)
return true
end)
end
addTouchRows(rows, add, opts, hooks)
return rows
@@ -581,6 +659,7 @@ end
-- writes options while a modal covers it, so the cached table stays true.
-- `hooks` carries the host actions a row cannot perform itself:
-- editTouchControls() -- hand the screen to the touch-overlay editor
-- openSkinStudio() -- hand the screen to the desktop skin studio
--
-- `version` is the game the gear was opened on. It picks the row set, and
-- for Gold it also picks WHICH table the rows edit: the `gold` block inside
+248 -6
View File
@@ -776,6 +776,26 @@ end
-- A hand-drawn X / check: the UI font has no guaranteed glyph for either,
-- and the launcher ships no icon asset for them.
-- Skins tab glyph: a bezel with a screen cutout and two face buttons, drawn
-- rather than shipped as art so the tab needs no new asset.
local function drawSkinGlyph(x, y, w, h, hot)
local box = math.min(w, h)
local bx = x + (w - box) / 2
local by = y + (h - box) / 2
local pad = math.floor(box * 0.22)
local ow, oh = box - 2 * pad, box - 2 * pad
local ink = hot and PAL.inverse or PAL.ink
local a = 1
Theme.strokeRounded(bx + pad, by + pad, ow, oh, ink, a,
math.max(1, math.floor(Kit.scale)), math.floor(oh * 0.22))
local sw, sh = ow * 0.58, oh * 0.40
Theme.fillRounded(bx + pad + ow * 0.10, by + pad + oh * 0.14, sw, sh, ink, a,
math.max(1, math.floor(sh * 0.2)))
local r = math.max(1, oh * 0.09)
Theme.fillRounded(bx + pad + ow * 0.60, by + pad + oh * 0.64, r * 2, r * 2, ink, a, r)
Theme.fillRounded(bx + pad + ow * 0.80, by + pad + oh * 0.50, r * 2, r * 2, ink, a, r)
end
local function drawCross(x, y, size, color)
love.graphics.push("all")
love.graphics.setColor(color)
@@ -807,18 +827,46 @@ end
-- frame. Their tab rows, opts tables and action closures are built once
-- instead of 60 times a second -- only `active`, `image` and the queued
-- action are written per frame.
-- The four cartridges used to be four tabs of their own. They are one
-- dropdown now: the tab row was seven controls wide and wrapped to two rows on
-- anything narrow, and only ever one game is being looked at.
local GAME_TABS = {
{ id = "red", key = "tab-red", letter = "R", color = PAL.railRed,
label = "Red" },
{ id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue,
label = "Blue" },
{ id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold,
label = "Yellow" },
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber,
label = "Gold" },
}
local HEADER_TABS = {
{ id = "red", key = "tab-red", letter = "R", color = PAL.railRed },
{ id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue },
{ id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold },
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber },
{ id = "mods", key = "tab-mods" },
{ id = "find", key = "tab-find" },
{ id = "skins", key = "tab-skins", glyph = true },
}
for _, t in ipairs(HEADER_TABS) do
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
if t.glyph then t.opts.drawFn = drawSkinGlyph end
end
-- Which cartridge the dropdown is showing: the open game tab, else the last
-- one visited, else Red. Kept as a function so the mods/find/skins panels
-- still answer "for which game" without a game tab being open.
local function currentGame(imp)
for _, g in ipairs(GAME_TABS) do
if imp.tab == g.id then return g end
end
for _, g in ipairs(GAME_TABS) do
if imp.modScope == g.id then return g end
end
return GAME_TABS[1]
end
LauncherView.GAME_TABS = GAME_TABS
LauncherView.currentGame = currentGame
local QUIT_INK_HOT = { 0, 0, 0, 1 }
local QUIT_INK_REST = { 1, 1, 1, 0.85 }
@@ -837,11 +885,27 @@ local function headerChrome(imp)
hot and QUIT_INK_HOT or QUIT_INK_REST)
end },
tab = {},
game = { face = "tab", font = "tab",
action = function()
local g = currentGame(imp)
if imp.tab == g.id then
imp._gamePopup = true
else
imp:_switchTab(g.id)
end
end },
}
for _, t in ipairs(HEADER_TABS) do
local id = t.id
c.tab[id] = function() imp:_switchTab(id) end
end
for _, g in ipairs(GAME_TABS) do
local id = g.id
c.tab[id] = function()
imp._gamePopup = nil
imp:_switchTab(id)
end
end
imp._headerChrome = c
return c
end
@@ -927,7 +991,10 @@ local function buildHeader(imp, m)
-- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not
-- collide.
local tabs = HEADER_TABS
tabs[5].icon, tabs[6].icon = imp._modsIcon, imp._findIcon
for _, t in ipairs(tabs) do
if t.id == "mods" then t.icon = imp._modsIcon end
if t.id == "find" then t.icon = imp._findIcon end
end
local tabH = m.chip
local tx = m.x + m.pad
local ty = y + math.floor(6 * m.s)
@@ -935,6 +1002,40 @@ local function buildHeader(imp, m)
local tabRight = m.x + m.w - m.pad
local tabGap = math.floor(6 * m.s)
local tabRowGap = math.floor(4 * m.s)
-- the cartridge dropdown, sized to its longest label so switching games
-- never reflows the row
local chrome0 = headerChrome(imp)
local game = currentGame(imp)
local labelW = 0
for _, g in ipairs(GAME_TABS) do
labelW = math.max(labelW, Kit.textWidth("tab", Strings(g.label)))
end
local dropW = math.min(tabRight - tabLeft,
tabH + labelW + math.floor(34 * m.s))
chrome0.game.color = game.color
chrome0.game.letter = game.letter
chrome0.game.active = imp.tab == game.id
local gameHot = Kit.hover(tx, ty, dropW, tabH)
local gameDown = gameHot and Kit.mouseDown
-- face "tab" inverts on hover as well as when active, so the caret has to
-- flip with it or it vanishes into the cartridge colour
local gameInvert = chrome0.game.active or gameHot
chrome0.game.ring = gameHot and not chrome0.game.active or nil
btn(imp, tx, ty, dropW, tabH, "tab-game", Strings(game.label), chrome0.game)
do
local cw = math.floor(7 * m.s)
local ccx = tx + dropW - math.floor(14 * m.s)
local ccy = ty + tabH / 2 + (gameDown and math.floor(1 * m.s) or 0)
if love.graphics.polygon then
Theme.col(gameInvert and PAL.inverse or PAL.ink, gameDown and 1 or 0.9)
love.graphics.polygon("fill",
ccx - cw, ccy - cw * 0.5, ccx + cw, ccy - cw * 0.5, ccx, ccy + cw * 0.8)
love.graphics.setColor(1, 1, 1, 1)
end
end
tx = tx + dropW + tabGap
for _, t in ipairs(tabs) do
local w = tabH
if tx > tabLeft and tx + w > tabRight then
@@ -979,7 +1080,7 @@ function LauncherView._updateControl(imp)
end
-- idle / uptodate / error: offer a manual check, with no glow.
return status, Strings("Check for updates"),
function() pcall(imp.Check.start) end, false
function() pcall(imp.Check.start, true) end, false
end
-- ------------------------------------------------------------ game panel
@@ -1909,6 +2010,117 @@ end
-- ---------------------------------------------------------- find mods panel
-- SKINS tab: pick the on-screen skin, import one, or open the desktop studio.
local function buildSkinsPanel(imp, x, y, w, availH, m)
local skins = imp:_ensureSkins()
local active = imp:_activeSkin()
local gap = m.gap
local cy = y
if imp._skinNotice then
cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w,
imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s)
end
-- Studio button. Desktop only: the host supplies the hook nowhere else.
if imp.onOpenSkinStudio then
local label = Strings("Open Skin Studio")
local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s))
btn(imp, x, cy, bw, m.btnH, "skins-studio", label, {
kind = "accent", font = "small",
action = function()
-- the studio boots the game on Play, so hand it a real cartridge
imp.onOpenSkinStudio(imp.modScope or "red")
end })
local hint = Strings("Design bezels and button layouts, then test them.")
Kit.text("small", Kit.ellipsize("small", hint,
w - bw - math.floor(12 * m.s)), x + bw + math.floor(12 * m.s),
cy + math.floor((m.btnH - Kit.textHeight("small")) / 2), PAL.muted)
cy = cy + m.btnH + gap
end
Kit.caption(x, cy, Strings("INSTALLED"))
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s))
imp._skinGear = imp._skinGear
or love.graphics.newImage("assets/launcher/gear.png")
-- The row itself is "use this skin"; the gear beside it configures that
-- entry -- the built-in pad opens the drag-a-button layout editor, a skin
-- opens the studio, so neither lands on a screen that cannot edit it.
local function skinRow(key, id, title, detail, selected, configure)
local gearW = configure and rowH or 0
local rowW = w - (gearW > 0 and (gearW + math.floor(6 * m.s)) or 0)
local ink = rowHit(imp, x, cy, rowW, rowH, selected, key,
function() imp:_useSkin(id) end)
local tagW = selected
and (Kit.textWidth("small", Strings("IN USE")) + math.floor(20 * m.s))
or math.floor(12 * m.s)
local textW = rowW - math.floor(24 * m.s) - tagW
local tx = x + math.floor(12 * m.s)
local ty = cy + math.floor(7 * m.s)
Kit.text("mono", Kit.ellipsize("mono", title, textW), tx, ty,
ink or PAL.heading)
Kit.text("small", Kit.ellipsize("small", detail, textW),
tx, ty + Kit.textHeight("mono"), ink or PAL.muted)
if selected then
Kit.textRight("small", Strings("IN USE"), x + rowW - math.floor(12 * m.s),
cy + math.floor((rowH - Kit.textHeight("small")) / 2), ink or PAL.green)
end
if configure then
btn(imp, x + w - gearW, cy, gearW, gearW, key .. "-cfg", "", {
face = "invert", image = imp._skinGear, action = configure })
end
cy = cy + rowH + math.floor(4 * m.s)
end
skinRow("skin-none", nil, Strings("Built-in pad"),
Strings("The default on-screen buttons."), active == nil,
imp.onEditTouchControls and function()
imp.onEditTouchControls(imp.modScope or "red")
end or nil)
for _, entry in ipairs(skins) do
local bits = {}
bits[#bits + 1] = entry.source == "user" and Strings("installed")
or Strings("bundled")
if entry.controls > 0 then
bits[#bits + 1] = entry.controls .. " " .. Strings("buttons")
else
bits[#bits + 1] = Strings("bezel only")
end
if entry.pages > 1 then
bits[#bits + 1] = entry.pages .. " " .. Strings("pages")
end
if entry.screen then bits[#bits + 1] = Strings("screen cutout") end
local configure = imp.onOpenSkinStudio and function()
imp.onOpenSkinStudio(imp.modScope or "red", entry.id)
end or nil
skinRow("skin-" .. entry.id, entry.id, entry.id,
table.concat(bits, " \194\183 "), active == entry.id, configure)
end
if #skins == 0 then
Kit.emptyBox(x, cy, w, math.floor(72 * m.s),
Strings("No skins installed yet."))
cy = cy + math.floor(72 * m.s) + gap
end
cy = cy + math.floor(6 * m.s)
local TouchSkin = require("src.core.TouchSkin")
Kit.caption(x, cy, Strings("IMPORT"))
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
local boxH = math.floor(76 * m.s)
Kit.card(x, cy, w, boxH, "muted")
Kit.textWrapped("small", Strings(
"Drop a skin .zip on this window to install it, or put a folder in the skins folder of your save directory. RetroArch overlay .cfg files work as-is."),
x + math.floor(14 * m.s), cy + math.floor(12 * m.s),
w - math.floor(28 * m.s), PAL.muted, 3)
Kit.text("small", TouchSkin.USER_ROOT .. "/", x + math.floor(14 * m.s),
cy + boxH - Kit.textHeight("small") - math.floor(10 * m.s), PAL.faint)
end
local function buildFindPanel(imp, x, y, w, availH, m)
imp:_ensureFind()
imp:_ensureMods()
@@ -2780,6 +2992,32 @@ local function buildModScopeModal(imp, m)
action = function() imp._modScopePopup = nil end })
end
-- The cartridge dropdown's list. Replaces the four R/B/Y/G tabs, so it is
-- also what a controller reaches after the tab row.
local function buildGameModal(imp, m)
local pad = math.floor(18 * m.s)
local gap = math.floor(8 * m.s)
local w = math.floor(360 * m.s)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ #GAME_TABS * (m.btnH + gap) + m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Strings("Choose game"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
local chrome = headerChrome(imp)
for _, g in ipairs(GAME_TABS) do
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-" .. g.id,
Strings(g.label), {
face = "tab", font = "small", letter = g.letter, color = g.color,
active = imp.tab == g.id,
action = chrome.tab[g.id] })
cy = cy + m.btnH + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-close",
Strings("Close"), { font = "small",
action = function() imp._gamePopup = nil end })
end
-- Category filter for FIND MODS. Two columns, because an index can list
-- enough categories to overflow a single stacked column on a short window.
local function buildFilterModal(imp, m)
@@ -3612,6 +3850,7 @@ local function modalUp(imp)
or imp._appPatchNotes
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
or imp._filterPopup or imp._modScopePopup or imp._indexManage
or imp._gamePopup
or imp._modActions or imp._modImports
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
@@ -3753,6 +3992,7 @@ local function buildModals(imp, m)
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
if imp._sortPopup then buildSortModal(imp, m) return true end
if imp._gamePopup then buildGameModal(imp, m) return true end
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
if imp._filterPopup then buildFilterModal(imp, m) return true end
if imp._indexManage then buildIndexesModal(imp, m) return true end
@@ -3906,6 +4146,8 @@ function LauncherView.draw(imp)
buildModsPanel(imp, x, contentY, w, availH, m)
elseif imp.tab == "find" then
buildFindPanel(imp, x, contentY, w, availH, m)
elseif imp.tab == "skins" then
buildSkinsPanel(imp, x, contentY, w, availH, m)
else
buildGamePanel(imp, x, contentY, w, availH, m, imp.tab)
end
+11
View File
@@ -49,6 +49,9 @@ local MAP_GROUP_COUNT = 26 -- constants/map_constants.asm NUM_MAP_GROUPS
-- EnvironmentColorsPointers, and the Tilesets row only stores a 16-bit
-- pointer, so the bank has to come from here.
local PAL_MAP_BANK = 0x02
-- LoadBallIconGFX.gfx (engine/battle/trainer_huds.asm:225-232); bank $0b
-- carries no manifest symbol to resolve it through.
local BALL_ICON_GFX = { 0x0b, 0x41a4 }
-- A tileset sheet is 96 tiles (128x48 at 8x8), and its PalMap packs two
-- tiles per byte: low nibble first tile, high nibble second (`dn` in the
-- tilepal macro). The high bit of each nibble is the VRAM bank, not colour.
@@ -4912,6 +4915,14 @@ function RomExtractorGen2:extractMenuGfx()
hud.expBarFirstTile = 0x55
hud.expBarCells = 9
-- Four OAM tiles at $31 -- normal, statused, fainted, empty -- and OBJ
-- colour 0 is transparent (engine/battle/trainer_huds.asm:47-99, :225-232).
local balls = self.symbols["LoadBallIconGFX.gfx"] or BALL_ICON_GFX
self:write2bpp(self.rom:bytes(balls[1], balls[2], 4 * 16), 32, 8,
"battle/hud/balls.png", true)
hud.balls = "assets/generated/battle/hud/balls.png"
hud.ballsFirstTile = 0x31
-- "HP:" and the bar cells, as a plain 2bpp sheet rather than the ink-on-
-- transparent font page: the bar's rule is shade 3 (black) while its fill is
-- shade 1/2 (the HP colour), so flattening it to one ink loses the very
+93 -3
View File
@@ -1224,6 +1224,7 @@ function RomImporter.new(onComplete, opts)
forceImport = opts.forceImport or false,
onEditSave = opts.onEditSave,
onEditTouchControls = opts.onEditTouchControls,
onOpenSkinStudio = opts.onOpenSkinStudio,
isNX = isNX,
romImportMode = romImportMode,
mobileFileBridge = mobileFileBridge,
@@ -1784,7 +1785,12 @@ function RomImporter:filedropped(file)
-- readDroppedFile does here.
local name = file:getFilename() or ""
if name:lower():match("%.zip$") then
self:_installMod(file)
-- On the SKINS tab a zip is a skin; everywhere else it is a mod archive.
if self.tab == "skins" then
self:_installSkinZip(file)
else
self:_installMod(file)
end
return
end
-- A dropped .sav is a battery save: import it to a new slot for the active
@@ -2621,7 +2627,7 @@ function RomImporter:resumeAfterOverlay()
end
function RomImporter:_cycleTab(delta)
local order = { "red", "blue", "yellow", "gold", "mods", "find" }
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins" }
local idx = 1
for i, id in ipairs(order) do
if id == self.tab then idx = i; break end
@@ -2987,11 +2993,78 @@ function RomImporter:_switchTab(id)
self.tab = id
self._findSearchFocus = false
self:_disarmTextInput()
-- the skins list is cheap and can change behind the launcher's back
-- (an export, a hand-dropped folder), so re-read it on every visit
if id == "skins" then self:_ensureSkins(true) end
if GameVersion.VERSIONS[id] then
self:_setModScope(id)
end
end
-- ------- skins tab (touch skins + the desktop Skin Studio)
function RomImporter:_ensureSkins(force)
if self._skins and not force then return self._skins end
local TouchSkin = require("src.core.TouchSkin")
local out = {}
for _, entry in ipairs(TouchSkin.list()) do
local skin = TouchSkin.load(entry.root, entry.id)
local page = skin and skin.pages[1]
local controls = 0
for _, ctl in ipairs(page and page.controls or {}) do
if not ctl.decorative then controls = controls + 1 end
end
out[#out + 1] = {
id = entry.id,
source = entry.source,
pages = skin and #skin.pages or 0,
controls = controls,
screen = page ~= nil and page.viewport ~= nil,
ok = skin ~= nil,
}
end
self._skins = out
return out
end
function RomImporter:_activeSkin()
local opts = require("src.core.SaveData").loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
return tc.skin
end
function RomImporter:_useSkin(id)
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
tc.enabled = true
tc.skin = id
opts.touchControls = tc
SaveData.saveOptions(opts)
self._skinNotice = {
ok = true,
text = id and ("Now using " .. id) or "Now using the built-in pad",
}
end
function RomImporter:_installSkinZip(file)
local TouchSkin = require("src.core.TouchSkin")
local name = file:getFilename() or ""
local data, readError = readDroppedFile(file)
if not data then
self._skinNotice = { ok = false,
text = "Could not read the dropped file: " .. tostring(readError) }
return
end
local id, err = TouchSkin.installArchive(name, data)
self:_ensureSkins(true)
if not id then
self._skinNotice = { ok = false, text = "Import failed: " .. tostring(err) }
return
end
self._skinNotice = { ok = true, text = "Imported " .. id }
end
function RomImporter:_toggleFindSearchFocus()
self._findSearchFocus = not self._findSearchFocus
if self._findSearchFocus then
@@ -3018,6 +3091,13 @@ function RomImporter:_openSettings()
self.onEditTouchControls(version)
end
end
if self.onOpenSkinStudio then
local version = self.tab
hooks.openSkinStudio = function(skinId)
self:_closeSettings()
self.onOpenSkinStudio(version, skinId)
end
end
-- The tab the gear was opened on decides the row set: Gold reads a
-- different option block entirely, and offering it Gen 1's rows meant a
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
@@ -3514,7 +3594,17 @@ function RomImporter:_setAllMods(want, confirmed)
local LauncherMods = require("src.mods.LauncherMods")
local ids, experimental = {}, false
for _, m in ipairs(self.mods or {}) do
if m.enabled ~= want then
local mismatched = m.enabled ~= want
if not self.modScope and type(m.enabledByVersion) == "table" then
mismatched = false
for _, game in ipairs(GameVersion.ORDER) do
if m.enabledByVersion[game] ~= want then
mismatched = true
break
end
end
end
if mismatched then
ids[#ids + 1] = m.id
if want and m.experimental then experimental = true end
end
+31 -2
View File
@@ -68,14 +68,20 @@ end
-- Acquisition-ordered id list (wBagItems). Rebuilt sorted once for
-- saves from before the order existed, then maintained incrementally.
function Bag.order(save)
function Bag.order(save, data)
local order = save.bagOrder
if not order then
local items = (data or require("src.core.Data")).items
order = {}
for id in pairs(save.inventory) do
if not isBadge(id) then table.insert(order, id) end
end
table.sort(order)
table.sort(order, function(a, b)
local ia = (items and items[a] and items[a].index) or math.huge
local ib = (items and items[b] and items[b].index) or math.huge
if ia ~= ib then return ia < ib end
return a < b
end)
save.bagOrder = order
end
-- drop stale ids, append unknown ones (defensive against direct
@@ -95,6 +101,29 @@ function Bag.order(save)
return order
end
-- engine/items/switch_items.asm:38 SwitchItemsInBag .below / .above -- the
-- rotate the PACK's SELECT performs, over the rows of ONE pocket.
function Bag.move(save, id, pocket, toIndex, data)
local order = Bag.order(save, data)
local slots, ids = {}, {}
for i = 1, #order do
if pocketOf(order[i], data) == pocket then
slots[#slots + 1] = i
ids[#ids + 1] = order[i]
end
end
local from
for i = 1, #ids do
if ids[i] == id then from = i break end
end
if not from then return false end
local to = math.max(1, math.min(math.floor(tonumber(toIndex) or from), #ids))
if to == from then return false end
table.insert(ids, to, table.remove(ids, from))
for i = 1, #slots do order[slots[i]] = ids[i] end
return true
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).
+16 -3
View File
@@ -59,6 +59,16 @@ local STONES = {
LEAF_STONE = true, MOON_STONE = true,
}
-- Strings.source, not Strings: harvested at require time so the catalog
-- generator can see the literal, same pattern as MoveEffects.lua's
-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument
-- the harvester can't discover.
local STAT_LABEL = {
hp = Strings.source("HP"), attack = Strings.source("ATTACK"),
defense = Strings.source("DEFENSE"), speed = Strings.source("SPEED"),
special = Strings.source("SPECIAL"), accuracy = Strings.source("ACCURACY"),
}
-- vitamins: stat-exp boosters (ItemUseVitamin)
local VITAMINS = { HP_UP = "hp", PROTEIN = "attack", IRON = "defense",
CARBOS = "speed", CALCIUM = "special" }
@@ -294,7 +304,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
"Nothing happened!") }
end
b.stages[stat] = cur + 1
return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) }
end
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
-- the item, even when it is already active
@@ -493,7 +503,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- Spanish ROM puts the stat before the name), so the extracted line
-- cannot be filled positionally; the engine wording stands
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
Strings(STAT_LABEL[vitaminStat])) }
end
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
@@ -515,7 +525,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
if not target then return "failed", { noEffect(data) } end
local speciesDef = data.pokemon[target.species]
local ok = false
for _, m in ipairs(speciesDef.tmhm) do
-- a species record with no tmhm list at all is "teaches nothing", the
-- same as one whose list just does not name this move -- not a reason
-- to crash instead of refusing normally
for _, m in ipairs(speciesDef.tmhm or {}) do
if m == itemDef.machine.move then ok = true break end
end
if not ok then
+2
View File
@@ -1297,6 +1297,7 @@ function ManagerState:drawOverlay()
love.graphics.rectangle("fill", 2 * 8, ty * 8, 16 * 8, th * 8)
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(2, ty, 16, th)
love.graphics.setColor(0, 0, 0, 1)
for i, line in ipairs(lines) do
drawTruncated(line, 4 * 8, (ty + i) * 8, 14)
end
@@ -1325,6 +1326,7 @@ function ManagerState:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(0, 0, 20, 18)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8)
if self.screen == "list" then
self:drawList()
+1 -1
View File
@@ -12,7 +12,7 @@ local Manifest = {}
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
Manifest.PERMISSIONS = { network = true, filesystem = true,
engine_internals = true, steps = true,
background = true }
background = true, compute = true }
-- link-relevant registries; a mod that writes into one of these while
-- declaring affects_link = false gets an attributed warning from the loader
+22 -4
View File
@@ -28,8 +28,14 @@ local DENIED = {
}
-- Same idea one level up: love.filesystem is reachable by name, and
-- love.thread starts a Lua state this sandbox has no say over.
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
-- love.thread starts a Lua state this sandbox has no say over. jit.util
-- is the LuaJIT-specific equal of the debug library above -- funcbc,
-- funck and friends read the bytecode and constants of any function a
-- chunk can reach, which is enough to walk back to upvalues (the real
-- _G, love, io) the rest of this file exists to keep out of reach. The
-- bare `jit` table stays -- env.jit above hands it over directly for
-- jit.on/off/flush -- so only the submodule require is denied.
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true, ["jit"] = true }
-- The wire, which is what the network permission governs.
local NETWORK = { socket = true, enet = true, http = true, https = true,
@@ -79,13 +85,25 @@ local BLOCKED_LOVE = {
-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed
-- by that mod's own overlay and must not be shared.
local function loveFacade(compat)
local function loveFacade(compat, permissions)
if not _G.love then return nil end
local overrides = compat and compat.love
return setmetatable({}, {
__index = function(_, key)
local override = overrides and overrides[key]
if override ~= nil then return override end
if key == "thread" then
-- Threads open a fresh Lua state with a full standard library, so
-- they stay blocked unless the mod declares the `compute`
-- permission (the mod's own source runs in the worker, and the
-- worker ships source-only like every other mod file). The mod
-- must never receive arbitrary code from elsewhere: channels
-- carry data only.
if not (permissions or {}).compute then
error('love.thread needs the "compute" permission in manifest.json', 2)
end
return _G.love.thread
end
local hint = BLOCKED_LOVE[key]
if hint then
error(("love.%s is not available to mods%s"):format(key,
@@ -215,7 +233,7 @@ function Sandbox.envFor(opts)
opts = opts or {}
local compat = opts.compat
local env = baseGlobals()
env.love = loveFacade(compat)
env.love = loveFacade(compat, opts.permissions)
env.require = sandboxedRequire(opts.modId, opts.permissions, compat)
local loader = sandboxedLoad(env)
env.load = loader
+62 -7
View File
@@ -91,6 +91,28 @@ function f.rec(fields, opts)
desc = "{" .. table.concat(parts, ", ") .. "}" }
end
-- An open record: the listed fields are typed (including f.id
-- cross-references) and everything else on the value passes through
-- unexamined, unlike f.rec's nested shapes, which reject any key they do
-- not name. Map objects are why this exists -- NPCs, signs, items, warps
-- and static encounters all share one array, and only a full union of
-- every kind's shape could describe it as f.rec; that is a lot of surface
-- to keep in sync with the loader for fields nothing here needs to check.
-- f.partial types just the field that actually names another registry and
-- leaves every kind-specific field around it alone.
function f.partial(fields)
local names = {}
for name in pairs(fields) do names[#names + 1] = name end
table.sort(names)
local parts = {}
for _, name in ipairs(names) do
local ft = fields[name]
parts[#parts + 1] = name .. (ft.kind == "opt" and "?" or "")
end
return { kind = "partial", fields = fields,
desc = "{" .. table.concat(parts, ", ") .. ", ...}" }
end
function f.union(alts)
local parts = {}
for _, alt in ipairs(alts) do parts[#parts + 1] = alt.desc end
@@ -197,6 +219,23 @@ checkValue = function(t, value, path, patchMode, errors, top)
end
return
end
if kind == "partial" then
-- the open counterpart of "rec": listed fields are checked exactly
-- like a rec's, and any key not listed is left alone rather than
-- flagged, so a heterogeneous blob (map objects) can have one field
-- typed without every other shape sharing the array being rejected
if type(value) ~= "table" then return fail(errors, path, t.desc, value) end
for key, ft in pairs(t.fields) do
local sub = value[key]
if sub ~= nil then
checkValue(ft, sub, path .. "." .. tostring(key), patchMode, errors)
elseif ft.kind ~= "opt" and not patchMode then
errors[#errors + 1] = ("%s.%s: missing required field (%s)")
:format(path, key, ft.desc)
end
end
return
end
if kind == "union" then
for _, alt in ipairs(t.alts) do
local scratch = {}
@@ -299,7 +338,7 @@ collectRefs = function(t, value, path, out)
for k, v in pairs(value) do
collectRefs(t.value, v, path .. "." .. tostring(k), out)
end
elseif kind == "rec" and type(value) == "table" then
elseif (kind == "rec" or kind == "partial") and type(value) == "table" then
for key, ft in pairs(t.fields) do
collectRefs(ft, value[key], path .. "." .. tostring(key), out)
end
@@ -377,11 +416,19 @@ function Schemas.crossValidate(loader, data)
and loader.content[ref.registry]
-- A registry with no home in this generation has no id space to
-- check against: its base view resolves to nothing, so EVERY
-- reference into it would read as dangling. Gold's species carry a
-- growthRate and an evolution method like Red's do; the ids are
-- fine, it is the Gen 1 `growth_rates` / `evolution_methods`
-- namespaces that are not there to confirm them. Skipped for the
-- same reason an undeclared registry is: unknown, not wrong.
-- reference into it would read as dangling. `transitions` is the
-- standing example -- Gold draws its own battle intro and never
-- reads the merged table, so a mod's transition id there is
-- unconfirmable, not wrong. `growth_rates` and `evolution_methods`
-- used to sit in that category too, back when Gold had no id space
-- for either. Both are routed now: `growth_rates` keeps its Gen 1
-- path and is seeded from data.pokemon.growthRates (the extractor's
-- Gold curves, src/battle/gen2/Mon.lua), and `evolution_methods`
-- routes to gen2EvolutionMethods (src/core/gen2/Evolution.lua's
-- literal EVOLVE_* ids, present with or without a ROM import). A
-- Gold species' growthRate or evolution method is checked against
-- real ids exactly like a Red one's, so a genuine typo is still
-- caught here rather than waved through as "unknown, not wrong."
if refRegistry and Schemas.gatedFor(ref.registry, loader.generation) then
refRegistry = nil
end
@@ -887,7 +934,15 @@ R.maps = {
destMap = f.str, destWarp = f.int(0),
destGroup = f.opt(f.int(0)),
destMapNum = f.opt(f.int(0)) })),
objects = f.opt(f.list(f.any)),
-- NPCs, signs, items, warps and static wild encounters all share this
-- one array, with no field the loader could use to tell them apart
-- ahead of time -- an f.rec strict enough to describe every kind would
-- reject the others. f.partial types only `pokemon` (the static
-- encounter's species, OverworldController.lua's `d.pokemon` ->
-- BattleState.newWild) so a bad id is a load-time error, the same as
-- an encounter slot's species, instead of the crash newWild has no
-- guard against. Every other object field passes through untouched.
objects = f.opt(f.list(f.partial{ pokemon = f.opt(f.id("pokemon")) })),
signs = f.opt(f.list(f.any)),
connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)),
},
+125
View File
@@ -0,0 +1,125 @@
-- Minimal second-window process for src/render/DesktopScreen.lua.
local DesktopCompanion = {}
function DesktopCompanion.install(config)
local enet = require("enet")
local host = assert(enet.host_create())
local peer = assert(host:connect(("127.0.0.1:%d"):format(config.port), 2))
local image, sourceW, sourceH, preference
local background = { 0, 0, 0, 1 }
local connected, commandedQuit = false, false
local pointerDown = false
local started = love.timer.getTime()
local lastContact = started
local function send(kind, payload)
if not connected then return end
pcall(peer.send, peer, kind .. config.token .. (payload or ""), 1, "reliable")
end
local function receiveFrame(data)
local prefix = "F" .. config.token .. "\n"
if data:sub(1, #prefix) ~= prefix then return end
local split = data:find("\n", #prefix + 1, true)
if not split then return end
local w, h, rgb, mode = data:sub(#prefix + 1, split - 1)
:match("^(%d+),(%d+),(%d+),([%w_:.-]+)$")
w, h, rgb = tonumber(w), tonumber(h), tonumber(rgb)
if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return end
local ok, raw = pcall(love.data.decompress, "string", "lz4",
data:sub(split + 1))
if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return end
local made, pixels = pcall(love.image.newImageData, w, h, "rgba8", raw)
if not made then return end
if not image or sourceW ~= w or sourceH ~= h then
if image and image.release then image:release() end
image = love.graphics.newImage(pixels)
else
image:replacePixels(pixels)
end
sourceW, sourceH, preference = w, h, mode
image:setFilter(mode:find("cover", 1, true) and "linear" or "nearest",
mode:find("cover", 1, true) and "linear" or "nearest")
background = {
math.floor(rgb / 0x10000) % 0x100 / 255,
math.floor(rgb / 0x100) % 0x100 / 255,
rgb % 0x100 / 255, 1,
}
end
local function service()
while true do
local event = host:service(0)
if not event then break end
if event.type == "connect" then
connected, lastContact = true, love.timer.getTime()
send("H")
elseif event.type == "receive" then
lastContact = love.timer.getTime()
if event.data == "Q" .. config.token then
commandedQuit = true
love.event.quit()
elseif event.data ~= "P" .. config.token then
receiveFrame(event.data)
end
elseif event.type == "disconnect" then
love.event.quit()
end
end
end
local function placement()
if not image then return 0, 0, 1 end
local ww, wh = love.graphics.getDimensions()
local cover = preference and preference:find("cover", 1, true)
local scale = (cover and math.max or math.min)(ww / sourceW, wh / sourceH)
return (ww - sourceW * scale) / 2, (wh - sourceH * scale) / 2, scale
end
local function input(action, x, y)
if not image then return false end
local dx, dy, scale = placement()
local sx, sy = math.floor((x - dx) / scale), math.floor((y - dy) / scale)
if sx < 0 or sy < 0 or sx >= sourceW or sy >= sourceH then return false end
send("I", ("\n%s,%d,%d"):format(action, sx, sy))
return true
end
function love.update()
service()
local t = love.timer.getTime()
if (not connected and t - started > 5) or t - lastContact > 5 then
love.event.quit()
end
end
function love.draw()
love.graphics.clear(background[1], background[2], background[3], background[4])
if not image then return end
local x, y, scale = placement()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(image, x, y, 0, scale, scale)
end
function love.mousepressed(x, y, button)
if button == 1 then pointerDown = input("down", x, y) end
end
function love.mousereleased(x, y, button)
if button == 1 and pointerDown then
if not input("up", x, y) then send("I", "\ncancel,0,0") end
pointerDown = false
end
end
function love.touchpressed(_, x, y) input("down", x, y) end
function love.touchreleased(_, x, y) input("up", x, y) end
function love.keypressed(key)
if key == "escape" then love.event.quit() end
end
function love.quit()
if not commandedQuit then send("C") end
pcall(peer.disconnect_now, peer)
end
end
return DesktopCompanion
+137
View File
@@ -0,0 +1,137 @@
-- Cross-platform desktop secondary display. LOVE owns one window, so a
-- second minimal instance of this same app owns the companion window. ENet
-- is bundled with LOVE; binding it to loopback keeps frames and input local.
local Platform = require("src.core.Platform")
local HostShell = require("src.core.HostShell")
local okEnet, enet = pcall(require, "enet")
local DesktopScreen = {}
local state = {
enabled = false, blocked = false, host = nil, peer = nil,
token = nil, port = nil, touches = {}, retryAt = 0, heartbeatAt = 0,
}
local function now()
return love and love.timer and love.timer.getTime and love.timer.getTime()
or os.clock()
end
local function destroy(sendQuit)
if state.peer and sendQuit then
pcall(state.peer.send, state.peer, "Q" .. state.token, 1, "reliable")
end
if state.peer then pcall(state.peer.disconnect_now, state.peer) end
if state.host then pcall(state.host.destroy, state.host) end
state.host, state.peer, state.token, state.port = nil, nil, nil, nil
state.touches = {}
end
local function token()
local seed = table.concat({ tostring(os.time()), tostring(now()), tostring({}) }, ":")
local digest = love.data.hash("sha256", seed)
return love.data.encode("string", "hex", digest):sub(1, 24)
end
local function start()
if state.host or state.blocked or now() < state.retryAt then
return state.host ~= nil
end
local base = 49152 + math.floor(now() * 1000) % 12000
for attempt = 0, 31 do
local port = 49152 + (base - 49152 + attempt * 37) % 12000
local ok, host = pcall(enet.host_create,
("127.0.0.1:%d"):format(port), 1, 2)
if ok and host then
state.host, state.port, state.token = host, port, token()
local launched = HostShell.spawnSelfDetached({
("--display-companion=%d,%s"):format(port, state.token),
})
if launched then return true end
destroy(false)
break
end
end
state.retryAt = now() + 1
return false
end
local function service()
if not state.enabled or state.blocked then return end
if not state.host and not start() then return end
while state.host do
local ok, event = pcall(state.host.service, state.host, 0)
if not ok then
destroy(false)
state.retryAt = now() + 1
return
end
if not event then break end
if event.type == "receive" then
local data = event.data or ""
if data == "H" .. state.token then
state.peer = event.peer
elseif event.peer == state.peer
and data:sub(1, #state.token + 2) == "I" .. state.token .. "\n" then
state.touches[#state.touches + 1] = data:sub(#state.token + 3)
elseif event.peer == state.peer and data == "C" .. state.token then
state.blocked = true
destroy(false)
return
end
elseif event.type == "disconnect" and event.peer == state.peer then
destroy(false)
state.retryAt = now() + 1
return
end
end
if state.peer and now() >= state.heartbeatAt then
state.heartbeatAt = now() + 1
pcall(state.peer.send, state.peer, "P" .. state.token, 1, "unreliable")
end
end
function DesktopScreen.usable()
return okEnet and enet ~= nil and Platform.canSpawnProcess()
end
function DesktopScreen.available()
return DesktopScreen.detected()
end
function DesktopScreen.detected()
service()
return state.peer ~= nil
end
function DesktopScreen.push(imageData, w, h, background, preference)
service()
if not state.peer or not imageData or not imageData.getString then return false end
w, h = tonumber(w), tonumber(h)
if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return false end
local ok, raw = pcall(imageData.getString, imageData)
if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return false end
local packed = love.data.compress("string", "lz4", raw, 1)
local header = ("F%s\n%d,%d,%u,%s\n"):format(state.token, w, h,
tonumber(background) or 0, tostring(preference or "auto"):gsub("[^%w_:.-]", ""))
local sent = pcall(state.peer.send, state.peer, header .. packed, 0, "reliable")
return sent
end
function DesktopScreen.pollTouch()
service()
return table.remove(state.touches, 1)
end
function DesktopScreen.setEnabled(on)
on = on == true
if on == state.enabled then
if on then service() end
return
end
state.enabled = on
state.blocked = false
if on then start(); service() else destroy(true) end
end
return DesktopScreen
+185
View File
@@ -0,0 +1,185 @@
-- Optional game viewport inside the OS window. A layout mod may reserve any
-- window-space rectangle through render.viewport; the game then renders as if
-- that rectangle were its whole display. With no subscriber this module is a
-- pass-through and allocates no canvas.
local Runtime = require("src.mods.Runtime")
local Viewport = {
rect = nil,
full = nil,
canvas = nil,
generation = nil,
frameActive = false,
}
local function finite(value)
return type(value) == "number" and value == value
and value > -math.huge and value < math.huge
end
local function realMetrics()
local G = love.graphics
local w, h = G.getDimensions()
local pw, ph = w, h
if G.getPixelDimensions then pw, ph = G.getPixelDimensions() end
local dpiX = w > 0 and pw / w or 1
local dpiY = h > 0 and ph / h or 1
if dpiX < 1e-6 then dpiX = 1 end
if dpiY < 1e-6 then dpiY = 1 end
return math.max(1, w), math.max(1, h),
math.max(1, pw), math.max(1, ph), dpiX, dpiY
end
local function clampRect(value, w, h)
if type(value) ~= "table" then
return { x = 0, y = 0, width = w, height = h }
end
local x = finite(value.x) and math.floor(value.x) or 0
local y = finite(value.y) and math.floor(value.y) or 0
local rw = finite(value.width) and math.floor(value.width) or w
local rh = finite(value.height) and math.floor(value.height) or h
x = math.max(0, math.min(x, w - 1))
y = math.max(0, math.min(y, h - 1))
rw = math.max(1, math.min(rw, w - x))
rh = math.max(1, math.min(rh, h - y))
return { x = x, y = y, width = rw, height = rh }
end
local function sameSize(canvas, w, h)
return canvas and canvas:getWidth() == w and canvas:getHeight() == h
end
function Viewport.begin(generation)
local w, h, pw, ph, dpiX, dpiY = realMetrics()
local context = {
width = w, height = h, pixelWidth = pw, pixelHeight = ph,
dpiX = dpiX, dpiY = dpiY, generation = generation,
}
local requested
if Runtime.wantsHook("render.viewport") then
requested = Runtime.call("render.viewport", function(ctx)
return { x = 0, y = 0, width = ctx.width, height = ctx.height }
end, context)
end
local rect = clampRect(requested, w, h)
Viewport.full = context
Viewport.rect = rect
Viewport.generation = generation
local active = type(requested) == "table" and requested.capture == true
or rect.x ~= 0 or rect.y ~= 0
or rect.width ~= w or rect.height ~= h
Viewport.frameActive = active
if active then
if not sameSize(Viewport.canvas, rect.width, rect.height) then
if Viewport.canvas and Viewport.canvas.release then
Viewport.canvas:release()
end
Viewport.canvas = love.graphics.newCanvas(rect.width, rect.height)
Viewport.canvas:setFilter("nearest", "nearest")
end
else
if Viewport.canvas and Viewport.canvas.release then
Viewport.canvas:release()
end
Viewport.canvas = nil
end
return rect
end
function Viewport.active()
return Viewport.frameActive == true and Viewport.canvas ~= nil
end
function Viewport.dimensions()
if Viewport.active() then
return Viewport.rect.width, Viewport.rect.height
end
return love.graphics.getDimensions()
end
function Viewport.pixelDimensions()
if Viewport.active() then
if Viewport.canvas.getPixelDimensions then
local w, h = Viewport.canvas:getPixelDimensions()
return math.max(1, w), math.max(1, h)
end
return math.max(1, math.floor(Viewport.rect.width * Viewport.full.dpiX)),
math.max(1, math.floor(Viewport.rect.height * Viewport.full.dpiY))
end
if love.graphics.getPixelDimensions then
return love.graphics.getPixelDimensions()
end
return love.graphics.getDimensions()
end
function Viewport.fullDimensions()
if Viewport.full then return Viewport.full.width, Viewport.full.height end
return love.graphics.getDimensions()
end
function Viewport.target()
return Viewport.canvas
end
function Viewport.setTarget()
love.graphics.setCanvas(Viewport.canvas)
end
function Viewport.toLocal(x, y)
local rect = Viewport.rect
if not rect then return x, y, true end
local lx, ly = x - rect.x, y - rect.y
return lx, ly,
lx >= 0 and ly >= 0 and lx < rect.width and ly < rect.height
end
function Viewport.localSafeRect(x, y, w, h)
local rect = Viewport.rect
if not Viewport.active() or not rect then return x, y, w, h end
local x1, y1 = math.max(x, rect.x), math.max(y, rect.y)
local x2 = math.min(x + w, rect.x + rect.width)
local y2 = math.min(y + h, rect.y + rect.height)
if x2 <= x1 or y2 <= y1 then
return 0, 0, rect.width, rect.height
end
return x1 - rect.x, y1 - rect.y, x2 - x1, y2 - y1
end
function Viewport.finish(game)
if not Viewport.active() then return end
local G = love.graphics
local rect, full = Viewport.rect, Viewport.full
G.setCanvas()
G.push("all")
G.origin()
G.setScissor()
G.setShader()
G.setBlendMode("alpha")
G.clear(0, 0, 0, 1)
local context = {
canvas = Viewport.canvas,
x = rect.x, y = rect.y, width = rect.width, height = rect.height,
windowWidth = full.width, windowHeight = full.height,
dpiX = full.dpiX, dpiY = full.dpiY,
generation = Viewport.generation,
}
Runtime.call("render.window", function(_, ctx)
G.setColor(1, 1, 1, 1)
G.draw(ctx.canvas, ctx.x, ctx.y)
end, game, context)
G.pop()
end
function Viewport.reset()
Viewport.frameActive = false
Viewport.rect = nil
Viewport.full = nil
Viewport.generation = nil
if Viewport.canvas and Viewport.canvas.release then
Viewport.canvas:release()
end
Viewport.canvas = nil
end
return Viewport
+126 -28
View File
@@ -12,8 +12,10 @@ local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local PixelCanvas = require("src.render.PixelCanvas")
local Runtime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport")
-- leaf module (no renderer dependency), so requiring it here cannot cycle
local FaithfulRes = require("src.core.FaithfulRes")
local TouchSkin = require("src.core.TouchSkin")
local Renderer = {}
@@ -69,11 +71,9 @@ Renderer.UPRIGHT_MARGIN = 160
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
-- on BOTH axes (square).
local function displayMetrics()
local ww, wh = love.graphics.getDimensions()
local ww, wh = GameViewport.dimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
pw, ph = GameViewport.pixelDimensions()
local dpiX, dpiY = 1, 1
if ww > 0 and pw > 0 then dpiX = pw / ww end
if wh > 0 and ph > 0 then dpiY = ph / wh end
@@ -85,7 +85,13 @@ local function displayMetrics()
end
if dpiX < 1e-6 then dpiX = 1 end
if dpiY < 1e-6 then dpiY = 1 end
return ww, wh, pw, ph, dpiX, dpiY
local vx, vy = 0, 0
local sx, sy, sw, sh = TouchSkin.viewport(pw, ph)
if sw and sw >= 1 and sh >= 1 then
vx, vy = math.floor(sx), math.floor(sy)
pw, ph = math.floor(sw), math.floor(sh)
end
return ww, wh, pw, ph, dpiX, dpiY, vx, vy
end
function Renderer:init()
@@ -94,6 +100,7 @@ function Renderer:init()
-- reason -- worldViewSize() already works in drawable pixels.
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
self.battleHUDCanvas = nil
self.worldCanvas = nil
self.worldActive = false
-- tilt mode only: a transparent overlay canvas the size of the world
@@ -202,10 +209,38 @@ function Renderer:setUISize(w, h)
w, h = math.floor(w), math.floor(h)
if w == self.uiWidth and h == self.uiHeight and self.canvas then return end
if self.canvas and self.canvas.release then self.canvas:release() end
if self.battleHUDCanvas and self.battleHUDCanvas.release then
self.battleHUDCanvas:release()
end
self.battleHUDCanvas = nil
self.uiWidth, self.uiHeight = w, h
self.canvas = PixelCanvas.new(w, h, "nearest")
end
-- Transparent native-pixel surface for an extended WIDE battle HUD. The
-- battle scene remains in `canvas`; endFrame places registered HUD regions
-- afterward in physical-window space.
function Renderer:beginBattleHUDPass()
local w, h = self:uiSize()
if not self.battleHUDCanvas
or self.battleHUDCanvas:getWidth() ~= w
or self.battleHUDCanvas:getHeight() ~= h then
if self.battleHUDCanvas and self.battleHUDCanvas.release then
self.battleHUDCanvas:release()
end
self.battleHUDCanvas = PixelCanvas.new(w, h, "nearest")
end
local previous = love.graphics.getCanvas and love.graphics.getCanvas()
or self.canvas
love.graphics.setCanvas(self.battleHUDCanvas)
love.graphics.clear(0, 0, 0, 0)
return previous
end
function Renderer:endBattleHUDPass(previous)
love.graphics.setCanvas(previous or self.canvas)
end
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
-- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
-- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
@@ -246,6 +281,10 @@ function Renderer:worldViewSize()
-- this is the same sum with the viewport standing in for the window, so
-- both platforms show the same map area at the same zoom.
local cap = FaithfulRes.scaleCap()
if not cap and TouchSkin.hasViewport() then
local page = TouchSkin.page()
if not page.viewportExpand then cap = self:fitScale() end
end
if cap then
local uiw, uih = self:uiSize()
pw, ph = uiw * cap, uih * cap
@@ -671,6 +710,17 @@ end
-- centred letterbox. Declared during the element's own draw, in UI-canvas
-- pixels, and consumed by endFrame this frame only.
-- anchor: "bottom" | "topright" | "topleft" | "bottomright"
local function addUIAnchor(renderer, x, y, w, h, anchor, windowClamped,
canvas, extract)
renderer.uiAnchors = renderer.uiAnchors or {}
renderer.uiAnchors[#renderer.uiAnchors + 1] = {
x = x, y = y, w = w, h = h, anchor = anchor,
windowClamped = windowClamped and true or false,
canvas = canvas,
extract = extract ~= false,
}
end
function Renderer:setUIAnchor(x, y, w, h, anchor)
-- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw from
-- save.options.uiLayout): every element stays where it was drawn in the
@@ -684,9 +734,16 @@ function Renderer:setUIAnchor(x, y, w, h, anchor)
-- battle -- keeps every element inside it, so the box blits where it was
-- drawn in the canvas instead of being pulled to the window edge.
if self.uiAnchorHold then return end
self.uiAnchors = self.uiAnchors or {}
self.uiAnchors[#self.uiAnchors + 1] =
{ x = x, y = y, w = w, h = h, anchor = anchor }
addUIAnchor(self, x, y, w, h, anchor, false, self.canvas, true)
end
-- Battle-owned window-space placement. Unlike ordinary UI anchors this is
-- intentionally allowed while BattleState holds general dialogue/menu
-- anchors inside the battle surface. Callers must gate it to an explicit
-- battle HUD mode.
function Renderer:setBattleUIAnchor(x, y, w, h, anchor)
addUIAnchor(self, x, y, w, h, anchor, true,
self.battleHUDCanvas or self.canvas, false)
end
-- zones: optional list of SGB palette regions (see PaletteFX) in
@@ -698,8 +755,10 @@ end
-- When GBC FX is active the composite is drawn into presentCanvas and
-- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas()
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
GameViewport.setTarget()
local ww, wh, pw, ph, dpiX, dpiY, vx, vy = displayMetrics()
local vux, vuy = vx / dpiX, vy / dpiY
local vuw, vuh = pw / dpiX, ph / dpiY
-- Sp = integer framebuffer pixels per GB pixel;
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
local Sp = self:fitScale()
@@ -707,8 +766,8 @@ function Renderer:endFrame(zones, worldZones)
local uiw, uih = self:uiSize()
local vpw, vph = uiw * Sx, uih * Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
local ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
local oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY
-- The UI has its own scale: it steps down as the survey zoom goes out (see
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
-- are identical to Sp/ox/oy and every rect below is what it always was.
@@ -724,8 +783,8 @@ function Renderer:endFrame(zones, worldZones)
end
local Ux, Uy = Up / dpiX, Up / dpiY
local uvpw, uvph = uiw * Ux, uih * Uy
local uox = math.floor((pw - uiw * Up) / 2) / dpiX
local uoy = math.floor((ph - uih * Up) / 2) / dpiY
local uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
local uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY
local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -799,6 +858,8 @@ function Renderer:endFrame(zones, worldZones)
-- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in
-- a visibly brighter border.
local clearR, clearG, clearB = 0, 0, 0
local extendedBlackBand = false
local bandR, bandG, bandB = 1, 1, 1
if not self.worldActive then
local ok, Game = pcall(require, "src.core.Game")
local stack = ok and Game and Game.stack
@@ -825,7 +886,15 @@ function Renderer:endFrame(zones, worldZones)
-- screen stays black (src/core/FaithfulRes.lua); the paper surround
-- painted the whole phone white on New Game and in battle (#864), so
-- the lock keeps the default black bars.
if state and state.letterboxWhite
if state and state.extendedBlackHUD and state:extendedBlackHUD()
and not FaithfulRes.scaleCap() then
-- Extended/Black keeps the author's black surround, but extends the
-- fixed battle's paper field vertically through the physical window.
-- The band uses the exact centred fixed-width composition bounds, so
-- only vertical black bars remain at the sides.
extendedBlackBand = true
bandR, bandG, bandB = PaletteFX.paperShade(Game and Game.data)
elseif state and state.letterboxWhite
and not (state.bgMode and state:bgMode() == "black")
and not FaithfulRes.scaleCap() then
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
@@ -833,6 +902,10 @@ function Renderer:endFrame(zones, worldZones)
end
love.graphics.setColor(clearR, clearG, clearB, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh)
if extendedBlackBand then
love.graphics.setColor(bandR, bandG, bandB, 1)
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
end
love.graphics.setColor(1, 1, 1, 1)
-- render.letterbox: SGB borders / custom void art in the bars around the
-- 160x144 (or world) blit. Drawn after the clear and before the game
@@ -861,7 +934,7 @@ function Renderer:endFrame(zones, worldZones)
-- skipped entirely (nothing drew into it). The UI blit below still
-- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.setScissor(vux, vuy, vuw, vuh)
local loveMajor = love.getVersion()
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
@@ -873,7 +946,7 @@ function Renderer:endFrame(zones, worldZones)
local fade = self.worldFadeAlpha
if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
love.graphics.setColor(1, 1, 1, 1)
end
elseif self.worldActive then
@@ -881,8 +954,8 @@ function Renderer:endFrame(zones, worldZones)
local sx, sy = sp / dpiX, sp / dpiY
local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight()
local wox = math.floor((pw - wvw * sp) / 2) / dpiX
local woy = math.floor((ph - wvh * sp) / 2) / dpiY
local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX
local woy = (vy + math.floor((ph - wvh * sp) / 2)) / dpiY
-- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -893,9 +966,9 @@ function Renderer:endFrame(zones, worldZones)
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
if not projected then
if worldZones then
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh)
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, vux, vuy, vuw, vuh)
else
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh)
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, vux, vuy, vuw, vuh)
end
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
@@ -904,7 +977,7 @@ function Renderer:endFrame(zones, worldZones)
local redraws = PaletteFX.spriteRedraws()
if redraws[1] then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.setScissor(vux, vuy, vuw, vuh)
local activeShader = nil
for _, r in ipairs(redraws) do
local wanted = r.colors
@@ -936,7 +1009,7 @@ function Renderer:endFrame(zones, worldZones)
if self.uprightActive then
local M = self.UPRIGHT_MARGIN
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.setScissor(vux, vuy, vuw, vuh)
love.graphics.draw(self.uprightCanvas, wox - M * sx, woy - M * sy, 0, sx, sy)
love.graphics.setScissor()
end
@@ -947,7 +1020,7 @@ function Renderer:endFrame(zones, worldZones)
local fade = self.worldFadeAlpha
if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
love.graphics.setColor(1, 1, 1, 1)
end
end
@@ -969,12 +1042,28 @@ function Renderer:endFrame(zones, worldZones)
-- (and its duplicate #772).
if self.battleDim and self.battleDim > 0 then
love.graphics.setColor(0, 0, 0, self.battleDim)
for _, r in ipairs(subtractRect({ { 0, 0, ww, wh } }, uox, uoy, uvpw, uvph)) do
for _, r in ipairs(subtractRect({ { vux, vuy, vuw, vuh } }, uox, uoy, uvpw, uvph)) do
love.graphics.rectangle("fill", r[1], r[2], r[3], r[4])
end
love.graphics.setColor(1, 1, 1, 1)
end
-- Extended/WORLD keeps the frozen world as the physical surround, but stock
-- Gen 1 back sprites rely on the battle's paper shade for visible highlights.
-- Back the exact fixed-width composition from physical top to bottom so only
-- the left and right sides expose the world. The battle canvas and detached
-- HUD remain transparent layers composited afterward.
-- A worldOverride is an arena provider's completed scene (for example,
-- StadiumBattleFX/Dramaless). It replaces the stock paper-backed battle
-- field, so never cover it with the native back-sprite fallback.
if self.extendedWorldBand and not self.worldOverride
and not FaithfulRes.scaleCap() then
local ok, Game = pcall(require, "src.core.Game")
love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data))
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
love.graphics.setColor(1, 1, 1, 1)
end
-- UI: anchored regions against their screen edges, the rest in the classic
-- centred letterbox. With nothing anchored this is the single blit it has
-- always been.
@@ -997,14 +1086,23 @@ function Renderer:endFrame(zones, worldZones)
if a.anchor == "bottom" then
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
dy = wh - gapB - dh
elseif a.anchor == "top" then
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
dy = a.y * Uy
elseif a.anchor == "topright" then
dx = ww - gapR - dw
dy = a.y * Uy
else -- unknown anchor: leave it where it is
dx, dy = uox + a.x * Ux, uoy + a.y * Uy
end
if a.windowClamped then
dx = math.max(0, math.min(math.max(0, ww - dw), dx))
dy = math.max(0, math.min(math.max(0, wh - dh), dy))
end
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
if a.extract then
rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
end
end
for _, r in ipairs(rest) do
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4])
@@ -1013,7 +1111,7 @@ function Renderer:endFrame(zones, worldZones)
-- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy).
-- The zone scissors are computed from the same origin, so an SGB
-- region travels with the element instead of staying in the letterbox.
blit(self.canvas, Ux, Uy, zones, Ux, Uy,
blit(p.a.canvas or self.canvas, Ux, Uy, zones, Ux, Uy,
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
end
end
@@ -1064,7 +1162,7 @@ function Renderer:endFrame(zones, worldZones)
end
if present then
love.graphics.setCanvas()
GameViewport.setTarget()
-- Post-process pipelines run over the finished composite -- world, UI
-- and all -- and before GBC FX, so a blur or colour grade is what the
-- LCD grid is then drawn over rather than something that smears the
+67 -6
View File
@@ -1,11 +1,13 @@
-- 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.
-- Shared secondary-display facade. Android uses its native Presentation
-- bridge; process-capable desktop hosts fall back to a companion window.
-- Everything is guarded, so unsupported hosts keep the in-window layout.
local SecondScreen = {}
local C = nil
local ffi = nil
local desktop = nil
local nativePresent = false
local nativeTarget = false
local function log(msg)
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
@@ -21,6 +23,10 @@ do
int love_android_secondary_ready();
void love_android_push_secondary(const void *rgba, int w, int h);
void love_android_secondary_enable(int on);
int love_android_secondary_detected();
int love_android_present_secondary(const void *rgba, int w, int h,
unsigned int background, int cover);
void love_android_secondary_target(int target);
const char *love_android_poll_secondary_touch();
]])
local okLib, lib = pcall(ffi.load, "love")
@@ -34,21 +40,74 @@ do
log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
:format(tostring(okLib)))
end
if C then
local okDetected, detected = pcall(function()
return C.love_android_secondary_detected
end)
local okPresent, present = pcall(function()
return C.love_android_present_secondary
end)
local okTarget, target = pcall(function()
return C.love_android_secondary_target
end)
nativePresent = okDetected and detected ~= nil
and okPresent and present ~= nil
nativeTarget = okTarget and target ~= nil
end
end
end
if not C then
local ok, backend = pcall(require, "src.render.DesktopScreen")
if ok and backend and backend.usable and backend.usable() then
desktop = backend
log("desktop companion backend ready")
end
end
function SecondScreen.usable()
return C ~= nil
return C ~= nil or desktop ~= nil
end
function SecondScreen.available()
if desktop then return desktop.available() end
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)
-- A connected display is not necessarily the current Presentation yet. This
-- distinction lets a companion retry its first frame after hotplug/re-target.
function SecondScreen.detected()
if desktop then return desktop.detected() end
if nativePresent then
local ok, r = pcall(C.love_android_secondary_detected)
return ok and r ~= 0
end
return SecondScreen.available()
end
function SecondScreen.push(imageData, w, h, background, preference)
if desktop then
return desktop.push(imageData, w, h, background, preference)
end
if not C or not imageData then return false end
if nativePresent and (background ~= nil or preference ~= nil) then
if nativeTarget then
local target = 0
if preference == "handheld" or preference == "handheld:cover" then
target = 1
elseif preference == "secondary" or preference == "secondary:cover" then
target = 2
end
pcall(C.love_android_secondary_target, target)
end
local cover = type(preference) == "string"
and preference:sub(-6) == ":cover"
local ok, shown = pcall(C.love_android_present_secondary,
imageData:getFFIPointer(), w, h, background or 0, cover and 1 or 0)
return ok and shown ~= 0
end
return pcall(function()
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
end)
@@ -57,6 +116,7 @@ end
-- Returns the oldest queued secondary-display event as "action,x,y", where
-- coordinates are in the submitted frame's pixel space.
function SecondScreen.pollTouch()
if desktop then return desktop.pollTouch() end
if not C then return nil end
local ok, event = pcall(function()
return C.love_android_poll_secondary_touch()
@@ -66,6 +126,7 @@ function SecondScreen.pollTouch()
end
function SecondScreen.setEnabled(on)
if desktop then return desktop.setEnabled(on) end
if not C then return end
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
end
+4
View File
@@ -26,6 +26,7 @@ local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
-- up over the still-visible text (YesNoChoicePokeCenter and friends);
-- the box then closes and choice(yes) runs instead of onDone.
-- opts.defaultNo starts the cursor on NO.
-- opts.choiceLabels / opts.choiceBox: data/yes_no_menu_strings.asm:16
-- opts.auto: texts with no `prompt` (a text_asm/text_end tail, like
-- _UsedStrengthText) never wait for a button: once the last page has
-- typed out, auto.sound() runs (returning an audio source blocks like
@@ -51,6 +52,8 @@ function TextBox.new(game, text, onDone, opts)
self.choice = opts and opts.choice
self.defaultNo = opts and opts.defaultNo
self.choiceNoSound = opts and opts.noSound
self.choiceLabels = opts and opts.choiceLabels
self.choiceBox = opts and opts.choiceBox
self.money = opts and opts.money
self.auto = opts and opts.auto
self.stay = opts and opts.stay
@@ -340,6 +343,7 @@ function TextBox:update(dt)
self.game.stack:pop() -- this text box, under the choice
self.choice(yes)
end, { defaultNo = self.defaultNo, noSound = self.choiceNoSound,
labels = self.choiceLabels, box = self.choiceBox,
-- this box is anchored below it; the pair moves together
anchor = "bottom" }))
end
+14
View File
@@ -134,6 +134,10 @@ function Commands.show_text(ctx, textId, subs, extraOpts)
opts[k] = v
end
end
-- MONEY_BOX (engine/menus/text_box.asm:133) reads the live wallet
if opts and opts.money == true then
opts.money = function() return ctx.save.money end
end
ctx.game.stack:push(TextBox.new(ctx.game, text, function()
runner:resume()
end, opts))
@@ -780,6 +784,16 @@ function Commands.give_money(ctx, amount)
ctx.save.money = math.max(0, ctx.save.money + amount)
end
-- check_money <amount>: HasEnoughMoney (home/money.asm:1)
function Commands.check_money(ctx, amount)
ctx.lastCheck = (ctx.save.money or 0) >= (amount or 0)
end
-- take_money <amount>: SubBCDPredef (engine/math/bcd.asm:193)
function Commands.take_money(ctx, amount)
ctx.save.money = math.max(0, (ctx.save.money or 0) - (amount or 0))
end
-- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the
-- live overworld memory in sync so a scripted home warp from the HoF PC
-- does not leave Red's house mats aimed at Indigo Plateau (#103).
+21 -5
View File
@@ -4,6 +4,7 @@
local ItemEffects = require("src.inventory.ItemEffects")
local ListMenu = require("src.ui.ListMenu")
local Runtime = require("src.mods.Runtime")
local TextBox = require("src.render.TextBox")
local BagMenu = {}
@@ -46,7 +47,16 @@ end
-- the stack, so every exit that prints has to close it afterwards. For
-- every other item the picker popped itself first and closePicker's identity
-- check makes it a no-op (#252).
local function useOn(game, battle, id, target, list, moveIndex, picker)
--
-- Every result string used to fall through to this one unconditional
-- function with no seam around it: a mod could not suppress a message,
-- delay it behind a screen of its own, or replace the outcome for one item
-- id. The "item.use" hook wraps the whole dispatch (not a name per
-- result -- a mod deciding what a Poké Doll or a stone does needs the
-- SAME reach a vanilla `if result == ...` branch has, not a narrower one),
-- the way "battle.overlay" and "ui.party.submenu" already wrap a
-- screen's own default behavior elsewhere in src/ui.
local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
local result, payload, extra = ItemEffects.use(game.data, game.save, id, target,
battle, moveIndex, game.overworld)
local function closePicker()
@@ -290,7 +300,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
end
list.index = math.min(list.index, math.max(1, #list.items))
if extra and extra.evolveTo then
list:close()
-- engine/menus/start_sub_menus.asm:408 .useItem_partyMenu
local Evolution = require("src.pokemon.Evolution")
-- item_effects.asm ItemUseEvoStone sets wForceEvolution before
-- TryEvolvingMon, so a stone evolution's B press is read and
@@ -375,6 +385,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
showMessages(game, payload, closePicker) -- failed
end
local function useOn(game, battle, id, target, list, moveIndex, picker)
return Runtime.call("item.use", vanillaUseOn,
game, battle, id, target, list, moveIndex, picker)
end
local function pickTargetAndUse(game, battle, id, list)
-- pick a target from the party
-- the ETHERs and PP UP open the move menu after picking a mon
@@ -413,11 +428,12 @@ local function pickTargetAndUse(game, battle, id, list)
-- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon
-- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on
-- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM ->
-- party_menu.asm TM/HM type). Stones and other pickOnly items keep the
-- plain HP layout (Gen 1 shows no ABLE/NOT ABLE for them), so gate
-- strictly on def.machine. #210
-- party_menu.asm TM/HM type). #210 Stones get the same ABLE / NOT ABLE
-- column: ItemUseEvoStone sets EVO_STONE_PARTY_MENU (party_menu.asm:114).
if def and def.machine then
opts.tmhm = { move = def.machine.move, kind = def.machine.kind }
elseif ItemEffects.isStone(id) then
opts.evoStone = id
end
require("src.ui.Screens").push(game, "PartyMenu", opts)
end
+10 -4
View File
@@ -23,11 +23,15 @@ function ChoiceBox.new(game, onChoose, opts)
-- and docking it to the window edge instead tears it off that screen by
-- however far the letterbox sits from the edge.
self.anchor = opts and opts.anchor or nil
local box = Theme.choiceBox
local box = (opts and opts.box) or Theme.choiceBox
self.tx = (opts and opts.tx) or box.tx
self.ty = (opts and opts.ty) or box.ty
self.tw = (opts and opts.tw) or box.tw
self.th = (opts and opts.th) or box.th
-- TwoOptionMenuStrings rows carry their own labels and a "blank line
-- before first menu item?" flag (data/yes_no_menu_strings.asm:8-16)
self.labels = (opts and opts.labels) or { "YES", "NO" }
self.firstItem = (opts and opts.firstItem) or box.firstItem or 1
return self
end
@@ -81,10 +85,12 @@ function ChoiceBox:draw()
local paper = self.game and self.game.textboxPaper and self.game:textboxPaper()
Font.drawBox(tx, ty, tw, th, paper)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8)
-- <NEXT> advances 2 * SCREEN_WIDTH -- home/text.asm:64
local row = self.firstItem
Font.draw(Strings(self.labels[1]), (tx + 2) * 8, (ty + row) * 8)
Font.draw(Strings(self.labels[2]), (tx + 2) * 8, (ty + row + 2) * 8)
Font.drawCode(Theme.cursor, (tx + 1) * 8,
(ty + (self.index == 1 and 1 or 3)) * 8)
(ty + row + (self.index == 1 and 0 or 2)) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
+15 -8
View File
@@ -293,23 +293,30 @@ function IntroMovie:update(dt)
return
end
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("start") then
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
-- on a fresh START or A -- B alone never skips the intro.
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
-- white hold stands in for that beat before the title is built.
self:exitToTitle()
return
end
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
-- on a fresh START or A -- B alone never skips the intro.
local skip = input:wasPressed("a") or input:wasPressed("start")
self.timer = self.timer + 1
if self.phase == 1 then
-- the copyright card is a bare DelayFrames, deaf to input (intro.asm:311)
if self.timer >= COPYRIGHT_FRAMES then self:startPhase(2) end
elseif self.phase == 2 then
if self.timer == STAR_START then
Sound.play(self.game.data, "Shooting_Star") -- splash.asm:29-30
end
-- intro.asm:325 `jr c, .next`
if skip and self.timer >= STAR_START and self.timer < WAVES_END then
self:startPhase(3)
return
end
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
else
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
-- white hold stands in for that beat before the title is built.
if skip then
self:exitToTitle()
return
end
-- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing
-- (intro.asm:337), so PlayIntroScene's first op is not on the music's
-- own frame
+42 -1
View File
@@ -180,6 +180,11 @@ local function buildRows(game)
step = function(g)
local o = g.save.options
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
if o.battleLayout ~= "wide" then
o.battleHud = "standard"
elseif o.battleFit == "fill" and o.battleHud == "extended" then
o.battleBg = "white"
end
return true
end },
-- FIXED keeps the classic integer-scaled letterbox -- a GB pixel is a
@@ -195,6 +200,31 @@ local function buildRows(game)
step = function(g)
local o = g.save.options
o.battleFit = o.battleFit == "fill" and "fixed" or "fill"
if o.battleFit == "fill" and o.battleLayout == "wide"
and o.battleHud == "extended" then
o.battleBg = "white"
end
return true
end },
{ id = "battleHud", label = Strings("BATTLE HUD"),
value = function(g)
local o = g.save.options
return o.battleLayout == "wide" and o.battleHud == "extended"
and Strings("EXTENDED")
or Strings("STANDARD")
end,
step = function(g)
local o = g.save.options
-- The extended HUD is a widescreen-only composition. Keep OG locked
-- to the author's standard HUD even if an older save says otherwise.
if o.battleLayout ~= "wide" then
o.battleHud = "standard"
return false
end
o.battleHud = o.battleHud == "extended" and "standard" or "extended"
if o.battleHud == "extended" and o.battleFit == "fill" then
o.battleBg = "white"
end
return true
end },
-- What sits behind and around the battle. WHITE is the classic paper
@@ -203,13 +233,24 @@ local function buildRows(game)
-- shows through everywhere the battle does not paint).
{ id = "battleBg", label = Strings("BATTLE BG"),
value = function(g)
local m = g.save.options.battleBg
local o = g.save.options
if o.battleLayout == "wide" and o.battleFit == "fill"
and o.battleHud == "extended" then
o.battleBg = "white"
return Strings("AUTO")
end
local m = o.battleBg
if m == "black" then return Strings("BLACK") end
if m == "world" then return Strings("WORLD") end
return Strings("WHITE")
end,
step = function(g, dir)
local o = g.save.options
if o.battleLayout == "wide" and o.battleFit == "fill"
and o.battleHud == "extended" then
o.battleBg = "white"
return false
end
local order = { "white", "black", "world" }
local cur = 1
for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end
+19 -16
View File
@@ -313,6 +313,9 @@ function PartyMenu.new(game, opts)
-- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the
-- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210
self.tmhm = opts.tmhm
-- Evolution stones: opts.evoStone = item id gives Gen 1's
-- EVO_STONE_PARTY_MENU ABLE / NOT ABLE display (party_menu.asm:114). #1411
self.evoStone = opts.evoStone
self.forceSwitch = opts.forceSwitch
self.battle = opts.battle
self.party = party -- link/scoped battles pass their local party view
@@ -628,22 +631,8 @@ function PartyMenu:update(dt)
end
if self.softboiledFrom then
local user = party[self.softboiledFrom]
local heal = math.floor(user.stats.hp / 5)
if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp
or user.hp <= heal then
self.softboiledFrom = nil
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect.")))
else
user.hp = user.hp - heal
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
self.softboiledFrom = nil
require("src.core.Sound").play(self.game.data, "Heal_HP")
local def = self.game.data.pokemon[mon.species]
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game,
Strings("%s's HP\nwas restored!", mon.nickname or def.name)))
end
self.softboiledFrom = nil
self.game.overworld:useSoftboiledFieldMove(user, mon)
elseif self.swapFrom then
if self.swapFrom ~= self.index then
party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom]
@@ -814,6 +803,20 @@ function PartyMenu:draw()
else
Font.draw(Strings("NOT ABLE"), 88, y + 8)
end
elseif self.evoStone then
-- party_menu.asm:114 .evolutionStoneMenu: an EVOLVE_ITEM row matching
-- wEvoStoneItemID, printed in the TM/HM strings' row+1 column+9 slot
local can = false
for _, evo in ipairs(def.evolutions or {}) do
if evo.method == "ITEM" and evo.item == self.evoStone then
can = true break
end
end
if can then
Font.draw(Strings("ABLE"), 120, y + 8)
else
Font.draw(Strings("NOT ABLE"), 88, y + 8)
end
else
if mon.hp <= 0 then
Font.draw(Strings("FNT"), 136, y)
+926
View File
@@ -0,0 +1,926 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local PAL = Theme.PAL
local TouchSkin = require("src.core.TouchSkin")
local TouchControls = require("src.core.TouchControls")
local SaveData = require("src.core.SaveData")
local Studio = {}
Studio.CANVASES = {
{ id = "phone_portrait", label = "Phone portrait", w = 1080, h = 1920 },
{ id = "phone_landscape", label = "Phone landscape", w = 1920, h = 1080 },
{ id = "tablet_portrait", label = "Tablet portrait", w = 1536, h = 2048 },
{ id = "tablet_landscape", label = "Tablet landscape", w = 2048, h = 1536 },
{ id = "steamdeck", label = "Steam Deck", w = 1280, h = 800 },
{ id = "desktop_1080", label = "Desktop 1080p", w = 1920, h = 1080 },
{ id = "ultrawide", label = "Ultrawide 21:9", w = 2560, h = 1080 },
{ id = "sgb_border", label = "Super Game Boy border", w = 256, h = 224,
lockViewport = { x = 48 / 256, y = 40 / 224, w = 160 / 256, h = 144 / 224 } },
}
local HANDLE = 7
local HANDLES = {
{ "nw", 0, 0 }, { "n", 0.5, 0 }, { "ne", 1, 0 },
{ "w", 0, 0.5 }, { "e", 1, 0.5 },
{ "sw", 0, 1 }, { "s", 0.5, 1 }, { "se", 1, 1 },
}
local GB_ASPECT = 160 / 144
local function clamp01(v)
if v ~= v then return 0 end
if v < 0 then return 0 end
if v > 1 then return 1 end
return v
end
local function round(v) return math.floor(v + 0.5) end
function Studio.canvas()
return Studio.CANVASES[Studio.canvasIndex] or Studio.CANVASES[1]
end
function Studio.page()
local skin = Studio.skin
if not skin then return nil end
return skin.pages[Studio.pageIndex] or skin.pages[1]
end
function Studio.selectedControl()
local page = Studio.page()
if not page then return nil end
return page.controls[Studio.selected]
end
local function markDirty()
Studio.dirty = true
Studio.status = nil
end
local function syncActive()
TouchSkin.setActive(Studio.skin)
TouchSkin.pageIndex = Studio.pageIndex
end
function Studio.setCanvas(index)
local n = #Studio.CANVASES
Studio.canvasIndex = ((index - 1) % n) + 1
local canvas = Studio.canvas()
local page = Studio.page()
if page and canvas.lockViewport then
page.viewport = {
x = canvas.lockViewport.x, y = canvas.lockViewport.y,
w = canvas.lockViewport.w, h = canvas.lockViewport.h,
}
page.viewportFill = false
markDirty()
end
if page then page.aspect = canvas.w / canvas.h end
end
function Studio.load(opts)
opts = opts or {}
Studio.onClose = opts.onClose
Studio.onPlay = opts.onPlay
Studio.version = opts.version
Studio.pageIndex = 1
Studio.selected = nil
Studio.testing = false
Studio.dirty = false
Studio.status = nil
Studio.drag = nil
Studio.pendingPlay = false
Studio.canvasIndex = 1
Studio.aspectLock = true
Studio.skinIdField = ""
Studio.available = TouchSkin.list()
Studio.imageTarget = "idle"
TouchControls:init()
TouchControls.active = true
TouchControls.enabled = true
TouchControls:setPreview(true)
local start = opts.skinId
if not start then
local saved = SaveData.loadOptions()
local tc = type(saved.touchControls) == "table" and saved.touchControls or {}
start = tc.skin
end
if start and TouchSkin.find(start) then
Studio.open(start)
else
Studio.skin = TouchSkin.newSkin("new_skin")
Studio.skinIdField = Studio.skin.id
syncActive()
end
end
function Studio.open(id)
local entry = TouchSkin.find(id)
if not entry then return false end
local loaded = TouchSkin.load(entry.root, entry.id)
if not loaded then return false end
Studio.skin = TouchSkin.clone(loaded)
Studio.skinIdField = Studio.skin.id
Studio.pageIndex = 1
Studio.selected = nil
Studio.dirty = false
Studio.images = TouchSkin.listImages(Studio.skin.root)
syncActive()
return true
end
function Studio.unload()
Studio.pendingPlay = false
TouchSkin.setSurface(nil)
TouchSkin.setActive(nil)
TouchControls:setPreview(false)
TouchControls:reset()
Studio.skin = nil
Studio.onClose = nil
Studio.onPlay = nil
Studio.drag = nil
end
-- --------------------------------------------------------------- editing
function Studio.addControl()
local page = Studio.page()
if not page then return end
page.controls[#page.controls + 1] =
TouchSkin.newControl("a", 0.5, 0.5, 0.16, 0.09, "radial")
Studio.selected = #page.controls
markDirty()
end
function Studio.deleteControl()
local page = Studio.page()
if not page or not Studio.selected then return end
table.remove(page.controls, Studio.selected)
Studio.selected = page.controls[Studio.selected] and Studio.selected
or (#page.controls > 0 and #page.controls or nil)
markDirty()
end
function Studio.duplicateControl()
local page, ctl = Studio.page(), Studio.selectedControl()
if not page or not ctl then return end
local copy = TouchSkin.newControl(ctl.spec, clamp01(ctl.x + 0.04),
clamp01(ctl.y + 0.04), ctl.rangeX * 2, ctl.rangeY * 2, ctl.shape)
copy.imagePath, copy.image = ctl.imagePath, ctl.image
copy.pressedImagePath, copy.pressedImage = ctl.pressedImagePath, ctl.pressedImage
copy.rangeMod, copy.alphaMod = ctl.rangeMod, ctl.alphaMod
table.insert(page.controls, Studio.selected + 1, copy)
Studio.selected = Studio.selected + 1
markDirty()
end
function Studio.cycleBind(dir)
local ctl = Studio.selectedControl()
if not ctl then return end
local list, at = TouchSkin.BINDS, 1
for i, spec in ipairs(list) do
if spec == ctl.spec then at = i break end
end
TouchSkin.setBind(ctl, list[((at - 1 + dir) % #list) + 1])
markDirty()
end
function Studio.cycleImage(dir)
local ctl = Studio.selectedControl()
local page = Studio.page()
if not page then return end
Studio.images = Studio.images or TouchSkin.listImages(Studio.skin.root)
local list = Studio.images
local field = Studio.imageTarget
local owner = (field == "bezel") and page or ctl
if not owner then return end
local key = (field == "pressed") and "pressedImagePath" or "imagePath"
if field == "bezel" then key = "imagePath" end
local at = 0
for i, rel in ipairs(list) do
if rel == owner[key] then at = i break end
end
local next_ = at + dir
if next_ < 0 then next_ = #list end
if next_ > #list then next_ = 0 end
local rel = (next_ >= 1) and list[next_] or nil
owner[key] = rel
local img = rel and TouchSkin.resolveImage(Studio.skin.root, rel) or nil
if field == "pressed" then owner.pressedImage = img else owner.image = img end
markDirty()
end
function Studio.assignImage(rel)
local page, ctl = Studio.page(), Studio.selectedControl()
local img = TouchSkin.resolveImage(Studio.skin.root, rel)
if ctl and Studio.imageTarget == "pressed" then
ctl.pressedImagePath, ctl.pressedImage = rel, img
elseif ctl and Studio.imageTarget == "idle" then
ctl.imagePath, ctl.image = rel, img
elseif page then
page.imagePath, page.image = rel, img
end
Studio.images = TouchSkin.listImages(Studio.skin.root)
Studio.dirty = true
end
function Studio.filedropped(file)
if not Studio.skin then return end
local path = (file.getFilename and file:getFilename()) or ""
local base = path:match("([^/\\]+)$") or path
if not base:lower():match("%.png$") and not base:lower():match("%.jpe?g$") then
Studio.status = "Drop a PNG or JPG to use it as art."
return
end
local ok, data = pcall(function()
file:open("r")
local bytes = file:read()
file:close()
return bytes
end)
if not ok or not data then
Studio.status = "Could not read " .. base
return
end
local rel, err = TouchSkin.importImage(Studio.skin, base, data)
if not rel then
Studio.status = "Import failed: " .. tostring(err)
return
end
Studio.assignImage(rel)
local where = Studio.selectedControl()
and (Studio.imageTarget == "pressed" and "pressed art" or "idle art")
or "bezel"
Studio.status = "Imported " .. rel .. " as " .. where
Studio.skinIdField = Studio.skin.id
end
function Studio.detectViewport()
local page = Studio.page()
if not page or not Studio.skin then return end
if Studio.canvas().lockViewport then
Studio.status = "This preset locks the screen position."
return
end
if not page.imagePath then
Studio.status = "Pick a bezel image first."
return
end
local rect, pw, ph = TouchSkin.detectViewport(Studio.skin.root, page.imagePath)
if not rect then
Studio.status = "No transparent screen hole found in " .. page.imagePath
return
end
page.viewport = rect
Studio.status = ("Screen detected: %dx%d px in the bezel art"):format(pw, ph)
Studio.dirty = true
end
function Studio.toggleViewport()
local page = Studio.page()
if not page then return end
if Studio.canvas().lockViewport then return end
if page.viewport then
page.viewport = nil
else
page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 }
end
markDirty()
end
function Studio.addPage()
local skin = Studio.skin
if not skin then return end
local page = TouchSkin.newSkin(skin.id).pages[1]
page.name = "page" .. (#skin.pages + 1)
page.index = #skin.pages + 1
skin.pages[#skin.pages + 1] = page
Studio.pageIndex = #skin.pages
Studio.selected = nil
syncActive()
markDirty()
end
function Studio.save()
local skin = Studio.skin
if not skin then return end
local id = (Studio.skinIdField or ""):gsub("[^%w_%-]", "")
if id == "" then
Studio.status = "Give the skin a name first."
return
end
local dest, failed = TouchSkin.saveTo(skin, id)
if not dest then
Studio.status = "Save failed: " .. tostring(failed)
return
end
Studio.dirty = false
Studio.available = TouchSkin.list()
Studio.status = "Saved to " .. dest
if type(failed) == "table" and failed[1] then
Studio.status = Studio.status .. " (" .. #failed .. " image(s) not found)"
end
end
function Studio.export()
local skin = Studio.skin
if not skin then return end
if Studio.dirty then Studio.save() end
local path, missing = TouchSkin.export(skin)
if not path then
Studio.status = "Export failed: " .. tostring(missing)
return
end
Studio.status = "Exported " .. path
Studio.available = TouchSkin.list()
end
function Studio.play()
local skin = Studio.skin
if not skin then return end
Studio.save()
if Studio.dirty then return end
local opts = SaveData.loadOptions()
local block = type(opts.touchControls) == "table" and opts.touchControls or {}
block.enabled = true
block.skin = skin.id
opts.touchControls = block
SaveData.saveOptions(opts)
-- Handing off here would unload the studio inside its own draw pass and
-- leave the rest of this frame drawing against a dead skin; Studio.update
-- runs it on the next tick instead.
Studio.pendingPlay = true
Studio.status = "Starting the game with " .. skin.id .. "..."
end
-- ---------------------------------------------------------------- canvas
function Studio.canvasRect(x, y, w, h)
local canvas = Studio.canvas()
local aspect = canvas.w / canvas.h
local cw, ch = w, w / aspect
if ch > h then ch, cw = h, h * aspect end
return x + (w - cw) * 0.5, y + (h - ch) * 0.5, cw, ch
end
local function controlRect(page, ctl, r)
local cx, cy, halfW, halfH =
TouchSkin.controlGeometry(page, ctl, r.w, r.h, r.x, r.y)
return cx - halfW, cy - halfH, halfW * 2, halfH * 2
end
local function viewportRect(page, r)
local v = page.viewport
if not v then return nil end
return r.x + v.x * r.w, r.y + v.y * r.h, v.w * r.w, v.h * r.h
end
local function handleRects(bx, by, bw, bh)
local out = {}
for _, h in ipairs(HANDLES) do
out[#out + 1] = {
id = h[1],
x = bx + bw * h[2] - HANDLE * Kit.scale,
y = by + bh * h[3] - HANDLE * Kit.scale,
w = HANDLE * 2 * Kit.scale, h = HANDLE * 2 * Kit.scale,
}
end
return out
end
local function applyResize(id, bx, by, bw, bh, dx, dy)
if id:find("w") then bx = bx + dx bw = bw - dx end
if id:find("e") then bw = bw + dx end
if id:find("n") then by = by + dy bh = bh - dy end
if id:find("s") then bh = bh + dy end
return bx, by, math.max(4, bw), math.max(4, bh)
end
function Studio.beginCanvasDrag(mx, my, r)
local page = Studio.page()
if not page then return end
local ctl = Studio.selectedControl()
if ctl then
local bx, by, bw, bh = controlRect(page, ctl, r)
for _, h in ipairs(handleRects(bx, by, bw, bh)) do
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
Studio.drag = { kind = "control-resize", handle = h.id, mx = mx, my = my,
bx = bx, by = by, bw = bw, bh = bh }
return
end
end
end
local vx, vy, vw, vh = viewportRect(page, r)
if vx and not Studio.canvas().lockViewport then
for _, h in ipairs(handleRects(vx, vy, vw, vh)) do
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
Studio.drag = { kind = "viewport-resize", handle = h.id, mx = mx, my = my,
bx = vx, by = vy, bw = vw, bh = vh }
Studio.selected = nil
return
end
end
end
for i = #page.controls, 1, -1 do
local c = page.controls[i]
local bx, by, bw, bh = controlRect(page, c, r)
if mx >= bx and mx <= bx + bw and my >= by and my <= by + bh then
Studio.selected = i
Studio.drag = { kind = "control-move", mx = mx, my = my,
bx = bx, by = by, bw = bw, bh = bh }
return
end
end
if vx and not Studio.canvas().lockViewport
and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then
Studio.selected = nil
Studio.drag = { kind = "viewport-move", mx = mx, my = my,
bx = vx, by = vy, bw = vw, bh = vh }
return
end
Studio.selected = nil
end
function Studio.updateDrag(mx, my, r)
local d = Studio.drag
local page = Studio.page()
if not d or not page then return end
local dx, dy = mx - d.mx, my - d.my
local bx, by, bw, bh = d.bx, d.by, d.bw, d.bh
if d.kind == "control-move" or d.kind == "viewport-move" then
bx, by = bx + dx, by + dy
else
bx, by, bw, bh = applyResize(d.handle, bx, by, bw, bh, dx, dy)
end
if d.kind:find("viewport") and Studio.aspectLock and d.handle then
-- Derive the axis the handle does not drive, or an edge handle fights the
-- lock and appears dead. A corner drives whichever way the pointer moved
-- furthest, so dragging it up/down resizes as readily as left/right.
local byHeight = (d.handle == "n" or d.handle == "s")
if not byHeight and #d.handle > 1 then
byHeight = math.abs(dy) > math.abs(dx)
end
if byHeight then
local newW = bh * GB_ASPECT
if d.handle:find("w") then bx = bx + (bw - newW)
elseif not d.handle:find("e") then bx = bx + (bw - newW) * 0.5 end
bw = newW
else
local newH = bw / GB_ASPECT
if d.handle:find("n") then by = by + (bh - newH)
elseif not d.handle:find("s") then by = by + (bh - newH) * 0.5 end
bh = newH
end
end
if d.kind:find("control") then
local ctl = Studio.selectedControl()
if not ctl then return end
ctl.x = clamp01(((bx + bw * 0.5) - r.x) / r.w)
ctl.y = clamp01(((by + bh * 0.5) - r.y) / r.h)
ctl.rangeX = math.max(0.002, (bw * 0.5) / r.w)
ctl.rangeY = math.max(0.002, (bh * 0.5) / r.h)
else
page.viewport = {
x = clamp01((bx - r.x) / r.w), y = clamp01((by - r.y) / r.h),
w = math.max(0.02, bw / r.w), h = math.max(0.02, bh / r.h),
}
end
markDirty()
end
-- ----------------------------------------------------------------- draw
local function drawCanvas(x, y, w, h)
local page = Studio.page()
local r = { }
r.x, r.y, r.w, r.h = Studio.canvasRect(x, y, w, h)
Studio.lastCanvas = r
if not page then return r end
Theme.fill(r.x, r.y, r.w, r.h, { 0, 0, 0 }, 1)
TouchSkin.setSurface(r.x, r.y, r.w, r.h)
syncActive()
local vx, vy, vw, vh = viewportRect(page, r)
if vx then
local scale = math.min(vw / 160, vh / 144)
local gw, gh = 160 * scale, 144 * scale
local gx, gy = vx + (vw - gw) * 0.5, vy + (vh - gh) * 0.5
Theme.fill(vx, vy, vw, vh, { 12, 12, 12 }, 1)
Theme.fill(gx, gy, gw, gh, { 155, 188, 15 }, 1)
Kit.textCenter("small", "160 x 144", gx, gy + gh * 0.5 - Kit.textHeight("small") * 0.5,
gw, { 20, 40, 20 })
end
TouchControls:draw()
TouchSkin.setSurface(nil)
Theme.strokeRounded(r.x, r.y, r.w, r.h, PAL.line, Theme.A.hairline, 1, 2)
if Studio.testing then return r end
if vx then
Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2)
Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue)
if not Studio.canvas().lockViewport then
local a = Studio.selectedControl() and 0.45 or 1
for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a)
end
end
end
for i, ctl in ipairs(page.controls) do
local bx, by, bw, bh = controlRect(page, ctl, r)
local selected = (i == Studio.selected)
local c = ctl.decorative and PAL.muted or (selected and PAL.green or PAL.line)
Theme.strokeRounded(bx, by, bw, bh, c, selected and 1 or 0.45,
selected and 2 or 1, ctl.shape == "radial" and bh * 0.5 or 2)
if selected then
for _, hd in ipairs(handleRects(bx, by, bw, bh)) do
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.green, 1)
end
Kit.text("small", ctl.spec, bx, by - Kit.textHeight("small") - 2 * Kit.scale,
PAL.green)
end
end
return r
end
local function inspectorBody(x, y, w)
if not Studio.skin then return y end
local page = Studio.page()
local ctl = Studio.selectedControl()
local rowH = math.max(Kit.tapMin(), 30 * Kit.scale)
local gap = 6 * Kit.scale
local cy = y
Kit.caption(x, cy, "SKIN")
cy = cy + Kit.textHeight("small") + gap
Studio.skinIdField = Kit.textfield("skinid", x, cy, w, rowH,
Studio.skinIdField, "skin name")
cy = cy + rowH + gap
local third = (w - gap * 2) / 3
if Kit.button(x, cy, third, rowH, "Save", { id = "save" }) then Studio.save() end
if Kit.button(x + third + gap, cy, third, rowH, "Export", { id = "export" }) then
Studio.export()
end
if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Play", { id = "play" }) then
Studio.play()
end
cy = cy + rowH + gap * 2
Kit.caption(x, cy, "OPEN")
cy = cy + Kit.textHeight("small") + gap
local half = (w - gap) / 2
if Kit.button(x, cy, half, rowH, "New", { id = "new" }) then
Studio.skin = TouchSkin.newSkin("new_skin")
Studio.skinIdField = "new_skin"
Studio.pageIndex, Studio.selected = 1, nil
Studio.images = {}
syncActive()
markDirty()
end
if Kit.button(x + half + gap, cy, half, rowH,
"Load " .. (#Studio.available > 0 and "\226\150\184" or "-"),
{ id = "load", enabled = #Studio.available > 0 }) then
Studio.loadIndex = ((Studio.loadIndex or 0) % #Studio.available) + 1
Studio.open(Studio.available[Studio.loadIndex].id)
end
cy = cy + rowH + gap * 2
Kit.caption(x, cy, "PAGE " .. Studio.pageIndex .. " / " .. #(Studio.skin.pages or {}))
cy = cy + Kit.textHeight("small") + gap
if Kit.button(x, cy, half, rowH, "Next page", { id = "pagenext" }) then
Studio.pageIndex = (Studio.pageIndex % #Studio.skin.pages) + 1
Studio.selected = nil
syncActive()
end
if Kit.button(x + half + gap, cy, half, rowH, "Add page", { id = "pageadd" }) then
Studio.addPage()
end
cy = cy + rowH + gap
if page then
local bezel = page.imagePath or "(none)"
if Kit.button(x, cy, w, rowH, "Bezel: " .. bezel, { id = "bezel" }) then
Studio.imageTarget = "bezel"
Studio.cycleImage(1)
end
cy = cy + rowH + gap
local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF"
if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp",
enabled = not Studio.canvas().lockViewport }) then
Studio.toggleViewport()
end
Studio.aspectLock = Kit.checkbox(x + half + gap, cy, half, rowH,
Studio.aspectLock, "10:9 lock", "aspect")
cy = cy + rowH + gap
if Kit.button(x, cy, w, rowH, "Detect screen from bezel", { id = "detect",
enabled = page.imagePath ~= nil and not Studio.canvas().lockViewport }) then
Studio.detectViewport()
end
cy = cy + rowH + gap * 2
end
Kit.caption(x, cy, "CONTROLS")
cy = cy + Kit.textHeight("small") + gap
if Kit.button(x, cy, third, rowH, "Add", { id = "add" }) then Studio.addControl() end
if Kit.button(x + third + gap, cy, third, rowH, "Dup",
{ id = "dup", enabled = ctl ~= nil }) then
Studio.duplicateControl()
end
if Kit.button(x + (third + gap) * 2, cy, third, rowH, "Del",
{ id = "del", enabled = ctl ~= nil }) then
Studio.deleteControl()
end
cy = cy + rowH + gap
if not ctl then
Kit.textWrapped("small", page and #page.controls == 0
and "No controls yet. Add one, then drag it on the canvas."
or "Click a control on the canvas to edit it.", x, cy, w, PAL.muted, 3)
return cy + Kit.textHeight("small") * 3
end
local canvas = Studio.canvas()
if Kit.button(x, cy, w, rowH, "Bind: " .. ctl.spec, { id = "bind" }) then
Studio.cycleBind(1)
end
cy = cy + rowH + 2 * Kit.scale
Kit.text("small", TouchSkin.describeBind(ctl.spec), x, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + gap
if Kit.button(x, cy, half, rowH, "Shape: " .. ctl.shape, { id = "shape" }) then
ctl.shape = ctl.shape == "radial" and "rect" or "radial"
markDirty()
end
if Kit.button(x + half + gap, cy, half, rowH,
string.format("Reach x%.2f", ctl.rangeMod), { id = "rangemod" }) then
ctl.rangeMod = ctl.rangeMod >= 2 and 0.5 or (ctl.rangeMod + 0.25)
markDirty()
end
cy = cy + rowH + gap
local quarter = (w - gap * 3) / 4
local fields = {
{ "X", round((ctl.x - ctl.rangeX) * canvas.w) },
{ "Y", round((ctl.y - ctl.rangeY) * canvas.h) },
{ "W", round(ctl.rangeX * 2 * canvas.w) },
{ "H", round(ctl.rangeY * 2 * canvas.h) },
}
for i, f in ipairs(fields) do
local fx = x + (quarter + gap) * (i - 1)
Kit.text("small", f[1], fx, cy, PAL.faint)
local id = "num" .. f[1]
local shown = Studio.editing == id and Studio.editBuf or tostring(f[2])
local typed = Kit.textfield(id, fx, cy + Kit.textHeight("small"),
quarter, rowH, shown, "0")
if Kit.focus == id then
Studio.editing, Studio.editBuf = id, typed
elseif Studio.editing == id then
Studio.commitField(id, Studio.editBuf)
Studio.editing, Studio.editBuf = nil, nil
end
end
cy = cy + Kit.textHeight("small") + rowH + gap
Kit.text("small", ("canvas %dx%d px"):format(canvas.w, canvas.h), x, cy, PAL.faint)
cy = cy + Kit.textHeight("small") + gap
local idle = ctl.imagePath or "(none)"
if Kit.button(x, cy, w, rowH, "Idle art: " .. idle, { id = "img" }) then
Studio.imageTarget = "idle"
Studio.cycleImage(1)
end
cy = cy + rowH + gap
local pressed = ctl.pressedImagePath or "(none)"
if Kit.button(x, cy, w, rowH, "Pressed art: " .. pressed, { id = "imgp" }) then
Studio.imageTarget = "pressed"
Studio.cycleImage(1)
end
return cy + rowH
end
local function drawInspector(x, y, w, h)
local pad = 10 * Kit.scale
Kit.card(x, y, w, h)
local scroll = Studio.inspectorScroll or 0
if Kit.hit(x, y, w, h) and (Kit.wheelY or 0) ~= 0 then
scroll = scroll - Kit.wheelY * 48 * Kit.scale
end
local maxScroll = math.max(0, (Studio.inspectorH or 0) - (h - pad * 2))
scroll = math.max(0, math.min(scroll, maxScroll))
Studio.inspectorScroll = scroll
Kit.pushClip(x, y, w, h)
local top = y + pad - scroll
local endY = inspectorBody(x + pad, top, w - pad * 2) or top
Studio.inspectorH = endY - top
Kit.popClip()
if maxScroll > 0 then
local frac = h / (h + maxScroll)
local barH = math.max(24 * Kit.scale, h * frac)
local barY = y + (h - barH) * (scroll / maxScroll)
Theme.fill(x + w - 4 * Kit.scale, barY, 3 * Kit.scale, barH, PAL.line, 0.4)
end
end
function Studio.commitField(id, text)
local ctl = Studio.selectedControl()
if not ctl then return end
local n = tonumber(text)
if not n then return end
local canvas = Studio.canvas()
local left = (ctl.x - ctl.rangeX) * canvas.w
local top = (ctl.y - ctl.rangeY) * canvas.h
local wpx = ctl.rangeX * 2 * canvas.w
local hpx = ctl.rangeY * 2 * canvas.h
if id == "numX" then left = n
elseif id == "numY" then top = n
elseif id == "numW" then wpx = math.max(1, n)
elseif id == "numH" then hpx = math.max(1, n) end
ctl.rangeX = (wpx * 0.5) / canvas.w
ctl.rangeY = (hpx * 0.5) / canvas.h
ctl.x = clamp01((left + wpx * 0.5) / canvas.w)
ctl.y = clamp01((top + hpx * 0.5) / canvas.h)
markDirty()
end
function Studio.draw()
local W, H = love.graphics.getDimensions()
Kit.layout(W, H)
local mx, my = love.mouse.getPosition()
Kit.beginFrame(mx, my, Studio.clicked, Studio.wheel)
Studio.clicked, Studio.wheel = false, 0
Theme.fill(0, 0, W, H, PAL.bg, 1)
local pad = 14 * Kit.scale
local barH = math.max(Kit.tapMin(), 34 * Kit.scale) + pad
Kit.textBold("title", "Skin Studio", pad, pad * 0.6, PAL.heading)
local titleW = Kit.textWidth("title", "Skin Studio") + pad * 2
local btnH = math.max(Kit.tapMin(), 32 * Kit.scale)
local bx = titleW
local canvas = Studio.canvas()
if Kit.button(bx, pad * 0.5, 200 * Kit.scale, btnH,
canvas.label, { id = "canvas" }) then
Studio.setCanvas(Studio.canvasIndex + 1)
end
bx = bx + 208 * Kit.scale
if Kit.button(bx, pad * 0.5, 110 * Kit.scale, btnH,
Studio.testing and "Test: ON" or "Test: OFF",
{ id = "test", active = Studio.testing }) then
Studio.testing = not Studio.testing
TouchControls:setPreview(not Studio.testing)
TouchControls:reset()
end
bx = bx + 118 * Kit.scale
if Studio.dirty then
Kit.text("small", "unsaved", bx, pad * 0.5 + btnH * 0.3, PAL.yellow)
end
local closeW = 100 * Kit.scale
if Kit.button(W - pad - closeW, pad * 0.5, closeW, btnH, "Close",
{ id = "close" }) then
if Studio.onClose then Studio.onClose() end
Kit.endFrame()
return
end
local panelW = math.min(360 * Kit.scale, W * 0.34)
local bodyY = barH + pad * 0.5
local bodyH = H - bodyY - pad
drawInspector(pad, bodyY, panelW, bodyH)
local cx = pad * 2 + panelW
local cw = W - cx - pad
local r = drawCanvas(cx, bodyY, cw, bodyH - 40 * Kit.scale)
local footY = bodyY + bodyH - 30 * Kit.scale
local msg = Studio.status
if not msg and Studio.testing then
local held = {}
for btn in pairs(TouchControls.held or {}) do held[#held + 1] = btn end
table.sort(held)
msg = "TEST: click the pad. Held: "
.. (held[1] and table.concat(held, ", ") or "(none)")
end
if not msg then
msg = "Drag to move, corner handles to resize, blue box is the game screen."
end
Kit.text("small", Kit.ellipsize("small", msg, cw), cx, footY, PAL.detail)
Kit.endFrame()
Studio.canvasArea = r
end
-- ---------------------------------------------------------------- input
function Studio.update()
if not Studio.pendingPlay then return end
Studio.pendingPlay = false
local onPlay, version, canvas = Studio.onPlay, Studio.version, Studio.canvas()
if onPlay then onPlay(version, canvas) end
end
function Studio.mousepressed(x, y, button)
if button ~= 1 then return end
Studio.clicked = true
local r = Studio.lastCanvas
if not r then return end
if Studio.testing then
if x >= r.x and x <= r.x + r.w and y >= r.y and y <= r.y + r.h then
TouchControls:touchpressed("studio", x, y)
end
return
end
local slop = HANDLE * 2 * Kit.scale
if x < r.x - slop or x > r.x + r.w + slop
or y < r.y - slop or y > r.y + r.h + slop then
return
end
Studio.beginCanvasDrag(x, y, r)
end
function Studio.mousemoved(x, y)
if Studio.testing then
TouchControls:touchmoved("studio", x, y)
return
end
if Studio.drag and love.mouse.isDown(1) and Studio.lastCanvas then
Studio.updateDrag(x, y, Studio.lastCanvas)
end
end
function Studio.mousereleased(x, y, button)
if button ~= 1 then return end
if Studio.testing then
TouchControls:touchreleased("studio", x, y)
return
end
Studio.drag = nil
end
function Studio.wheelmoved(_, dy)
Studio.wheel = dy
end
function Studio.textinput(text)
Kit.textinput(text)
end
function Studio.keypressed(key)
if Kit.focus then
Kit.keypressed(key)
return
end
if key == "escape" then
if Studio.onClose then Studio.onClose() end
elseif key == "delete" or key == "backspace" then
Studio.deleteControl()
elseif key == "n" then
Studio.addControl()
elseif key == "d" then
Studio.duplicateControl()
elseif key == "t" then
Studio.testing = not Studio.testing
TouchControls:setPreview(not Studio.testing)
TouchControls:reset()
elseif key == "s" then
Studio.save()
elseif key == "tab" then
local page = Studio.page()
if page and #page.controls > 0 then
Studio.selected = ((Studio.selected or 0) % #page.controls) + 1
end
else
Kit.keypressed(key)
end
end
function Studio.available_desktop()
local osName = love.system and love.system.getOS and love.system.getOS()
return osName ~= "Android" and osName ~= "iOS"
end
return Studio
+6
View File
@@ -17,6 +17,12 @@ local Theme = {
textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 },
-- InitYesNoTextBoxParameters / AskName: hlcoord 14, 7 (YES_NO_MENU 4x3)
choiceBox = { tx = 14, ty = 7, tw = 6, th = 5 },
-- YesNoChoicePokeCenter: hlcoord 11, 6 (HEAL_CANCEL_MENU 7x4, blank line
-- before the first item) -- home/yes_no.asm:21, data/yes_no_menu_strings.asm:16
healCancelBox = { tx = 11, ty = 6, tw = 9, th = 6, firstItem = 2 },
-- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7
-- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384
trainerSwitchBox = { tx = 0, ty = 7, tw = 6, th = 5 },
}
function Theme.load(data)
+129 -11
View File
@@ -18,6 +18,7 @@
local SaveData = require("src.core.SaveData")
local TouchControls = require("src.core.TouchControls")
local TouchSkin = require("src.core.TouchSkin")
local PadCursor = require("src.ui.PadCursor")
local GamepadMap = require("src.core.GamepadMap")
@@ -46,6 +47,15 @@ local function roundRect(mode, x, y, w, h, r)
love.graphics.rectangle(mode, x, y, w, h, r, r)
end
local function fitText(font, text, maxW)
text = tostring(text)
if maxW <= 0 or font:getWidth(text) <= maxW then return text end
while #text > 1 and font:getWidth(text .. "...") > maxW do
text = text:sub(1, #text - 1)
end
return text .. "..."
end
function Editor.load(opts)
opts = opts or {}
Editor.onClose = opts.onClose
@@ -53,6 +63,7 @@ function Editor.load(opts)
Editor.hostPoll = opts.hostPoll == true
Editor.drag = nil
Editor.rects = {}
Editor._closed = false
Editor._hostMouse = false
Editor._hostTouches = nil
Editor.fonts = {
@@ -74,9 +85,52 @@ function Editor.load(opts)
TouchControls:applyOptions(applied)
TouchControls:setPreview(true)
Editor.enabled = TouchControls.enabled ~= false
Editor.skins = TouchSkin.list()
Editor.exportMsg = nil
PadCursor.reset()
end
function Editor.skinIndex()
for i, entry in ipairs(Editor.skins or {}) do
if entry.id == TouchControls.skinId then return i + 1 end
end
return 1
end
function Editor.skinLabel()
local entry = (Editor.skins or {})[Editor.skinIndex() - 1]
if not entry then return "Built-in pad" end
local skin = TouchSkin.active
local pages = skin and #skin.pages or 1
if pages > 1 then return entry.id .. " (" .. pages .. " pages)" end
return entry.id
end
function Editor.cycleSkin(dir)
local n = #(Editor.skins or {}) + 1
local idx = ((Editor.skinIndex() - 1 + (dir or 1)) % n) + 1
local entry = Editor.skins[idx - 1]
Editor.exportMsg = nil
TouchControls:selectSkin(entry and entry.id or nil)
TouchControls:ensureImages()
end
function Editor.exportSkin()
local skin = TouchSkin.active
if not skin then return end
local path, missing = TouchSkin.export(skin)
if not path then
Editor.exportMsg = "Export failed: " .. tostring(missing)
return
end
Editor.exportMsg = "Exported to " .. path .. " in your save folder."
if type(missing) == "table" and missing[1] then
Editor.exportMsg = Editor.exportMsg
.. " " .. #missing .. " image(s) were missing and left out."
end
Editor.skins = TouchSkin.list()
end
function Editor.unload()
TouchControls:setPreview(false)
TouchControls:reset()
@@ -84,6 +138,7 @@ function Editor.unload()
Editor.drag = nil
Editor.onClose = nil
Editor.version = nil
Editor.rects = {}
Editor._hostMouse = false
Editor._hostTouches = nil
end
@@ -93,6 +148,7 @@ local function persist()
local cfg = TouchControls:config()
local block = {
enabled = cfg.enabled,
skin = cfg.skin,
layouts = cfg.layouts,
}
if Editor.version == "gold" then
@@ -105,6 +161,8 @@ local function persist()
end
local function close()
if Editor._closed then return end
Editor._closed = true
persist()
local cb = Editor.onClose
Editor.unload()
@@ -158,6 +216,8 @@ function Editor.draw()
col(PAL.bgTop, 0.85)
love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
if TouchSkin.active then TouchControls:draw() end
local pad = 18 * s
local barH = 56 * s
local btnH = 40 * s
@@ -170,16 +230,18 @@ function Editor.draw()
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
love.graphics.setFont(Editor.fonts.title)
col(PAL.white)
love.graphics.print(
fitText(Editor.fonts.title, "Touch Controls", reset.x - 10 * s - (ox + pad)),
ox + pad, oy + pad + 4 * s)
local function chromeBtn(r, label, fill)
col(fill, 0.9)
roundRect("fill", r.x, r.y, r.w, r.h, 8 * s)
@@ -223,9 +285,48 @@ function Editor.draw()
chromeBtn(toggle, on and "Disable" or "Enable",
on and PAL.red or PAL.green)
local skinY = cardY + cardH + 10 * s
col(PAL.card, 0.88)
roundRect("fill", cardX, skinY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4)
roundRect("line", cardX, skinY, cardW, cardH, 12 * s)
local stepW = 52 * s
local skinNext = { x = cardX + cardW - 16 * s - stepW,
y = skinY + (cardH - btnH) / 2, w = stepW, h = btnH }
local skinPrev = { x = skinNext.x - 10 * s - stepW, y = skinNext.y,
w = stepW, h = btnH }
Editor.rects.skinNext, Editor.rects.skinPrev = skinNext, skinPrev
local leftmost = skinPrev.x
if TouchSkin.active then
local expW = 86 * s
local export = { x = skinPrev.x - 10 * s - expW, y = skinPrev.y,
w = expW, h = btnH }
Editor.rects.export = export
leftmost = export.x
else
Editor.rects.export = nil
end
local skinTextW = leftmost - 10 * s - (cardX + 16 * s)
love.graphics.setFont(Editor.fonts.body)
col(PAL.label)
love.graphics.print("Skin", cardX + 16 * s, skinY + 12 * s)
love.graphics.setFont(Editor.fonts.btn)
col(TouchControls.skinId and PAL.green or PAL.white)
love.graphics.print(fitText(Editor.fonts.btn, Editor.skinLabel(), skinTextW),
cardX + 16 * s, skinY + 34 * s)
chromeBtn(skinPrev, "<", { 60, 70, 110 })
chromeBtn(skinNext, ">", { 60, 70, 110 })
if Editor.rects.export then
chromeBtn(Editor.rects.export, "Export", { 60, 70, 110 })
end
-- size card (#633): -/+ resize every control in the orientation on
-- screen; the heading names it so it is plain the other one is untouched
local sizeY = cardY + cardH + 10 * s
local sizeY = skinY + cardH + 10 * s
col(PAL.card, 0.88)
roundRect("fill", cardX, sizeY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4)
@@ -243,7 +344,6 @@ function Editor.draw()
string.format("%d%%", math.floor((bucket.scale or 1) * 100 + 0.5)),
cardX + 16 * s, sizeY + 34 * s)
local stepW = 52 * s
local plus = { x = cardX + cardW - 16 * s - stepW,
y = sizeY + (cardH - btnH) / 2, w = stepW, h = btnH }
local minus = { x = plus.x - 10 * s - stepW, y = plus.y, w = stepW, h = btnH }
@@ -254,13 +354,19 @@ function Editor.draw()
-- hint
love.graphics.setFont(Editor.fonts.body)
col(PAL.label, 0.9)
local hint = on
and "Drag each button to reposition, -/+ to resize. Portrait and landscape are saved separately when you tap Done."
or "Controls are hidden in-game. Enable them to show and edit the layout."
local hint
if Editor.exportMsg then
hint = Editor.exportMsg
elseif not on then
hint = "Controls are hidden in-game. Enable them to show and edit the layout."
elseif TouchSkin.active then
hint = "This skin owns its own art, hitboxes and screen position, so size and dragging are off. Drop a RetroArch overlay folder or .zip into the skins folder of your save directory to add more."
else
hint = "Drag each button to reposition, -/+ to resize. Portrait and landscape are saved separately when you tap Done."
end
love.graphics.printf(hint, ox + pad, sizeY + cardH + 12 * s, ww - 2 * pad, "left")
-- the overlay itself (preview mode; dimmed when disabled)
TouchControls:draw()
if not TouchSkin.active then TouchControls:draw() end
-- highlight the control under drag
if Editor.drag then
@@ -282,6 +388,9 @@ local function beginDrag(id, x, y)
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
if inside(Editor.rects.skinPrev, x, y) then Editor.cycleSkin(-1); return end
if inside(Editor.rects.skinNext, x, y) then Editor.cycleSkin(1); return end
if inside(Editor.rects.export, x, y) then Editor.exportSkin(); return end
if inside(Editor.rects.sizeDown, x, y) then
TouchControls:nudgeScale(-TouchControls.SCALE_STEP); return
end
@@ -289,6 +398,7 @@ local function beginDrag(id, x, y)
TouchControls:nudgeScale(TouchControls.SCALE_STEP); return
end
if TouchSkin.active then return end
local name = TouchControls:hitTest(x, y)
if not name then return end
local zone = TouchControls:layout()[name]
@@ -321,6 +431,7 @@ function Editor.pollHostPointers()
if not Editor._hostMouse then
Editor._hostMouse = true
beginDrag("mouse", x, y)
if Editor._closed then return end
else
moveDrag("mouse", x, y)
end
@@ -340,6 +451,7 @@ function Editor.pollHostPointers()
if not Editor._hostTouches[id] then
Editor._hostTouches[id] = true
beginDrag(id, tx, ty)
if Editor._closed then return end
else
moveDrag(id, tx, ty)
end
@@ -439,6 +551,12 @@ function Editor.keypressed(key)
close()
elseif key == "r" then
resetLayout()
elseif key == "[" then
Editor.cycleSkin(-1)
elseif key == "]" then
Editor.cycleSkin(1)
elseif key == "e" then
Editor.exportSkin()
-- desktop shortcuts for the size -/+ (POKEPORT_TOUCH=1 testing, #633)
elseif key == "-" or key == "kp-" then
TouchControls:nudgeScale(-TouchControls.SCALE_STEP)
+27 -1
View File
@@ -14,7 +14,9 @@
-- This is what the party-menu FLY field move opens (#195).
local Font = require("src.render.Font")
local PaletteFX = require("src.render.PaletteFX")
local Sound = require("src.core.Sound")
local SpriteRenderer = require("src.render.SpriteRenderer")
local TownMap = {}
TownMap.__index = TownMap
@@ -227,7 +229,21 @@ function TownMap.new(game, opts)
local sprites = game.data.sprites or {}
local red = sprites[playerSprites.walk or "SPRITE_RED"]
or sprites.SPRITE_RED
local ok, img = pcall(love.graphics.newImage, red and red.image)
-- the marker is the overworld walking sheet, so it wears that sheet's OBJ
-- palette and shade-0 keying -- engine/items/town_map.asm:342
local colors, group
if PaletteFX.usesGbcPack() then
colors, group = PaletteFX.spriteObp(red, "player")
end
if not colors then
if PaletteFX.usesSpriteObp() then
colors, group = PaletteFX.ogObj()
else
colors, group = PaletteFX.dmgObj()
end
end
local ok, img = pcall(SpriteRenderer.obpImage,
red and red.image, colors, group)
if ok and img then
self.playerSheet = img
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
@@ -316,6 +332,13 @@ function TownMap:update(dt)
end
end
-- OG RED bakes the boot-ROM OBJ palette in, so the marker has to be replayed
-- over the screen-wide TOWNMAP zone pass the way every other OBJ is (#301)
function TownMap:markPlayerRedraw(x, y)
if not PaletteFX.usesSpriteObp() then return end
PaletteFX.markUiSpriteRedraw(self.playerSheet, self.playerQuad, x, y)
end
local function drawSquare(loc)
if isRoute(loc) then
love.graphics.setColor(0.62, 0.62, 0.62, 1) -- routes lighter
@@ -364,6 +387,7 @@ function TownMap:draw()
local x, y = markerXY(self.playerLoc)
if self.playerSheet then
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
self:markPlayerRedraw(x - 4, y - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
@@ -406,6 +430,8 @@ function TownMap:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.playerSheet, self.playerQuad,
self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3)
self:markPlayerRedraw(self.playerLoc.x * 8 - 4,
self.playerLoc.y * 8 - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
+3 -6
View File
@@ -256,10 +256,7 @@ function YellowIntro.new(game, onDone)
local ok, canvas = pcall(love.graphics.newCanvas, 256, 256)
self.bgCanvas = ok and canvas or nil
-- Yellow boots exactly like Red up to the attract movie: the copyright
-- card and the GAME FREAK shooting-star splash play first. Reuse
-- IntroMovie's phases 1-2 and take over where its Gengar fight (phase
-- 3) would begin; a skip press during the pre-roll skips everything.
-- intro.asm:311 copyright card, splash.asm:29 shooting star
local IntroMovie = require("src.ui.IntroMovie")
local pre = IntroMovie.new(game, nil)
local baseStart = pre.startPhase
@@ -644,8 +641,8 @@ 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.
-- buffers before the title screen is built (#523). A press during the
-- pre-roll splash starts the scenes instead of reaching here.
function YellowIntro:exitToTitle()
if self.finished or self.exiting then return end
self.exiting = true
+74 -18
View File
@@ -48,6 +48,18 @@ local TILE_PLAYER_BOTTOM_LEFT = 0x6f
-- (engine/battle/trainer_huds.asm:143-152).
local TILE_CAUGHT = 0x5d
-- The ball icons StageBallTilesData stages, one per party slot
-- (engine/battle/trainer_huds.asm:47-99).
local TILE_BALL_NORMAL = 0x31
local TILE_BALL_STATUSED = 0x32
local TILE_BALL_FAINTED = 0x33
local TILE_BALL_EMPTY = 0x34
-- DrawPlayerPartyIconHUDBorder's corner (trainer_huds.asm:118-132), which is
-- DrawPlayerHUDBorder's with $77 swapped for $5c.
local TILE_PARTY_ICON_BOTTOM_RIGHT = 0x5c
BattleHud.PARTY_LENGTH = 6
function BattleHud.new(menuGfx, palettes)
local self = setmetatable({}, BattleHud)
self.gfx = menuGfx and menuGfx.battleHud or nil
@@ -184,18 +196,25 @@ local TILE_EXP_FULL = 0x6a -- FontBattleExtra
local TILE_EXP_EMPTY = 0x62 -- FontBattleExtra
local EXP_PARTIAL_BASE = 0x54 -- $54 + remainder lands in ExpBarGFX
-- PAL_BATTLE_BG_EXP, which the attrmap lays over (10,11)..(18,11)
-- (engine/gfx/cgb_layouts.asm:142-145).
function BattleHud:expColors()
local pal = self.palettes and self.palettes.expBar
if not pal then return nil end
return {
{ 255, 255, 255 },
{ pal[1][1], pal[1][2], pal[1][3] },
{ pal[2][1], pal[2][2], pal[2][3] },
{ 0, 0, 0 },
}
end
function BattleHud:drawExpBar(fraction, tx, ty)
if not self:image("hpBar") then return false end
fraction = math.max(0, math.min(1, fraction or 0))
local pixels = math.floor(fraction * BattleHud.EXP_LENGTH_PX)
-- The whole row wears the exp bar's palette, full and empty cells included.
local pal = self.palettes and self.palettes.expBar
local colors = pal and {
{ 255, 255, 255 },
{ pal[1][1], pal[1][2], pal[1][3] },
{ pal[2][1], pal[2][2], pal[2][3] },
{ 0, 0, 0 },
} or nil
local colors = self:expColors()
local remaining = pixels
for cell = BattleHud.EXP_CELLS - 1, 0, -1 do
@@ -269,19 +288,56 @@ end
-- DrawPlayerHUDBorder: hlcoord 18, 10 stepping LEFT, tiles $73 / $77 / $6f /
-- $76, plus the extra vertical bar DrawPlayerHUD writes at (18,9) so the stub
-- is two rows tall.
local PLAYER_FRAME_TILES = {
sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST,
cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST,
-- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet
-- (engine/gfx/load_font.asm:57-65).
farSheet = "enemyBorder", farFirst = ENEMY_BORDER_FIRST,
side = TILE_PLAYER_RIGHT,
nearCorner = TILE_PLAYER_BOTTOM_RIGHT,
farCorner = TILE_PLAYER_BOTTOM_LEFT,
bottom = TILE_BOTTOM_SIDE,
}
function BattleHud:drawPlayerFrame()
self:drawTile("playerBorder", PLAYER_BORDER_FIRST, TILE_PLAYER_RIGHT, 18, 9)
self:placeBorder({
sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST,
cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST,
-- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet
-- (engine/gfx/load_font.asm:57-65).
farSheet = "enemyBorder", farFirst = ENEMY_BORDER_FIRST,
side = TILE_PLAYER_RIGHT,
nearCorner = TILE_PLAYER_BOTTOM_RIGHT,
farCorner = TILE_PLAYER_BOTTOM_LEFT,
bottom = TILE_BOTTOM_SIDE,
}, 18, 10, -1)
self:placeBorder(PLAYER_FRAME_TILES, 18, 10, -1)
end
-- DrawPlayerPartyIconHUDBorder (engine/battle/trainer_huds.asm:118-132): the
-- player border with $5c for the bottom right, and no bar at (18,9).
function BattleHud:drawPartyIconFrame()
self:placeBorder(PLAYER_FRAME_TILES, 18, 10, -1)
return self:drawTile("expBar", self.gfx and self.gfx.expBarFirstTile,
TILE_PARTY_ICON_BOTTOM_RIGHT, 18, 11, self:expColors())
end
-- StageBallTilesData's .GetHUDTile, and the $34 it stages past the party
-- count (engine/battle/trainer_huds.asm:47-100).
local function ballTile(mon)
if not mon then return TILE_BALL_EMPTY end
if (mon.hp or 0) <= 0 then return TILE_BALL_FAINTED end
return mon.status and TILE_BALL_STATUSED or TILE_BALL_NORMAL
end
-- PAL_BATTLE_OB_YELLOW (engine/battle/trainer_huds.asm:213-214).
function BattleHud:ballColors()
local pals = self.palettes and self.palettes.battleObjects
return pals and pals.PAL_BATTLE_OB_YELLOW or nil
end
-- LoadTrainerHudOAM (engine/battle/trainer_huds.asm:203-223): six sprites
-- from (tx, ty), each one tile further along `step`.
function BattleHud:drawBallRow(party, tx, ty, step)
if not self:image("balls") then return false end
local first = self.gfx.ballsFirstTile or TILE_BALL_NORMAL
local colors = self:ballColors()
for slot = 1, BattleHud.PARTY_LENGTH do
self:drawTile("balls", first, ballTile(party and party[slot]),
tx + (slot - 1) * step, ty, colors)
end
return true
end
BattleHud.TILE_HP_LABEL = TILE_HP_LABEL
+110 -12
View File
@@ -314,6 +314,13 @@ function BattleState.new(game, opts)
self.showEnemyHud = false
self.showPlayerHud = false
-- BattleStart_TrainerHuds, farcalled from BattleStartMessage before the
-- opening line (engine/battle/trainer_huds.asm:1-9, core.asm:8733).
self.ballRows = {
player = true,
enemy = not (self.battle and self.battle.wild),
}
-- InitEnemyTrainer (engine/battle/core.asm:7848) puts the CLASS's 7x7
-- frontpic in the enemy pic box BEFORE the intro slide, and it stays there
-- until ResetEnemyBattleVars slides it off; only then is the mon drawn. The
@@ -349,6 +356,7 @@ function BattleState.new(game, opts)
local enemy = self.battle and self.battle.enemy
self:noteFirstUnown(enemy)
self:markSeen(enemy)
if enemy then
if self.battle.wild then
-- BattleCheckEnemyShininess: a shiny wild mon gets ANIM_SEND_OUT_MON's
@@ -399,6 +407,13 @@ function BattleState.new(game, opts)
-- "X fainted!" is even displayed. The replacement arrives with its own
-- `send` event, which is where the cart's send-out animation sits.
self.shownMon = { player = player, enemy = enemy }
-- home/battle.asm:150 UpdateBattleHuds
self.shownStatus = {
player = (player and player.status) or false,
enemy = (enemy and enemy.status) or false,
}
-- engine/battle/trainer_huds.asm:142-151
self.caughtMark = self:dexCaught(enemy)
-- And the same for the two numbers AnimateExpBar walks: wBattleMonLevel is
-- only advanced inside its level loop, right after that level's bar has
-- crawled full (engine/battle/core.asm:7267-7274), so neither the level nor
@@ -443,6 +458,16 @@ function BattleState:noteFirstUnown(mon)
save.firstUnownSeen = Unown.monLetter(mon)
end
-- LoadEnemyMon's "Saw this mon" (engine/battle/core.asm:6203-6209): the seen
-- flag is stamped for every battle mode, so a trainer's mon counts too.
function BattleState:markSeen(mon)
local save = self.save
if not (save and mon and mon.species) then return end
save.pokedex = save.pokedex or { seen = {}, caught = {} }
save.pokedex.seen = save.pokedex.seen or {}
save.pokedex.seen[mon.species] = true
end
-- The DUDE answering a prompt. Every re-arm in the ASM sits at the moment the
-- cart starts WAITING for a button (`.wait_input` in home/joypad.asm, BattleMenu
-- before LoadBattleMenu, TutorialPack before its own loop), so each one goes
@@ -936,6 +961,25 @@ function BattleState:dexCaught(mon)
return (mon and caught and caught[mon.species]) and true or false
end
-- The status the HUD prints, one drain behind the engine the way shownHp is:
-- UpdateBattleHuds runs after the animation and its line (home/battle.asm:150).
function BattleState:hudStatus(mon, side)
local shown = side and self.shownStatus and self.shownStatus[side]
if shown == nil then return mon and mon.status or nil end
return shown or nil
end
-- home/battle.asm:150, for the clears no queued event carries (a mon waking,
-- a thaw, a bag cure): the tags catch up once the queue is idle.
function BattleState:syncShownStatus()
local shown, battle = self.shownStatus, self.battle
if not (shown and battle) then return end
for _, side in ipairs({ "player", "enemy" }) do
local mon = battle[side]
shown[side] = (mon and mon.status) or false
end
end
-- The low-HP alarm is not an SFX id at all. PlayDanger (audio/engine.asm:531)
-- runs every frame while DANGER_ON_F is set in wLowHealthAlarm and writes a
-- two-tone square straight to channel 1 -- DangerSoundHigh ($750) at counter 0,
@@ -1153,7 +1197,7 @@ function BattleState:stepAnim(input)
-- Cart still reaches the after-anim arm after a move script ends; a skip
-- of the move should not drop the hit shake that follows it.
if self:startPendingAfterAnim() then return end
return self:endSendOutAnim()
return self:endSendOutAnim(true)
end
if not self.anim:step() then
self:latchCaughtPic()
@@ -1170,10 +1214,17 @@ end
-- Whatever Call_PlayBattleAnim was standing in front of: a send-out's cry and
-- HUD update run the moment its animation is done, cut short or not.
function BattleState:endSendOutAnim()
function BattleState:endSendOutAnim(skipped)
local after = self.afterSendOut
if not after then return end
self.afterSendOut = nil
if after.shiny and not skipped then
after.shiny = nil
if self:animForId("ANIM_SEND_OUT_MON", after.side, 1) then
self.afterSendOut = after
return
end
end
self:finishSendOut(after)
end
@@ -1202,6 +1253,7 @@ function BattleState:advanceQueue()
self.introTextShown = nil
end
if not event then
self:syncShownStatus()
-- `jp PlayerSwitch`, which follows the enemy's own send-out and spends no
-- turn (engine/battle/core.asm:2955-2963).
if self.shiftSwitchIndex then
@@ -1315,6 +1367,14 @@ function BattleState:advanceQueue()
self.shownHp[event.side] = event.mon.hp or 0
if self.hpAnim and self.hpAnim.side == event.side then self.hpAnim = nil end
end
-- And the same lag for the status tag (home/battle.asm:150); a send snaps it
-- to the incoming mon.
if self.shownStatus and event.side
and (event.kind == "status"
or (event.kind == "send" and event.mon)) then
self.shownStatus[event.side] =
(event.kind == "send" and event.mon.status or event.status) or false
end
-- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE
-- GiveExperiencePoints before the exp is committed (the call at :6888 sits
-- ahead of the commit at :6889-6901), so the bar crawls from the figures
@@ -1339,7 +1399,12 @@ function BattleState:advanceQueue()
-- is still on screen for its own line and the replacement arrives here.
if self.shownMon then self.shownMon[event.side] = event.mon end
-- The second of the cart's two wFirstUnownSeen writes (core.asm:3251).
if event.side == "enemy" then self:noteFirstUnown(event.mon) end
if event.side == "enemy" then
self:noteFirstUnown(event.mon)
self:markSeen(event.mon)
-- engine/battle/trainer_huds.asm:142-151
self.caughtMark = self:dexCaught(event.mon)
end
if event.side == "player" then
-- SendOutPlayerMon zeroes wBattleMenuCursorPosition and wCurMoveNum back
-- to back (engine/battle/core.asm:3809), so a switched-in mon opens on
@@ -1535,12 +1600,18 @@ end
-- SetEnemyTurn / SetPlayerTurn, then ANIM_SEND_OUT_MON. The cry and the HUD
-- come after the animation, not with it.
function BattleState:startSendOut(side, mon)
local after = { side = side, mon = mon }
-- BattleCheckPlayerShininess / BattleCheckEnemyShininess replay the anim's
-- `.Shiny` arm before the cry (core.asm:3826-3831, :3371-3377).
local after = { side = side, mon = mon, shiny = mon and mon.shiny and true }
-- ShowSetEnemyMonAndSendOutAnimation and SendOutPlayerMon both draw the pic
-- into the box before they play the animation, which is the one thing that
-- undoes a cleared box.
self.picHidden[side] = false
self.faintSlide = nil
-- The rows are shadow OAM (engine/battle/trainer_huds.asm:203-223), which
-- this animation's own sprites overwrite.
self.ballRows.player = false
self.ballRows.enemy = false
if self:animForId("ANIM_SEND_OUT_MON", side) then
self.afterSendOut = after
return true
@@ -2569,6 +2640,9 @@ function BattleState:pushCaught(enemy, itemId)
-- in the box", item_effects.asm:624). Boxes.deposit stays an append: the
-- PC's own move is InsertPokemonIntoBox, which inserts at the cursor.
table.insert(box, 1, enemy)
-- SendMonIntoBox refills the boxed slot's PP before it closes SRAM
-- (move_mon.asm:1062-1063).
Boxes.restorePP(enemy)
-- `.SendToPC` re-reads sBoxCount AFTER the insert and sets
-- BATTLERESULT_BOX_FULL when the box has just filled
-- (item_effects.asm:612-619); Script_reloadmapafterbattle tests that bit
@@ -2638,6 +2712,9 @@ end
-- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231).
function BattleState:offerShiftSwitch(mon)
self.shiftIndex = 1
-- HandleEnemySwitch farcalls EnemySwitch_TrainerHud before the prompt
-- (engine/battle/core.asm:2246, engine/battle/trainer_huds.asm:11-15).
self.ballRows.enemy = true
local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe"
local player = (self.save and self.save.player and self.save.player.name)
or "GOLD"
@@ -2921,6 +2998,9 @@ function BattleState:useItem(itemId)
end
local player = self.battle.player
caught, rate = Catching.attempt({
battle = self.battle,
mon = enemy,
def = enemyDef,
maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp),
hp = enemy.hp,
catchRate = enemyDef and enemyDef.catchRate or 45,
@@ -3024,8 +3104,10 @@ function BattleState:useItem(itemId)
-- Everything else the pack can spend on a party mon runs the same
-- item_effects.asm routine the field pack runs: the potion line and the
-- drinks, the status cures and their berries, REVIVE / MAX REVIVE, and
-- the ETHER / ELIXER family.
local action = ItemEffects.partyAction(itemId)
-- the ETHER / ELIXER family. Without the merged dataset this can only
-- ever see RECORDS, the module's own built-ins, the same gap
-- Game2:usePartyItem had for the field pack.
local action = ItemEffects.partyAction(itemId, self.game and self.game.data)
if action then
return self:useOnPartyMon(itemId, action)
end
@@ -3229,13 +3311,13 @@ function BattleState:drawHud()
-- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag
-- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops
-- the level entirely while one is up.
Chrome.print(self:statusTag(enemy) or ("<LV>" .. tostring(enemy.level or 1)),
6, 1)
Chrome.print(self:statusTag(enemy, "enemy")
or ("<LV>" .. tostring(enemy.level or 1)), 6, 1)
local enemyGender = self:genderSymbol(enemy)
if enemyGender then Chrome.print(enemyGender, 9, 1) end
-- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at
-- (1,1) (engine/battle/trainer_huds.asm:140-152).
if self.battle and self.battle.wild and self:dexCaught(enemy) then
if self.battle and self.battle.wild and self.caughtMark then
self.hud:drawCaughtIcon(1, 1, self:hudHp(enemy, "enemy"),
enemy.maxHp or (enemy.stats and enemy.stats.hp))
end
@@ -3247,6 +3329,14 @@ function BattleState:drawHud()
self:drawFrame(1, 3, 10, false)
end
end
-- ShowOTTrainerMonsRemaining (engine/battle/trainer_huds.asm:32-45): balls
-- walking LEFT from OAM (72, 32), which the -8/-16 offset puts at (8, 2).
if showStatus and self.ballRows.enemy then
if not self.showEnemyHud and self.hud:available() then
self.hud:drawEnemyFrame()
end
self.hud:drawBallRow(self.battle and self.battle.enemyParty, 8, 2, -1)
end
self:drawPic(enemy, false)
@@ -3255,6 +3345,14 @@ function BattleState:drawHud()
-- the HP bar at (10,9), its numbers below; the vertical bar at (18,9), the
-- border from (18,10) going left, and the exp bar at (10,11).
self:drawPic(player, true)
-- ShowPlayerMonsRemaining (engine/battle/trainer_huds.asm:17-30): balls
-- walking RIGHT from OAM (96, 96), i.e. tile (11, 10).
if showStatus and self.ballRows.player then
if not self.showPlayerHud and self.hud:available() then
self.hud:drawPartyIconFrame()
end
self.hud:drawBallRow(self.battle and self.battle.party, 11, 10, 1)
end
-- No player HUD in the catching tutorial: DrawPlayerHUD lives in
-- SendOutPlayerMon, which BATTLETYPE_TUTORIAL jumps straight over, and
-- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's
@@ -3268,7 +3366,7 @@ function BattleState:drawHud()
Chrome.print(self:name(player), 10, 7)
-- PrintPlayerHUD places the same status tag at (14,8) and skips the level
-- while it is up.
Chrome.print(self:statusTag(player)
Chrome.print(self:statusTag(player, "player")
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8)
local playerGender = self:genderSymbol(player)
if playerGender then Chrome.print(playerGender, 17, 8) end
@@ -3301,8 +3399,8 @@ local STATUS_TAGS = {
paralyze = "PAR", sleep = "SLP",
}
function BattleState:statusTag(mon)
return mon and STATUS_TAGS[mon.status] or nil
function BattleState:statusTag(mon, side)
return mon and STATUS_TAGS[self:hudStatus(mon, side)] or nil
end
-- ♂ / ♀ after the level, or nil for a genderless species (PrintPlayerHUD
+2 -1
View File
@@ -28,6 +28,7 @@
-- covered by tests; the state at the bottom is the only part that draws.
local GbcPalette = require("src.render.GbcPalette")
local GameViewport = require("src.render.GameViewport")
local Palettes = require("src.world.gen2.Palettes")
local Runtime = require("src.mods.Runtime")
local SpriteAnims = require("src.ui.gen2.SpriteAnims")
@@ -509,7 +510,7 @@ function BattleTransition:blackAt(col, row)
end
function BattleTransition:draw()
local w, h = love.graphics.getDimensions()
local w, h = GameViewport.dimensions()
self:drawWidescreen(w, h)
end
+47 -18
View File
@@ -78,8 +78,12 @@ local MOVE_SUBMENU = { "MOVE", "STATS", "CANCEL" }
-- engine/pokemon/bills_pc.asm:472-478: BillsPC_Withdraw's menu rows.
local WITHDRAW_SUBMENU = { "WITHDRAW", "STATS", "RELEASE", "CANCEL" }
-- BillsPCDepositMenuHeader's .MenuData (engine/pokemon/bills_pc.asm:234-240).
local DEPOSIT_SUBMENU = { "DEPOSIT", "STATS", "RELEASE", "CANCEL" }
function BoxMenu:submenuRows()
if self.mode == "move" then return MOVE_SUBMENU end
if self.mode == "deposit" then return DEPOSIT_SUBMENU end
return WITHDRAW_SUBMENU
end
@@ -219,21 +223,12 @@ function BoxMenu:act()
if self.onClose then self.onClose() end
return
end
-- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu.
if self.mode == "move" or self.mode == "withdraw" then
if not self:selected() then return end
self.phase = "submenu"
-- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on MOVE.
self.submenuIndex = 1
return
end
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
if not ok then
self.message = result
return
end
self.message = nil
self:clampIndex()
-- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu,
-- and _DepositPKMN's .a_button steps to .WhatsUp the same way (:94-102).
if not self:selected() then return end
self.phase = "submenu"
-- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on its top row.
self.submenuIndex = 1
end
-- ------------------------------------------------------------ MOVE, step 2
@@ -243,8 +238,9 @@ end
-- the PARTY, so a boxed mon walks straight past. Returns true, or false and
-- the string the cart places.
function BoxMenu:checkMailPreventBlackout()
-- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`.
if not self:isParty() then return true end
-- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`; _DepositPKMN zeroes
-- that byte too, so its list is the party (bills_pc.asm:17-18).
if not (self:isParty() or self.mode == "deposit") then return true end
local party = self.save.party or {}
-- `cp $3 / jr c, .ItsYourLastPokemon`: a party of one or two may not send
-- one away at all, however healthy the rest of it is.
@@ -311,10 +307,25 @@ function BoxMenu:doWithdraw()
self:clampIndex()
end
-- engine/pokemon/bills_pc.asm:155 BillsPCDepositFuncDeposit
function BoxMenu:doDeposit()
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
if not ok then
self.message = result
return
end
self.message = nil
self.phase = nil
self.index, self.scroll = 1, 0
self:clampIndex()
end
function BoxMenu:chooseSubmenu()
local row = self:submenuRows()[self.submenuIndex]
if row == "MOVE" then
self:beginMove()
elseif row == "DEPOSIT" then
self:doDeposit()
elseif row == "WITHDRAW" then
self:doWithdraw()
elseif row == "STATS" then
@@ -393,6 +404,9 @@ function BoxMenu:insertMon()
target = target - 1
end
table.insert(dest, math.max(1, math.min(target, #dest + 1)), mon)
-- .CopyToBox is InsertPokemonIntoBox, which tails into
-- RestorePPOfDepositedPokemon (engine/pokemon/move_mon_wo_mail.asm:35-37).
if not self:isParty(destIndex) then Boxes.restorePP(mon) end
self.phase = nil
self.moveFrom, self.backup = nil, nil
self.index, self.scroll = 1, 0
@@ -521,6 +535,16 @@ function BoxMenu:askRelease()
if self:isCancel() then return end
local mon = self:selected()
if not mon then return end
-- BillsPCDepositFuncRelease runs the mail/blackout net BEFORE the egg check
-- (engine/pokemon/bills_pc.asm:183-187).
if self.mode == "deposit" then
local allowed, refusal = self:checkMailPreventBlackout()
if not allowed then
self.phase = nil
self.message = refusal
return
end
end
-- Both release paths run BillsPC_IsMonAnEgg first, so the question is never
-- even asked over an egg (engine/pokemon/bills_pc.asm:186-187 and :427-428).
if mon.isEgg then
@@ -534,7 +558,12 @@ function BoxMenu:askRelease()
local name = mon.nickname or mon.name or mon.species or "?"
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then return end
local ok, err = Boxes.release(self.save, self.boxIndex, self.index)
local ok, err
if self.mode == "deposit" then
ok, err = Boxes.releaseFromParty(self.save, self.index)
else
ok, err = Boxes.release(self.save, self.boxIndex, self.index)
end
if not ok then
self.message = err
return
+17 -9
View File
@@ -60,10 +60,11 @@ InitClock.TEXT = TEXT
-- same three and has always had them right.
local MORN_HOUR, DAY_HOUR, NITE_HOUR = 4, 10, 18
-- data/text/day_of_week.asm order, which is wCurDay's own: SUNDAY is 0.
local DAYS = {
"SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY",
}
-- Clock.DAY_NAMES / Clock.weekdayName is the single translated home for this
-- table: MainMenu's clock box and the Pokegear's clock card read the same
-- weekday off the same save and must never disagree about what it is
-- called.
local DAYS = Clock.DAY_NAMES
InitClock.DAYS = DAYS
function InitClock:wantsFillScale() return true end
@@ -88,12 +89,15 @@ function InitClock.hourString(hour)
local h = math.floor(hour or 0) % 24
local display = h % 12
if display == 0 then display = 12 end
local word = require("src.world.gen2.Palettes").clockDaytime(h)
-- Clock.daytimeLabel, not Palettes.clockDaytime: the printed word,
-- translated -- this string reaches the player as-is, unlike the internal
-- MORN/DAY/NITE key other palette code compares against.
local word = Clock.daytimeLabel(h)
return ("%s %d"):format(word, display)
end
function InitClock.oclockString(hour)
return InitClock.hourString(hour) .. " o'clock"
return Strings("%s o'clock", InitClock.hourString(hour))
end
function InitClock.timeString(hour, minute)
@@ -187,7 +191,7 @@ function InitClock:question()
return Strings(TEXT.whoaMinutes, self.minute)
end
if self.phase == "confirm-day" then
return Strings(TEXT.confirmDay, DAYS[self.day + 1] or "?")
return Strings(TEXT.confirmDay, Clock.weekdayName(self.day + 1) or "?")
end
if self.phase == "response" then
return Strings(TEXT[InitClock.responseKey(self.hour)],
@@ -196,11 +200,15 @@ function InitClock:question()
return ""
end
-- data/text/common_1.asm's "@MIN." suffix (DisplayMinutesWithMinString),
-- separate from TEXT.whoaMinutes' own "%d min.?" confirmation line above.
local MINUTES = Strings.source("%d min.")
-- The value the picker box shows, or nil while a page is up with no picker.
function InitClock:display()
if self.phase == "hour" then return InitClock.oclockString(self.hour) end
if self.phase == "minute" then return ("%d min."):format(self.minute) end
if self.phase == "day" then return DAYS[self.day + 1] or "?" end
if self.phase == "minute" then return Strings(MINUTES, self.minute) end
if self.phase == "day" then return Clock.weekdayName(self.day + 1) or "?" end
return nil
end
+3 -2
View File
@@ -518,8 +518,9 @@ function ItemPcMenu:drawList()
local entry = self.rows[i]
if i == self.listIndex then Chrome.cursor(5, ty) end
Chrome.print(entry.name, 6, ty)
-- PlaceMenuItemQuantity: the xNN is the entry's second line.
Chrome.print(TIMES .. tostring(entry.count), 7, ty + 1)
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:24): the xNN is the
-- entry's second line, right-aligned in a blank-padded 2-digit field.
Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1)
elseif i == self:listTotal() then
if i == self.listIndex then Chrome.cursor(5, ty) end
Chrome.print("CANCEL", 6, ty)
+10 -10
View File
@@ -12,9 +12,9 @@
-- * The two tilemaps behind DrawMagnetTrain (MagnetTrainBGTiles, a 2x18
-- vertical strip repeated across all 32 columns, and MagnetTrainTilemap,
-- the 20x4 train laid over rows 6-9) come from the extracted cache at
-- data.field.magnetTrain. A cache built before the extractor learned to
-- follow them has neither, and then the ride runs with a blank screen
-- rather than with invented art.
-- data.gen2Field.magnetTrain. A cache built before the extractor
-- learned to follow them has neither, and then the ride runs with a
-- blank screen rather than with invented art.
-- * The background is baked into a 256x144 canvas once, because the only
-- thing that changes per frame is the per-band SCX.
-- * SetMagnetTrainPals gives the four bush rows and the four bottom rows
@@ -57,7 +57,7 @@ function MagnetTrainRide.new(game, opts)
self.onDone = opts.onDone
self.finished = false
local field = self.data and self.data.field
local field = self.data and self.data.gen2Field
local gfx = field and field.magnetTrain
self.ride = MagnetTrain.new({
toGoldenrod = opts.toGoldenrod,
@@ -65,8 +65,8 @@ function MagnetTrainRide.new(game, opts)
fgTilemap = gfx and gfx.tilemap,
})
self.tileset = self.data and self.data.tilesets
and self.data.tilesets.TILESET_TRAIN_STATION
self.tileset = self.data and self.data.gen2Tilesets
and self.data.gen2Tilesets.TILESET_TRAIN_STATION
self.palettes = self:bgPalettes()
self.spriteSheet = self:playerSheet()
@@ -84,7 +84,7 @@ end
-- even though both stations are INDOOR maps.
function MagnetTrainRide:bgPalettes()
local data = self.data
local palettes = data and data.palettes
local palettes = data and data.gen2Palettes
if not palettes then return nil end
return Palettes.bgSet(palettes, { environment = "TOWN" },
Palettes.clockDaytime())
@@ -129,7 +129,7 @@ end
-- are cut per 8x8 sub-tile rather than by the sheet's 16-pixel width, because
-- the OAM data addresses the four tiles of a frame individually.
function MagnetTrainRide:playerSheet()
local sprites = self.data and self.data.sprites
local sprites = self.data and self.data.gen2Sprites
local def = sprites and (sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS)
local path = def and def.image
if not path then return nil end
@@ -259,8 +259,8 @@ end
-- MapObjectPals' PAL_OW_RED, the palette every .OAMData_MagnetTrainRed entry
-- names.
function MagnetTrainRide:playerPalette()
local palettes = self.data and self.data.palettes
local sprites = self.data and self.data.sprites
local palettes = self.data and self.data.gen2Palettes
local sprites = self.data and self.data.gen2Sprites
if not palettes then return nil end
return Palettes.spritePalette(palettes, Palettes.clockDaytime(),
sprites and sprites.SPRITE_CHRIS)
+6 -5
View File
@@ -29,10 +29,11 @@ local MainMenu = {}
MainMenu.__index = MainMenu
MainMenu.isOpaque = true
-- MainMenu_PrintCurrentTimeAndDay's PrintDayOfWeek strings.
local DAYS = {
"SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY",
}
-- MainMenu_PrintCurrentTimeAndDay's PrintDayOfWeek strings. Clock.DAY_NAMES
-- / Clock.weekdayName is the single translated home for this table (see
-- InitClock.lua's DAYS), so this screen's clock box cannot drift from the
-- Pokegear's own.
local DAYS = Clock.DAY_NAMES
-- MUSIC_MAIN_MENU; resolved by name so a cache without it just stays quiet.
local MENU_MUSIC = "Music_MainMenu"
@@ -164,7 +165,7 @@ function MainMenu:drawClockBox()
-- Textbox at (0,12) with 4 interior rows and 13 interior columns.
Chrome.textbox(0, 12, 13, 4)
local hour, minute, weekday = self:clockParts()
Chrome.print(DAYS[weekday] or "DAY", 1, 14)
Chrome.print(Clock.weekdayName(weekday) or "DAY", 1, 14)
-- PrintHour prints 1-12 with no leading zero, then ':' then two zero-padded
-- minutes; the AM/PM half is drawn by PrintHour itself.
local display = hour % 12
+9
View File
@@ -211,6 +211,15 @@ local ROWS = {
TC.buzz(options.haptics)
if game and game.persistOptions then game:persistOptions() end
end },
{ label = "MAX FPS", key = "fpsCap", port = true,
cycle = function(options, delta)
local FrameCap = require("src.core.FrameCap")
options.fpsCap = FrameCap.cycle(options.fpsCap, delta)
FrameCap.apply(options.fpsCap)
end,
text = function(options)
return require("src.core.FrameCap").label(options.fpsCap)
end },
{ label = "CANCEL", cancel = true },
}
+93 -27
View File
@@ -67,6 +67,10 @@ local SUBMENU_LABEL = {
-- (data/text/common_2.asm), the three lines TossMenu prints in order.
local TOSS_HOW_MANY = { "Throw away how", "many?" }
-- _AskItemMoveText (data/text/common_2.asm:322), printed while wSwitchItem
-- holds a row and the cursor is looking for its new home.
local ASK_ITEM_MOVE = { "Where should this", "be moved to?" }
-- _YouDontHaveAMonText and .AnEggCantHoldAnItemText, GiveItem's two refusals.
local NO_POKEMON = { "You don't have a", "#MON!" }
local EGG_CANT_HOLD = { "An EGG can't hold", "an item." }
@@ -127,6 +131,9 @@ function PackMenu.new(game, opts)
self.game = game
self.save = opts.save or (game and game.save)
self.items = opts.items or (game and game.data and game.data.items)
-- Bag.order / Bag.move read `.items` off an injectable data table, and the
-- one this screen draws from is not always the Data singleton.
self.bagData = { items = self.items }
self.world = opts.world or (game and game.world)
self.onChoose = opts.onChoose
self.onClose = opts.onClose
@@ -207,7 +214,13 @@ end
function PackMenu:rebuild()
local pocket = self:pocket().id
local rows = {}
for itemId, raw in pairs((self.save and self.save.inventory) or {}) do
local save = self.save
local inventory = (save and save.inventory) or {}
-- Row order IS wBagItems' order, which is what SELECT rewrites.
local order = (save and save.inventory)
and Bag.order(save, self.bagData) or {}
for _, itemId in ipairs(order) do
local raw = inventory[itemId]
-- A count that is not a number at all (a hand-written save, a mod, an old
-- migration) counts as one rather than raising out of the draw.
local count = tonumber(raw) or (raw and 1) or 0
@@ -219,18 +232,13 @@ function PackMenu:rebuild()
name = PackMenu.label(itemId, def),
teaches = self:moveLabel(def and def.teaches),
tmNumber = def and def.tmNumber,
-- KEY_ITEM and TM_HM rows do not show a quantity on the cart.
showCount = pocket == "ITEM" or pocket == "BALL",
index = def and def.index or math.huge,
-- A KEY_ITEM never shows one, and engine/items/tmhm.asm:390 skips the
-- count for an HM only -- a TM prints ×NN like any other stack.
showCount = pocket == "ITEM" or pocket == "BALL"
or (pocket == "TM_HM" and tostring(itemId):sub(1, 3) ~= "HM_"),
}
end
end
-- Bag order on the cart is acquisition order; without that recorded, item id
-- order is the stable, reproducible choice.
table.sort(rows, function(a, b)
if a.index ~= b.index then return a.index < b.index end
return a.id < b.id
end)
self.rows = rows
self.index = math.min(self.index, #rows + 1)
if self.index < 1 then self.index = 1 end
@@ -671,6 +679,11 @@ function PackMenu:update(_dt)
self:updateQuantity(input)
return
end
-- engine/items/pack.asm:1237 Pack_InterpretJoypad .switching_item
if self.switching then
self:updateSwitch(input)
return
end
if self.message then
if input:wasPressed("a") or input:wasPressed("b") then
self.message = nil
@@ -716,11 +729,52 @@ function PackMenu:update(_dt)
end
return
elseif input:wasPressed("select") then
self:registerSelected()
self:armSwitch()
return
end
end
-- engine/items/pack.asm:1290 Pack_InterpretJoypad .select
function PackMenu:armSwitch()
if self:isCancel() then return end
if not self.rows[self.index] then return end
self.switching = self.index
self.message = ASK_ITEM_MOVE
end
-- `.switching_item` (engine/items/pack.asm:1297): A or SELECT places, B backs
-- out, and left/right cannot leave the pocket mid-move.
function PackMenu:updateSwitch(input)
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or self:total()
self:ensureVisible()
elseif input:wasPressed("down") then
self.index = self.index < self:total() and self.index + 1 or 1
self:ensureVisible()
elseif input:wasPressed("a") or input:wasPressed("select") then
self:placeSwitch()
elseif input:wasPressed("b") then
self:endSwitch()
end
end
-- engine/items/pack.asm:1307 .place_insert / .end_switch
function PackMenu:placeSwitch()
local from = self.switching
local row = self.rows[from]
if row and not self:isCancel() and self.index ~= from then
Bag.move(self.save, row.id, self:pocket().id, self.index, self.bagData)
self:rebuild()
self:storeCursor()
end
self:endSwitch()
end
function PackMenu:endSwitch()
self.switching = nil
self.message = nil
end
-- VerticalMenu over the submenu rows: up/down wrap, A picks, B is the carry
-- that ExitMenu answers with (`ret c`), which is QUIT by another name.
function PackMenu:updateSubmenu(input)
@@ -777,12 +831,10 @@ function PackMenu:updateConfirm(input)
end
end
-- RegisterItem (engine/items/pack.asm), the submenu's SEL row. SELECT on the
-- highlighted row reaches the same routine: the cart's SELECT is the bag's own
-- item shuffle, which this port does not have, so the button is free and a
-- player who knows Gen 1's registration shortcut gets it. World:registerItem
-- re-runs CheckSelectableItem's gate (TM/HM and anything CANT_SELECT_F
-- refuses), so neither door can register what the cart would not.
-- RegisterItem (engine/items/pack.asm), the submenu's SEL row and its ONLY
-- door -- the cart's SELECT is the bag's own item shuffle (see armSwitch).
-- World:registerItem re-runs CheckSelectableItem's gate (TM/HM and anything
-- CANT_SELECT_F refuses), so it cannot register what the cart would not.
function PackMenu:registerSelected()
if self:isCancel() then return end
local row = self.rows[self.index]
@@ -828,22 +880,38 @@ end
-- The list, description and cursor, on top of whatever chrome was drawn.
--
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm) writes the ×N one row DOWN
-- and one column RIGHT of the name -- the quantity is the entry's second line,
-- not a right-aligned column, which is why every PACK row is two tiles tall.
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:10) writes the ×N one row
-- DOWN and one column RIGHT of the name -- the quantity is the entry's second
-- line, not a right-aligned column, which is why every PACK row is two tiles
-- tall. Its `lb bc, 1, 2` is a TWO-digit field with the leading digit blanked,
-- so the ones digit sits at name + 3 whether the count is 5 or 50.
--
-- ScrollingMenu_PlaceCursor (engine/menus/scrolling_menu.asm:438) marks the
-- row SELECT armed with the hollow ▷ while the solid ▶ goes on looking.
function PackMenu:drawList(listX, listY)
for row = 1, VISIBLE_ROWS do
local i = row + self.scroll
local ty = listY + (row - 1) * LIST_SPACING
if i <= #self.rows then
local entry = self.rows[i]
if i == self.index then Chrome.cursor(listX - 1, ty) end
if i == self.index then
Chrome.cursor(listX - 1, ty)
elseif i == self.switching then
Chrome.cursor(listX - 1, ty, true)
end
Chrome.print(entry.name, listX, ty)
if entry.teaches then
-- The TM pocket puts the move the TM teaches on that second line.
-- The TM pocket puts the move the TM teaches on that second line, and
-- its count at listX + 9 (engine/items/tmhm.asm:392) -- on the LABEL's
-- line here, since the move name owns the one below it.
Chrome.print(entry.teaches, listX + 1, ty + 1)
if entry.showCount then
Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2),
listX + 9, ty)
end
elseif entry.showCount then
Chrome.print("\xc3\x97" .. tostring(entry.count), listX + 1, ty + 1)
Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2),
listX + 1, ty + 1)
end
elseif i == self:total() then
if i == self.index then Chrome.cursor(listX - 1, ty) end
@@ -897,12 +965,10 @@ function PackMenu:drawSubmenu()
end
end
-- TossItem_MenuHeader is `menu_coords 15, 9, SCREEN_WIDTH - 1, TEXTBOX_Y - 1`
-- with NoPriceToDisplay behind it: a small box in the bottom right holding
-- nothing but the count.
-- engine/items/buy_sell_toss.asm:133 BuySellToss_UpdateQuantityDisplay
function PackMenu:drawQuantity()
Chrome.box(15, 9, 5, 3)
Chrome.print("\xc3\x97" .. tostring(self.qtyState.qty), 16, 10)
Chrome.print("\xc3\x97" .. Chrome.number(self.qtyState.qty, 2, true), 16, 10)
end
-- YesNoBox's own coords, the same box every other Gen 2 screen here draws.
+3 -7
View File
@@ -20,6 +20,7 @@ local Chrome = require("src.ui.gen2.Chrome")
local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local HpBar = require("src.battle.gen2.HpBar")
local ItemEffects = require("src.core.gen2.ItemEffects")
local Logger = require("src.core.Logger")
local Mail = require("src.core.gen2.Mail")
local Mon = require("src.battle.gen2.Mon")
@@ -668,17 +669,12 @@ end
-- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon
-- with no HP reads FNT whatever its status byte says.
local STATUS_STRING = {
slp = "SLP", psn = "PSN", brn = "BRN", frz = "FRZ", par = "PAR",
poison = "PSN", burn = "BRN", freeze = "FRZ", paralysis = "PAR",
sleep = "SLP", toxic = "PSN",
}
local function statusString(mon)
if (mon.hp or 0) <= 0 then return "FNT" end
local status = mon.status
if not status then return nil end
return STATUS_STRING[tostring(status):lower()]
local class = ItemEffects.STATUS_CLASS[tostring(status):lower()]
return class and class:upper()
end
-- One list row's strings, exactly what WritePartyMenuTilemap's quality
+127 -2
View File
@@ -24,6 +24,8 @@ local Chrome = require("src.ui.gen2.Chrome")
local Logger = require("src.core.Logger")
local Mail = require("src.core.gen2.Mail")
local Runtime = require("src.mods.Runtime")
local Save = require("src.core.gen2.Save")
local SaveMenu = require("src.ui.gen2.SaveMenu")
local Screens = require("src.ui.Screens")
local Strings = require("src.core.Strings")
@@ -36,6 +38,13 @@ local MON_HOLDING_MAIL = {
Strings.source("Please remove the\nMAIL."),
}
-- _ChangeBoxSaveText (data/text/common_2.asm:1306) is three lines whose `cont`
-- has already scrolled by the time YesNoBox goes up over its last two.
local CHANGE_BOX_SAVE = { "#MON BOX, data", "will be saved. OK?" }
-- YesNoBox's own `lb bc, SCREEN_WIDTH - 6, 7` (home/menu.asm:382-383).
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5
local PcMenu = {}
PcMenu.__index = PcMenu
PcMenu.isOpaque = true
@@ -90,6 +99,13 @@ function PcMenu.new(game, opts)
self.onClose = opts.onClose
self.house = opts.house and true or false
self.events = opts.events
-- The same route the start menu's SAVE row takes (src/core/Game2.lua:435),
-- so the save.write veto and the save.writing event fire here too.
self.writer = opts.writer
or (game and type(game.writeSave) == "function"
and function() return game:writeSave() end)
or Save.save
self.saveExists = opts.saveExists
-- The folded MAIL BOX row belongs to the item PC, and the whose-PC menu
-- reaches that through <PLAYER>'s PC (src/ui/gen2/ItemPcMenu.lua), so
-- BILL's PC shows the cart's own five rows. The bedroom's PC keeps it: the
@@ -156,6 +172,91 @@ function PcMenu:close()
if self.onClose then self.onClose(self.changedDecorations) end
end
-- engine/pokemon/bills_pc.asm:2403 BillsPC_ChangeBoxSubmenu .Switch
-- engine/menus/save.asm:40 ChangeBoxSaveGame
function PcMenu:beginChangeBox(index)
self.changeBox = index
self.savePhase = "confirm"
self.saveChoice = 1
self.saveTimer = 0
self.saved = nil
local existed = self.saveExists
if existed == nil then existed = Save.exists("gold") end
self.existed = existed
end
-- .refused: `pop de / ret`, with wCurBox untouched and the picker still up.
function PcMenu:refuseChangeBox()
self.savePhase, self.changeBox = nil, nil
self.saveTimer = 0
end
function PcMenu:acceptChangeBox()
if self.saveChoice == 2 then return self:refuseChangeBox() end
if self.savePhase == "confirm" and self.existed then
self.savePhase = "overwrite"
self.saveChoice = 1
return
end
self.savePhase = "saving"
self.saveTimer = 0
end
-- `pop de / ld a, e / ld [wCurBox], a` sits between SaveBox and
-- SavingDontTurnOffThePower, so the new index rides the file that is written.
function PcMenu:writeChangeBox()
Boxes.setCurrent(self.save, self.changeBox)
local ok = self.writer(self.save)
self.saved = ok and true or false
if ok then SaveMenu.playSaveSfx(self.game, SaveMenu.SFX_SAVE) end
end
function PcMenu:savePrompt()
if self.savePhase == "overwrite" then return SaveMenu.OVERWRITE_PROMPT end
if self.savePhase == "saving" then return SaveMenu.SAVING_PROMPT end
if self.savePhase == "done" then
if self.saved then
local name = (self.save.player and self.save.player.name) or "GOLD"
return { name .. " saved", "the game." }
end
return { "Could not save.", "" }
end
return CHANGE_BOX_SAVE
end
function PcMenu:updateChangeBox()
-- SavingDontTurnOffThePower is DelayFrames, not a prompt: no button does
-- anything until the sequence runs out (engine/menus/save.asm:55).
if self.savePhase == "saving" then
self.saveTimer = self.saveTimer + 1
if self.saveTimer >= SaveMenu.SAVING_FRAMES then
self:writeChangeBox()
self.savePhase = "done"
self.saveTimer = 0
end
return
end
if self.savePhase == "done" then
self.saveTimer = self.saveTimer + 1
if self.saveTimer >= SaveMenu.SAVED_FRAMES then
self.savePhase, self.changeBox = nil, nil
self.picking = false
end
return
end
local input = self.game and self.game.input
if not input then return end
if input:wasPressed("up") or input:wasPressed("down") then
self.saveChoice = self.saveChoice == 1 and 2 or 1
elseif input:wasPressed("a") then
self:acceptChangeBox()
elseif input:wasPressed("b") then
-- B out of a yes/no is NO (InterpretTwoOptionMenu returns carry).
self:refuseChangeBox()
end
end
function PcMenu:choose()
local entry = self.entries[self.index]
if not entry then return end
@@ -231,6 +332,11 @@ function PcMenu:update(_dt)
return
end
if self.savePhase then
self:updateChangeBox()
return
end
if self.picking then
local total = Boxes.NUM_BOXES
if input:wasPressed("up") then
@@ -238,8 +344,11 @@ function PcMenu:update(_dt)
elseif input:wasPressed("down") then
self.pickIndex = self.pickIndex < total and self.pickIndex + 1 or 1
elseif input:wasPressed("a") then
Boxes.setCurrent(self.save, self.pickIndex)
self.picking = false
if self.pickIndex == (self.save.currentBox or 1) then
self.picking = false
else
self:beginChangeBox(self.pickIndex)
end
elseif input:wasPressed("b") then
self.picking = false
end
@@ -287,6 +396,22 @@ function PcMenu:drawPanel()
("%d/%d"):format(Boxes.count(self.save, i), Boxes.MONS_PER_BOX),
18, ty)
end
if self.savePhase then
-- ChangeBoxSaveGame's MenuTextbox, then YesNoBox over the box list.
Chrome.box(0, 12, 20, 6)
local lines = self:savePrompt()
Chrome.print(lines[1] or "", 1, 14)
Chrome.print(lines[2] or "", 1, 16)
if self.savePhase == "confirm" or self.savePhase == "overwrite" then
Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H)
Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1)
Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3)
Chrome.cursor(YESNO_X + 1,
YESNO_Y + (self.saveChoice == 1 and 1 or 3))
end
love.graphics.setColor(1, 1, 1, 1)
return
end
Chrome.box(0, 14, 20, 4)
Chrome.print("Which BOX?", 1, 16)
love.graphics.setColor(1, 1, 1, 1)

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