mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
@@ -53,7 +53,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
|
||||
|
||||
### Watch the latest update video
|
||||
|
||||
[](https://youtu.be/yi7LkWQPKKM)
|
||||
[](https://youtu.be/0hgtLDNGpdo)
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
or download a disassembly. A canonical US Poke Red, Blue, Yellow, Gold,
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
-- Yellow-only Advanced (redpp) deltas. Merged on top of data/palettes_gbc.lua
|
||||
-- world tables when GameVersion.isYellow(); Red/Blue never load this file's
|
||||
-- world path. Named SuperPalettes stay washed on Yellow -- PaletteFX.pal
|
||||
-- prefers CGBBase under Advanced instead (title MEWMON/LOGO, YELLOWMON).
|
||||
-- See #1639.
|
||||
|
||||
return {
|
||||
world = {
|
||||
-- Summer Beach House: Yellow-only tileset. Tile→group map mirrors HOUSE
|
||||
-- (closest Advanced indoor profile); colors lean sand / wood / water.
|
||||
tileGroups = {
|
||||
BEACH_HOUSE = {
|
||||
[0] = 5,
|
||||
[1] = 0,
|
||||
[2] = 0,
|
||||
[3] = 0,
|
||||
[4] = 1,
|
||||
[5] = 0,
|
||||
[6] = 3,
|
||||
[7] = 3,
|
||||
[8] = 2,
|
||||
[9] = 2,
|
||||
[10] = 2,
|
||||
[11] = 2,
|
||||
[12] = 2,
|
||||
[13] = 2,
|
||||
[14] = 5,
|
||||
[15] = 5,
|
||||
[16] = 0,
|
||||
[17] = 0,
|
||||
[18] = 0,
|
||||
[19] = 0,
|
||||
[20] = 1,
|
||||
[21] = 0,
|
||||
[22] = 3,
|
||||
[23] = 3,
|
||||
[24] = 5,
|
||||
[25] = 5,
|
||||
[26] = 5,
|
||||
[27] = 5,
|
||||
[28] = 0,
|
||||
[29] = 0,
|
||||
[30] = 5,
|
||||
[31] = 5,
|
||||
[32] = 0,
|
||||
[33] = 0,
|
||||
[34] = 3,
|
||||
[35] = 5,
|
||||
[36] = 5,
|
||||
[37] = 5,
|
||||
[38] = 5,
|
||||
[39] = 5,
|
||||
[40] = 5,
|
||||
[41] = 5,
|
||||
[42] = 2,
|
||||
[43] = 2,
|
||||
[44] = 5,
|
||||
[45] = 5,
|
||||
[46] = 5,
|
||||
[47] = 5,
|
||||
[48] = 5,
|
||||
[49] = 5,
|
||||
[50] = 5,
|
||||
[51] = 5,
|
||||
[52] = 5,
|
||||
[53] = 5,
|
||||
[54] = 5,
|
||||
[55] = 5,
|
||||
[56] = 5,
|
||||
[57] = 5,
|
||||
[58] = 5,
|
||||
[59] = 5,
|
||||
[60] = 5,
|
||||
[61] = 5,
|
||||
[62] = 5,
|
||||
[63] = 5,
|
||||
[64] = 0,
|
||||
[65] = 0,
|
||||
[66] = 0,
|
||||
[67] = 0,
|
||||
[68] = 0,
|
||||
[69] = 0,
|
||||
[70] = 5,
|
||||
[71] = 5,
|
||||
[72] = 5,
|
||||
[73] = 5,
|
||||
[74] = 5,
|
||||
[75] = 5,
|
||||
[76] = 5,
|
||||
[77] = 5,
|
||||
[78] = 5,
|
||||
[79] = 5,
|
||||
[80] = 5,
|
||||
[81] = 5,
|
||||
[82] = 5,
|
||||
[83] = 5,
|
||||
[84] = 5,
|
||||
[85] = 5,
|
||||
[86] = 5,
|
||||
[87] = 5,
|
||||
[88] = 5,
|
||||
[89] = 5,
|
||||
[90] = 5,
|
||||
[91] = 5,
|
||||
[92] = 5,
|
||||
[93] = 5,
|
||||
[94] = 5,
|
||||
[95] = 5,
|
||||
},
|
||||
},
|
||||
groupColors = {
|
||||
BEACH_HOUSE = {
|
||||
-- sand / wood floor
|
||||
{
|
||||
{ 255, 239, 198 },
|
||||
{ 206, 173, 115 },
|
||||
{ 156, 123, 74 },
|
||||
{ 58, 58, 58 },
|
||||
},
|
||||
-- signs / accents (warm coral)
|
||||
{
|
||||
{ 255, 239, 198 },
|
||||
{ 255, 156, 148 },
|
||||
{ 230, 82, 66 },
|
||||
{ 58, 58, 58 },
|
||||
},
|
||||
-- foliage
|
||||
{
|
||||
{ 255, 239, 198 },
|
||||
{ 123, 189, 82 },
|
||||
{ 66, 123, 41 },
|
||||
{ 58, 58, 58 },
|
||||
},
|
||||
-- water / windows
|
||||
{
|
||||
{ 255, 239, 198 },
|
||||
{ 99, 189, 230 },
|
||||
{ 41, 115, 189 },
|
||||
{ 58, 58, 58 },
|
||||
},
|
||||
-- yellow props
|
||||
{
|
||||
{ 255, 239, 198 },
|
||||
{ 255, 255, 82 },
|
||||
{ 230, 148, 25 },
|
||||
{ 58, 58, 58 },
|
||||
},
|
||||
-- wood walls / furniture
|
||||
{
|
||||
{ 255, 239, 198 },
|
||||
{ 189, 140, 74 },
|
||||
{ 140, 99, 41 },
|
||||
{ 58, 58, 58 },
|
||||
},
|
||||
-- sky / cool trim
|
||||
{
|
||||
{ 255, 239, 198 },
|
||||
{ 148, 189, 255 },
|
||||
{ 99, 148, 230 },
|
||||
{ 58, 58, 58 },
|
||||
},
|
||||
-- text
|
||||
{
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 255 },
|
||||
{ 255, 255, 255 },
|
||||
{ 0, 0, 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
-- Yellow spriteOrder is 82 entries; Red Advanced assignment is 72.
|
||||
-- Indices 60-69 are Yellow inserts (Pikachu, Jenny, mons, Jessie/James);
|
||||
-- Red's ball/fossil/snorlax/... shift to 70-81.
|
||||
spriteAssignment = {
|
||||
[0] = 0,
|
||||
[1] = 1,
|
||||
[2] = 3,
|
||||
[3] = "random",
|
||||
[4] = 0,
|
||||
[5] = "random",
|
||||
[6] = "random",
|
||||
[7] = "random",
|
||||
[8] = 0,
|
||||
[9] = "random",
|
||||
[10] = "random",
|
||||
[11] = "random",
|
||||
[12] = "random",
|
||||
[13] = "random",
|
||||
[14] = "random",
|
||||
[15] = 1,
|
||||
[16] = 1,
|
||||
[17] = "random",
|
||||
[18] = "random",
|
||||
[19] = "random",
|
||||
[20] = "random",
|
||||
[21] = 2,
|
||||
[22] = 1,
|
||||
[23] = 3,
|
||||
[24] = "random",
|
||||
[25] = "random",
|
||||
[26] = "random",
|
||||
[27] = "random",
|
||||
[28] = "random",
|
||||
[29] = 0,
|
||||
[30] = 3,
|
||||
[31] = 3,
|
||||
[32] = "random",
|
||||
[33] = "random",
|
||||
[34] = "random",
|
||||
[35] = "random",
|
||||
[36] = "random",
|
||||
[37] = "random",
|
||||
[38] = "random",
|
||||
[39] = "random",
|
||||
[40] = 0,
|
||||
[41] = 2,
|
||||
[42] = "random",
|
||||
[43] = "random",
|
||||
[44] = "random",
|
||||
[45] = "random",
|
||||
[46] = "random",
|
||||
[47] = "random",
|
||||
[48] = "random",
|
||||
[49] = "random",
|
||||
[50] = "random",
|
||||
[51] = "random",
|
||||
[52] = "random",
|
||||
[53] = "random",
|
||||
[54] = "random",
|
||||
[55] = 0,
|
||||
[56] = 1,
|
||||
[57] = 3,
|
||||
[58] = 0,
|
||||
[59] = 0,
|
||||
[60] = 4, -- SPRITE_PIKACHU
|
||||
[61] = 1, -- SPRITE_OFFICER_JENNY
|
||||
[62] = 3, -- SPRITE_SANDSHREW
|
||||
[63] = 2, -- SPRITE_ODDISH
|
||||
[64] = 2, -- SPRITE_BULBASAUR
|
||||
[65] = "random", -- SPRITE_JIGGLYPUFF
|
||||
[66] = "random", -- SPRITE_CLEFAIRY
|
||||
[67] = "random", -- SPRITE_CHANSEY
|
||||
[68] = "random", -- SPRITE_JESSIE
|
||||
[69] = "random", -- SPRITE_JAMES
|
||||
[70] = 0, -- SPRITE_POKE_BALL
|
||||
[71] = 0, -- SPRITE_FOSSIL
|
||||
[72] = 3, -- SPRITE_BOULDER
|
||||
[73] = 3, -- SPRITE_PAPER
|
||||
[74] = 0, -- SPRITE_POKEDEX
|
||||
[75] = 3, -- SPRITE_CLIPBOARD
|
||||
[76] = 0, -- SPRITE_SNORLAX
|
||||
[77] = 3, -- SPRITE_UNUSED_OLD_AMBER
|
||||
[78] = 3, -- SPRITE_OLD_AMBER
|
||||
[79] = "random",
|
||||
[80] = "random",
|
||||
[81] = 3, -- SPRITE_GAMBLER_ASLEEP
|
||||
},
|
||||
|
||||
-- Group 4 is Pikachu yellow on Yellow Advanced (Red keeps its own [4]).
|
||||
spritePalettes = {
|
||||
[4] = {
|
||||
{ 222, 255, 222 },
|
||||
{ 255, 255, 0 },
|
||||
{ 230, 115, 0 },
|
||||
{ 0, 0, 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -505,6 +505,42 @@ Menu choices and moves use the same engine methods as the native controls;
|
||||
their mutable logic. Tutorial, link, forced, stale, and covered battle states
|
||||
refuse core intents. Use `mod.input` for ordinary text advance.
|
||||
|
||||
## Battle rule hooks
|
||||
|
||||
Two decisions the OPTION screen and the cart make for the player, which a game
|
||||
mode can make instead (RFC 0015). Neither writes the player's saved
|
||||
preference, so a mode can hold a rule for as long as it is active and hand the
|
||||
player's own setting back untouched.
|
||||
|
||||
`battle.style` wraps the SHIFT/SET read at the moment the foe's Pokémon faints
|
||||
and the engine would offer a free switch:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("battle.style", function(next, battle)
|
||||
if myMode.active then return "set" end -- no "will you change POKéMON?"
|
||||
return next(battle) -- the OPTION row, as today
|
||||
end)
|
||||
```
|
||||
|
||||
Return `"set"` or `"shift"`; anything else reads as the vanilla answer.
|
||||
|
||||
`catch.nickname` wraps the `AskName` prompt after a capture (party or box),
|
||||
the same question `pokemon.before_give`'s `gift.nickname` already answers for
|
||||
script gifts:
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("catch.nickname", function(next, mon, ctx)
|
||||
-- ctx = { battle = <BattleState>, name = <display name>, game = <Game> }
|
||||
if myMode.active then return false end -- keep the species name
|
||||
if myNames then return myNames[mon.species] end -- a string names it, no prompt
|
||||
return next(mon, ctx) -- true: ask, as today
|
||||
end)
|
||||
```
|
||||
|
||||
`false` keeps the species name with no prompt. A string is the nickname with
|
||||
no prompt, clipped to the naming grid's ten characters (an empty string names
|
||||
nothing). Anything else queues the prompt.
|
||||
|
||||
## Party-full custody at a catch
|
||||
|
||||
When a capture lands on a full party, the cart deposits the mon in storage
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# RFC 0015: Battle rule hooks — `battle.style` and `catch.nickname`
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Motivation
|
||||
|
||||
Two decisions in a Red/Blue/Yellow battle are made for the player by the
|
||||
OPTION screen and by the cart, and a game mode has no way to make them
|
||||
instead:
|
||||
|
||||
**Whether a faint offers a free switch.** `EnemySendOutFirstMon` reads the
|
||||
battle-style bit: SHIFT asks "will you change POKéMON?" when the foe's Pokémon
|
||||
faints, SET does not. The engine reads `save.options.battleStyle` inline at
|
||||
that moment. A mode that wants SET — a tournament, a Nuzlocke, a battle royale
|
||||
where party-as-health is the whole design — can only get it by writing the
|
||||
player's saved preference, which leaks into their real playthrough the first
|
||||
time anything calls `Game:writeOptions` (the speed hotkey does, on every
|
||||
press).
|
||||
|
||||
**Whether a catch asks for a nickname.** `AddPartyMon` and `SendNewMonToBox`
|
||||
both run `AskName` after a capture. A mode with a disposable team, a
|
||||
randomizer that names what it hands out, a speedrun practice mod — all want to
|
||||
answer that prompt themselves, and the only way today is to drive the yes/no
|
||||
box from outside. The script-gift path already has this seam: a mod that sets
|
||||
`gift.nickname` on `pokemon.before_give` skips `AskName`. The catch path does
|
||||
not.
|
||||
|
||||
The immediate consumer is a battle-royale mode; neither hook is specific to it.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
This extends the **additive, guarded seam convention** Route B in
|
||||
`CONTRIBUTING-mods.md` documents, and is gated by the parity guarantee
|
||||
`tests/engine/gate_meta_coverage.lua` enforces. `catch.nickname` mirrors a
|
||||
contract that already exists for gifts (`pokemon.before_give`'s
|
||||
`gift.nickname`), so the two ways a Pokémon joins the party answer the same
|
||||
question the same way.
|
||||
|
||||
There is no in-repo D-number registry to amend.
|
||||
|
||||
## Exact API delta
|
||||
|
||||
### New hook: `battle.style`
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("battle.style", function(next, battle)
|
||||
if myMode.active then return "set" end
|
||||
return next(battle) -- the OPTION row, as today
|
||||
end)
|
||||
```
|
||||
|
||||
Call site: `BattleState:battleStyle()`, called from the enemy send-out path at
|
||||
the moment the SHIFT prompt would be offered. The vanilla link reads
|
||||
`save.options.battleStyle` (lower-cased, default `"shift"`) exactly as the
|
||||
inline read did. A return of `"set"` or `"shift"` is used; anything else reads
|
||||
as the vanilla answer, so a hook that returns nothing by mistake cannot change
|
||||
the rule. The player's saved preference is never written.
|
||||
|
||||
### New hook: `catch.nickname`
|
||||
|
||||
```lua
|
||||
mod.hooks:wrap("catch.nickname", function(next, mon, ctx)
|
||||
-- ctx = { battle = <BattleState>, name = <display name>, game = <Game> }
|
||||
if myMode.active then return false end -- keep the species name
|
||||
if randomizer then return pickName(mon) end -- a string names it
|
||||
return next(mon, ctx) -- true: ask, as today
|
||||
end)
|
||||
```
|
||||
|
||||
Call site: `BattleState:offerNickname(mon, displayName)`, called from the
|
||||
capture path where `AskName` ran, before the prompt is queued. The vanilla
|
||||
link returns `true`. `false` skips the prompt and keeps the species name; a
|
||||
string skips the prompt and is the nickname, clipped to the naming grid's ten
|
||||
characters (an empty string names nothing, like the grid's own empty entry);
|
||||
anything else queues the prompt as today. The method returns whether a prompt
|
||||
was queued.
|
||||
|
||||
### No other surface changes
|
||||
|
||||
Both call sites are guarded by `Runtime.wantsHook`, and both vanilla links are
|
||||
file-local functions, so a build with nothing wrapped runs the branch exactly
|
||||
as before and allocates nothing it did not allocate before. `askNicknameUI` is
|
||||
unchanged and still public.
|
||||
|
||||
## Migration
|
||||
|
||||
Nothing changes for existing mods. A mod that was writing
|
||||
`save.options.battleStyle` to force a style should wrap `battle.style` instead
|
||||
and stop writing the option.
|
||||
|
||||
## Verification
|
||||
|
||||
- `tests/modkit/cases/battle_rule_hooks.lua` — through the public mod API: the
|
||||
no-mod answers match the OPTION row and the cart's AskName; a wrapped mod
|
||||
forces either style without the row being written; `false`, a string, a
|
||||
too-long string, an empty string, and a fall-through each do what this RFC
|
||||
says at the catch prompt.
|
||||
- `tests/engine/gate_hooks.lua` — both names are in the live catalog and pass
|
||||
the no-mod parity gate (vanilla called exactly once, result unchanged,
|
||||
nothing allocated).
|
||||
- `tests/engine/gate_meta_coverage.lua` — both names are covered by the unit
|
||||
corpus.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
Additive. No existing hook, event, registry or manifest field changes shape.
|
||||
The two new methods on `BattleState` are called only from the paths that
|
||||
previously inlined their logic; the results with no subscriber are identical.
|
||||
@@ -384,12 +384,14 @@ function bootGame(version, cartId)
|
||||
Game = require("src.core.Game2").new()
|
||||
Game:load()
|
||||
else
|
||||
-- Gen1 Game is a module singleton. In-process EXIT GAME resets it in
|
||||
-- place; if a prior teardown left load missing, rebuild from source.
|
||||
-- Gen1 Game is a module singleton. Always re-require after in-process
|
||||
-- EXIT GAME so a prior session cannot leave a table whose rawget(load)
|
||||
-- is nil (release Android: bootGame then dies with load-a-nil-value).
|
||||
-- rawget: type(mod.load) can lie via __index and skip a rebuild.
|
||||
package.loaded["src.core.Game"] = nil
|
||||
local gameMod = require("src.core.Game")
|
||||
if type(gameMod.load) ~= "function" then
|
||||
package.loaded["src.core.Game"] = nil
|
||||
gameMod = require("src.core.Game")
|
||||
if type(rawget(gameMod, "load")) ~= "function" then
|
||||
error("src.core.Game missing load after reload")
|
||||
end
|
||||
Game = gameMod
|
||||
Game:load()
|
||||
@@ -1125,15 +1127,23 @@ function love.quit()
|
||||
-- docs/modding.md's core.quit_to_launcher entry) may veto returning to
|
||||
-- this Lua launcher via that hook. Vanilla behavior (used when no mod
|
||||
-- claims the hook) is exactly the condition below.
|
||||
local isAndroid = (love.system and love.system.getOS and love.system.getOS() == "Android")
|
||||
--
|
||||
-- Android and iOS both tear down LOVE in-process rather than
|
||||
-- love.event.quit("restart"): Android's vendored love.cpp PHYSFS-crashes
|
||||
-- on a second init (#575), and iOS's love.cpp forces DONE_RESTART for
|
||||
-- every quit while warning that leftover threads make that unreliable.
|
||||
-- SessionLifecycle workers (ChipAudio / Fetch / Check) make that warning
|
||||
-- real -- endProcess joins them, then the native restart still blows up.
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
local inProcessReturn = (osName == "Android" or osName == "iOS")
|
||||
local wouldReturnToLauncher = PlatformHooks.quitToLauncher(function()
|
||||
return Game and not Importer and not quitToLauncher and not scripted
|
||||
and (isAndroid or not launchedIntoGame)
|
||||
and (inProcessReturn or not launchedIntoGame)
|
||||
end)
|
||||
if wouldReturnToLauncher then
|
||||
if isAndroid then
|
||||
if inProcessReturn then
|
||||
returnToLauncher()
|
||||
return true -- abort this quit; the restart lands back in the launcher
|
||||
return true -- abort this quit; stay in the same LOVE run
|
||||
end
|
||||
quitToLauncher = true
|
||||
-- Tell the fresh boot to ignore any boot-straight-into-a-game option this
|
||||
|
||||
@@ -182,7 +182,7 @@ void System::vibrate(double seconds) const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::pickFile(const char *kind) const
|
||||
bool System::pickFile(const char *kind, const char *destination) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
const char *dest = "picked_rom.gb";
|
||||
@@ -193,7 +193,8 @@ bool System::pickFile(const char *kind) const
|
||||
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
|
||||
dest = "picked_save.sav";
|
||||
else if (strcmp(kind, "required_import") == 0)
|
||||
dest = "picked_required_import.bin";
|
||||
dest = (destination != nullptr && destination[0] != '\0')
|
||||
? destination : "picked_required_import.bin";
|
||||
else if (strcmp(kind, "rom") == 0)
|
||||
dest = "picked_rom.gb";
|
||||
// Unknown kinds used to fall through to the ROM destination. Refuse them
|
||||
|
||||
@@ -116,7 +116,7 @@ public:
|
||||
* "required_import" -> picked_required_import.bin.
|
||||
* @return Whether the picker was shown.
|
||||
**/
|
||||
virtual bool pickFile(const char *kind = nullptr) const;
|
||||
virtual bool pickFile(const char *kind = nullptr, const char *destination = nullptr) const;
|
||||
virtual const char *pickFileKinds() const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -101,7 +101,8 @@ int w_vibrate(lua_State *L)
|
||||
int w_pickFile(lua_State *L)
|
||||
{
|
||||
const char *kind = luaL_optstring(L, 1, nullptr);
|
||||
luax_pushboolean(L, instance()->pickFile(kind));
|
||||
const char *destination = luaL_optstring(L, 2, nullptr);
|
||||
luax_pushboolean(L, instance()->pickFile(kind, destination));
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.AlarmManager;
|
||||
@@ -111,6 +112,8 @@ public class GameActivity extends SDLActivity {
|
||||
// basename as its body, so RomImporter:focus can say so in the launcher
|
||||
// instead of leaving the player on "No ROM imported" (issue #442).
|
||||
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
|
||||
// Written after a direct required-import copy has been fully published.
|
||||
private static final String PICK_COMPLETE_FILENAME = "pick_complete.flag";
|
||||
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
|
||||
// consumed by the Pokéwalker mod, same contract as the iOS
|
||||
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
|
||||
@@ -576,6 +579,21 @@ public class GameActivity extends SDLActivity {
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav, or
|
||||
* picked_required_import.bin)
|
||||
*/
|
||||
private static boolean isDirectRequiredDestination(String relative) {
|
||||
if (relative == null || relative.length() == 0 || relative.startsWith("/")) return false;
|
||||
String normalized = relative.replace('\\', '/');
|
||||
if (!normalized.startsWith("mods/")) return false;
|
||||
int marker = normalized.indexOf("/baseroms/");
|
||||
if (marker <= "mods/".length() || marker + "/baseroms/".length() >= normalized.length()) {
|
||||
return false;
|
||||
}
|
||||
return !normalized.contains("//")
|
||||
&& !normalized.equals("..")
|
||||
&& !normalized.startsWith("../")
|
||||
&& !normalized.contains("/../")
|
||||
&& !normalized.endsWith("/..");
|
||||
}
|
||||
|
||||
/** Legacy single-argument entry; resolves the save dir itself. */
|
||||
@Keep
|
||||
public static boolean showFilePicker(String destFilename) {
|
||||
@@ -593,14 +611,30 @@ public class GameActivity extends SDLActivity {
|
||||
// onActivityResult copies the pick there, not into a recomputed
|
||||
// (possibly different-volume) root (#604, #839).
|
||||
self.pendingPickSaveDir = (saveDir != null) ? saveDir : "";
|
||||
// Reject path separators so a hostile JNI caller cannot escape the
|
||||
// save identity directory.
|
||||
if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) {
|
||||
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
|
||||
// Basename destinations keep the historical ROM/mod/save staging path.
|
||||
// A nested destination is accepted only for an engine-generated mod
|
||||
// baseroms path, then canonicalized beneath LOVE's mounted save root.
|
||||
String normalizedDest = destFilename.replace('\\', '/');
|
||||
boolean nested = normalizedDest.indexOf('/') >= 0;
|
||||
if (nested && !isDirectRequiredDestination(normalizedDest)) {
|
||||
Log.d("GameActivity", "refusing non-baseroms picker dest: " + destFilename);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
File rootCanonical = self.saveIdentityDir().getCanonicalFile();
|
||||
File destCanonical = new File(rootCanonical, normalizedDest).getCanonicalFile();
|
||||
String rootPrefix = rootCanonical.getPath() + File.separator;
|
||||
if (destCanonical.equals(rootCanonical)
|
||||
|| !destCanonical.getPath().startsWith(rootPrefix)) {
|
||||
Log.d("GameActivity", "refusing unsafe picker dest: " + destFilename);
|
||||
return false;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not validate picker dest: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
self.pendingPickFilename = destFilename;
|
||||
self.pendingPickFilename = normalizedDest;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 21) {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
@@ -1277,13 +1311,7 @@ public class GameActivity extends SDLActivity {
|
||||
|
||||
/** Drops a small flag file in the save identity for Lua to consume on focus. */
|
||||
private void writeSaveDirFlag(String name, String body) {
|
||||
try {
|
||||
FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false);
|
||||
fos.write(body.getBytes());
|
||||
fos.close();
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
|
||||
}
|
||||
writeFlagFile(saveIdentityDir(), name, body);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1506,9 +1534,26 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "could not create " + destDir);
|
||||
return;
|
||||
}
|
||||
String destName = pendingPickFilename != null
|
||||
final String destName = pendingPickFilename != null
|
||||
? pendingPickFilename : PICKED_ROM_FILENAME;
|
||||
File destFile = new File(destDir, destName);
|
||||
final boolean directRequired = isDirectRequiredDestination(destName);
|
||||
final File destFile;
|
||||
try {
|
||||
File rootCanonical = destDir.getCanonicalFile();
|
||||
destFile = new File(rootCanonical, destName).getCanonicalFile();
|
||||
String rootPrefix = rootCanonical.getPath() + File.separator;
|
||||
if (destFile.equals(rootCanonical)
|
||||
|| !destFile.getPath().startsWith(rootPrefix)
|
||||
|| (destName.indexOf('/') >= 0 && !directRequired)) {
|
||||
Log.d("GameActivity", "refusing unsafe result dest: " + destName);
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
return;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "could not validate result dest: " + e.getMessage());
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
return;
|
||||
}
|
||||
|
||||
// ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but
|
||||
// some OEM shells (ColorOS) offer third-party file managers in a
|
||||
@@ -1534,15 +1579,110 @@ public class GameActivity extends SDLActivity {
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
return;
|
||||
}
|
||||
if (!copyAssetFile(source, destFile.getPath())) {
|
||||
final InputStream pickedSource = source;
|
||||
final File pickedRoot = destDir;
|
||||
if (directRequired) {
|
||||
// Optical-disc-sized imports must not block Android's UI thread and
|
||||
// must not create a second picked_required_import.bin copy.
|
||||
new Thread(new Runnable() {
|
||||
@Override public void run() {
|
||||
PickCopyResult result = copyRequiredImport(pickedSource, destFile);
|
||||
if (!result.ok) {
|
||||
writeFlagFile(pickedRoot, PICK_ERROR_FILENAME, destName);
|
||||
return;
|
||||
}
|
||||
String marker = "v1\n" + destName + "\n" + result.md5 + "\n"
|
||||
+ Long.toString(result.bytes) + "\n";
|
||||
writeFlagFile(pickedRoot, PICK_COMPLETE_FILENAME, marker);
|
||||
}
|
||||
}, "gen1recomp-required-import").start();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!copyAssetFile(pickedSource, destFile.getPath())) {
|
||||
Log.d("GameActivity", "could not copy picked file to " + destFile);
|
||||
// A truncated pick would only fail verification later, so drop it
|
||||
// and report instead.
|
||||
destFile.delete();
|
||||
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PickCopyResult {
|
||||
boolean ok = false;
|
||||
long bytes = 0;
|
||||
String md5 = "";
|
||||
}
|
||||
|
||||
private static String hex(byte[] bytes) {
|
||||
StringBuilder out = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) out.append(String.format(Locale.US, "%02x", b & 0xff));
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private static void writeFlagFile(File dir, String name, String body) {
|
||||
try {
|
||||
if (!dir.isDirectory() && !dir.mkdirs()) return;
|
||||
File tmp = new File(dir, name + ".tmp");
|
||||
File dest = new File(dir, name);
|
||||
FileOutputStream out = new FileOutputStream(tmp, false);
|
||||
out.write(body.getBytes("UTF-8"));
|
||||
out.getFD().sync();
|
||||
out.close();
|
||||
if (dest.exists() && !dest.delete()) { tmp.delete(); return; }
|
||||
if (!tmp.renameTo(dest)) tmp.delete();
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not write " + name + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static PickCopyResult copyRequiredImport(InputStream source, File destination) {
|
||||
PickCopyResult result = new PickCopyResult();
|
||||
File parent = destination.getParentFile();
|
||||
File partial = new File(destination.getPath() + ".part");
|
||||
BufferedInputStream in = null;
|
||||
BufferedOutputStream out = null;
|
||||
FileOutputStream rawOut = null;
|
||||
try {
|
||||
if (parent != null && !parent.isDirectory() && !parent.mkdirs()) return result;
|
||||
if (partial.exists() && !partial.delete()) return result;
|
||||
MessageDigest md5 = MessageDigest.getInstance("MD5");
|
||||
in = new BufferedInputStream(source, 1024 * 1024);
|
||||
rawOut = new FileOutputStream(partial, false);
|
||||
out = new BufferedOutputStream(rawOut, 1024 * 1024);
|
||||
byte[] buf = new byte[1024 * 1024];
|
||||
int n;
|
||||
long total = 0;
|
||||
while ((n = in.read(buf)) != -1) {
|
||||
if (n == 0) continue;
|
||||
out.write(buf, 0, n);
|
||||
md5.update(buf, 0, n);
|
||||
total += n;
|
||||
}
|
||||
out.flush();
|
||||
rawOut.getFD().sync();
|
||||
out.close(); out = null; rawOut = null;
|
||||
in.close(); in = null;
|
||||
|
||||
// Publish only a complete same-directory file. Validation still
|
||||
// happens in Lua against the manifest before a receipt is written.
|
||||
if (destination.exists() && !destination.delete()) return result;
|
||||
if (!partial.renameTo(destination)) return result;
|
||||
result.ok = true;
|
||||
result.bytes = total;
|
||||
result.md5 = hex(md5.digest());
|
||||
Log.d("GameActivity", "direct required import copied " + total
|
||||
+ " bytes to " + destination);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "direct required import failed: " + e.getMessage());
|
||||
return result;
|
||||
} finally {
|
||||
try { if (in != null) in.close(); } catch (IOException ignored) {}
|
||||
try { if (out != null) out.close(); } catch (IOException ignored) {}
|
||||
try { if (rawOut != null) rawOut.close(); } catch (IOException ignored) {}
|
||||
if (!result.ok && partial.exists()) partial.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a given file from the assets folder to the destination.
|
||||
*
|
||||
@@ -1565,13 +1705,12 @@ public class GameActivity extends SDLActivity {
|
||||
assert (source != null && destination != null);
|
||||
|
||||
try {
|
||||
byte[] buf = new byte[1024];
|
||||
chunk_read = source.read(buf);
|
||||
do {
|
||||
byte[] buf = new byte[1024 * 1024];
|
||||
while ((chunk_read = source.read(buf)) != -1) {
|
||||
if (chunk_read == 0) continue;
|
||||
destination.write(buf, 0, chunk_read);
|
||||
bytes_written += chunk_read;
|
||||
chunk_read = source.read(buf);
|
||||
} while (chunk_read != -1);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.d("GameActivity", "Copying failed:" + e.getMessage());
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
import CryptoKit
|
||||
|
||||
@objc(GRPickerBridge)
|
||||
public final class GRPickerBridge: NSObject {
|
||||
@@ -190,9 +191,17 @@ public final class GRPickerBridge: NSObject {
|
||||
@objc(presentPickerWithKind:saveDir:)
|
||||
public static func presentPicker(kind: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?) -> Bool {
|
||||
return presentPicker(kind: kind, saveDir: saveDir, destination: nil)
|
||||
}
|
||||
|
||||
@objc(presentPickerWithKind:saveDir:destination:)
|
||||
public static func presentPicker(kind: UnsafePointer<CChar>?,
|
||||
saveDir: UnsafePointer<CChar>?,
|
||||
destination: UnsafePointer<CChar>?) -> Bool {
|
||||
let kindStr = kind.map { String(cString: $0) } ?? "rom"
|
||||
guard let dir = resolvedSaveDir(saveDir) else { return false }
|
||||
|
||||
let requestedDestination = destination.map { String(cString: $0) }
|
||||
let destName: String
|
||||
var types: [UTType] = []
|
||||
switch kindStr {
|
||||
@@ -202,7 +211,13 @@ public final class GRPickerBridge: NSObject {
|
||||
case "sav":
|
||||
destName = "picked_save.sav"
|
||||
case "required_import":
|
||||
destName = "picked_required_import.bin"
|
||||
if let requestedDestination,
|
||||
isDirectRequiredDestination(requestedDestination),
|
||||
safeDestination(in: dir, relative: requestedDestination) != nil {
|
||||
destName = requestedDestination
|
||||
} else {
|
||||
destName = "picked_required_import.bin"
|
||||
}
|
||||
// A Nintendo 64 cartridge, for mods that build assets out of one --
|
||||
// the voxel mod's Pokemon Stadium battle models are the caller this
|
||||
// was added for. Its own filename on purpose: an N64 ROM landing on
|
||||
@@ -234,12 +249,18 @@ public final class GRPickerBridge: NSObject {
|
||||
types.append(.data)
|
||||
if !types.contains(.item) { types.append(.item) }
|
||||
|
||||
let directRequired = kindStr == "required_import"
|
||||
&& destName != "picked_required_import.bin"
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types,
|
||||
asCopy: true)
|
||||
asCopy: !directRequired)
|
||||
picker.allowsMultipleSelection = false
|
||||
let delegate = PickerDelegate { urls in
|
||||
guard let src = urls.first else { return }
|
||||
copyItem(at: src, into: dir, named: destName)
|
||||
if directRequired {
|
||||
copyRequiredItemAsync(at: src, into: dir, relative: destName)
|
||||
} else {
|
||||
copyItem(at: src, into: dir, named: destName)
|
||||
}
|
||||
}
|
||||
return present(picker, with: delegate)
|
||||
}
|
||||
@@ -410,6 +431,82 @@ public final class GRPickerBridge: NSObject {
|
||||
try? fm.moveItem(at: item, to: target)
|
||||
}
|
||||
|
||||
private static func isDirectRequiredDestination(_ relative: String) -> Bool {
|
||||
let normalized = relative.replacingOccurrences(of: "\\", with: "/")
|
||||
guard normalized.hasPrefix("mods/"),
|
||||
let range = normalized.range(of: "/baseroms/"),
|
||||
range.lowerBound > normalized.index(normalized.startIndex, offsetBy: 5),
|
||||
range.upperBound < normalized.endIndex else { return false }
|
||||
return !normalized.hasPrefix("/")
|
||||
&& !normalized.contains("//")
|
||||
&& !normalized.contains("/../")
|
||||
&& !normalized.hasSuffix("/..")
|
||||
}
|
||||
|
||||
private static func safeDestination(in root: URL, relative: String) -> URL? {
|
||||
guard isDirectRequiredDestination(relative) else { return nil }
|
||||
let rootURL = root.standardizedFileURL
|
||||
let candidate = rootURL.appendingPathComponent(relative).standardizedFileURL
|
||||
let rootPath = rootURL.path.hasSuffix("/") ? rootURL.path : rootURL.path + "/"
|
||||
guard candidate.path.hasPrefix(rootPath) else { return nil }
|
||||
return candidate
|
||||
}
|
||||
|
||||
private static func writeFlag(in dir: URL, name: String, body: String) {
|
||||
try? body.data(using: .utf8)?.write(to: dir.appendingPathComponent(name),
|
||||
options: .atomic)
|
||||
}
|
||||
|
||||
private static func copyRequiredItemAsync(at src: URL, into dir: URL,
|
||||
relative: String) {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let scoped = src.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
||||
guard let dest = safeDestination(in: dir, relative: relative) else {
|
||||
writeFlag(in: dir, name: "pick_error.flag", body: relative)
|
||||
return
|
||||
}
|
||||
let fm = FileManager.default
|
||||
ensureDirectory(dest.deletingLastPathComponent())
|
||||
let partial = URL(fileURLWithPath: dest.path + ".part")
|
||||
try? fm.removeItem(at: partial)
|
||||
guard fm.createFile(atPath: partial.path, contents: nil) else {
|
||||
writeFlag(in: dir, name: "pick_error.flag", body: relative)
|
||||
return
|
||||
}
|
||||
|
||||
var hasher = Insecure.MD5()
|
||||
var total: UInt64 = 0
|
||||
do {
|
||||
let input = try FileHandle(forReadingFrom: src)
|
||||
let output = try FileHandle(forWritingTo: partial)
|
||||
defer {
|
||||
try? input.close()
|
||||
try? output.close()
|
||||
}
|
||||
while true {
|
||||
let data = input.readData(ofLength: 1024 * 1024)
|
||||
if data.isEmpty { break }
|
||||
hasher.update(data: data)
|
||||
output.write(data)
|
||||
total += UInt64(data.count)
|
||||
}
|
||||
output.synchronizeFile()
|
||||
try? fm.removeItem(at: dest)
|
||||
try fm.moveItem(at: partial, to: dest)
|
||||
let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
||||
let marker = "v1\n" + relative + "\n" + digest + "\n"
|
||||
+ String(total) + "\n"
|
||||
writeFlag(in: dir, name: "pick_complete.flag", body: marker)
|
||||
NSLog("GRPickerBridge: direct required import delivered %llu bytes", total)
|
||||
} catch {
|
||||
try? fm.removeItem(at: partial)
|
||||
NSLog("GRPickerBridge: direct required import failed: \(error)")
|
||||
writeFlag(in: dir, name: "pick_error.flag", body: relative)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func copyItem(at src: URL, into dir: URL, named name: String) {
|
||||
let scoped = src.startAccessingSecurityScopedResource()
|
||||
defer { if scoped { src.stopAccessingSecurityScopedResource() } }
|
||||
@@ -426,7 +523,7 @@ public final class GRPickerBridge: NSObject {
|
||||
let report = "Could not copy \(src.lastPathComponent): " +
|
||||
error.localizedDescription
|
||||
try? report.data(using: .utf8)?
|
||||
.write(to: dir.appendingPathComponent("pick_error.txt"))
|
||||
.write(to: dir.appendingPathComponent("pick_error.flag"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,20 @@ static int gr_callBridge(lua_State *L, const char *className,
|
||||
int w_pickFile(lua_State *L)
|
||||
{
|
||||
const char *kind = luaL_optstring(L, 1, "rom");
|
||||
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
|
||||
const char *destination = luaL_optstring(L, 2, nullptr);
|
||||
Class cls = objc_getClass("GRPickerBridge");
|
||||
if (cls == nullptr)
|
||||
{
|
||||
lua_pushboolean(L, 0);
|
||||
return 1;
|
||||
}
|
||||
typedef signed char (*GRPick)(Class, SEL, const char *, const char *,
|
||||
const char *);
|
||||
signed char ok = ((GRPick)objc_msgSend)(
|
||||
cls, sel_registerName("presentPickerWithKind:saveDir:destination:"),
|
||||
kind, gr_saveDirectory(), destination);
|
||||
lua_pushboolean(L, ok != 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// love.system.pickFileKinds() -> the comma-separated kinds supported by the
|
||||
|
||||
@@ -106,6 +106,13 @@ if [ -n "$TEST_APPLICATION_ID" ]; then
|
||||
APPLICATION_ID="$TEST_APPLICATION_ID"
|
||||
APP_NAME="$APP_NAME (test)"
|
||||
fi
|
||||
# Optional overrides for side-by-side test APKs (never used by CI shipping builds).
|
||||
if [ -n "${GEN1RECOMP_ANDROID_APPLICATION_ID:-}" ]; then
|
||||
APPLICATION_ID="$GEN1RECOMP_ANDROID_APPLICATION_ID"
|
||||
fi
|
||||
if [ -n "${GEN1RECOMP_ANDROID_APP_NAME:-}" ]; then
|
||||
APP_NAME="$GEN1RECOMP_ANDROID_APP_NAME"
|
||||
fi
|
||||
|
||||
# --------------------------------------------------------------- preconditions
|
||||
if [ ! -f "$ANDROID_DIR/settings.gradle" ] || [ ! -f "$ANDROID_DIR/gradlew" ]; then
|
||||
|
||||
@@ -114,6 +114,7 @@ fi
|
||||
|
||||
run_tier "T0 ROM builder version routing" python3 tests/build_rom_data_cli_test.py
|
||||
run_tier "T0 ROM manifest generator pin/overrides" python3 tests/rom_manifest_generator_test.py
|
||||
run_tier "T0 Yellow title OBP eye remap" python3 tests/title_pikachu_obp_test.py
|
||||
run_tier "T0 Crystal manifest + specials coverage" "$LUA" tests/crystal_import_test.lua
|
||||
run_tier "T0 switch CI workflow content gate" "$LUA" tests/switch_ci_workflows_test.lua
|
||||
run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.lua
|
||||
|
||||
@@ -4531,8 +4531,7 @@ function BattleState:enemyMonFainted()
|
||||
-- remaining HP); SET / single-mon / fainted active skip the prompt.
|
||||
local nextMon = self.enemyParty[self.enemyIndex]
|
||||
local nextName = nextMon.nickname or self.data.pokemon[nextMon.species].name
|
||||
local style = tostring((self.game.save.options or {}).battleStyle or "shift")
|
||||
:lower()
|
||||
local style = self:battleStyle()
|
||||
local partyCount = #self:playerPartyView()
|
||||
-- ReplaceFaintedEnemyMon (core.asm:892-896): DrawEnemyPokeballs puts the
|
||||
-- foe's party ball row -- and the HUD chrome PlaceEnemyHUDTiles lays
|
||||
@@ -5100,6 +5099,57 @@ function BattleState:ballMissMessage(shakes)
|
||||
return t._ItemUseBallText04 or self:romText("_ItemUseBallText04", "Shoot! It was so\nclose too!")
|
||||
end
|
||||
|
||||
-- ------- battle rules a mode may own
|
||||
--
|
||||
-- Two decisions the OPTION screen and the cart make for the player that a
|
||||
-- game mode may want to make instead: whether a faint offers a free switch,
|
||||
-- and whether a catch asks for a nickname. Each is a hook around the vanilla
|
||||
-- answer, so a mode can force it without touching the player's saved
|
||||
-- preference and without the player being able to change it mid-match.
|
||||
--
|
||||
-- The vanilla links are file-locals so an empty chain allocates no closure.
|
||||
|
||||
local function styleFromOptions(battle)
|
||||
return tostring(((battle.game.save or {}).options or {}).battleStyle or "shift")
|
||||
:lower()
|
||||
end
|
||||
|
||||
local function alwaysAsk() return true end
|
||||
|
||||
-- "shift" or "set" for this battle. battle.style wraps the OPTION row: a
|
||||
-- mod returns "set" or "shift"; anything else reads as the vanilla answer.
|
||||
function BattleState:battleStyle()
|
||||
if not Runtime.wantsHook("battle.style") then return styleFromOptions(self) end
|
||||
local style = Runtime.call("battle.style", styleFromOptions, self)
|
||||
if style == "set" then return "set" end
|
||||
if style == "shift" then return "shift" end
|
||||
return styleFromOptions(self)
|
||||
end
|
||||
|
||||
-- AskName for a catch (AddPartyMon / SendNewMonToBox). Vanilla queues the
|
||||
-- yes/no prompt. catch.nickname may answer for the player: false keeps the
|
||||
-- species name and shows nothing; a string is the nickname, shown nothing;
|
||||
-- anything else asks as usual. Returns whether a prompt was queued.
|
||||
--
|
||||
-- The same verdict a script gift already takes from pokemon.before_give's
|
||||
-- gift.nickname, for the other way a Pokemon joins the party.
|
||||
function BattleState:offerNickname(mon, displayName)
|
||||
if Runtime.wantsHook("catch.nickname") then
|
||||
local verdict = Runtime.call("catch.nickname", alwaysAsk, mon,
|
||||
{ battle = self, name = displayName, game = self.game })
|
||||
if verdict == false then return false end
|
||||
if type(verdict) == "string" then
|
||||
-- the naming grid's own limit, so a mod cannot hand the party a name
|
||||
-- the summary screen has no room to draw
|
||||
verdict = verdict:sub(1, 10)
|
||||
if #verdict > 0 then mon.nickname = verdict end
|
||||
return false
|
||||
end
|
||||
end
|
||||
self:uiNext(function() return self:askNicknameUI(mon, displayName) end)
|
||||
return true
|
||||
end
|
||||
|
||||
-- AskName (engine/menus/naming_screen.asm): ClearSprites, wild field blank,
|
||||
-- PrintText, YES/NO while text stays (TextBox opts.choice). Shared by party
|
||||
-- AddPartyMon and SendNewMonToBox (#172).
|
||||
@@ -5180,11 +5230,7 @@ function BattleState:storeCaughtMon()
|
||||
end)
|
||||
end
|
||||
local function askCaughtNickname()
|
||||
local caught = self.enemy.mon
|
||||
local enemyName = self.enemy.name
|
||||
self:uiNext(function()
|
||||
return self:askNicknameUI(caught, enemyName)
|
||||
end)
|
||||
self:offerNickname(self.enemy.mon, self.enemy.name)
|
||||
end
|
||||
if Party.add(game.save.party, self.enemy.mon) then
|
||||
askCaughtNickname()
|
||||
|
||||
@@ -170,6 +170,10 @@ function Data:seedDefaults(version)
|
||||
-- writes no header for him -- seed one so he engages on sight and has
|
||||
-- his defeat / re-talk lines (same idea as the Cinnabar seed above).
|
||||
Data.seedFightingDojoKarateMaster(self)
|
||||
-- #1743: Mt. Moon B2F Super Nerd is text_asm (no def_trainers), so Yellow's
|
||||
-- extractor never writes his header -- seed one so engageTrainer finds
|
||||
-- battle/won/after text instead of the "I like shorts!" fallback.
|
||||
Data.seedMtMoonB2FSuperNerd(self)
|
||||
-- #189: 1F cabin door order vs rooms map (survey zoom)
|
||||
require("src.world.SsAnneLayout").apply(self.maps)
|
||||
end
|
||||
@@ -201,6 +205,25 @@ function Data:seedFightingDojoKarateMaster()
|
||||
}
|
||||
end
|
||||
|
||||
-- Mt. Moon B2F Super Nerd (object index 1) is text_asm with no def_trainers
|
||||
-- row, so Yellow never gets a trainerHeaders.MtMoonB2F[1] entry (Red/Blue
|
||||
-- pin one in make_rom_manifest.py). Without it, engageTrainer falls through
|
||||
-- to the hard-coded "I like shorts!" fallback (#1743). trainerDefeated still
|
||||
-- tracks him via defeatedTrainers[npc.id]; the fabricated event name matches
|
||||
-- the shipped Red/Blue manifest pin for consistency with fossil drivers.
|
||||
function Data:seedMtMoonB2FSuperNerd()
|
||||
local headers = self.trainer_headers
|
||||
if not headers then return end
|
||||
headers.MtMoonB2F = headers.MtMoonB2F or {}
|
||||
if headers.MtMoonB2F[1] then return end
|
||||
headers.MtMoonB2F[1] = {
|
||||
battle = "_MtMoonB2FSuperNerdTheyreBothMineText",
|
||||
won = "_MtMoonB2FSuperNerdOkIllShareText",
|
||||
after = "_MtMoonB2FSuperNerdTheresAPokemonLabText",
|
||||
event = "EVENT_BEAT_MT_MOON_3_SUPER_NERD",
|
||||
}
|
||||
end
|
||||
|
||||
function Data:seedCinnabarGymTrainerHeaders()
|
||||
local headers = self.trainer_headers
|
||||
if not headers or headers.CinnabarGym then return end
|
||||
|
||||
+17
-1
@@ -158,7 +158,15 @@ end
|
||||
-- and the app dies. There we relaunch through the GameActivity.restartApp
|
||||
-- JNI bridge (love.system.restartApp), which schedules our launch intent
|
||||
-- and kills the process so no native state can leak into the fresh run.
|
||||
-- On every other platform the in-process restart works, so keep it.
|
||||
-- iOS is the same class of problem with a sharper edge: love.cpp under
|
||||
-- LOVE_IOS forces DONE_RESTART for *every* quit (Apple forbids programmatic
|
||||
-- exit) and comments that leftover threads make that restart unreliable --
|
||||
-- which our ChipAudio / Fetch / Check workers are. There is no
|
||||
-- restartApp bridge on iOS, so callers that want "back to launcher" must
|
||||
-- use main.lua's in-process returnToLauncher (love.quit aborts the quit);
|
||||
-- HostShell.restart itself refuses quit("restart") and falls back to a
|
||||
-- bare quit() so a mod that still calls restart does not pick the worst
|
||||
-- path on purpose.
|
||||
function HostShell.restart()
|
||||
if not (love and love.event and love.event.quit) then return end
|
||||
|
||||
@@ -174,6 +182,14 @@ function HostShell.restart()
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
if osName == "iOS" then
|
||||
-- No process-kill bridge. A bare quit still becomes DONE_RESTART in
|
||||
-- love.cpp, but quit("restart") is the path that also runs our
|
||||
-- endProcess worker joins first and then re-enters runlove -- the
|
||||
-- combination that crashes EXIT GAME. Prefer the softer quit.
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
|
||||
local appimage = os.getenv("APPIMAGE")
|
||||
if not appimage then
|
||||
|
||||
@@ -227,7 +227,11 @@ local function metadata(options, context)
|
||||
end
|
||||
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
|
||||
local version = appVersion()
|
||||
add("App", version ~= "" and Version.title() or "gen1recomp")
|
||||
-- Bug reports always want the stamped engine when we have one; window
|
||||
-- chrome hides it on release builds (Version.title). Unstamped
|
||||
-- working-tree builds stay as plain "gen1recomp" so the placeholder
|
||||
-- never lands in a filed issue.
|
||||
add("App", version ~= "" and ("gen1recomp v" .. version) or "gen1recomp")
|
||||
add("LÖVE", loveVersion())
|
||||
if safeMode then add("Safe mode", "on") end
|
||||
return {
|
||||
|
||||
@@ -30,6 +30,8 @@ function SessionLifecycle.endMountedSession(version)
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
if Runtime.reset then Runtime.reset() end
|
||||
if Assets.installLoader then Assets.installLoader(nil) end
|
||||
local Loader = package.loaded["src.mods.Loader"]
|
||||
if Loader and Loader.endSession then Loader.endSession() end
|
||||
local okCompat, LegacyCompat = pcall(require, "src.mods.LegacyCompat")
|
||||
if okCompat and LegacyCompat.reset then LegacyCompat.reset() end
|
||||
end
|
||||
@@ -64,11 +66,18 @@ end
|
||||
|
||||
-- EXIT GAME / intent_game before dropping Game. Stops audio and resets the
|
||||
-- live game instance so map/GPU holders are gone before endMountedSession.
|
||||
--
|
||||
-- ChipAudio's worker stays alive across game sessions (see tier comment
|
||||
-- above). shutdown() joins the thread and is process-exit only -- calling
|
||||
-- it here on every Android EXIT GAME has been observed to leave the Gen1
|
||||
-- Game singleton unbootable (Game.load nil) on the next Play of a version
|
||||
-- already opened this process, while a debug APK with a different liblove
|
||||
-- did not reproduce.
|
||||
function SessionLifecycle.endGameSession(game)
|
||||
pcall(function() require("src.core.Music").stop() end)
|
||||
pcall(function() require("src.core.Sound").stop() end)
|
||||
if package.loaded["src.core.ChipAudio"] then
|
||||
pcall(package.loaded["src.core.ChipAudio"].shutdown)
|
||||
pcall(package.loaded["src.core.ChipAudio"].stopMusic)
|
||||
end
|
||||
if package.loaded["src.core.DiscordPresence"] then
|
||||
pcall(package.loaded["src.core.DiscordPresence"].shutdown)
|
||||
@@ -87,11 +96,11 @@ function SessionLifecycle.endGameSession(game)
|
||||
if game and game.reset then
|
||||
pcall(function() game:reset() end)
|
||||
end
|
||||
-- Gen1 Game is the module singleton. If teardown left it without load,
|
||||
-- drop the cached module so the next require rebuilds a clean table
|
||||
-- (bootGame also guards this; doing it here keeps Play-again reliable).
|
||||
if game and type(game.load) ~= "function"
|
||||
and package.loaded["src.core.Game"] == game then
|
||||
-- Gen1 Game is the module singleton. Always drop the cached module after
|
||||
-- a session that owned it so the next bootGame require rebuilds a clean
|
||||
-- table. A type(game.load) check is not enough: a wrong table parked in
|
||||
-- package.loaded (e.g. with __index) can still look like it has load.
|
||||
if game and package.loaded["src.core.Game"] == game then
|
||||
package.loaded["src.core.Game"] = nil
|
||||
end
|
||||
|
||||
|
||||
+15
-3
@@ -22,10 +22,22 @@ local Version = {
|
||||
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
||||
}
|
||||
|
||||
-- "gen1recomp v0.0.0-dev" (or the stamped release version in shipped builds)
|
||||
-- True for the working-tree placeholder and any stamped "-dev" pre-release.
|
||||
-- Shipped builds get a bare X.Y.Z from CI and are not "dev" here.
|
||||
function Version.isDev()
|
||||
local engine = tostring(Version.engine or "")
|
||||
return engine == "0.0.0-dev" or engine:find("%-dev", 1) ~= nil
|
||||
end
|
||||
|
||||
-- Window / chrome title. Dev builds keep the version visible
|
||||
-- ("gen1recomp v0.0.0-dev"); release builds are just the base name so Linux
|
||||
-- window chrome and taskbars do not read "gen1recomp 0.1.73".
|
||||
function Version.title(base)
|
||||
return (base or "gen1recomp")
|
||||
.. " v" .. Version.engine
|
||||
base = base or "gen1recomp"
|
||||
if Version.isDev() then
|
||||
return base .. " v" .. Version.engine
|
||||
end
|
||||
return base
|
||||
end
|
||||
|
||||
return Version
|
||||
|
||||
@@ -7,6 +7,28 @@ local SHADES = {
|
||||
{ 0, 0, 0, 1 },
|
||||
}
|
||||
|
||||
ImageWriter.SHADES = SHADES
|
||||
|
||||
-- Title screen rOBP0 = %11100000 ($E0): OBJ shades 1 and 2 draw as white,
|
||||
-- shade 3 as black (pokeyellow engine/movie/title.asm after PlacePikachu).
|
||||
-- Eye OAM is baked into the MEWMON-colored BG PNG; without this remap the
|
||||
-- shade-1 glints become body yellow under the title palette.
|
||||
function ImageWriter.applyTitleObp0(image)
|
||||
local mid, dark = SHADES[2][1], SHADES[3][1]
|
||||
local w, h = image:getWidth(), image:getHeight()
|
||||
for y = 0, h - 1 do
|
||||
for x = 0, w - 1 do
|
||||
local r, g, b, a = image:getPixel(x, y)
|
||||
if a ~= 0 then
|
||||
if math.abs(r - mid) < 0.02 or math.abs(r - dark) < 0.02 then
|
||||
image:setPixel(x, y, 1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return image
|
||||
end
|
||||
|
||||
local function assertDimensions(raw, width, height, bits)
|
||||
assert(width % 8 == 0 and height % 8 == 0,
|
||||
("%dbpp dimensions must be tile-aligned: %dx%d")
|
||||
|
||||
@@ -1535,6 +1535,14 @@ function RomExtractor:extractYellowTitleArt()
|
||||
local bg = sheetTiles("TitlePikachuBGGraphics", 64)
|
||||
local ob = sheetTiles("TitlePikachuOBGraphics", 12)
|
||||
local obClear = sheetTiles("TitlePikachuOBGraphics", 12, true)
|
||||
-- Title rOBP0=$E0: remap eye OAM shades 1/2 → white before baking into the
|
||||
-- MEWMON BG composition (otherwise glints read as body yellow).
|
||||
local eyeOb = {}
|
||||
for i, tile in ipairs(obClear) do
|
||||
local copy = ImageWriter.blank(8, 8, 0, 0, 0, 0)
|
||||
ImageWriter.blit(copy, tile, 0, 0)
|
||||
eyeOb[i] = ImageWriter.applyTitleObp0(copy)
|
||||
end
|
||||
local function tileFor(id)
|
||||
if id < 0x80 then return logo[id + 1] end
|
||||
if id < 0xF0 then return bg[id - 0x80 + 1] end
|
||||
@@ -1623,13 +1631,13 @@ function RomExtractor:extractYellowTitleArt()
|
||||
local overlay = ImageWriter.blank(48, 16, 1, 1, 1, 0)
|
||||
ImageWriter.blit(overlay, pikachu, 0, 0, 24, 16, 48, 16)
|
||||
for _, e in ipairs(EYE_LAYOUT) do
|
||||
blitSprite(overlay, obClear[base + e[1]], e[2] - 24, e[3] - 16, e[4])
|
||||
blitSprite(overlay, eyeOb[base + e[1]], e[2] - 24, e[3] - 16, e[4])
|
||||
end
|
||||
overlays[suffix] = overlay
|
||||
end
|
||||
-- open eyes bake into pikachu.png AFTER the blank-face crops
|
||||
for _, e in ipairs(EYE_LAYOUT) do
|
||||
blitSprite(pikachu, obClear[e[1]], e[2], e[3], e[4])
|
||||
blitSprite(pikachu, eyeOb[e[1]], e[2], e[3], e[4])
|
||||
end
|
||||
self:save(pikachu, "title/pikachu.png")
|
||||
for suffix, overlay in pairs(overlays) do
|
||||
|
||||
+150
-2
@@ -9,6 +9,9 @@ local SafeArea = require("src.core.SafeArea")
|
||||
local RomImporter = {}
|
||||
RomImporter.__index = RomImporter
|
||||
|
||||
local PICK_COMPLETE_FILENAME = "pick_complete.flag"
|
||||
local finishDirectRequiredImport
|
||||
|
||||
-- love.system.pickFile is a NATIVE BRIDGE, not part of LÖVE: it exists only on
|
||||
-- builds that compiled one (Android, and iOS builds patched by
|
||||
-- mobile/ios/patch_love_src.py). A build without it must fall back to the
|
||||
@@ -1582,8 +1585,11 @@ function RomImporter:focus(f)
|
||||
.. love.filesystem.getSaveDirectory()
|
||||
local legacyRequiredPick = self.requiredImportLegacyRomPick
|
||||
and self.pickerPendingKind == "required_import"
|
||||
if pickError:find("picked_required_import", 1, true)
|
||||
if self.pickerPendingKind == "required_import"
|
||||
or pickError:find("picked_required_import", 1, true)
|
||||
or pickError:find("picked_stadium", 1, true)
|
||||
or pickError:find("/baseroms/", 1, true)
|
||||
or pickError:find("\\baseroms\\", 1, true)
|
||||
or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then
|
||||
self.modNotice = { ok = false, text = text }
|
||||
self.pickerPendingKind = nil
|
||||
@@ -1606,6 +1612,22 @@ function RomImporter:focus(f)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- Current mobile bridge: the native picker has already streamed a raw
|
||||
-- dependency into mods/<id>/baseroms and published its digest/size marker.
|
||||
local completedRequired = love.filesystem.getInfo(PICK_COMPLETE_FILENAME, "file")
|
||||
and love.filesystem.read(PICK_COMPLETE_FILENAME)
|
||||
if completedRequired then
|
||||
love.filesystem.remove(PICK_COMPLETE_FILENAME)
|
||||
self.pickPending = nil
|
||||
finishDirectRequiredImport(self, completedRequired)
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId = nil
|
||||
self.pickerPendingImportId = nil
|
||||
self.requiredImportLegacyRomPick = nil
|
||||
return
|
||||
end
|
||||
|
||||
local requiredName = findPendingRequiredImport(self)
|
||||
if requiredName then
|
||||
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
||||
@@ -2059,6 +2081,122 @@ local function requiredImportNotice(self, modId, importId, text)
|
||||
}
|
||||
end
|
||||
|
||||
-- Current Android/iOS bridges can stream a raw required import directly from
|
||||
-- the system document provider into its engine-owned baseroms destination.
|
||||
-- The bridge hashes/counts the same bytes and then publishes this tiny marker:
|
||||
-- v1\n<relative destination>\n<md5>\n<byte count>\n
|
||||
-- Older mobile builds still stage picked_required_import.bin, so this is an
|
||||
-- additive completion path rather than a replacement for the legacy one.
|
||||
local function directRequiredImportTarget(self, marker)
|
||||
local fields = {}
|
||||
for line in tostring(marker or ""):gmatch("[^\r\n]+") do
|
||||
fields[#fields + 1] = line
|
||||
if #fields > 4 then break end
|
||||
end
|
||||
local version, path, digest, count = fields[1], fields[2], fields[3], fields[4]
|
||||
if version ~= "v1" or not path or not digest or not count or #digest ~= 32
|
||||
or not digest:match("^%x+$") or not count:match("^%d+$") then
|
||||
return nil, "The completed dependency marker was malformed."
|
||||
end
|
||||
digest = digest:lower()
|
||||
count = tonumber(count)
|
||||
if not count then return nil, "The completed dependency size was invalid." end
|
||||
|
||||
if not self.mods and self._refreshMods then pcall(self._refreshMods, self) end
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
|
||||
local function matchRow(row, wantedImportId)
|
||||
local manifest = row and row.manifest
|
||||
if not manifest then return nil end
|
||||
for _, spec in ipairs(RequiredImports.specs(manifest)) do
|
||||
if (not wantedImportId or spec.id == wantedImportId)
|
||||
and RequiredImports.path(manifest, spec) == path then
|
||||
return manifest, spec, row.id or manifest.id
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Normal resume: bind the marker to exactly the request that opened picker.
|
||||
if self.pickerPendingModId and self.pickerPendingImportId then
|
||||
for _, row in ipairs(self.mods or {}) do
|
||||
if row.id == self.pickerPendingModId then
|
||||
local manifest, spec, modId = matchRow(row, self.pickerPendingImportId)
|
||||
if manifest then return path, digest, count, manifest, spec, modId end
|
||||
end
|
||||
end
|
||||
return nil, "The completed dependency did not match the pending import request."
|
||||
end
|
||||
|
||||
-- Android may recreate GameActivity while DocumentsUI is open. If Lua was
|
||||
-- restarted too, recover by the exact engine-owned destination. The path
|
||||
-- includes the mod id and only a manifest-declared import can match it.
|
||||
for _, row in ipairs(self.mods or {}) do
|
||||
local manifest, spec, modId = matchRow(row)
|
||||
if manifest then return path, digest, count, manifest, spec, modId end
|
||||
end
|
||||
return nil, "The completed dependency did not match an installed mod request."
|
||||
end
|
||||
|
||||
finishDirectRequiredImport = function(self, marker)
|
||||
local path, digestOrErr, nativeSize, manifest, spec, modId =
|
||||
directRequiredImportTarget(self, marker)
|
||||
if not path then
|
||||
self.modNotice = { ok = false, text = tostring(digestOrErr) }
|
||||
return nil
|
||||
end
|
||||
local digest = digestOrErr
|
||||
local RequiredImports = require("src.mods.RequiredImports")
|
||||
local info = love.filesystem.getInfo(path, "file")
|
||||
if not info or type(info.size) ~= "number" then
|
||||
requiredImportNotice(self, modId, spec.id,
|
||||
"The completed dependency file was not found.")
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
if info.size ~= nativeSize then
|
||||
love.filesystem.remove(path)
|
||||
if RequiredImports.receiptPath then
|
||||
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
|
||||
end
|
||||
requiredImportNotice(self, modId, spec.id,
|
||||
("Dependency copy size changed after completion (expected %d bytes, found %d).")
|
||||
:format(nativeSize, info.size))
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
|
||||
if sizeErr then
|
||||
love.filesystem.remove(path)
|
||||
if RequiredImports.receiptPath then
|
||||
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
|
||||
end
|
||||
requiredImportNotice(self, modId, spec.id, sizeErr)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
|
||||
-- No second 665 MiB / 1.46 GiB read: native calculated this digest while
|
||||
-- streaming the selected document into the one final copy.
|
||||
local ok, detail = RequiredImports.acceptStoredDigest(
|
||||
manifest, spec.id, digest, love.filesystem)
|
||||
if not ok then
|
||||
love.filesystem.remove(path)
|
||||
if RequiredImports.receiptPath then
|
||||
love.filesystem.remove(RequiredImports.receiptPath(manifest, spec))
|
||||
end
|
||||
requiredImportNotice(self, modId, spec.id, detail)
|
||||
self.modNotice = nil
|
||||
return nil
|
||||
end
|
||||
|
||||
self.requiredImportNotice = nil
|
||||
self.modNotice = { ok = true, text = "Imported " .. tostring(spec.id)
|
||||
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
|
||||
self:_refreshMods()
|
||||
return true
|
||||
end
|
||||
|
||||
function RomImporter:_importRequiredData(modId, importId, data)
|
||||
local manifest = requiredManifest(self, modId)
|
||||
if not manifest then
|
||||
@@ -2218,7 +2356,15 @@ function RomImporter:chooseRequiredImport(modId, importId)
|
||||
self.pickerPendingModId = modId
|
||||
self.pickerPendingImportId = importId
|
||||
self.requiredImportLegacyRomPick = legacyAndroidPicker or nil
|
||||
if not pickFile(legacyAndroidPicker and "rom" or "required_import") then
|
||||
-- Raw imports can go straight to their final private destination on current
|
||||
-- Android/iOS bridges. N64 stays on staging because the launcher still has
|
||||
-- to canonicalize byte order/copier headers before storing it.
|
||||
local directDestination = (self.mobileFileBridge and not legacyAndroidPicker
|
||||
and spec.format ~= "n64"
|
||||
and type(manifest.path) == "string" and manifest.path ~= "")
|
||||
and require("src.mods.RequiredImports").path(manifest, spec) or nil
|
||||
if not pickFile(legacyAndroidPicker and "rom" or "required_import",
|
||||
directDestination) then
|
||||
self.pickerPendingKind = nil
|
||||
self.pickerPendingModId = nil
|
||||
self.pickerPendingImportId = nil
|
||||
@@ -2549,6 +2695,8 @@ function RomImporter:_pollPickedFiles(dt)
|
||||
return
|
||||
end
|
||||
local found = love.filesystem.getInfo("export_done.flag", "file") ~= nil
|
||||
or love.filesystem.getInfo("pick_error.flag", "file") ~= nil
|
||||
or love.filesystem.getInfo(PICK_COMPLETE_FILENAME, "file") ~= nil
|
||||
if not found then
|
||||
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
|
||||
local n = name:lower()
|
||||
|
||||
@@ -184,6 +184,14 @@ local function adjacentSleepingSnorlax(save, ow)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function itemUseLine(data, save, name)
|
||||
return romText(data, "_ItemUseText001", "%s used\n%s!", save.player.name, name)
|
||||
end
|
||||
|
||||
-- PrintItemUseTextAndRemoveItem (item_effects.asm): used-line + SFX_HEAL_AILMENT.
|
||||
-- BagMenu plays Heal_Ailment via TextBox.soundOpts when extra.useJingle is set.
|
||||
local USE_JINGLE = { useJingle = true }
|
||||
|
||||
-- Use an item on a target party mon (target may be nil for targetless
|
||||
-- items). data = generated data tables; battle = BattleState when used
|
||||
-- mid-battle; ow = the overworld (OverworldState), needed only to check
|
||||
@@ -280,7 +288,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
"All sleeping\nPOKéMON woke up!") }
|
||||
end
|
||||
|
||||
-- battle-only items
|
||||
-- battle-only items (PrintItemUseTextAndRemoveItem + Heal_Ailment, #1635)
|
||||
if X_ITEMS[itemId] or itemId == "DIRE_HIT" or itemId == "GUARD_SPEC"
|
||||
or itemId == "POKE_DOLL" then
|
||||
if not battle then
|
||||
@@ -293,21 +301,23 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
require("src.world.PikachuFollower")
|
||||
.modifyHappiness(save, "USEDXITEM", b and b.mon)
|
||||
end
|
||||
local used = itemUseLine(data, save, name)
|
||||
if itemId == "X_ACCURACY" then
|
||||
-- ItemUseXAccuracy sets USING_X_ACCURACY: moves never miss
|
||||
-- (not an accuracy stage)
|
||||
-- (not an accuracy stage); vanilla prints only the used line
|
||||
b.xAccuracy = true
|
||||
return "consumed", { Strings("%s's\nhits will never\nmiss!", b.name) }
|
||||
return "consumed", { used }, USE_JINGLE
|
||||
end
|
||||
if X_ITEMS[itemId] then
|
||||
local stat = X_ITEMS[itemId]
|
||||
local cur = b.stages[stat] or 0
|
||||
-- ItemUseXStat removes the item BEFORE running the stat-up
|
||||
-- effect, so at +6 it is still consumed and StatModifierUpEffect
|
||||
-- just prints "Nothing happened!"
|
||||
-- ItemUseXStat: PrintItemUseTextAndRemoveItem, then StatModifierUpEffect
|
||||
if cur >= 6 then
|
||||
return "consumed", { romText(data, "_NothingHappenedText",
|
||||
"Nothing happened!") }
|
||||
return "consumed", { used }, {
|
||||
useJingle = true,
|
||||
afterMessages = { romText(data, "_NothingHappenedText",
|
||||
"Nothing happened!") },
|
||||
}
|
||||
end
|
||||
b.stages[stat] = cur + 1
|
||||
b.hazeStatReset = nil
|
||||
@@ -315,26 +325,29 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
and battle.kind ~= "link" then
|
||||
require("src.battle.Damage").reapplyBadgeBoosts(b, stat)
|
||||
end
|
||||
return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) }
|
||||
return "consumed", { used }, {
|
||||
useJingle = true,
|
||||
afterMessages = { 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
|
||||
-- the item, even when it is already active; vanilla prints only used
|
||||
if itemId == "DIRE_HIT" then
|
||||
b.focusEnergy = true
|
||||
return "consumed", { romText(data, "_GettingPumpedText",
|
||||
"%s's\ngetting pumped!", b.name) }
|
||||
return "consumed", { used }, USE_JINGLE
|
||||
end
|
||||
if itemId == "GUARD_SPEC" then
|
||||
b.mist = true
|
||||
return "consumed", { Strings("%s's\nprotected against\nstat changes!", b.name) }
|
||||
return "consumed", { used }, USE_JINGLE
|
||||
end
|
||||
if itemId == "POKE_DOLL" then
|
||||
if battle.kind ~= "wild" then
|
||||
-- ItemUsePokeDoll jumps to ItemUseNotTime in trainer battles
|
||||
return "failed", { notTime(data, save) }
|
||||
end
|
||||
return "consumed_escape", { romText(data, "_WildRanText",
|
||||
"The wild POKéMON\nran away!", battle.enemy and battle.enemy.name) }
|
||||
-- PrintItemUseTextAndRemoveItem then escape
|
||||
return "consumed_escape", { used }, USE_JINGLE
|
||||
end
|
||||
end
|
||||
|
||||
@@ -613,7 +626,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if REPELS[itemId] then
|
||||
local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250
|
||||
save.repelSteps = steps
|
||||
return "consumed", { Strings("%s used\n%s!", save.player.name, name) }
|
||||
return "consumed", { itemUseLine(data, save, name) }, USE_JINGLE
|
||||
end
|
||||
|
||||
return "failed", { notTime(data, save) }
|
||||
|
||||
+14
-2
@@ -147,12 +147,19 @@ local SUPPORTED_REQUIRES = {
|
||||
local ENGINE_PREFIX = (debug.getinfo(1, "S").source or "")
|
||||
:gsub("^@", ""):gsub("mods[/\\]Loader%.lua$", "")
|
||||
|
||||
local ENGINE_CHUNKS = {
|
||||
["main.lua"] = true,
|
||||
["conf.lua"] = true,
|
||||
}
|
||||
|
||||
local function callerIsMod(level)
|
||||
if ENGINE_PREFIX == "" then return false end
|
||||
local info = debug.getinfo(level, "S")
|
||||
local source = info and info.source
|
||||
if not source or source:sub(1, 1) ~= "@" then return false end
|
||||
return source:sub(2, 1 + #ENGINE_PREFIX) ~= ENGINE_PREFIX
|
||||
local path = source:sub(2)
|
||||
if ENGINE_CHUNKS[path] then return false end
|
||||
return path:sub(1, #ENGINE_PREFIX) ~= ENGINE_PREFIX
|
||||
end
|
||||
|
||||
local function scanRequire(name)
|
||||
@@ -207,6 +214,11 @@ local function engineRequire(name)
|
||||
return module
|
||||
end
|
||||
|
||||
function Loader.endSession()
|
||||
devShim.generation = nil
|
||||
devShim.errors = nil
|
||||
end
|
||||
|
||||
function Loader:_installDevShim()
|
||||
for id, mod in pairs(self.mods) do
|
||||
devShim.permissions[id] = mod.manifest.permissionSet
|
||||
@@ -234,7 +246,7 @@ function Loader:_installDevShim()
|
||||
-- The Gen 1 name a mod asked for, answered by the Gen 2 arm behind it.
|
||||
-- Engine code keeps the real module: src/render/PaletteFX.lua:776
|
||||
-- requires src.core.Game on both generations and means it.
|
||||
if devShim.generation ~= 1 and Gen2Compat.serves(name)
|
||||
if devShim.generation == 2 and Gen2Compat.serves(name)
|
||||
and (owner or callerIsMod(3)) then
|
||||
local adapter = Gen2Compat.resolve(name, Runtime.currentMod)
|
||||
if adapter then
|
||||
|
||||
+49
-13
@@ -20,6 +20,8 @@ local PaletteFX = {}
|
||||
local shader -- false = unavailable (headless / no shader support)
|
||||
local gbcPack -- false = missing; nil = not loaded yet
|
||||
local yellowPack -- false = missing; nil = not loaded yet
|
||||
local gbcYellowPack -- Yellow Advanced deltas; false = missing; nil = not loaded yet
|
||||
|
||||
|
||||
-- Cycle order matches OptionsMenu / hotkey 2. The three real colorizations
|
||||
-- come first (OG RED/BLUE/YELLOW = GBC hardware, SGB = per-map Super Game Boy,
|
||||
@@ -300,6 +302,38 @@ function PaletteFX.yellowPack()
|
||||
return yellowPack or nil
|
||||
end
|
||||
|
||||
-- Yellow-only Advanced deltas (BEACH_HOUSE, sprite remap, YELLOWMON). Never
|
||||
-- consulted on Red/Blue (#1639).
|
||||
function PaletteFX.gbcYellowPack()
|
||||
if gbcYellowPack == nil then
|
||||
local ok, pack = pcall(require, "data.palettes_gbc_yellow")
|
||||
gbcYellowPack = ok and pack or false
|
||||
end
|
||||
return gbcYellowPack or nil
|
||||
end
|
||||
|
||||
-- Active world bake tables for Advanced. Yellow merges gbc_yellow deltas
|
||||
-- via __index so shared Red tilesets stay identical byte-for-byte.
|
||||
function PaletteFX.worldPack()
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
if not w then return nil end
|
||||
if not GameVersion.isYellow() then return w end
|
||||
local y = PaletteFX.gbcYellowPack()
|
||||
local yw = y and y.world
|
||||
if not yw then return w end
|
||||
return {
|
||||
tileGroups = setmetatable(yw.tileGroups or {}, { __index = w.tileGroups }),
|
||||
groupColors = setmetatable(yw.groupColors or {}, { __index = w.groupColors }),
|
||||
roofGroup = w.roofGroup,
|
||||
roofByMapIndex = w.roofByMapIndex,
|
||||
spriteAssignment = yw.spriteAssignment or w.spriteAssignment,
|
||||
spritePalettes = yw.spritePalettes
|
||||
and setmetatable(yw.spritePalettes, { __index = w.spritePalettes })
|
||||
or w.spritePalettes,
|
||||
}
|
||||
end
|
||||
|
||||
function PaletteFX.usesGbcPack(mode)
|
||||
mode = mode or PaletteFX.mode
|
||||
return mode == "redpp"
|
||||
@@ -453,7 +487,10 @@ function PaletteFX.pal(data, name)
|
||||
if fromRom then return fromRom end
|
||||
end
|
||||
if GameVersion.isYellow() then
|
||||
if PaletteFX.usesYellowCgb() then
|
||||
-- OG YELLOW and Advanced both use CGBBase for named pals: SuperPalettes
|
||||
-- wash out yellows (title MEWMON/LOGO, YELLOWMON) to pale cream. World
|
||||
-- tile bake still comes from the Advanced GBC pack via worldPack (#1639).
|
||||
if PaletteFX.usesYellowCgb() or PaletteFX.usesGbcPack() then
|
||||
local fromCgb = yellowCgbNamedPal(data, name)
|
||||
if fromCgb then return fromCgb end
|
||||
end
|
||||
@@ -511,7 +548,8 @@ function PaletteFX.monPal(data, species, transformed)
|
||||
if pal then return pal end
|
||||
end
|
||||
local name = p.pokemon[species] or "MEWMON"
|
||||
if PaletteFX.usesYellowCgb() then
|
||||
if PaletteFX.usesYellowCgb()
|
||||
or (GameVersion.isYellow() and PaletteFX.usesGbcPack()) then
|
||||
local yc = PaletteFX.pal(data, name)
|
||||
if yc then return yc end
|
||||
end
|
||||
@@ -618,16 +656,14 @@ local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBel
|
||||
-- (false for a mod tileset with no pokered-gbc counterpart, or when the
|
||||
-- pack failed to load at all)
|
||||
function PaletteFX.hasWorldTileset(tileset)
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
local w = PaletteFX.worldPack()
|
||||
return (w and w.tileGroups[tileset]) ~= nil
|
||||
end
|
||||
|
||||
-- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset,
|
||||
-- with the current map's tile-id exceptions (if any) applied first
|
||||
function PaletteFX.worldGroupAt(tileset, mapId, tileId)
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
local w = PaletteFX.worldPack()
|
||||
local groups = w and w.tileGroups[tileset]
|
||||
if not groups then return nil end
|
||||
local exc = TILE_GROUP_EXCEPTIONS[mapId]
|
||||
@@ -642,8 +678,7 @@ end
|
||||
-- Saffron's roof colors while the player stands in its top 2 cell rows,
|
||||
-- like pokered's wYCoord check -- data is Game.data, for the map lookup)
|
||||
function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
local w = PaletteFX.worldPack()
|
||||
local base = w and w.groupColors[tileset]
|
||||
if not base then return nil end
|
||||
if not w.roofGroup[tileset] then return darkGroups(base) end
|
||||
@@ -678,17 +713,18 @@ end
|
||||
-- here): a stable hash instead, so the same NPC instance always shows the
|
||||
-- same one of the 4 SPR_PAL_* colors.
|
||||
function PaletteFX.spriteObp(spriteDef, seed)
|
||||
local pack = PaletteFX.gbcPack()
|
||||
local w = pack and pack.world
|
||||
local w = PaletteFX.worldPack()
|
||||
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
|
||||
if not (w and src) then return nil end
|
||||
local idx = tonumber(src:match("%[(%d+)%]"))
|
||||
-- RedBikeSprite and SurfingPikachuSprite load outside
|
||||
-- SpriteSheetPointerTable, so their source has no bracketed index;
|
||||
-- they wear the player's OBP palette (spriteAssignment[0]).
|
||||
if not idx and (src:find("RedBikeSprite", 1, true)
|
||||
or src:find("SurfingPikachuSprite", 1, true)) then
|
||||
-- they wear the player's OBP palette (spriteAssignment[0]) on Red/Blue.
|
||||
-- On Yellow, Surfing Pikachu uses the Pikachu yellow OBJ group (#1639).
|
||||
if not idx and src:find("RedBikeSprite", 1, true) then
|
||||
idx = 0
|
||||
elseif not idx and src:find("SurfingPikachuSprite", 1, true) then
|
||||
idx = GameVersion.isYellow() and 60 or 0
|
||||
end
|
||||
local group = idx and w.spriteAssignment[idx]
|
||||
if group == nil then return nil end
|
||||
|
||||
@@ -338,6 +338,7 @@ function Renderer:beginFrame(transparent)
|
||||
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
||||
-- a popped transition cannot leave a sticky black veil
|
||||
self.worldFadeAlpha = nil
|
||||
self.worldFadeColor = nil
|
||||
-- battle-transition wipe, drawn over the whole surface (BattleTransition)
|
||||
self.battleWipe = nil
|
||||
-- whole-surface veil in screen space (battle-transition flash, the
|
||||
@@ -1045,7 +1046,8 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- the screen-space overlays the flat path draws over its composite
|
||||
local fade = self.worldFadeAlpha
|
||||
if fade and fade > 0 then
|
||||
love.graphics.setColor(0, 0, 0, fade)
|
||||
local c = self.worldFadeColor or { 0, 0, 0 }
|
||||
love.graphics.setColor(c[1], c[2], c[3], fade)
|
||||
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
@@ -1132,7 +1134,8 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- composite normally if one is ever stacked that way.
|
||||
local fade = self.worldFadeAlpha
|
||||
if fade and fade > 0 then
|
||||
love.graphics.setColor(0, 0, 0, fade)
|
||||
local c = self.worldFadeColor or { 0, 0, 0 }
|
||||
love.graphics.setColor(c[1], c[2], c[3], fade)
|
||||
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -65,16 +65,24 @@ end
|
||||
-- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call
|
||||
-- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the
|
||||
-- symmetric 32-frame fade back in (home/fade.asm:21, b = 4).
|
||||
function Transition.new(game, onMidpoint, onDone, warp)
|
||||
--
|
||||
-- opts.color = {r,g,b} in 0..1 (default black). opts.frames / opts.framesIn
|
||||
-- override duration. Fly/Teleport/Dig/Escape Rope use white
|
||||
-- GBFadeOutToWhite / GBFadeInFromWhite (#1644).
|
||||
function Transition.new(game, onMidpoint, onDone, warp, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, Transition)
|
||||
self.game = game
|
||||
self.onMidpoint = onMidpoint
|
||||
self.onDone = onDone
|
||||
self.t = 0
|
||||
self.phase = "out"
|
||||
self.color = opts.color or { 0, 0, 0 }
|
||||
local style = styleOf(game, "warp_fade")
|
||||
self.frames = style.frames or FRAMES
|
||||
if warp then
|
||||
self.frames = opts.frames or style.frames or FRAMES
|
||||
if opts.framesIn ~= nil then
|
||||
self.framesIn = opts.framesIn
|
||||
elseif warp then
|
||||
-- a style may still ask for a fade in (mods, and the record is
|
||||
-- data-driven); the built-in warp is 0, matching hardware
|
||||
self.framesIn = style.framesIn or FRAMES_IN
|
||||
@@ -125,6 +133,7 @@ end
|
||||
|
||||
function Transition:draw()
|
||||
local alpha = self:alpha()
|
||||
local c = self.color or { 0, 0, 0 }
|
||||
-- Survey zoom draws the overworld into a window-filling world canvas
|
||||
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
|
||||
-- UI canvas only darkens that center box (issue #121); when the world
|
||||
@@ -133,9 +142,10 @@ function Transition:draw()
|
||||
local r = self.game and self.game.renderer
|
||||
if r and r.worldActive then
|
||||
r.worldFadeAlpha = alpha
|
||||
r.worldFadeColor = c
|
||||
return
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, alpha)
|
||||
love.graphics.setColor(c[1], c[2], c[3], alpha)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+19
-4
@@ -54,6 +54,21 @@ local function showMessages(game, msgs, onDone, opts)
|
||||
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts))
|
||||
end
|
||||
|
||||
-- PrintItemUseTextAndRemoveItem: used-line TextBox carries Heal_Ailment, then
|
||||
-- optional afterMessages (X Stat effect line) before onDone (#1635).
|
||||
local function showUseMessages(game, msgs, onDone, extra)
|
||||
local opts = (extra and extra.useJingle)
|
||||
and TextBox.soundOpts(game, "Heal_Ailment") or nil
|
||||
local after = extra and extra.afterMessages
|
||||
if after and #after > 0 then
|
||||
showMessages(game, msgs, function()
|
||||
showMessages(game, after, onDone)
|
||||
end, opts)
|
||||
else
|
||||
showMessages(game, msgs, onDone, opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- run the use-flow for an item on a chosen target. `picker` is the party
|
||||
-- menu when it was opened with keepOpen (HP medicine only): it is still on
|
||||
-- the stack, so every exit that prints has to close it afterwards. For
|
||||
@@ -115,7 +130,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
if result == "consumed_escape" then -- Poké Doll
|
||||
consume(game, id, list)
|
||||
list:close()
|
||||
showMessages(game, payload, function()
|
||||
showUseMessages(game, payload, function()
|
||||
-- ItemUsePokeDoll sets wEscapedFromBattle and never touches
|
||||
-- wBattleResult, so a script that reads the result afterwards sees
|
||||
-- 0 -- "defeated". The ghost MAROWAK's script keys on exactly that
|
||||
@@ -125,7 +140,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
battle.result = "run"
|
||||
battle.afterQueue = "finish"
|
||||
battle.phase = "messages"
|
||||
end)
|
||||
end, extra)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -403,9 +418,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
if battle then
|
||||
list:close()
|
||||
showMessages(game, payload, function() battle:itemUsed({}) end)
|
||||
showUseMessages(game, payload, function() battle:itemUsed({}) end, extra)
|
||||
else
|
||||
showMessages(game, payload, closePicker)
|
||||
showUseMessages(game, payload, closePicker, extra)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -4818,6 +4818,15 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
require("src.core.Sound").play(Game.data,
|
||||
outdoor and "Go_Outside" or "Go_Inside")
|
||||
end
|
||||
-- Fly/Teleport/Dig/Escape Rope: GBFadeOutToWhite / GBFadeInFromWhite
|
||||
-- (player_animations.asm). Door warps stay black with no fade-in (#1644).
|
||||
local Timing = require("src.core.Timing")
|
||||
local specialWarp = arriveWarp == "fly" or arriveWarp == "teleport"
|
||||
local fadeOpts = specialWarp and {
|
||||
color = { 1, 1, 1 },
|
||||
frames = Timing.FADE_OUT_TO_WHITE,
|
||||
framesIn = Timing.FADE_IN_FROM_WHITE,
|
||||
} or nil
|
||||
Game.stack:push(Transition.new(Game, function()
|
||||
self:setMap(mapId, x, y, facing or "down", opts)
|
||||
-- the departure-side hide from flyAnim/teleportOut ends here, on the new
|
||||
@@ -4884,7 +4893,7 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
end, function()
|
||||
self.transitioning = false
|
||||
if onDone then onDone() end
|
||||
end, true)) -- warp shape: no fade back in (LoadGBPal restores in one write)
|
||||
end, not specialWarp, fadeOpts))
|
||||
end
|
||||
|
||||
-- Re-read a map record after its data changed (WorldAPI:invalidateMap,
|
||||
|
||||
@@ -109,9 +109,35 @@ do
|
||||
-- Mimic a shared net module parked on the singleton (handle-style release).
|
||||
Game.net = { release = function(id) end }
|
||||
SessionLifecycle.endGameSession(Game)
|
||||
check(package.loaded["src.core.Game"] == nil,
|
||||
"endGameSession drops the Gen1 Game module cache")
|
||||
local again = require("src.core.Game")
|
||||
check(type(again.load) == "function",
|
||||
check(type(rawget(again, "load")) == "function",
|
||||
"Play-again can call Game:load after endGameSession")
|
||||
check(again ~= Game, "Play-again gets a fresh Gen1 Game module table")
|
||||
end
|
||||
|
||||
-- 7. endGameSession must not join the ChipAudio worker (process-tier only).
|
||||
-- Joining on every EXIT GAME correlates with release-APK Game.load nil
|
||||
-- when reopening a version already played this process.
|
||||
do
|
||||
local SessionLifecycle = require("src.core.SessionLifecycle")
|
||||
local ChipAudio = require("src.core.ChipAudio")
|
||||
local shutdownCalls, stopCalls = 0, 0
|
||||
local origShutdown, origStop = ChipAudio.shutdown, ChipAudio.stopMusic
|
||||
ChipAudio.shutdown = function(...)
|
||||
shutdownCalls = shutdownCalls + 1
|
||||
return origShutdown(...)
|
||||
end
|
||||
ChipAudio.stopMusic = function(...)
|
||||
stopCalls = stopCalls + 1
|
||||
return origStop(...)
|
||||
end
|
||||
local game = { reset = function() end, load = function() end }
|
||||
SessionLifecycle.endGameSession(game)
|
||||
ChipAudio.shutdown, ChipAudio.stopMusic = origShutdown, origStop
|
||||
eq(shutdownCalls, 0, "endGameSession does not ChipAudio.shutdown")
|
||||
check(stopCalls >= 1, "endGameSession stops chip music (Music.stop and/or stopMusic)")
|
||||
end
|
||||
|
||||
T.finish("android_exit_to_launcher_test")
|
||||
|
||||
@@ -118,8 +118,13 @@ local systemFile = assert(io.open(systemPath, "rb"))
|
||||
local system = systemFile:read("*a")
|
||||
systemFile:close()
|
||||
check(system:find('strcmp(kind, "required_import")', 1, true)
|
||||
and system:find('dest = "picked_required_import.bin"', 1, true)
|
||||
and system:find('destination != nullptr', 1, true)
|
||||
and system:find('"picked_required_import.bin"', 1, true)
|
||||
and system:find('return "rom,mod,sav,required_import"', 1, true),
|
||||
"native Android bridge advertises and routes required imports")
|
||||
check(source:find('normalized.startsWith("mods/")', 1, true)
|
||||
and source:find('/baseroms/', 1, true)
|
||||
and source:find('PICK_COMPLETE_FILENAME', 1, true),
|
||||
"direct required imports stay inside mod baseroms and publish completion")
|
||||
|
||||
print("android_host_extension_test: ok")
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local S = require("tests.harness").suite("gen2 shim engine require")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Loader = require("src.mods.Loader")
|
||||
local Gen2Compat = require("src.mods.Gen2Compat")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local SessionLifecycle = require("src.core.SessionLifecycle")
|
||||
|
||||
local NAME = "src.core.Game"
|
||||
local REAL = { load = function() end }
|
||||
|
||||
local function memfs(files)
|
||||
return {
|
||||
files = files,
|
||||
read = function(path) return files[path] end,
|
||||
write = function(path, content) files[path] = content return true end,
|
||||
remove = function(path) files[path] = nil return true end,
|
||||
getInfo = function(path)
|
||||
if files[path] then return { type = "file" } end
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
load = function(path)
|
||||
if not files[path] then return nil, "no file: " .. path end
|
||||
return load(files[path], path)
|
||||
end,
|
||||
createDirectory = function() return true end,
|
||||
getDirectoryItems = function(path)
|
||||
local seen, items = {}, {}
|
||||
local prefix = path .. "/"
|
||||
for key in pairs(files) do
|
||||
if key:sub(1, #prefix) == prefix then
|
||||
local child = key:sub(#prefix + 1):match("^[^/]+")
|
||||
if child and not seen[child] then
|
||||
seen[child] = true
|
||||
items[#items + 1] = child
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(items)
|
||||
return items
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function requireFrom(source)
|
||||
local chunk = assert(loadstring(
|
||||
"local name = ... local mod = require(name) return mod", "@" .. source))
|
||||
return chunk(NAME)
|
||||
end
|
||||
|
||||
local savedVersion = GameVersion.get()
|
||||
local savedModule = package.loaded[NAME]
|
||||
package.loaded[NAME] = REAL
|
||||
|
||||
GameVersion.set("gold")
|
||||
local files = {
|
||||
["mods/facade/manifest.json"] =
|
||||
[[{"id":"facade","name":"facade","version":"1.0.0","entry":"main.lua",]]
|
||||
.. [["gen2compat":true}]],
|
||||
["mods/facade/main.lua"] = "return function(mod) end",
|
||||
}
|
||||
local loader = Loader.new({ fs = memfs(files) })
|
||||
loader:load({ pokemon = {} })
|
||||
|
||||
eq(loader.generation, 2, "the fixture loader is a Gen 2 one")
|
||||
check(Gen2Compat.serves(NAME), "the facade serves " .. NAME)
|
||||
|
||||
local facade = Gen2Compat.resolve(NAME)
|
||||
eq(requireFrom("mods/facade/main.lua"), facade,
|
||||
"a mod's require still resolves to the Gen 2 facade")
|
||||
eq(requireFrom("main.lua"), REAL,
|
||||
"main.lua's require is the engine's own, never the facade")
|
||||
eq(requireFrom("conf.lua"), REAL, "conf.lua's require is the engine's own")
|
||||
|
||||
SessionLifecycle.endMountedSession()
|
||||
eq(requireFrom("mods/facade/main.lua"), REAL,
|
||||
"after the session ends the facade stops answering for the next boot")
|
||||
|
||||
package.loaded[NAME] = savedModule
|
||||
GameVersion.set(savedVersion)
|
||||
|
||||
S.finish()
|
||||
@@ -4,7 +4,10 @@
|
||||
-- The fix prefers the love.system.restartApp JNI bridge (which kills the
|
||||
-- process, so a true return is never observed live) and, on an old APK
|
||||
-- whose liblove lacks the bridge, falls back to a CLEAN quit with no
|
||||
-- argument. Desktop keeps the in-process quit("restart").
|
||||
-- argument. iOS has no restartApp bridge and love.cpp forces DONE_RESTART
|
||||
-- for every quit; HostShell.restart must still refuse quit("restart") so a
|
||||
-- leftover caller does not pick the worker-join + native-restart path that
|
||||
-- crashes EXIT GAME. Desktop keeps the in-process quit("restart").
|
||||
-- luajit tests/engine/host_restart_android_bug575.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
@@ -48,12 +51,18 @@ HostShell.restart()
|
||||
eq(#quits, 2, "a bridge-less APK quits cleanly instead of crashing")
|
||||
eq(quits[2].n, 0, "again with no restart argument")
|
||||
|
||||
-- iOS: no process-kill bridge; never quit("restart")
|
||||
osName = "iOS"
|
||||
HostShell.restart()
|
||||
eq(#quits, 3, "iOS HostShell.restart still quits once")
|
||||
eq(quits[3].n, 0, "iOS uses a bare quit(), never quit(\"restart\")")
|
||||
|
||||
-- desktop (no AppImage in a test environment) keeps the in-process restart
|
||||
if not os.getenv("APPIMAGE") then
|
||||
osName = "OS X"
|
||||
HostShell.restart()
|
||||
eq(quits[3] and quits[3].arg, "restart",
|
||||
"non-Android still restarts in-process")
|
||||
eq(quits[4] and quits[4].arg, "restart",
|
||||
"non-mobile still restarts in-process")
|
||||
end
|
||||
|
||||
T.finish("host_restart_android_bug575")
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
-- #1635: PrintItemUseTextAndRemoveItem items must request Heal_Ailment
|
||||
-- via extra.useJingle after the "X used Y!" line.
|
||||
--
|
||||
-- ROM-free: stubs items/text only (CI headless has no data/generated/).
|
||||
--
|
||||
-- luajit tests/engine/item_use_jingle_bug1635.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("item use Heal_Ailment jingle #1635")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local ITEM_IDS = {
|
||||
"REPEL", "SUPER_REPEL", "MAX_REPEL",
|
||||
"X_ATTACK", "X_DEFEND", "X_SPEED", "X_SPECIAL",
|
||||
"X_ACCURACY", "DIRE_HIT", "GUARD_SPEC", "POKE_DOLL",
|
||||
}
|
||||
|
||||
local Data = { items = {}, text = {} }
|
||||
for _, id in ipairs(ITEM_IDS) do
|
||||
Data.items[id] = { name = id:gsub("_", " ") }
|
||||
end
|
||||
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "RED"
|
||||
|
||||
local function battleStub(playerName)
|
||||
return {
|
||||
kind = "wild",
|
||||
player = {
|
||||
name = playerName or "PIKACHU",
|
||||
stages = { attack = 0, defense = 0, speed = 0, special = 0, accuracy = 0 },
|
||||
mon = {},
|
||||
},
|
||||
enemy = { name = "RATTATA" },
|
||||
}
|
||||
end
|
||||
|
||||
local function assertJingle(itemId, battle)
|
||||
local result, msgs, extra = ItemEffects.use(Data, save, itemId, nil, battle)
|
||||
check(result == "consumed" or result == "consumed_escape",
|
||||
itemId .. " succeeds (" .. tostring(result) .. ")")
|
||||
check(extra and extra.useJingle, itemId .. " sets useJingle")
|
||||
check(msgs and msgs[1] and msgs[1]:find("used"),
|
||||
itemId .. " prints used line")
|
||||
return result, msgs, extra
|
||||
end
|
||||
|
||||
for _, id in ipairs({ "REPEL", "SUPER_REPEL", "MAX_REPEL" }) do
|
||||
assertJingle(id, nil)
|
||||
end
|
||||
|
||||
local b = battleStub()
|
||||
for _, id in ipairs({
|
||||
"X_ATTACK", "X_DEFEND", "X_SPEED", "X_SPECIAL",
|
||||
"X_ACCURACY", "DIRE_HIT", "GUARD_SPEC",
|
||||
}) do
|
||||
local _, _, extra = assertJingle(id, b)
|
||||
if id == "X_ATTACK" then
|
||||
check(extra.afterMessages and #extra.afterMessages > 0,
|
||||
"X_ATTACK follows used line with effect text")
|
||||
end
|
||||
end
|
||||
|
||||
local _, _, dollExtra = assertJingle("POKE_DOLL", battleStub())
|
||||
eq(dollExtra.afterMessages, nil, "Poké Doll is used-line only")
|
||||
|
||||
S.finish()
|
||||
@@ -64,12 +64,17 @@ do
|
||||
Game.linkFetch = {
|
||||
release = function(id) jobs[id] = nil end,
|
||||
}
|
||||
local before = Game
|
||||
SessionLifecycle.endGameSession(Game)
|
||||
check(type(Game.load) == "function",
|
||||
"endGameSession leaves Game.load intact for the next bootGame")
|
||||
check(package.loaded["src.core.Game"] == Game
|
||||
or type((package.loaded["src.core.Game"] or {}).load) == "function",
|
||||
"Gen1 Game module remains require-able after endGameSession")
|
||||
check(type(before.load) == "function",
|
||||
"endGameSession reset leaves methods on the old table")
|
||||
check(package.loaded["src.core.Game"] == nil,
|
||||
"endGameSession drops Gen1 Game from package.loaded")
|
||||
local again = require("src.core.Game")
|
||||
check(type(rawget(again, "load")) == "function",
|
||||
"require rebuilds a bootable Gen1 Game after endGameSession")
|
||||
check(again ~= before, "Play-again uses a fresh Gen1 Game module table")
|
||||
Game = again
|
||||
end
|
||||
|
||||
-- ---- Game2:reset releases world GPU and present canvases ------------------
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- #1743: Mt. Moon B2F Super Nerd is text_asm with no def_trainers row, so
|
||||
-- Yellow never gets trainerHeaders.MtMoonB2F[1]. Without a seed,
|
||||
-- engageTrainer falls through to the "I like shorts!" fallback.
|
||||
--
|
||||
-- luajit tests/engine/mt_moon_super_nerd_header_bug1743.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("mt moon super nerd header #1743")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
|
||||
local empty = { trainer_headers = {} }
|
||||
Data.seedMtMoonB2FSuperNerd(empty)
|
||||
local seeded = empty.trainer_headers.MtMoonB2F[1]
|
||||
check(seeded ~= nil, "seed fills MtMoonB2F[1] when missing")
|
||||
eq(seeded.battle, "_MtMoonB2FSuperNerdTheyreBothMineText",
|
||||
"pre-battle text is They're both mine")
|
||||
eq(seeded.won, "_MtMoonB2FSuperNerdOkIllShareText", "won text seeded")
|
||||
eq(seeded.after, "_MtMoonB2FSuperNerdTheresAPokemonLabText", "after text seeded")
|
||||
eq(seeded.event, "EVENT_BEAT_MT_MOON_3_SUPER_NERD",
|
||||
"event name matches shipped Red/Blue pin")
|
||||
|
||||
-- idempotent: existing header wins
|
||||
empty.trainer_headers.MtMoonB2F[1] = { battle = "KEEP" }
|
||||
Data.seedMtMoonB2FSuperNerd(empty)
|
||||
eq(empty.trainer_headers.MtMoonB2F[1].battle, "KEEP",
|
||||
"seed does not overwrite an existing header")
|
||||
|
||||
S.finish()
|
||||
@@ -197,6 +197,16 @@ check(source("src/update/Check.lua"):find("registerProcessShutdown(Check.shutdow
|
||||
check(source("src/net/Fetch.lua"):find("registerProcessShutdown(Fetch.shutdown)", 1, true) ~= nil,
|
||||
"Fetch registers its shutdown hook at load")
|
||||
|
||||
-- iOS EXIT GAME must share Android's in-process returnToLauncher: love.cpp
|
||||
-- under LOVE_IOS forces DONE_RESTART for every quit and warns that leftover
|
||||
-- threads make that restart unreliable (ChipAudio / Fetch / Check).
|
||||
check(quitHook:find('osName == "Android" or osName == "iOS"', 1, true) ~= nil,
|
||||
"love.quit treats Android and iOS as in-process return platforms")
|
||||
check(quitHook:find("inProcessReturn", 1, true) ~= nil,
|
||||
"love.quit gates returnToLauncher on inProcessReturn")
|
||||
check(quitHook:find('require("src.core.HostShell").restart()', 1, true) ~= nil,
|
||||
"desktop return-to-launcher still reaches HostShell.restart")
|
||||
|
||||
-- The Android half: LOVE keeps the JVM process after the native main returns,
|
||||
-- so the quit event exits the process outright. It has to sit after the
|
||||
-- love.quit() veto test, or the editor's abort-quit path would die on a quit
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- #1644: Fly/Teleport special warps fade white (GBFadeOutToWhite /
|
||||
-- GBFadeInFromWhite), not black door-warp shape.
|
||||
--
|
||||
-- luajit tests/engine/special_warp_white_fade_bug1644.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("special warp white fade #1644")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Timing = require("src.core.Timing")
|
||||
local Transition = require("src.render.Transition")
|
||||
|
||||
local white = { 1, 1, 1 }
|
||||
local t = Transition.new(nil, function() end, nil, false, {
|
||||
color = white,
|
||||
frames = Timing.FADE_OUT_TO_WHITE,
|
||||
framesIn = Timing.FADE_IN_FROM_WHITE,
|
||||
})
|
||||
eq(t.color[1], 1, "special warp veil is white R")
|
||||
eq(t.color[2], 1, "special warp veil is white G")
|
||||
eq(t.color[3], 1, "special warp veil is white B")
|
||||
eq(t.frames, Timing.FADE_OUT_TO_WHITE, "fade out uses FADE_OUT_TO_WHITE")
|
||||
eq(t.framesIn, Timing.FADE_IN_FROM_WHITE, "fade in uses FADE_IN_FROM_WHITE")
|
||||
|
||||
local door = Transition.new(nil, function() end, nil, true)
|
||||
eq(door.color[1], 0, "door warp stays black")
|
||||
eq(door.framesIn, 0, "door warp has no fade in")
|
||||
check(door.frames == Timing.WARP_FADE_OUT, "door warp uses WARP_FADE_OUT")
|
||||
|
||||
S.finish()
|
||||
@@ -31,16 +31,19 @@ local save = SaveData.newGame()
|
||||
local player = { name = "FIXMON", stages = {} }
|
||||
local xBattle = { player = player, kind = "wild" }
|
||||
|
||||
local _, baseline = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle)
|
||||
T.check(baseline[1]:find("ATTACK", 1, true) ~= nil,
|
||||
local _, baseline, baselineExtra = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle)
|
||||
local baselineRose = baselineExtra and baselineExtra.afterMessages
|
||||
and baselineExtra.afterMessages[1] or baseline[1]
|
||||
T.check(baselineRose:find("ATTACK", 1, true) ~= nil,
|
||||
"X ATTACK's rose! message names the stat in English with no catalog")
|
||||
|
||||
withCatalog({ ATTACK = "ATTAQUE" }, function()
|
||||
player.stages.attack = nil
|
||||
local _, msgs = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle)
|
||||
T.check(msgs[1]:find("ATTAQUE", 1, true) ~= nil,
|
||||
local _, msgs, extra = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle)
|
||||
local rose = extra and extra.afterMessages and extra.afterMessages[1] or msgs[1]
|
||||
T.check(rose:find("ATTAQUE", 1, true) ~= nil,
|
||||
"a catalog translating ATTACK reaches the X ATTACK rose! message")
|
||||
T.check(msgs[1]:find("ATTACK", 1, true) == nil,
|
||||
T.check(rose:find("ATTACK", 1, true) == nil,
|
||||
"...and the untranslated English stat name is gone")
|
||||
end)
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Title eye OBP remap lives in ImageWriter.applyTitleObp0; pixel-level
|
||||
-- coverage is tests/title_pikachu_obp_test.py (love_stub ImageData is a
|
||||
-- no-op). This file only pins the export so the Lua bake path stays wired.
|
||||
--
|
||||
-- luajit tests/engine/title_pikachu_obp_bug.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("title pikachu OBP export")
|
||||
local check = S.check
|
||||
|
||||
local ImageWriter = require("src.import.ImageWriter")
|
||||
check(type(ImageWriter.applyTitleObp0) == "function",
|
||||
"ImageWriter.applyTitleObp0 is exported for RomExtractor")
|
||||
check(type(ImageWriter.SHADES) == "table" and #ImageWriter.SHADES == 4,
|
||||
"ImageWriter.SHADES exposes the 4 DMG ramps")
|
||||
|
||||
S.finish()
|
||||
@@ -52,7 +52,10 @@ Game.save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
local stack = Game.stack
|
||||
|
||||
-- The draw guard both entity passes use: the player sprite is skipped while
|
||||
-- any of flyAnim / flyArrive / playerHidden is set.
|
||||
-- any of flyAnim / flyArrive / playerHidden is set. Dig/Teleport spinDrop
|
||||
-- does NOT skip the sprite -- EnterMapAnim draws the spinning trainer under
|
||||
-- the white fade-in (#1644) -- so "bare" is only a frame with no hide flag
|
||||
-- and no arrival (gapFrames below).
|
||||
local function playerHidden(ow)
|
||||
return ow.flyAnim ~= nil or ow.flyArrive ~= nil or ow.playerHidden == true
|
||||
end
|
||||
@@ -115,8 +118,12 @@ check(st.warpFrame ~= nil, "dig departure ends and the warp fade begins")
|
||||
check(st.fadeFrames > 0, "dig warp fade ran (" .. st.fadeFrames .. " frames)")
|
||||
eq(st.gapFrames, 0,
|
||||
"no dig fade frame leaves the player standing bare (#916)")
|
||||
check(st.fadeFramesHidden >= st.fadeFrames - 1,
|
||||
"dig fade hidden on every frame but the arrival-arming midpoint ("
|
||||
-- Dig uses white fade-out + fade-in (#1644). Draw-skip hide covers the
|
||||
-- fade-out; the fade-in deliberately shows spinDrop under the veil, so
|
||||
-- fadeFramesHidden is only the out half (allow one midpoint tick).
|
||||
local Timing = require("src.core.Timing")
|
||||
check(st.fadeFramesHidden >= Timing.FADE_OUT_TO_WHITE - 1,
|
||||
"dig fade-out hidden ("
|
||||
.. st.fadeFramesHidden .. "/" .. st.fadeFrames .. ")")
|
||||
check(st.arrivalFrame ~= nil, "dig arrival spin-down arms")
|
||||
check(ow.playerHidden == false, "dig hide cleared on the new map")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
-- #1639: Yellow-only Advanced overlay — BEACH_HOUSE world bake, sprite
|
||||
-- coverage past Red's 72 entries, saturated YELLOWMON — without changing
|
||||
-- Red/Blue Advanced.
|
||||
--
|
||||
-- luajit tests/engine/yellow_advanced_palette_bug1639.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("yellow advanced palette #1639")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local prevVer = GameVersion.get()
|
||||
local prevMode = PaletteFX.mode
|
||||
|
||||
PaletteFX.setMode("redpp")
|
||||
|
||||
-- Red: no BEACH_HOUSE, shared HOUSE still present
|
||||
GameVersion.set("red")
|
||||
check(not PaletteFX.hasWorldTileset("BEACH_HOUSE"),
|
||||
"Red Advanced has no BEACH_HOUSE tileset")
|
||||
check(PaletteFX.hasWorldTileset("HOUSE"), "Red Advanced still has HOUSE")
|
||||
local redHouse = PaletteFX.worldGroupColors(nil, "HOUSE", "PALLET_TOWN", 0)
|
||||
|
||||
-- Blue unchanged for BEACH_HOUSE
|
||||
GameVersion.set("blue")
|
||||
check(not PaletteFX.hasWorldTileset("BEACH_HOUSE"),
|
||||
"Blue Advanced has no BEACH_HOUSE tileset")
|
||||
|
||||
-- Yellow: BEACH_HOUSE present, HOUSE still inherits from Red pack
|
||||
GameVersion.set("yellow")
|
||||
check(PaletteFX.hasWorldTileset("BEACH_HOUSE"),
|
||||
"Yellow Advanced profiles BEACH_HOUSE")
|
||||
check(PaletteFX.hasWorldTileset("HOUSE"),
|
||||
"Yellow Advanced still sees shared HOUSE")
|
||||
local beach = PaletteFX.worldGroupColors(nil, "BEACH_HOUSE", "SUMMER_BEACH_HOUSE", 0)
|
||||
check(beach ~= nil and #beach == 8, "BEACH_HOUSE has 8 group colors")
|
||||
eq(beach[1][1][1], 255, "BEACH_HOUSE sand group is warm (R=255)")
|
||||
|
||||
-- Sprite: Pikachu @60 and high indices resolve; Red path for bike stays 0
|
||||
local pika = PaletteFX.spriteObp({ source = "ROM:SpriteSheetPointerTable[60]" }, "pika")
|
||||
check(pika ~= nil, "Yellow SPRITE_PIKACHU gets an OBP")
|
||||
eq(pika[2][2], 255, "Pikachu OBP mid shade is saturated yellow G=255")
|
||||
local boulder = PaletteFX.spriteObp({ source = "ROM:SpriteSheetPointerTable[72]" }, "b")
|
||||
check(boulder ~= nil, "Yellow boulder (index 72) gets an OBP")
|
||||
|
||||
GameVersion.set("red")
|
||||
local redBall = PaletteFX.spriteObp({ source = "ROM:SpriteSheetPointerTable[60]" }, "ball")
|
||||
check(redBall ~= nil, "Red poke-ball @60 still resolves")
|
||||
-- Red group 0 mid is orange skin, not pure yellow
|
||||
check(redBall[2][2] < 200, "Red @60 is not Yellow's Pikachu ramp")
|
||||
|
||||
-- YELLOWMON / MEWMON / LOGO: Advanced uses CGBBase (saturated), not washed
|
||||
-- SuperPalettes
|
||||
GameVersion.set("yellow")
|
||||
local yellowMon = PaletteFX.pal(nil, "YELLOWMON")
|
||||
eq(yellowMon[2][2], 255, "Advanced Yellow YELLOWMON mid is saturated yellow G")
|
||||
eq(yellowMon[2][3], 0, "Advanced Yellow YELLOWMON mid is pure yellow B=0")
|
||||
local mewmon = PaletteFX.pal(nil, "MEWMON")
|
||||
eq(mewmon[2][1], 255, "MEWMON stays Yellow title yellow (not Red++ purple)")
|
||||
eq(mewmon[2][2], 255, "MEWMON body is saturated yellow G=255 (not washed 156)")
|
||||
eq(mewmon[2][3], 0, "MEWMON body is pure yellow B=0")
|
||||
check(mewmon[3][3] < 150, "MEWMON cheek stays red, not purple")
|
||||
local logo2 = PaletteFX.pal(nil, "LOGO2")
|
||||
eq(logo2[2][2], 255, "LOGO2 fill is saturated yellow under Advanced")
|
||||
eq(logo2[2][3], 0, "LOGO2 fill is pure yellow B=0")
|
||||
|
||||
-- Red HOUSE colors identical before/after Yellow merge path
|
||||
GameVersion.set("red")
|
||||
local redHouse2 = PaletteFX.worldGroupColors(nil, "HOUSE", "PALLET_TOWN", 0)
|
||||
eq(redHouse2[1][1][1], redHouse[1][1][1], "Red HOUSE colors unchanged")
|
||||
|
||||
PaletteFX.setMode(prevMode)
|
||||
GameVersion.set(prevVer)
|
||||
|
||||
S.finish()
|
||||
@@ -145,8 +145,24 @@ check(type(Version.engine) == "string"
|
||||
and Semver.parse(Version.engine) ~= nil,
|
||||
"engine version parses as a semver (triple, optionally with a pre-release)")
|
||||
check(Version.modApi == 2, "mod api version is 2")
|
||||
check(Version.isDev() == true, "the working-tree placeholder is a dev build")
|
||||
check(Version.title("X") == "X v" .. Version.engine,
|
||||
"window title carries the engine version")
|
||||
"dev window title carries the engine version")
|
||||
do
|
||||
local saved = Version.engine
|
||||
Version.engine = "0.1.73"
|
||||
check(Version.isDev() == false, "a stamped X.Y.Z is not a dev build")
|
||||
check(Version.title() == "gen1recomp",
|
||||
"release window title omits the version number")
|
||||
check(Version.title("Gen 1 Recompilation Project")
|
||||
== "Gen 1 Recompilation Project",
|
||||
"release titles keep the base string alone")
|
||||
Version.engine = "1.2.3-dev"
|
||||
check(Version.isDev() == true, "a -dev pre-release still counts as dev")
|
||||
check(Version.title() == "gen1recomp v1.2.3-dev",
|
||||
"and still shows the version in the title")
|
||||
Version.engine = saved
|
||||
end
|
||||
|
||||
-- null-object runtime: emit/call are safe with no loader installed
|
||||
local nullEvents, nullHooks = Runtime.events, Runtime.hooks
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
-- A sandboxed mod can decide two battle rules the OPTION screen and the cart
|
||||
-- otherwise decide for the player -- whether a faint offers a free switch
|
||||
-- (battle.style) and whether a catch asks for a nickname (catch.nickname) --
|
||||
-- using only public mod surfaces, and neither hook touches the player's
|
||||
-- saved preference.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local FIXTURE = {
|
||||
["mods/rules_probe/manifest.json"] = [[{
|
||||
"id": "rules_probe",
|
||||
"name": "Rules Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/rules_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.style = "set"
|
||||
mod.exports.nickname = false
|
||||
mod.hooks:wrap("battle.style", function(next, battle)
|
||||
mod.exports.styleCalls = (mod.exports.styleCalls or 0) + 1
|
||||
mod.exports.styleBattle = battle
|
||||
if mod.exports.style ~= nil then return mod.exports.style end
|
||||
return next(battle)
|
||||
end)
|
||||
mod.hooks:wrap("catch.nickname", function(next, mon, ctx)
|
||||
mod.exports.nameCalls = (mod.exports.nameCalls or 0) + 1
|
||||
mod.exports.nameCtx = ctx
|
||||
if mod.exports.nickname ~= nil then return mod.exports.nickname end
|
||||
return next(mon, ctx)
|
||||
end)
|
||||
]],
|
||||
}
|
||||
|
||||
local function fixtureBattle(style)
|
||||
local data = { pokemon = {}, text = {} }
|
||||
return setmetatable({
|
||||
game = { save = { options = { battleStyle = style } },
|
||||
stack = { push = function() end }, data = data },
|
||||
data = data,
|
||||
queue = {}, nextInsert = 0,
|
||||
}, { __index = BattleState })
|
||||
end
|
||||
|
||||
-- does the queued UI, if any, put the yes/no prompt on screen?
|
||||
local function promptQueued(battle)
|
||||
for _, item in ipairs(battle.queue) do
|
||||
if item.ui then
|
||||
local state = item.ui()
|
||||
if getmetatable(state) == TextBox and state.choice then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ------- no mod: the OPTION row and the cart's AskName decide
|
||||
|
||||
local vanilla = T.sdk.loadNone({})
|
||||
T.eq(fixtureBattle("shift"):battleStyle(), "shift", "no mod: SHIFT row reads shift")
|
||||
T.eq(fixtureBattle("set"):battleStyle(), "set", "no mod: SET row reads set")
|
||||
T.eq(fixtureBattle("SET"):battleStyle(), "set", "no mod: the row is case-insensitive")
|
||||
T.eq(fixtureBattle(nil):battleStyle(), "shift", "no mod: a missing row is the cart default")
|
||||
|
||||
local plain = fixtureBattle("shift")
|
||||
local mon = { species = "RATTATA" }
|
||||
T.eq(plain:offerNickname(mon, "RATTATA"), true, "no mod: a catch queues the prompt")
|
||||
T.eq(promptQueued(plain), true, "no mod: and it is the yes/no box")
|
||||
T.eq(mon.nickname, nil, "no mod: nothing is named behind the player's back")
|
||||
vanilla.release()
|
||||
|
||||
-- ------- a mod answers both
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/rules_probe" }, { fs = T.sdk.memfs(FIXTURE) })
|
||||
T.eq(#run.errors, 0, "the rules probe loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local probe = run.loader.exports.rules_probe
|
||||
|
||||
local forced = fixtureBattle("shift")
|
||||
T.eq(forced:battleStyle(), "set", "\"set\" wins over a SHIFT row")
|
||||
T.eq(forced.game.save.options.battleStyle, "shift", "without writing the row")
|
||||
T.eq(probe.styleCalls, 1, "the hook ran once")
|
||||
T.check(probe.styleBattle == forced, "and was handed the battle")
|
||||
probe.style = "shift"
|
||||
T.eq(fixtureBattle("set"):battleStyle(), "shift", "\"shift\" wins over a SET row")
|
||||
probe.style = "banana"
|
||||
T.eq(fixtureBattle("set"):battleStyle(), "set",
|
||||
"an answer that is neither reads as the row")
|
||||
probe.style = nil
|
||||
T.eq(fixtureBattle("set"):battleStyle(), "set", "falling through reads the row")
|
||||
|
||||
local skipped = fixtureBattle("shift")
|
||||
local kept = { species = "RATTATA" }
|
||||
T.eq(skipped:offerNickname(kept, "RATTATA"), false, "false: no prompt is queued")
|
||||
T.eq(#skipped.queue, 0, "nothing at all is queued")
|
||||
T.eq(kept.nickname, nil, "and the species name is kept")
|
||||
T.eq(probe.nameCalls, 1, "the hook ran once")
|
||||
T.check(probe.nameCtx and probe.nameCtx.battle == skipped, "with the battle in ctx")
|
||||
T.eq(probe.nameCtx and probe.nameCtx.name, "RATTATA", "and the display name")
|
||||
|
||||
probe.nickname = "SPIKE"
|
||||
local named = { species = "RATTATA" }
|
||||
T.eq(fixtureBattle("shift"):offerNickname(named, "RATTATA"), false,
|
||||
"a string: no prompt either")
|
||||
T.eq(named.nickname, "SPIKE", "and it is the nickname")
|
||||
|
||||
probe.nickname = "TOOLONGFORTHEGRID"
|
||||
local clipped = { species = "RATTATA" }
|
||||
fixtureBattle("shift"):offerNickname(clipped, "RATTATA")
|
||||
T.eq(clipped.nickname, "TOOLONGFOR", "a long string is clipped to the grid's ten")
|
||||
|
||||
probe.nickname = ""
|
||||
local blank = { species = "RATTATA" }
|
||||
T.eq(fixtureBattle("shift"):offerNickname(blank, "RATTATA"), false,
|
||||
"an empty string still declines the prompt")
|
||||
T.eq(blank.nickname, nil, "and names nothing, like the grid's own empty entry")
|
||||
|
||||
probe.nickname = nil
|
||||
local asked = fixtureBattle("shift")
|
||||
T.eq(asked:offerNickname({ species = "RATTATA" }, "RATTATA"), true,
|
||||
"falling through asks as usual")
|
||||
T.eq(promptQueued(asked), true, "with the real prompt")
|
||||
|
||||
run.release()
|
||||
T.finish("battle rule hooks")
|
||||
@@ -17,10 +17,11 @@ local saved = {
|
||||
pickFileKinds = love.system.pickFileKinds,
|
||||
}
|
||||
|
||||
local pickCalls = {}
|
||||
local pickCalls, pickDestinations = {}, {}
|
||||
love.system.getOS = function() return "Android" end
|
||||
love.system.pickFile = function(kind)
|
||||
love.system.pickFile = function(kind, destination)
|
||||
pickCalls[#pickCalls + 1] = kind or "rom"
|
||||
pickDestinations[#pickCalls] = destination
|
||||
return true
|
||||
end
|
||||
love.system.pickFileKinds = function() return "rom,mod,sav,required_import" end
|
||||
@@ -103,13 +104,15 @@ ri.nativePicker = true
|
||||
ri.mobileFileBridge = true
|
||||
ri.mods = { {
|
||||
id = "needs_source",
|
||||
manifest = { id = "needs_source", name = "Needs Source",
|
||||
manifest = { id = "needs_source", name = "Needs Source", path = "mods/needs_source",
|
||||
required_imports = { { id = "source", name = "Source", file = "source.bin",
|
||||
format = "raw", md5 = { "00000000000000000000000000000000" } } } },
|
||||
} }
|
||||
ri:chooseRequiredImport("needs_source", "source")
|
||||
eq(pickCalls[1], "required_import",
|
||||
"required file asks for the dedicated picker kind")
|
||||
eq(pickDestinations[1], "mods/needs_source/baseroms/source.bin",
|
||||
"raw required file receives its direct private baseroms destination")
|
||||
eq(ri.pickerPendingModId, "needs_source", "pending mod is remembered")
|
||||
eq(ri.pickerPendingImportId, "source", "pending import is remembered")
|
||||
|
||||
@@ -166,6 +169,52 @@ eq(ri._requiredImported.importId, "source", "focus routes to the pending declara
|
||||
check(love.filesystem.getInfo("picked_required_import.bin") == nil,
|
||||
"focus removes the staged required-file pick")
|
||||
|
||||
|
||||
-- Current bridge completion: the large source already lives in final baseroms;
|
||||
-- only the tiny path/digest/size marker crosses the launcher focus path.
|
||||
local directPath = "mods/needs_source/baseroms/source.bin"
|
||||
local directSavedGetInfo = love.filesystem.getInfo
|
||||
love.filesystem.getInfo = function(name, kind)
|
||||
local info = directSavedGetInfo(name, kind)
|
||||
if info and name == directPath then
|
||||
info.size = 12
|
||||
info.modtime = 123456
|
||||
end
|
||||
return info
|
||||
end
|
||||
love.filesystem.createDirectory("mods/needs_source/baseroms")
|
||||
love.filesystem.write(directPath, "source bytes")
|
||||
ri.mods[1].manifest.required_imports[1].md5 = {
|
||||
"fe1eb7483479c3a4e44fd41ce6f6d6ad"
|
||||
}
|
||||
ri.pickerPendingKind = "required_import"
|
||||
ri.pickerPendingModId = "needs_source"
|
||||
ri.pickerPendingImportId = "source"
|
||||
love.filesystem.write("pick_complete.flag",
|
||||
"v1\n" .. directPath .. "\nfe1eb7483479c3a4e44fd41ce6f6d6ad\n12\n")
|
||||
ri:focus(true)
|
||||
check(ri.modNotice ~= nil and ri.modNotice.ok == true,
|
||||
"focus accepts a direct native required-import completion")
|
||||
check(love.filesystem.getInfo(directPath, "file") ~= nil,
|
||||
"direct required import remains in final baseroms")
|
||||
check(love.filesystem.getInfo("pick_complete.flag") == nil,
|
||||
"direct completion marker is consumed")
|
||||
|
||||
-- A native digest that does not match the manifest is rejected and the direct
|
||||
-- copy is removed, so an invalid source cannot masquerade as a validated one.
|
||||
love.filesystem.write(directPath, "source bytes")
|
||||
ri.pickerPendingKind = "required_import"
|
||||
ri.pickerPendingModId = "needs_source"
|
||||
ri.pickerPendingImportId = "source"
|
||||
love.filesystem.write("pick_complete.flag",
|
||||
"v1\n" .. directPath .. "\n00000000000000000000000000000000\n12\n")
|
||||
ri:focus(true)
|
||||
check(ri.requiredImportNotice ~= nil,
|
||||
"bad direct digest reports a dependency validation error")
|
||||
check(love.filesystem.getInfo(directPath, "file") == nil,
|
||||
"bad direct digest removes the rejected final copy")
|
||||
love.filesystem.getInfo = directSavedGetInfo
|
||||
|
||||
-- Android releases with the updated launcher but the older native bridge do
|
||||
-- not advertise required_import. They still support the established ROM SAF
|
||||
-- picker, whose result must be quarantined to the pending dependency request.
|
||||
@@ -176,12 +225,14 @@ ri.nativePicker = true
|
||||
ri.mobileFileBridge = true
|
||||
ri.mods = { {
|
||||
id = "needs_source",
|
||||
manifest = { id = "needs_source", name = "Needs Source",
|
||||
manifest = { id = "needs_source", name = "Needs Source", path = "mods/needs_source",
|
||||
required_imports = { { id = "source", name = "Source", file = "source.bin",
|
||||
format = "raw", md5 = { "00000000000000000000000000000000" } } } },
|
||||
} }
|
||||
ri:chooseRequiredImport("needs_source", "source")
|
||||
eq(pickCalls[1], "rom", "legacy Android bridge falls back to its ROM SAF picker")
|
||||
check(pickDestinations[1] == nil,
|
||||
"legacy ROM fallback receives no nested dependency destination")
|
||||
check(ri.requiredImportLegacyRomPick,
|
||||
"legacy Android ROM picker result is marked as a required import")
|
||||
ri._importRequiredSource = function(self, modId, importId, source)
|
||||
@@ -216,5 +267,8 @@ love.filesystem.remove("picked_mod.zip")
|
||||
love.filesystem.remove("picked_save.sav")
|
||||
love.filesystem.remove("picked_required_import.bin")
|
||||
love.filesystem.remove("picked_rom.gb")
|
||||
love.filesystem.remove("pick_complete.flag")
|
||||
love.filesystem.remove("pick_error.flag")
|
||||
love.filesystem.remove("mods/needs_source/baseroms/source.bin")
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -102,5 +102,44 @@ class ApplyKnownNonreproducibleOverridesTest(TestCase):
|
||||
texts, field_data)
|
||||
|
||||
|
||||
class YellowSuperRodParseTest(TestCase):
|
||||
"""#1074: Yellow's inline Super Rod table (species, level), not Red's."""
|
||||
|
||||
def test_parse_super_rod_yellow_safari_dragonair(self):
|
||||
import json
|
||||
import tempfile
|
||||
from extract import field
|
||||
|
||||
asm = (
|
||||
"SuperRodFishingSlots::\n"
|
||||
"\tdb SAFARI_ZONE_CENTER, MAGIKARP, 5, MAGIKARP, 10, "
|
||||
"DRATINI, 10, DRAGONAIR, 15\n"
|
||||
"\tdb SAFARI_ZONE_EAST, MAGIKARP, 5, MAGIKARP, 10, "
|
||||
"MAGIKARP, 15, DRATINI, 15\n"
|
||||
"\tdb -1 ; end\n"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
wild = Path(td) / "data" / "wild"
|
||||
wild.mkdir(parents=True)
|
||||
(wild / "super_rod.asm").write_text(asm)
|
||||
parsed = field.parse_super_rod_yellow(td)
|
||||
|
||||
self.assertEqual(parsed["SAFARI_ZONE_CENTER"], [
|
||||
{"level": 5, "species": "MAGIKARP"},
|
||||
{"level": 10, "species": "MAGIKARP"},
|
||||
{"level": 10, "species": "DRATINI"},
|
||||
{"level": 15, "species": "DRAGONAIR"},
|
||||
])
|
||||
self.assertNotIn("DRAGONAIR",
|
||||
[s["species"] for s in parsed["SAFARI_ZONE_EAST"]])
|
||||
|
||||
def test_shipped_yellow_manifest_has_safari_dragonair(self):
|
||||
import json
|
||||
path = Path(__file__).resolve().parents[1] / "tools" / "rom_manifest_yellow.json"
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
center = data["field"]["superRod"]["SAFARI_ZONE_CENTER"]
|
||||
self.assertIn({"level": 15, "species": "DRAGONAIR"}, center)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -927,6 +927,113 @@ do
|
||||
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
|
||||
end
|
||||
|
||||
do
|
||||
-- Move picker search predicate: same plain-text rules as species (#541),
|
||||
-- plus type substring and whole-number power / accuracy matches.
|
||||
local S = State.new()
|
||||
S.data = Data
|
||||
S.cat = Catalog.build(Data)
|
||||
S.save = SaveData.newGame()
|
||||
|
||||
check(Ops.moveMatches(S, "THUNDERBOLT", "thunder"), "lowercase query matches an id")
|
||||
check(Ops.moveMatches(S, "THUNDERBOLT", "BOLT"), "a mid-word substring matches")
|
||||
check(Ops.moveMatches(S, "THUNDERBOLT", "electric"), "a type substring matches")
|
||||
check(Ops.moveMatches(S, "THUNDERBOLT", "95"), "a whole power matches")
|
||||
check(Ops.moveMatches(S, "THUNDERBOLT", "100"), "a whole accuracy matches")
|
||||
check(Ops.moveMatches(S, "THUNDERBOLT", "9") == false,
|
||||
"a power/accuracy number does not substring-match")
|
||||
check(Ops.moveMatches(S, "THUNDERBOLT", ""), "an empty query matches everything")
|
||||
check(Ops.moveUsable(S, "THUNDERBOLT"), "a real move is usable")
|
||||
check(Ops.moveUsable(S, "generation") == false, "a provenance scalar is not usable")
|
||||
|
||||
eq(#Ops.moveSearch(S, ""), #S.cat.moves, "an empty search lists the catalog")
|
||||
eq(#Ops.moveSearch(S, "%a"), 0, "a pattern class is literal")
|
||||
check(#Ops.moveSearch(S, "zzzznope") == 0, "a miss returns nothing")
|
||||
local bolt = Ops.moveSearch(S, "thunderbolt")
|
||||
eq(#bolt, 1, "an exact name search narrows to one")
|
||||
eq(bolt[1], "THUNDERBOLT", "and it is the right one")
|
||||
|
||||
-- Prefix beats mid-string: "sur" used to list ACUPRESSURE / FISSURE before
|
||||
-- SURF because the catalog is A-Z. Rank so Enter commits the obvious hit.
|
||||
local sur = Ops.moveSearch(S, "sur")
|
||||
check(#sur >= 1, "sur finds at least one move")
|
||||
eq(sur[1], "SURF", "a prefix match ranks above mid-string hits")
|
||||
local thunder = Ops.moveSearch(S, "thunder")
|
||||
check(#thunder >= 1, "thunder finds at least one move")
|
||||
eq(thunder[1], "THUNDER", "THUNDER prefixes beat THUNDERBOLT / THUNDERSHOCK")
|
||||
end
|
||||
|
||||
do
|
||||
-- Move picker end to end through App: open off a move row, type, commit
|
||||
-- with Enter. Same Enter/Escape-before-Kit rule as the species picker.
|
||||
local Kit = require("Kit")
|
||||
local MovePicker = require("MovePicker")
|
||||
local tmpPath = os.tmpname() .. "-movepicker-save.lua"
|
||||
local data = SaveData.newGame()
|
||||
data.party = { MonOps.create(Data, "WARTORTLE", 20) }
|
||||
local f = io.open(tmpPath, "wb")
|
||||
f:write(SaveData.encode(data))
|
||||
f:close()
|
||||
|
||||
App.load(tmpPath, { version = "red" })
|
||||
local S = App.getState()
|
||||
S.tab = "party"
|
||||
|
||||
check(Ops.openMovePicker(S, Kit, 1) == false,
|
||||
"the move picker refuses to open with no slot selected")
|
||||
check(S.movePicker == nil, "and it stayed closed")
|
||||
check(S.status:match("Pick a slot") ~= nil, "and it said why")
|
||||
|
||||
Ops.selectParty(S, 1)
|
||||
check(Ops.openMovePicker(S, Kit, 0) == false, "slot 0 is refused")
|
||||
check(Ops.openMovePicker(S, Kit, 1) == true, "the move picker opens on slot 1")
|
||||
check(S.movePicker ~= nil, "the picker is up")
|
||||
eq(S.movePicker.slot, 1, "for the requested slot")
|
||||
eq(S.movePicker.query, "", "it opens with an empty query")
|
||||
eq(Kit.focus, "move-picker", "it opens with the field focused (#529 keyboard)")
|
||||
|
||||
local ok, err = pcall(App.draw)
|
||||
check(ok, "the move picker draws headlessly: " .. tostring(err))
|
||||
|
||||
App.textinput("THUNDERBOLT")
|
||||
ok, err = pcall(App.draw)
|
||||
check(ok, "the move picker draws while typing: " .. tostring(err))
|
||||
eq(S.movePicker.query, "THUNDERBOLT", "typing reaches the picker's field")
|
||||
eq(#MovePicker.results(S), 1, "the list narrowed to the typed move")
|
||||
|
||||
App.keypressed("return")
|
||||
eq(S.save.party[1].moves[1].id, "THUNDERBOLT", "Enter commits the top match")
|
||||
check(S.movePicker == nil, "and closes the picker")
|
||||
check(S.dirty, "and the save is dirty")
|
||||
|
||||
-- Escape leaves without touching the mon
|
||||
local before = S.save.party[1].moves[1].id
|
||||
Ops.openMovePicker(S, Kit, 1)
|
||||
App.textinput("SURF")
|
||||
App.draw()
|
||||
App.keypressed("escape")
|
||||
check(S.movePicker == nil, "Escape closes the move picker")
|
||||
eq(S.save.party[1].moves[1].id, before, "Escape did not commit anything")
|
||||
check(S.editingMon ~= nil, "Escape closed the picker, not the selection")
|
||||
|
||||
-- a query nothing matches cannot commit
|
||||
Ops.openMovePicker(S, Kit, 2)
|
||||
App.textinput("zzzznope")
|
||||
App.draw()
|
||||
App.keypressed("return")
|
||||
check(S.movePicker ~= nil, "Enter on an empty result set keeps the picker up")
|
||||
check(S.status:match("No move matches") ~= nil, "and says so")
|
||||
Ops.closeMovePicker(S, Kit)
|
||||
|
||||
-- Ops.setMove refuses a scalar / missing id
|
||||
check(Ops.setMove(S, S.editingMon, 1, "generation") == false,
|
||||
"setMove refuses a provenance scalar")
|
||||
eq(S.save.party[1].moves[1].id, "THUNDERBOLT", "and leaves the slot alone")
|
||||
|
||||
os.remove(tmpPath)
|
||||
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
|
||||
end
|
||||
|
||||
do
|
||||
-- The inspector's nickname field is commit-on-Enter: the draft lives in
|
||||
-- S.nicknameDraft while typing, Enter commits it through Ops.setNickname,
|
||||
@@ -1127,10 +1234,13 @@ do
|
||||
|
||||
-- 720x1280 / 1280x720 are the #715 report's shapes (Android, both
|
||||
-- orientations): the Map tab used to lay its viewport out at a negative
|
||||
-- width in portrait and crash on the scissor. The desktop sizes pin that
|
||||
-- the responsive reflow does not disturb the layouts that already worked.
|
||||
-- width in portrait and crash on the scissor. RGxxx / Switch shapes pin
|
||||
-- short-landscape handhelds (RG34XXSP 720x480, RG35XX 640x480, NX 1280x720)
|
||||
-- and a tiny 360x640 phone. Desktop sizes keep the layouts that already
|
||||
-- worked.
|
||||
for _, size in ipairs({ { 720, 1560 }, { 1560, 720 }, { 480, 1040 },
|
||||
{ 1280, 800 }, { 720, 1280 }, { 1280, 720 },
|
||||
{ 720, 480 }, { 640, 480 }, { 480, 320 },
|
||||
{ 1024, 768 }, { 1920, 1080 }, { 360, 640 } }) do
|
||||
love.graphics.getDimensions = function() return size[1], size[2] end
|
||||
App.load(tmpPath, { version = "red" })
|
||||
@@ -1153,6 +1263,12 @@ do
|
||||
ok, err = pcall(App.draw)
|
||||
check(ok, ("the species picker redraws at %s: %s"):format(label, tostring(err)))
|
||||
Ops.closeSpeciesPicker(S, Kit)
|
||||
Ops.openMovePicker(S, Kit, 1)
|
||||
ok, err = pcall(App.draw)
|
||||
check(ok, ("the move picker draws at %s: %s"):format(label, tostring(err)))
|
||||
ok, err = pcall(App.draw)
|
||||
check(ok, ("the move picker redraws at %s: %s"):format(label, tostring(err)))
|
||||
Ops.closeMovePicker(S, Kit)
|
||||
end
|
||||
|
||||
love.graphics.getDimensions = oldDimensions
|
||||
@@ -1233,8 +1349,12 @@ do
|
||||
f:close()
|
||||
|
||||
local oldDimensions = love.graphics.getDimensions
|
||||
-- Include RG34XXSP (720x480) and Switch handheld; keep 640x480 out of the
|
||||
-- overlap audit (Map still needs its own short-landscape pass) but the
|
||||
-- phone draw suite above already covers it.
|
||||
local sizes = { { 500, 800 }, { 720, 1280 }, { 1280, 720 },
|
||||
{ 1024, 768 }, { 900, 700 }, { 1920, 1080 } }
|
||||
{ 720, 480 }, { 1024, 768 },
|
||||
{ 900, 700 }, { 1920, 1080 } }
|
||||
for _, size in ipairs(sizes) do
|
||||
local W, H = size[1], size[2]
|
||||
love.graphics.getDimensions = function() return W, H end
|
||||
@@ -1266,6 +1386,14 @@ do
|
||||
if ok then auditFrame(("%dx%d species picker"):format(W, H), W, H) end
|
||||
Kit.audit = nil
|
||||
Ops.closeSpeciesPicker(S, Kit)
|
||||
Ops.openMovePicker(S, Kit, 1)
|
||||
App.draw()
|
||||
Kit.audit = {}
|
||||
ok, err = pcall(App.draw)
|
||||
check(ok, ("%dx%d move picker draws: %s"):format(W, H, tostring(err)))
|
||||
if ok then auditFrame(("%dx%d move picker"):format(W, H), W, H) end
|
||||
Kit.audit = nil
|
||||
Ops.closeMovePicker(S, Kit)
|
||||
end
|
||||
love.graphics.getDimensions = oldDimensions
|
||||
|
||||
@@ -1349,5 +1477,150 @@ do
|
||||
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
|
||||
end
|
||||
|
||||
do
|
||||
-- #917: Pixel 9a Save was dead because the title cluster sat in the
|
||||
-- cutout / status unsafe band. Chrome and modal search fields must live
|
||||
-- inside love.window.getSafeArea (background may still paint full-bleed).
|
||||
local Kit = require("Kit")
|
||||
local tmpPath = os.tmpname() .. "-safe-area-save.lua"
|
||||
local f = io.open(tmpPath, "wb")
|
||||
f:write(SaveData.encode(SaveData.newGame()))
|
||||
f:close()
|
||||
|
||||
local W, H = 720, 1560
|
||||
local ox, oy, sw, sh = 0, 64, 720, 1456 -- punch-hole + home indicator
|
||||
local oldDimensions = love.graphics.getDimensions
|
||||
local oldSafe = love.window.getSafeArea
|
||||
love.graphics.getDimensions = function() return W, H end
|
||||
love.window.getSafeArea = function() return ox, oy, sw, sh end
|
||||
|
||||
App.load(tmpPath, { version = "blue" })
|
||||
local S = App.getState()
|
||||
Ops.partyAdd(S)
|
||||
S.dirty = true
|
||||
S.allowSave = true
|
||||
S.tab = "party"
|
||||
|
||||
local function insideSafe(rects, where)
|
||||
for _, r in ipairs(rects or {}) do
|
||||
if r.class == "control" then
|
||||
check(r.x >= ox - 0.5,
|
||||
where .. " '" .. r.label .. "' is right of the left inset")
|
||||
check(r.y >= oy - 0.5,
|
||||
where .. " '" .. r.label .. "' is below the notch (#917)")
|
||||
check(r.x + r.w <= ox + sw + 0.5,
|
||||
where .. " '" .. r.label .. "' stays left of the right inset")
|
||||
check(r.y + r.h <= oy + sh + 0.5,
|
||||
where .. " '" .. r.label .. "' stays above the home indicator")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Kit.audit = {}
|
||||
local ok, err = pcall(App.draw)
|
||||
check(ok, "editor draws inside an inset safe area: " .. tostring(err))
|
||||
insideSafe(Kit.audit, "chrome")
|
||||
|
||||
local saveBtn
|
||||
for _, r in ipairs(Kit.audit) do
|
||||
if r.class == "control" and (r.label == "SAVE" or r.label == "SAVED"
|
||||
or r.label == "SAVE LOCKED") then
|
||||
saveBtn = r
|
||||
break
|
||||
end
|
||||
end
|
||||
check(saveBtn ~= nil, "the Save button was audited")
|
||||
if saveBtn then
|
||||
check(saveBtn.y >= oy - 0.5,
|
||||
"Save clears the top safe inset (#917)")
|
||||
check(saveBtn.y + saveBtn.h <= oy + sh + 0.5,
|
||||
"Save stays above the bottom safe inset")
|
||||
end
|
||||
Kit.audit = nil
|
||||
|
||||
-- Species-picker search field must also clear the notch once chrome is inset.
|
||||
Ops.openSpeciesPicker(S, Kit)
|
||||
App.draw() -- opening frame is fully shielded
|
||||
Kit.audit = {}
|
||||
ok, err = pcall(App.draw)
|
||||
check(ok, "species picker draws inside an inset safe area: " .. tostring(err))
|
||||
insideSafe(Kit.audit, "species picker")
|
||||
local search
|
||||
for _, r in ipairs(Kit.audit) do
|
||||
if r.class == "control" and r.label == "species-picker" then
|
||||
search = r
|
||||
break
|
||||
end
|
||||
end
|
||||
check(search ~= nil, "the species search field was audited")
|
||||
if search then
|
||||
check(search.y >= oy - 0.5,
|
||||
"species search clears the top safe inset (#917)")
|
||||
end
|
||||
Kit.audit = nil
|
||||
Ops.closeSpeciesPicker(S, Kit)
|
||||
|
||||
love.graphics.getDimensions = oldDimensions
|
||||
love.window.getSafeArea = oldSafe
|
||||
os.remove(tmpPath)
|
||||
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
|
||||
end
|
||||
|
||||
do
|
||||
-- PickerChrome: short RGxxx / phone landscapes must nearly fill SafeArea
|
||||
-- (not sit in a 32px-guttered desk card that leaves no list body), and every
|
||||
-- interactive metric stays at or above the 26px tap floor.
|
||||
local Kit = require("Kit")
|
||||
local PickerChrome = require("PickerChrome")
|
||||
local oldDimensions = love.graphics.getDimensions
|
||||
local oldSafe = love.window.getSafeArea
|
||||
|
||||
local function checkDevice(W, H, safe, label)
|
||||
love.graphics.getDimensions = function() return W, H end
|
||||
if safe then
|
||||
love.window.getSafeArea = function()
|
||||
return safe[1], safe[2], safe[3], safe[4]
|
||||
end
|
||||
else
|
||||
love.window.getSafeArea = function() return 0, 0, W, H end
|
||||
end
|
||||
Kit.layout(safe and safe[3] or W, safe and safe[4] or H)
|
||||
local x, y, w, h, pad = PickerChrome.card(Kit, W, H)
|
||||
local ox = safe and safe[1] or 0
|
||||
local oy = safe and safe[2] or 0
|
||||
local sw = safe and safe[3] or W
|
||||
local sh = safe and safe[4] or H
|
||||
check(w > 0 and h > 0, label .. ": card has positive size")
|
||||
check(x >= ox - 0.5 and y >= oy - 0.5,
|
||||
label .. ": card origin stays inside the safe rect")
|
||||
check(x + w <= ox + sw + 0.5 and y + h <= oy + sh + 0.5,
|
||||
label .. ": card fits inside the safe rect")
|
||||
-- Short landscapes should use almost all of the safe height.
|
||||
if sh <= 560 * Kit.scale + 40 then
|
||||
check(h >= sh * 0.85,
|
||||
label .. ": short landscape card fills most of the safe height")
|
||||
end
|
||||
local tap = PickerChrome.tapMin(Kit)
|
||||
check(tap >= 26, label .. ": tapMin is at least 26px")
|
||||
check(PickerChrome.fieldH(Kit) >= tap, label .. ": search field meets tapMin")
|
||||
check(PickerChrome.closeSize(Kit) >= tap, label .. ": close meets tapMin")
|
||||
local listH, rowH = PickerChrome.listMetrics(Kit, y, h, pad,
|
||||
y + pad + 80 * Kit.scale)
|
||||
check(listH >= 0, label .. ": list height is non-negative")
|
||||
check(rowH >= tap, label .. ": list rows meet tapMin")
|
||||
end
|
||||
|
||||
checkDevice(720, 480, nil, "RG34XXSP 720x480")
|
||||
checkDevice(640, 480, nil, "RG35XX 640x480")
|
||||
checkDevice(1280, 720, nil, "Switch handheld 1280x720")
|
||||
checkDevice(720, 1280, { 0, 64, 720, 1176 }, "Pixel portrait + notch")
|
||||
checkDevice(1280, 720, { 48, 0, 1184, 720 }, "landscape + side cutout")
|
||||
checkDevice(360, 640, nil, "tiny phone 360x640")
|
||||
checkDevice(1920, 1080, nil, "desktop 1080p")
|
||||
|
||||
love.graphics.getDimensions = oldDimensions
|
||||
love.window.getSafeArea = oldSafe
|
||||
end
|
||||
|
||||
print(string.format("save editor tests: %d passed, %d failed", passed, failed))
|
||||
if failed > 0 then os.exit(1) end
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Title rOBP0=$E0 remaps eye OAM shades 1/2 to white (#1639 pupils)."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest import TestCase, main
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools"))
|
||||
import build_rom_data as b # noqa: E402
|
||||
from PIL import Image # noqa: E402
|
||||
|
||||
|
||||
class TitleObp0Test(TestCase):
|
||||
def test_mid_and_dark_become_white(self):
|
||||
img = Image.new("RGBA", (4, 1), (0, 0, 0, 0))
|
||||
img.putpixel((0, 0), b.GB_SHADES[1])
|
||||
img.putpixel((1, 0), b.GB_SHADES[2])
|
||||
img.putpixel((2, 0), b.GB_SHADES[3])
|
||||
img.putpixel((3, 0), b.GB_SHADES[0])
|
||||
b._apply_title_obp0(img)
|
||||
self.assertEqual(img.getpixel((0, 0)), b.GB_SHADES[0])
|
||||
self.assertEqual(img.getpixel((1, 0)), b.GB_SHADES[0])
|
||||
self.assertEqual(img.getpixel((2, 0)), b.GB_SHADES[3])
|
||||
self.assertEqual(img.getpixel((3, 0)), b.GB_SHADES[0])
|
||||
|
||||
def test_transparent_untouched(self):
|
||||
img = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
|
||||
b._apply_title_obp0(img)
|
||||
self.assertEqual(img.getpixel((0, 0))[3], 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -22,9 +22,12 @@ eq(mewmonSgb[3][1], 222, "SGB: Color 2 is red R=222")
|
||||
PaletteFX.setMode("redpp")
|
||||
local mewmonAdv = PaletteFX.pal(nil, "MEWMON")
|
||||
check(mewmonAdv ~= nil, "MEWMON palette exists in ADVANCED mode on Yellow")
|
||||
-- Must NOT be Red++'s Mew purple {115, 33, 165}! It must be Yellow's Pikachu palette!
|
||||
-- Must NOT be Red++'s Mew purple {115, 33, 165}! It must be Yellow's Pikachu
|
||||
-- CGBBase yellow {255,255,0}, not washed SuperPalette cream {255,255,156}.
|
||||
eq(mewmonAdv[1][1], 255, "ADVANCED: Color 0 is white R=255 (Pikachu eye sclera)")
|
||||
eq(mewmonAdv[2][1], 255, "ADVANCED: Color 1 is yellow R=255 (Pikachu body)")
|
||||
eq(mewmonAdv[2][2], 255, "ADVANCED: Color 1 is saturated yellow G=255")
|
||||
eq(mewmonAdv[2][3], 0, "ADVANCED: Color 1 is pure yellow B=0 (not washed cream)")
|
||||
check(mewmonAdv[3][3] < 150, "ADVANCED: Color 2 is not purple (B < 150, red cheeks)")
|
||||
|
||||
-- Test 3: OG YELLOW mode (ogred on Yellow)
|
||||
|
||||
+42
-1
@@ -77,6 +77,26 @@ GB_SHADES = (
|
||||
)
|
||||
|
||||
|
||||
def _apply_title_obp0(image):
|
||||
"""Title rOBP0=%11100000 ($E0): OBJ shades 1 and 2 → white, 3 → black.
|
||||
|
||||
Eye OAM is baked into the MEWMON-colored BG PNG; without this remap the
|
||||
shade-1 glints become body yellow under the title palette
|
||||
(pokeyellow engine/movie/title.asm after PlacePikachu).
|
||||
"""
|
||||
pixels = image.load()
|
||||
w, h = image.size
|
||||
mid, dark = GB_SHADES[1][0], GB_SHADES[2][0]
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r, g, b, a = pixels[x, y]
|
||||
if a == 0:
|
||||
continue
|
||||
if abs(r - mid) <= 2 or abs(r - dark) <= 2:
|
||||
pixels[x, y] = GB_SHADES[0]
|
||||
return image
|
||||
|
||||
|
||||
def _symbol(symbols, name):
|
||||
try:
|
||||
return symbols[name]
|
||||
@@ -1880,12 +1900,33 @@ def extract_field(rom, symbols, manifest, out_dir, assets_dir):
|
||||
(3, 24, 24, True), (2, 32, 24, True),
|
||||
(0, 56, 16, False), (1, 64, 16, False),
|
||||
(2, 56, 24, False), (3, 64, 24, False)):
|
||||
eye = ob_clear[ob_index]
|
||||
eye = ob_clear[ob_index].copy()
|
||||
_apply_title_obp0(eye)
|
||||
if flip:
|
||||
eye = eye.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
||||
pikachu.paste(eye, (px, py), eye)
|
||||
_save_png(pikachu, os.path.join(assets_dir, "title/pikachu.png"))
|
||||
|
||||
# Blink overlays (half/closed) — same OBP remap as open eyes.
|
||||
eye_layout = (
|
||||
(1, 24, 16, True), (0, 32, 16, True),
|
||||
(3, 24, 24, True), (2, 32, 24, True),
|
||||
(0, 56, 16, False), (1, 64, 16, False),
|
||||
(2, 56, 24, False), (3, 64, 24, False),
|
||||
)
|
||||
# Re-compose blank-face pika for overlays (open eyes already baked).
|
||||
blank_face = matte_color0(compose(13, 9, pika_cells))
|
||||
for suffix, base in (("eyes_half", 4), ("eyes_closed", 8)):
|
||||
overlay = Image.new("RGBA", (48, 16), (255, 255, 255, 0))
|
||||
overlay.paste(blank_face.crop((24, 16, 72, 32)), (0, 0))
|
||||
for ob_index, px, py, flip in eye_layout:
|
||||
eye = ob_clear[base + ob_index].copy()
|
||||
_apply_title_obp0(eye)
|
||||
if flip:
|
||||
eye = eye.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
||||
overlay.paste(eye, (px - 24, py - 16), eye)
|
||||
_save_png(overlay, os.path.join(assets_dir, f"title/{suffix}.png"))
|
||||
|
||||
falling_star = raw_2bpp(
|
||||
"FallingStar", 8, 8, "intro/falling_star.png",
|
||||
transparent=True)
|
||||
|
||||
@@ -86,6 +86,43 @@ def parse_super_rod(pokered):
|
||||
return out
|
||||
|
||||
|
||||
def parse_super_rod_yellow(pokeyellow):
|
||||
"""pokeyellow data/wild/super_rod.asm: inline species,level rows.
|
||||
|
||||
Yellow stores four (species, level) pairs per map on one `db` line
|
||||
(`db MAP, SPECIES, LEVEL, SPECIES, LEVEL, ...`), unlike Red's
|
||||
`dbw MAP, .Group` + `db level, species` groups. Slot order is kept
|
||||
so Super Rod weighted rolls and DexNav lists stay faithful (#1074).
|
||||
"""
|
||||
out = {}
|
||||
path = os.path.join(pokeyellow, "data/wild/super_rod.asm")
|
||||
for lineno, line in read_asm(path):
|
||||
s = line.strip()
|
||||
if not s.startswith("db ") or s == "db -1" or s.startswith("db -1 ;"):
|
||||
continue
|
||||
# db MAP, SPECIES, LEVEL, SPECIES, LEVEL, SPECIES, LEVEL, SPECIES, LEVEL
|
||||
parts = [p.strip() for p in s[3:].split(",")]
|
||||
if len(parts) < 3 or not re.match(r"^[A-Z][A-Z0-9_]*$", parts[0]):
|
||||
continue
|
||||
map_id = parts[0]
|
||||
slots = []
|
||||
rest = parts[1:]
|
||||
i = 0
|
||||
while i + 1 < len(rest):
|
||||
species, level = rest[i], rest[i + 1]
|
||||
if not re.match(r"^[A-Z][A-Z0-9_]*$", species):
|
||||
break
|
||||
try:
|
||||
level_n = int(level)
|
||||
except ValueError:
|
||||
break
|
||||
slots.append({"level": level_n, "species": species})
|
||||
i += 2
|
||||
if slots:
|
||||
out[map_id] = slots
|
||||
return out
|
||||
|
||||
|
||||
def parse_trades(pokered):
|
||||
"""data/events/trades.asm: npctrade give, get, dialogset, nickname."""
|
||||
# TRADE_DIALOGSET_* order (constants/script_constants.asm) indexes
|
||||
|
||||
@@ -453,6 +453,7 @@ def derive(red, pokeyellow, symbols_path):
|
||||
print(f"warning: parse_credits failed ({exc}); keeping Red credits")
|
||||
# TODO: hand-author a Yellow credits banner if pret layout drifts.
|
||||
yellow["field"]["trades"] = field.parse_trades(pokeyellow)
|
||||
yellow["field"]["superRod"] = field.parse_super_rod_yellow(pokeyellow)
|
||||
finally:
|
||||
util.ASM_DEFINES = saved
|
||||
|
||||
|
||||
+306
-250
@@ -8360,94 +8360,74 @@
|
||||
"superRod": {
|
||||
"CELADON_CITY": [
|
||||
{
|
||||
"level": 23,
|
||||
"species": "POLIWHIRL"
|
||||
"level": 5,
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SLOWPOKE"
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 20,
|
||||
"species": "GOLDEEN"
|
||||
}
|
||||
],
|
||||
"CERULEAN_CAVE_1F": [
|
||||
{
|
||||
"level": 23,
|
||||
"species": "SLOWBRO"
|
||||
"level": 25,
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 23,
|
||||
"level": 35,
|
||||
"species": "SEAKING"
|
||||
},
|
||||
{
|
||||
"level": 23,
|
||||
"species": "KINGLER"
|
||||
},
|
||||
{
|
||||
"level": 23,
|
||||
"species": "SEADRA"
|
||||
}
|
||||
],
|
||||
"CERULEAN_CAVE_2F": [
|
||||
{
|
||||
"level": 23,
|
||||
"species": "SLOWBRO"
|
||||
},
|
||||
{
|
||||
"level": 23,
|
||||
"level": 45,
|
||||
"species": "SEAKING"
|
||||
},
|
||||
{
|
||||
"level": 23,
|
||||
"species": "KINGLER"
|
||||
},
|
||||
{
|
||||
"level": 23,
|
||||
"species": "SEADRA"
|
||||
"level": 55,
|
||||
"species": "SEAKING"
|
||||
}
|
||||
],
|
||||
"CERULEAN_CAVE_B1F": [
|
||||
{
|
||||
"level": 23,
|
||||
"species": "SLOWBRO"
|
||||
"level": 30,
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 23,
|
||||
"level": 40,
|
||||
"species": "SEAKING"
|
||||
},
|
||||
{
|
||||
"level": 23,
|
||||
"species": "KINGLER"
|
||||
"level": 50,
|
||||
"species": "SEAKING"
|
||||
},
|
||||
{
|
||||
"level": 23,
|
||||
"species": "SEADRA"
|
||||
"level": 60,
|
||||
"species": "SEAKING"
|
||||
}
|
||||
],
|
||||
"CERULEAN_CITY": [
|
||||
{
|
||||
"level": 15,
|
||||
"species": "PSYDUCK"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"level": 25,
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
}
|
||||
],
|
||||
"CERULEAN_GYM": [
|
||||
{
|
||||
"level": 15,
|
||||
"species": "PSYDUCK"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"level": 30,
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
"level": 30,
|
||||
"species": "SEAKING"
|
||||
},
|
||||
{
|
||||
"level": 40,
|
||||
"species": "SEAKING"
|
||||
}
|
||||
],
|
||||
"CINNABAR_ISLAND": [
|
||||
@@ -8457,99 +8437,123 @@
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "HORSEA"
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SHELLDER"
|
||||
"level": 10,
|
||||
"species": "STARYU"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "GOLDEEN"
|
||||
"level": 30,
|
||||
"species": "TENTACOOL"
|
||||
}
|
||||
],
|
||||
"FUCHSIA_CITY": [
|
||||
{
|
||||
"level": 23,
|
||||
"species": "SEAKING"
|
||||
"level": 5,
|
||||
"species": "MAGIKARP"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "MAGIKARP"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "MAGIKARP"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "GYARADOS"
|
||||
}
|
||||
],
|
||||
"PALLET_TOWN": [
|
||||
{
|
||||
"level": 10,
|
||||
"species": "STARYU"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 5,
|
||||
"species": "STARYU"
|
||||
},
|
||||
{
|
||||
"level": 20,
|
||||
"species": "TENTACOOL"
|
||||
}
|
||||
],
|
||||
"ROUTE_10": [
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "GOLDEEN"
|
||||
"level": 20,
|
||||
"species": "KRABBY"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "MAGIKARP"
|
||||
}
|
||||
],
|
||||
"PALLET_TOWN": [
|
||||
{
|
||||
"level": 15,
|
||||
"species": "TENTACOOL"
|
||||
"level": 10,
|
||||
"species": "HORSEA"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "POLIWAG"
|
||||
}
|
||||
],
|
||||
"ROUTE_10": [
|
||||
{
|
||||
"level": 23,
|
||||
"species": "POLIWHIRL"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SLOWPOKE"
|
||||
"level": 25,
|
||||
"species": "KINGLER"
|
||||
}
|
||||
],
|
||||
"ROUTE_11": [
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SHELLDER"
|
||||
"level": 20,
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 5,
|
||||
"species": "HORSEA"
|
||||
}
|
||||
],
|
||||
"ROUTE_12": [
|
||||
{
|
||||
"level": 5,
|
||||
"species": "TENTACOOL"
|
||||
"level": 20,
|
||||
"species": "HORSEA"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
"level": 25,
|
||||
"species": "HORSEA"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "GOLDEEN"
|
||||
"level": 25,
|
||||
"species": "SEADRA"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "MAGIKARP"
|
||||
"level": 35,
|
||||
"species": "SEADRA"
|
||||
}
|
||||
],
|
||||
"ROUTE_13": [
|
||||
{
|
||||
"level": 5,
|
||||
"level": 15,
|
||||
"species": "HORSEA"
|
||||
},
|
||||
{
|
||||
"level": 20,
|
||||
"species": "HORSEA"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "MAGIKARP"
|
||||
"level": 20,
|
||||
"species": "SEADRA"
|
||||
}
|
||||
],
|
||||
"ROUTE_17": [
|
||||
@@ -8559,304 +8563,356 @@
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "GOLDEEN"
|
||||
"level": 25,
|
||||
"species": "SHELLDER"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "MAGIKARP"
|
||||
"level": 35,
|
||||
"species": "SHELLDER"
|
||||
}
|
||||
],
|
||||
"ROUTE_18": [
|
||||
{
|
||||
"level": 5,
|
||||
"level": 15,
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
"level": 20,
|
||||
"species": "SHELLDER"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "GOLDEEN"
|
||||
"level": 30,
|
||||
"species": "SHELLDER"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "MAGIKARP"
|
||||
"level": 40,
|
||||
"species": "SHELLDER"
|
||||
}
|
||||
],
|
||||
"ROUTE_19": [
|
||||
{
|
||||
"level": 15,
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 20,
|
||||
"species": "STARYU"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "HORSEA"
|
||||
"level": 30,
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SHELLDER"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "GOLDEEN"
|
||||
"level": 30,
|
||||
"species": "TENTACRUEL"
|
||||
}
|
||||
],
|
||||
"ROUTE_20": [
|
||||
{
|
||||
"level": 15,
|
||||
"level": 20,
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 20,
|
||||
"species": "TENTACRUEL"
|
||||
},
|
||||
{
|
||||
"level": 30,
|
||||
"species": "STARYU"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "HORSEA"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SHELLDER"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "GOLDEEN"
|
||||
"level": 40,
|
||||
"species": "TENTACRUEL"
|
||||
}
|
||||
],
|
||||
"ROUTE_21": [
|
||||
{
|
||||
"level": 15,
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 20,
|
||||
"species": "STARYU"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "HORSEA"
|
||||
"level": 30,
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SHELLDER"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "GOLDEEN"
|
||||
"level": 30,
|
||||
"species": "TENTACRUEL"
|
||||
}
|
||||
],
|
||||
"ROUTE_22": [
|
||||
{
|
||||
"level": 15,
|
||||
"species": "GOLDEEN"
|
||||
"level": 5,
|
||||
"species": "POLIWAG"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "POLIWAG"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "POLIWAG"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "POLIWHIRL"
|
||||
}
|
||||
],
|
||||
"ROUTE_23": [
|
||||
{
|
||||
"level": 23,
|
||||
"species": "SLOWBRO"
|
||||
"level": 25,
|
||||
"species": "POLIWAG"
|
||||
},
|
||||
{
|
||||
"level": 23,
|
||||
"species": "SEAKING"
|
||||
"level": 30,
|
||||
"species": "POLIWAG"
|
||||
},
|
||||
{
|
||||
"level": 23,
|
||||
"species": "KINGLER"
|
||||
"level": 30,
|
||||
"species": "POLIWHIRL"
|
||||
},
|
||||
{
|
||||
"level": 23,
|
||||
"species": "SEADRA"
|
||||
"level": 40,
|
||||
"species": "POLIWHIRL"
|
||||
}
|
||||
],
|
||||
"ROUTE_24": [
|
||||
{
|
||||
"level": 15,
|
||||
"species": "PSYDUCK"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"level": 20,
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
"level": 25,
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 30,
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 30,
|
||||
"species": "SEAKING"
|
||||
}
|
||||
],
|
||||
"ROUTE_25": [
|
||||
{
|
||||
"level": 15,
|
||||
"species": "PSYDUCK"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "GOLDEEN"
|
||||
"level": 10,
|
||||
"species": "KRABBY"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KINGLER"
|
||||
},
|
||||
{
|
||||
"level": 25,
|
||||
"species": "KINGLER"
|
||||
}
|
||||
],
|
||||
"ROUTE_4": [
|
||||
{
|
||||
"level": 15,
|
||||
"species": "PSYDUCK"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"level": 20,
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
"level": 25,
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 30,
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 30,
|
||||
"species": "SEAKING"
|
||||
}
|
||||
],
|
||||
"ROUTE_6": [
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
"level": 5,
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SHELLDER"
|
||||
"species": "GOLDEEN"
|
||||
},
|
||||
{
|
||||
"level": 20,
|
||||
"species": "GOLDEEN"
|
||||
}
|
||||
],
|
||||
"SAFARI_ZONE_CENTER": [
|
||||
{
|
||||
"level": 15,
|
||||
"level": 5,
|
||||
"species": "MAGIKARP"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "MAGIKARP"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "DRATINI"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "PSYDUCK"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SLOWPOKE"
|
||||
"species": "DRAGONAIR"
|
||||
}
|
||||
],
|
||||
"SAFARI_ZONE_EAST": [
|
||||
{
|
||||
"level": 5,
|
||||
"species": "MAGIKARP"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "MAGIKARP"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "MAGIKARP"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "DRATINI"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "PSYDUCK"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SLOWPOKE"
|
||||
}
|
||||
],
|
||||
"SAFARI_ZONE_NORTH": [
|
||||
{
|
||||
"level": 5,
|
||||
"species": "MAGIKARP"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "MAGIKARP"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "MAGIKARP"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "DRATINI"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "PSYDUCK"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SLOWPOKE"
|
||||
}
|
||||
],
|
||||
"SAFARI_ZONE_WEST": [
|
||||
{
|
||||
"level": 5,
|
||||
"species": "MAGIKARP"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "MAGIKARP"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "MAGIKARP"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "DRATINI"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "PSYDUCK"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SLOWPOKE"
|
||||
}
|
||||
],
|
||||
"SEAFOAM_ISLANDS_B3F": [
|
||||
{
|
||||
"level": 15,
|
||||
"level": 25,
|
||||
"species": "KRABBY"
|
||||
},
|
||||
{
|
||||
"level": 20,
|
||||
"species": "STARYU"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "HORSEA"
|
||||
"level": 35,
|
||||
"species": "KINGLER"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SHELLDER"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "GOLDEEN"
|
||||
"level": 40,
|
||||
"species": "STARYU"
|
||||
}
|
||||
],
|
||||
"SEAFOAM_ISLANDS_B4F": [
|
||||
{
|
||||
"level": 15,
|
||||
"level": 25,
|
||||
"species": "KRABBY"
|
||||
},
|
||||
{
|
||||
"level": 20,
|
||||
"species": "STARYU"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "HORSEA"
|
||||
"level": 35,
|
||||
"species": "KINGLER"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SHELLDER"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "GOLDEEN"
|
||||
"level": 40,
|
||||
"species": "STARYU"
|
||||
}
|
||||
],
|
||||
"VERMILION_CITY": [
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SHELLDER"
|
||||
"level": 20,
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 5,
|
||||
"species": "HORSEA"
|
||||
}
|
||||
],
|
||||
"VERMILION_DOCK": [
|
||||
{
|
||||
"level": 15,
|
||||
"species": "KRABBY"
|
||||
"level": 10,
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "SHELLDER"
|
||||
}
|
||||
],
|
||||
"VIRIDIAN_CITY": [
|
||||
{
|
||||
"level": 15,
|
||||
"species": "TENTACOOL"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "STARYU"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "SHELLDER"
|
||||
}
|
||||
],
|
||||
"VIRIDIAN_CITY": [
|
||||
{
|
||||
"level": 5,
|
||||
"species": "POLIWAG"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "POLIWAG"
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"species": "POLIWAG"
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"species": "POLIWAG"
|
||||
}
|
||||
]
|
||||
|
||||
+40
-11
@@ -9,7 +9,11 @@
|
||||
-- * Edit on a launcher save row (main.lua, embedded = true), Close returns
|
||||
-- to the launcher with the slot list refreshed
|
||||
--
|
||||
-- Vertical rhythm (scaled by Kit's height/768 factor, everything else flexes):
|
||||
-- Vertical rhythm inside the platform safe area (scaled by Kit's height/768
|
||||
-- factor, everything else flexes). Background still fills the full window so
|
||||
-- the notch / home-indicator bands match the field colour; interactive chrome
|
||||
-- starts at SafeArea.rect() so Save stays reachable on a punch-hole phone
|
||||
-- (#917). Offsets below are relative to that safe origin:
|
||||
-- 0 6px tri-colour version rail, identical to the launcher's
|
||||
-- 6 64px title bar identity, file chip, Save / Reload / Open / Close
|
||||
-- (104px when the bar reflows to two rows, #715)
|
||||
@@ -18,6 +22,7 @@
|
||||
-- -38 38px status bar the last Ops message + the keyboard map
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
local SaveIO = require("SaveIO")
|
||||
local Catalog = require("Catalog")
|
||||
@@ -37,6 +42,7 @@ local MapBrowser = require("MapBrowser")
|
||||
local Dex = require("Dex")
|
||||
-- chrome, not a tab panel, so deliberately kept out of PANELS below (#541)
|
||||
local SpeciesPicker = require("SpeciesPicker")
|
||||
local MovePicker = require("MovePicker")
|
||||
local ItemPicker = require("ItemPicker")
|
||||
|
||||
local App = {}
|
||||
@@ -772,7 +778,17 @@ function App.draw()
|
||||
-- tolerates that rather than indexing a torn-down state.
|
||||
if not S then return end
|
||||
local width, height = love.graphics.getDimensions()
|
||||
Kit.layout(width, height)
|
||||
width = math.max(1, tonumber(width) or 1)
|
||||
height = math.max(1, tonumber(height) or 1)
|
||||
-- Usable chrome rect. Background still fills the window so the notch /
|
||||
-- home-indicator bands stay the field colour; every button (Save first)
|
||||
-- lives inside the safe area, matching the launcher and Skin Studio (#917).
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
ox = math.max(0, tonumber(ox) or 0)
|
||||
oy = math.max(0, tonumber(oy) or 0)
|
||||
sw = math.max(1, tonumber(sw) or width)
|
||||
sh = math.max(1, tonumber(sh) or height)
|
||||
Kit.layout(sw, sh)
|
||||
local s = Kit.scale
|
||||
|
||||
local mx, my = love.mouse.getPosition()
|
||||
@@ -791,6 +807,7 @@ function App.draw()
|
||||
-- shield goes up before anything dispatches and comes down only for the
|
||||
-- picker's own layer at the bottom of this function (#541).
|
||||
Kit.blockClicks = (S.speciesPicker ~= nil) or (S.itemPicker ~= nil)
|
||||
or (S.movePicker ~= nil)
|
||||
|
||||
Theme.field(width, height)
|
||||
|
||||
@@ -799,26 +816,29 @@ function App.draw()
|
||||
-- the window is too narrow for both on one, instead of the buttons and the
|
||||
-- identity painting through each other (#715). The taller bar simply
|
||||
-- costs the content column height, which scrolls.
|
||||
local titleTwoRow = titleNeedsTwoRows(width)
|
||||
local titleTwoRow = titleNeedsTwoRows(sw)
|
||||
local titleH = (titleTwoRow and 104 or 64) * s
|
||||
local tabH = 66 * s
|
||||
local statusH = 38 * s
|
||||
|
||||
Theme.versionRail(0, 0, width, railH)
|
||||
drawTitleBar(0, railH, width, titleH, titleTwoRow)
|
||||
drawTabRail(0, railH + titleH, width, tabH)
|
||||
Theme.versionRail(ox, oy, sw, railH)
|
||||
drawTitleBar(ox, oy + railH, sw, titleH, titleTwoRow)
|
||||
drawTabRail(ox, oy + railH + titleH, sw, tabH)
|
||||
|
||||
local contentY = railH + titleH + tabH
|
||||
local contentH = height - contentY - statusH
|
||||
local contentY = oy + railH + titleH + tabH
|
||||
local contentH = sh - railH - titleH - tabH - statusH
|
||||
local panel = PANELS[S.tab]
|
||||
if panel then
|
||||
panel.draw(S, Kit, 22 * s, contentY + 20 * s,
|
||||
width - 44 * s, contentH - 38 * s)
|
||||
panel.draw(S, Kit, ox + 22 * s, contentY + 20 * s,
|
||||
sw - 44 * s, contentH - 38 * s)
|
||||
end
|
||||
|
||||
drawStatusBar(0, height - statusH, width, statusH)
|
||||
drawStatusBar(ox, oy + sh - statusH, sw, statusH)
|
||||
Kit.blockClicks = false
|
||||
-- Scrim still covers the full window (including unsafe bands); the card
|
||||
-- itself is centred in the safe rect so search fields clear the notch.
|
||||
SpeciesPicker.draw(S, Kit, width, height)
|
||||
MovePicker.draw(S, Kit, width, height)
|
||||
ItemPicker.draw(S, Kit, width, height)
|
||||
Kit.endFrame()
|
||||
PadInput.draw()
|
||||
@@ -841,6 +861,15 @@ function App.keypressed(key)
|
||||
return
|
||||
end
|
||||
end
|
||||
if S.movePicker then
|
||||
if key == "return" or key == "kpenter" then
|
||||
MovePicker.commitFirst(S, Kit)
|
||||
return
|
||||
elseif key == "escape" then
|
||||
Ops.closeMovePicker(S, Kit)
|
||||
return
|
||||
end
|
||||
end
|
||||
if S.speciesPicker then
|
||||
if key == "return" or key == "kpenter" then
|
||||
SpeciesPicker.commitFirst(S, Kit)
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
-- Hit testing is a plain rect with no z-order, so panels must draw
|
||||
-- overlapping controls in dispatch order and every target is >= 26px tall
|
||||
-- (rule 6 of the design spec) -- that sizing is the whole accessibility story
|
||||
-- here.
|
||||
-- here. Kit.tapMin() is the px floor after scale (a short handheld sits on
|
||||
-- the 0.9 scale floor, so bare `26 * s` would slip under 26).
|
||||
|
||||
local Theme = require("Theme")
|
||||
local PAL = Theme.PAL
|
||||
@@ -25,6 +26,10 @@ Kit.time = 0
|
||||
Kit.fonts = {}
|
||||
Kit.scale = 1
|
||||
|
||||
function Kit.tapMin()
|
||||
return math.max(26, math.floor(30 * (Kit.scale or 1)))
|
||||
end
|
||||
|
||||
local G = love and love.graphics or nil
|
||||
local edits = {} -- queued textinput / backspace since the last frame
|
||||
local kbField = nil -- id of the field the OS soft keyboard is raised for
|
||||
|
||||
+133
-3
@@ -329,6 +329,31 @@ function Ops.speciesSearch(S, query)
|
||||
for _, id in ipairs(S.cat.species) do
|
||||
if Ops.speciesMatches(S, id, query) then out[#out + 1] = id end
|
||||
end
|
||||
-- Rank hits so a typed prefix ("pika") puts PIKACHU above mid-string
|
||||
-- noise; empty query keeps the catalog's A-Z order.
|
||||
if query and tostring(query) ~= "" then
|
||||
local q = tostring(query):lower()
|
||||
local function rank(id)
|
||||
local idLower = id:lower()
|
||||
local def = S.data.pokemon[id]
|
||||
local nameLower = def and def.name and tostring(def.name):lower() or ""
|
||||
if idLower == q or nameLower == q then return 0 end
|
||||
if idLower:sub(1, #q) == q
|
||||
or (nameLower ~= "" and nameLower:sub(1, #q) == q) then
|
||||
return 1
|
||||
end
|
||||
if idLower:find(q, 1, true)
|
||||
or (nameLower ~= "" and nameLower:find(q, 1, true)) then
|
||||
return 2
|
||||
end
|
||||
return 3 -- dex-number hit
|
||||
end
|
||||
table.sort(out, function(a, b)
|
||||
local ra, rb = rank(a), rank(b)
|
||||
if ra ~= rb then return ra < rb end
|
||||
return a < b
|
||||
end)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
@@ -429,6 +454,8 @@ function Ops.setDv(S, mon, key, value)
|
||||
return Ops.mark(S, ("%s DV %d (HP DV now %d)"):format(key, mon.dvs[key], mon.dvs.hp))
|
||||
end
|
||||
|
||||
-- Kept for tests and any keyboard path; the inspector opens the searchable
|
||||
-- picker instead of walking the catalog one tap at a time.
|
||||
function Ops.cycleMove(S, mon, slot)
|
||||
if not mon or not (S.cat and S.cat.moves and #S.cat.moves > 0) then return false end
|
||||
local moves = S.cat.moves
|
||||
@@ -441,14 +468,117 @@ function Ops.cycleMove(S, mon, slot)
|
||||
end
|
||||
for step = 1, #moves do
|
||||
local nextId = moves[((idx + step - 1) % #moves) + 1]
|
||||
if S.data and S.data.moves and type(S.data.moves[nextId]) == "table" then
|
||||
MonOps.setMove(S.data, mon, slot, nextId)
|
||||
return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId))
|
||||
if Ops.moveUsable(S, nextId) then
|
||||
return Ops.setMove(S, mon, slot, nextId)
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- A move record is usable when it is a real table with a numeric PP -- the
|
||||
-- same floor Catalog already uses to keep provenance scalars out of the list.
|
||||
function Ops.moveUsable(S, id)
|
||||
local def = id and S.data and S.data.moves and S.data.moves[id]
|
||||
return type(def) == "table" and type(def.pp) == "number"
|
||||
end
|
||||
|
||||
-- Search predicate behind the move picker's field: id, display name, and type
|
||||
-- substring-match case-insensitively; power / accuracy match as whole numbers
|
||||
-- (same plain-text / no-pattern rule as Ops.speciesMatches).
|
||||
function Ops.moveMatches(S, id, query)
|
||||
if not query or query == "" then return true end
|
||||
local q = tostring(query):lower()
|
||||
if id:lower():find(q, 1, true) then return true end
|
||||
local def = S.data.moves[id]
|
||||
if type(def) ~= "table" then return false end
|
||||
local name = def.name
|
||||
if name and tostring(name):lower():find(q, 1, true) then return true end
|
||||
local typ = def.type
|
||||
if typ and tostring(typ):lower():find(q, 1, true) then return true end
|
||||
local power = tonumber(def.power)
|
||||
if power ~= nil and q == tostring(power) then return true end
|
||||
local accuracy = tonumber(def.accuracy)
|
||||
return accuracy ~= nil and q == tostring(accuracy)
|
||||
end
|
||||
|
||||
function Ops.moveSearch(S, query)
|
||||
local out = {}
|
||||
for _, id in ipairs(S.cat.moves or {}) do
|
||||
if Ops.moveMatches(S, id, query) then out[#out + 1] = id end
|
||||
end
|
||||
-- Rank hits so a typed prefix ("sur") puts SURF above mid-string noise
|
||||
-- like ACUPRESSURE / FISSURE; empty query keeps the catalog's A-Z order.
|
||||
if query and tostring(query) ~= "" then
|
||||
local q = tostring(query):lower()
|
||||
local function rank(id)
|
||||
local idLower = id:lower()
|
||||
local def = S.data.moves[id]
|
||||
local nameLower = (type(def) == "table" and def.name)
|
||||
and tostring(def.name):lower() or ""
|
||||
if idLower == q or nameLower == q then return 0 end
|
||||
if idLower:sub(1, #q) == q
|
||||
or (nameLower ~= "" and nameLower:sub(1, #q) == q) then
|
||||
return 1
|
||||
end
|
||||
if idLower:find(q, 1, true)
|
||||
or (nameLower ~= "" and nameLower:find(q, 1, true)) then
|
||||
return 2
|
||||
end
|
||||
return 3 -- type / power / accuracy hit
|
||||
end
|
||||
table.sort(out, function(a, b)
|
||||
local ra, rb = rank(a), rank(b)
|
||||
if ra ~= rb then return ra < rb end
|
||||
return a < b
|
||||
end)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- One funnel for assigning a move (picker commit and cycleMove). Refuses
|
||||
-- unknown / scalar ids before MonOps asserts, and speaks in the status bar.
|
||||
function Ops.setMove(S, mon, slot, id)
|
||||
if not mon then return false end
|
||||
slot = math.floor(tonumber(slot) or 0)
|
||||
if slot < 1 or slot > 4 then return false end
|
||||
local current = mon.moves and mon.moves[slot] and mon.moves[slot].id
|
||||
if id == current then
|
||||
return Ops.say(S, ("Move %d is already %s"):format(slot, tostring(id)))
|
||||
end
|
||||
if not Ops.moveUsable(S, id) then
|
||||
return Ops.say(S, ("%s is not a usable move, cannot assign it")
|
||||
:format(tostring(id)))
|
||||
end
|
||||
local ok, err = pcall(MonOps.setMove, S.data, mon, slot, id)
|
||||
if not ok then
|
||||
return Ops.say(S, ("Could not set move %d: %s"):format(slot, tostring(err)))
|
||||
end
|
||||
return Ops.mark(S, ("Move %d set to %s"):format(slot, id))
|
||||
end
|
||||
|
||||
-- Modal door for the move picker. `slot` is which of the four move rows the
|
||||
-- inspector opened; the picker writes back through Ops.setMove on commit.
|
||||
function Ops.openMovePicker(S, Kit, slot)
|
||||
if not S.editingMon then
|
||||
return Ops.say(S, "Pick a slot first, then choose a move")
|
||||
end
|
||||
slot = math.floor(tonumber(slot) or 0)
|
||||
if slot < 1 or slot > 4 then
|
||||
return Ops.say(S, "Move slots are 1 through 4")
|
||||
end
|
||||
if not (S.cat and S.cat.moves and #S.cat.moves > 0) then
|
||||
return Ops.say(S, "No moves in the catalog")
|
||||
end
|
||||
S.movePicker = { query = "", offset = 0, opened = true, slot = slot }
|
||||
if Kit then Kit.focus = "move-picker" end
|
||||
return true
|
||||
end
|
||||
|
||||
function Ops.closeMovePicker(S, Kit)
|
||||
S.movePicker = nil
|
||||
if Kit and Kit.blur then Kit.blur() end
|
||||
end
|
||||
|
||||
function Ops.clearMove(S, mon, slot)
|
||||
if not (mon and mon.moves and mon.moves[slot]) then
|
||||
return Ops.say(S, ("Move slot %d is already empty"):format(slot))
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
-- Shared modal-picker frame for Species / Move / Item pickers.
|
||||
--
|
||||
-- Desktop keeps a centred card capped at 520x560 logical px. Phones and
|
||||
-- RGxxx handhelds (RG34XXSP 720x480, RG35XX 640x480, Switch 1280x720, tall
|
||||
-- portrait Androids) need the card to nearly fill SafeArea.rect() so the
|
||||
-- search field, list, and pager stay usable instead of collapsing under a
|
||||
-- fixed 32px margin into a short landscape window (#917 / #715).
|
||||
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local PickerChrome = {}
|
||||
|
||||
-- Minimum tap target (design rule 6). Scale can sit on the 0.9 floor on a
|
||||
-- short handheld, so a bare `26 * s` would dip under 26px -- clamp in px.
|
||||
function PickerChrome.tapMin(Kit)
|
||||
local s = (Kit and Kit.scale) or 1
|
||||
return math.max(26, math.floor(30 * s))
|
||||
end
|
||||
|
||||
-- Usable card rect inside the platform safe area.
|
||||
-- Returns x, y, w, h, pad.
|
||||
function PickerChrome.card(Kit, windowW, windowH)
|
||||
local s = (Kit and Kit.scale) or 1
|
||||
windowW = math.max(1, tonumber(windowW) or 1)
|
||||
windowH = math.max(1, tonumber(windowH) or 1)
|
||||
local sox, soy, ssw, ssh = SafeArea.rect()
|
||||
sox = math.max(0, tonumber(sox) or 0)
|
||||
soy = math.max(0, tonumber(soy) or 0)
|
||||
ssw = math.max(1, tonumber(ssw) or windowW)
|
||||
ssh = math.max(1, tonumber(ssh) or windowH)
|
||||
|
||||
-- Shrink the gutter on short / narrow safe areas so the card keeps room
|
||||
-- for caption + search + at least one list row + pager.
|
||||
local gutter = math.floor(16 * s)
|
||||
local minSide = math.min(ssw, ssh)
|
||||
if minSide < 560 * s then
|
||||
gutter = math.max(4, math.floor(minSide * 0.03))
|
||||
end
|
||||
|
||||
local maxW = math.floor(520 * s)
|
||||
local maxH = math.floor(560 * s)
|
||||
local w = math.min(ssw - 2 * gutter, maxW)
|
||||
local h = math.min(ssh - 2 * gutter, maxH)
|
||||
-- Short landscapes (720x480 class): fill the safe height instead of
|
||||
-- leaving letterbox bands inside an already-short rect.
|
||||
if ssh <= maxH + 2 * gutter then
|
||||
h = ssh - 2 * gutter
|
||||
end
|
||||
if ssw <= maxW + 2 * gutter then
|
||||
w = ssw - 2 * gutter
|
||||
end
|
||||
w = math.max(1, w)
|
||||
h = math.max(1, h)
|
||||
|
||||
local x = sox + (ssw - w) / 2
|
||||
local y = soy + (ssh - h) / 2
|
||||
local pad = math.max(8, math.floor(math.min(18 * s, w * 0.04, h * 0.04)))
|
||||
return x, y, w, h, pad
|
||||
end
|
||||
|
||||
-- List-body metrics once caption / field / optional extra chrome are placed.
|
||||
-- `contentTop` is the y just below that chrome; returns listH, rowH, rowGap,
|
||||
-- pagerH sized so taps stay >= tapMin and listH never goes negative.
|
||||
function PickerChrome.listMetrics(Kit, cardY, cardH, pad, contentTop)
|
||||
local s = (Kit and Kit.scale) or 1
|
||||
local tap = PickerChrome.tapMin(Kit)
|
||||
local pagerH = math.max(tap, math.floor(30 * s))
|
||||
local rowH = math.max(tap, math.floor(40 * s))
|
||||
local rowGap = math.max(4, math.floor(6 * s))
|
||||
local listBottom = cardY + cardH - pad - pagerH - math.max(6, math.floor(10 * s))
|
||||
local listH = math.max(0, listBottom - contentTop)
|
||||
return listH, rowH, rowGap, pagerH
|
||||
end
|
||||
|
||||
function PickerChrome.fieldH(Kit)
|
||||
return math.max(PickerChrome.tapMin(Kit), math.floor(34 * ((Kit and Kit.scale) or 1)))
|
||||
end
|
||||
|
||||
function PickerChrome.closeSize(Kit)
|
||||
return math.max(PickerChrome.tapMin(Kit), math.floor(30 * ((Kit and Kit.scale) or 1)))
|
||||
end
|
||||
|
||||
return PickerChrome
|
||||
@@ -53,6 +53,11 @@ function State.new()
|
||||
-- without a z-order (#541).
|
||||
speciesPicker = nil,
|
||||
|
||||
-- move picker overlay: nil when closed, otherwise
|
||||
-- { query, offset, slot = 1..4 }. Same modal contract as speciesPicker;
|
||||
-- the inspector opens it instead of cycling the catalog one tap at a time.
|
||||
movePicker = nil,
|
||||
|
||||
-- item picker overlay: nil when closed, otherwise
|
||||
-- { query, offset, dest = "bag"|"pc" }. Same modal contract as
|
||||
-- speciesPicker above -- adding an item is now a full-screen picker
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
local Theme = require("Theme")
|
||||
local Ops = require("Ops")
|
||||
local PickerChrome = require("PickerChrome")
|
||||
local PAL = Theme.PAL
|
||||
|
||||
local Picker = {}
|
||||
@@ -57,38 +58,39 @@ function Picker.draw(S, Kit, width, height)
|
||||
Kit.blockClicks = true
|
||||
end
|
||||
|
||||
-- the scrim doubles as the "tap outside to cancel" target
|
||||
-- the scrim doubles as the "tap outside to cancel" target; it covers the
|
||||
-- full window so unsafe bands (notch / home indicator) stay dimmed too
|
||||
Theme.col(PAL.bg, 0.82)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
|
||||
local w = math.min(width - 32 * s, 520 * s)
|
||||
local h = math.min(height - 32 * s, 560 * s)
|
||||
local x = (width - w) / 2
|
||||
local y = (height - h) / 2
|
||||
-- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a
|
||||
-- usable list (#917 / #715).
|
||||
local x, y, w, h, pad = PickerChrome.card(Kit, width, height)
|
||||
if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then
|
||||
Ops.closeItemPicker(S, Kit)
|
||||
return
|
||||
end
|
||||
|
||||
Kit.card(x, y, w, h)
|
||||
local pad = 18 * s
|
||||
local cx, cy = x + pad, y + pad
|
||||
local inner = w - 2 * pad
|
||||
|
||||
Kit.caption(cx, cy, "ADD AN ITEM")
|
||||
local closeW = 30 * s
|
||||
if Kit.button(x + w - pad - closeW, cy - 4 * s, closeW, 26 * s, "x",
|
||||
local closeW = PickerChrome.closeSize(Kit)
|
||||
local captionH = Kit.textHeight("caption")
|
||||
local headH = math.max(captionH, closeW)
|
||||
Kit.caption(cx, cy + (headH - captionH) / 2, "ADD AN ITEM")
|
||||
if Kit.button(x + w - pad - closeW, cy + (headH - closeW) / 2, closeW, closeW, "x",
|
||||
{ font = "small" }) then
|
||||
Ops.closeItemPicker(S, Kit)
|
||||
return
|
||||
end
|
||||
cy = cy + Kit.textHeight("caption") + 10 * s
|
||||
cy = cy + headH + 10 * s
|
||||
|
||||
-- Destination toggle. Which list an item lands in is the only real choice
|
||||
-- here, so it is a pair of chips at the top rather than two buttons at the
|
||||
-- bottom that each mean "commit, and also pick a destination".
|
||||
local half = (inner - 8 * s) / 2
|
||||
local destH = 30 * s
|
||||
local destH = math.max(PickerChrome.tapMin(Kit), math.floor(30 * s))
|
||||
if Kit.chip(cx, cy, half, destH, "-> BAG", p.dest ~= "pc", PAL.green, PAL.steel) then
|
||||
p.dest = "bag"
|
||||
end
|
||||
@@ -98,16 +100,13 @@ function Picker.draw(S, Kit, width, height)
|
||||
end
|
||||
cy = cy + destH + 10 * s
|
||||
|
||||
local fieldH = 34 * s
|
||||
local fieldH = PickerChrome.fieldH(Kit)
|
||||
p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query,
|
||||
"type an item id")
|
||||
cy = cy + fieldH + 10 * s
|
||||
|
||||
local hits = Picker.results(S)
|
||||
local rowH = 36 * s
|
||||
local rowGap = 6 * s
|
||||
local pagerH = 30 * s
|
||||
local listH = (y + h - pad - pagerH - 10 * s) - cy
|
||||
local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy)
|
||||
local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap)))
|
||||
p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage))
|
||||
-- wheel / touch drag scroll the modal list too; the shield is already
|
||||
|
||||
@@ -213,7 +213,7 @@ local function drawMoveRows(S, Kit, mon, rightX, rowY, colW, rowH, rowGap)
|
||||
local ry = rowY + (slot - 1) * (rowH + rowGap)
|
||||
Theme.row(rightX, ry, colW, rowH, 10 * s, 0.6)
|
||||
local mv = mon.moves and mon.moves[slot]
|
||||
local clear = 24 * s
|
||||
local clear = Kit.tapMin()
|
||||
local clearX = rightX + colW - 10 * s - clear
|
||||
local ppText = mv and ("PP %d"):format(mv.pp or 0) or ""
|
||||
local ppW = Kit.textWidth("tiny", ppText)
|
||||
@@ -226,9 +226,9 @@ local function drawMoveRows(S, Kit, mon, rightX, rowY, colW, rowH, rowGap)
|
||||
mv and PAL.text or PAL.faint)
|
||||
Kit.textRight("tiny", ppText, clearX - 10 * s,
|
||||
ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.caption)
|
||||
-- the row body cycles, the x empties: two targets, no modal picker
|
||||
-- the row body opens the searchable picker; the x empties the slot
|
||||
if Kit.press(rightX, ry, clearX - rightX - 4 * s, rowH) then
|
||||
Ops.cycleMove(S, mon, slot)
|
||||
Ops.openMovePicker(S, Kit, slot)
|
||||
end
|
||||
if Kit.button(clearX, ry + (rowH - clear) / 2, clear, clear, "x",
|
||||
{ kind = "danger", font = "tiny", radius = 6 * s }) then
|
||||
@@ -264,10 +264,10 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
-- text keeps room.
|
||||
local narrow = inner < 470 * s
|
||||
local sprite = (narrow and 64 or 96) * s
|
||||
local rowH = 30 * s
|
||||
local rowH = math.max(Kit.tapMin(), 30 * s)
|
||||
local rowGap = 8 * s
|
||||
local cellH = 52 * s
|
||||
local actH = 34 * s
|
||||
local actH = math.max(Kit.tapMin(), 34 * s)
|
||||
|
||||
local hw = inner - sprite - 18 * s
|
||||
local levelInHeader = hw >= levelRowWidth(Kit, mon)
|
||||
@@ -449,7 +449,7 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
|
||||
local movesY = rowY + colRowsH + 14 * s
|
||||
Kit.caption(cx, movesY, "MOVES")
|
||||
Kit.textRight("tiny", "click a slot to cycle", cx + inner, movesY, PAL.caption)
|
||||
Kit.textRight("tiny", "click a slot to search", cx + inner, movesY, PAL.caption)
|
||||
local mRowY = movesY + capH + 10 * s
|
||||
drawMoveRows(S, Kit, mon, cx, mRowY, inner, rowH, rowGap)
|
||||
|
||||
@@ -472,7 +472,7 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
Kit.textRight("tiny", ("HP DV auto-derived . %d"):format(mon.dvs.hp or 0),
|
||||
cx + colW, colY, PAL.caption)
|
||||
Kit.caption(rightX, colY, "MOVES")
|
||||
Kit.textRight("tiny", "click a slot to cycle", rightX + colW, colY, PAL.caption)
|
||||
Kit.textRight("tiny", "click a slot to search", rightX + colW, colY, PAL.caption)
|
||||
|
||||
local rowY = colY + capH + 10 * s
|
||||
drawDvRows(S, Kit, mon, cx, rowY, colW, rowH, rowGap)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
-- Type-to-search move picker. The inspector used to change moves by cycling
|
||||
-- the catalog one tap at a time -- 165 taps to walk Gen1 -- which is the same
|
||||
-- class of friction the species arrows had before #541. This is the
|
||||
-- replacement: one modal list, filtered as you type, committing through
|
||||
-- Ops.setMove so an unusable / scalar id refuses instead of asserting.
|
||||
--
|
||||
-- Modal is literal. Kit hit-tests without a z-order, so App.draw raises
|
||||
-- Kit.blockClicks over the chrome and the panel while this is open and lowers
|
||||
-- it only for this overlay; nothing underneath can take the same tap.
|
||||
|
||||
local Theme = require("Theme")
|
||||
local Ops = require("Ops")
|
||||
local PickerChrome = require("PickerChrome")
|
||||
local PAL = Theme.PAL
|
||||
|
||||
local Picker = {}
|
||||
|
||||
local FIELD_ID = "move-picker"
|
||||
|
||||
function Picker.results(S)
|
||||
local p = S.movePicker
|
||||
return Ops.moveSearch(S, p and p.query or "")
|
||||
end
|
||||
|
||||
local function commit(S, id)
|
||||
local p = S.movePicker
|
||||
if not p then return false end
|
||||
return Ops.setMove(S, S.editingMon, p.slot, id)
|
||||
end
|
||||
|
||||
-- Enter commits the top match, which is the whole point of a search field.
|
||||
function Picker.commitFirst(S, Kit)
|
||||
local hits = Picker.results(S)
|
||||
if not hits[1] then return Ops.say(S, "No move matches that") end
|
||||
local ok = commit(S, hits[1])
|
||||
if ok then Ops.closeMovePicker(S, Kit) end
|
||||
return ok
|
||||
end
|
||||
|
||||
function Picker.draw(S, Kit, width, height)
|
||||
local p = S.movePicker
|
||||
if not p then return end
|
||||
local s = Kit.scale
|
||||
|
||||
-- The click that opened the picker is still the frame's click: the
|
||||
-- inspector dispatches earlier in App.draw than this overlay does, so
|
||||
-- without swallowing it the scrim below would read it as a tap outside and
|
||||
-- shut the picker in the same frame it went up. App re-raises the shield
|
||||
-- at the top of the next frame, so leaving it up here is safe.
|
||||
if p.opened then
|
||||
p.opened = nil
|
||||
Kit.blockClicks = true
|
||||
end
|
||||
|
||||
-- the scrim doubles as the "tap outside to cancel" target; it covers the
|
||||
-- full window so unsafe bands (notch / home indicator) stay dimmed too
|
||||
Theme.col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
|
||||
-- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a
|
||||
-- usable list (#917 / #715).
|
||||
local x, y, w, h, pad = PickerChrome.card(Kit, width, height)
|
||||
if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then
|
||||
Ops.closeMovePicker(S, Kit)
|
||||
return
|
||||
end
|
||||
|
||||
Kit.card(x, y, w, h)
|
||||
local cx, cy = x + pad, y + pad
|
||||
local inner = w - 2 * pad
|
||||
|
||||
local closeW = PickerChrome.closeSize(Kit)
|
||||
local captionH = Kit.textHeight("caption")
|
||||
local headH = math.max(captionH, closeW)
|
||||
Kit.caption(cx, cy + (headH - captionH) / 2, ("CHOOSE MOVE %d"):format(p.slot or 1))
|
||||
if Kit.button(x + w - pad - closeW, cy + (headH - closeW) / 2, closeW, closeW, "x",
|
||||
{ font = "small", radius = 7 * s }) then
|
||||
Ops.closeMovePicker(S, Kit)
|
||||
return
|
||||
end
|
||||
cy = cy + headH + 10 * s
|
||||
|
||||
local fieldH = PickerChrome.fieldH(Kit)
|
||||
p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query,
|
||||
"type a name, an id, or a type")
|
||||
cy = cy + fieldH + 10 * s
|
||||
|
||||
local hits = Picker.results(S)
|
||||
local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy)
|
||||
local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap)))
|
||||
p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage))
|
||||
-- wheel / touch drag scroll the modal list too; the shield is already
|
||||
-- lowered for this layer, so Kit.scroll works here and only here (#715)
|
||||
p.offset = Kit.scroll(cx, cy, inner, listH, p.offset, #hits, perPage)
|
||||
|
||||
local mon = S.editingMon
|
||||
local currentId = mon and mon.moves and mon.moves[p.slot]
|
||||
and mon.moves[p.slot].id
|
||||
|
||||
if #hits == 0 then
|
||||
Kit.emptyBox(cx, cy, inner, listH, "Nothing matches that.")
|
||||
else
|
||||
Kit.pushClip(cx, cy, inner, listH)
|
||||
for i = 1, perPage do
|
||||
local id = hits[p.offset + i]
|
||||
if not id then break end
|
||||
local ry = cy + (i - 1) * (rowH + rowGap)
|
||||
local def = S.data.moves[id]
|
||||
local usable = Ops.moveUsable(S, id)
|
||||
local current = currentId == id
|
||||
if Kit.row(cx, ry, inner, rowH, current, PAL.green, 9 * s) then
|
||||
if commit(S, id) then
|
||||
Ops.closeMovePicker(S, Kit)
|
||||
Kit.popClip()
|
||||
return
|
||||
end
|
||||
end
|
||||
local typ = usable and tostring(def and def.type or "?") or "no data"
|
||||
local pp = usable and ("PP %d"):format(tonumber(def and def.pp) or 0) or ""
|
||||
local tail = pp ~= "" and (typ .. " " .. pp) or typ
|
||||
local tailW = Kit.textWidth("tiny", tail)
|
||||
Kit.text("monoRow",
|
||||
Kit.ellipsize("monoRow", id, inner - tailW - 28 * s),
|
||||
cx + 12 * s, ry + (rowH - Kit.textHeight("monoRow")) / 2,
|
||||
usable and PAL.text or PAL.faint)
|
||||
Kit.textRight("tiny", tail, cx + inner - 10 * s,
|
||||
ry + (rowH - Kit.textHeight("tiny")) / 2, PAL.caption)
|
||||
end
|
||||
Kit.popClip()
|
||||
Kit.scrollbar(cx, cy, inner, listH, p.offset, #hits, perPage)
|
||||
end
|
||||
|
||||
p.offset = Kit.pager(cx, y + h - pad - pagerH, inner, p.offset, #hits, perPage)
|
||||
end
|
||||
|
||||
return Picker
|
||||
@@ -12,6 +12,7 @@
|
||||
local Theme = require("Theme")
|
||||
local Ops = require("Ops")
|
||||
local MonEditor = require("MonEditor")
|
||||
local PickerChrome = require("PickerChrome")
|
||||
local PAL = Theme.PAL
|
||||
|
||||
local Picker = {}
|
||||
@@ -59,44 +60,42 @@ function Picker.draw(S, Kit, width, height)
|
||||
Kit.blockClicks = true
|
||||
end
|
||||
|
||||
-- the scrim doubles as the "tap outside to cancel" target
|
||||
-- the scrim doubles as the "tap outside to cancel" target; it covers the
|
||||
-- full window so unsafe bands (notch / home indicator) stay dimmed too
|
||||
Theme.col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
|
||||
local w = math.min(width - 32 * s, 520 * s)
|
||||
local h = math.min(height - 32 * s, 560 * s)
|
||||
local x = (width - w) / 2
|
||||
local y = (height - h) / 2
|
||||
-- Card fills / centres in SafeArea so phones and RGxxx landscapes keep a
|
||||
-- usable list (#917 / #715).
|
||||
local x, y, w, h, pad = PickerChrome.card(Kit, width, height)
|
||||
if Kit.press(0, 0, width, height) and not Kit.hit(x, y, w, h) then
|
||||
Ops.closeSpeciesPicker(S, Kit)
|
||||
return
|
||||
end
|
||||
|
||||
Kit.card(x, y, w, h)
|
||||
local pad = 18 * s
|
||||
local cx, cy = x + pad, y + pad
|
||||
local inner = w - 2 * pad
|
||||
|
||||
Kit.caption(cx, cy, p.mode == "box-add"
|
||||
local closeW = PickerChrome.closeSize(Kit)
|
||||
local captionH = Kit.textHeight("caption")
|
||||
local headH = math.max(captionH, closeW)
|
||||
Kit.caption(cx, cy + (headH - captionH) / 2, p.mode == "box-add"
|
||||
and ("ADD TO BOX %d"):format(S.selectedBox or 1) or "CHOOSE A SPECIES")
|
||||
local closeW = 30 * s
|
||||
if Kit.button(x + w - pad - closeW, cy - 4 * s, closeW, 26 * s, "x",
|
||||
if Kit.button(x + w - pad - closeW, cy + (headH - closeW) / 2, closeW, closeW, "x",
|
||||
{ font = "small", radius = 7 * s }) then
|
||||
Ops.closeSpeciesPicker(S, Kit)
|
||||
return
|
||||
end
|
||||
cy = cy + Kit.textHeight("caption") + 10 * s
|
||||
cy = cy + headH + 10 * s
|
||||
|
||||
local fieldH = 34 * s
|
||||
local fieldH = PickerChrome.fieldH(Kit)
|
||||
p.query = Kit.textfield(FIELD_ID, cx, cy, inner, fieldH, p.query,
|
||||
"type a name, an id, or a dex number")
|
||||
cy = cy + fieldH + 10 * s
|
||||
|
||||
local hits = Picker.results(S)
|
||||
local rowH = 40 * s
|
||||
local rowGap = 6 * s
|
||||
local pagerH = 30 * s
|
||||
local listH = (y + h - pad - pagerH - 10 * s) - cy
|
||||
local listH, rowH, rowGap, pagerH = PickerChrome.listMetrics(Kit, y, h, pad, cy)
|
||||
local perPage = math.max(1, math.floor((listH + rowGap) / (rowH + rowGap)))
|
||||
p.offset = Theme.clamp(p.offset or 0, 0, math.max(0, #hits - perPage))
|
||||
-- wheel / touch drag scroll the modal list too; the shield is already
|
||||
|
||||
Reference in New Issue
Block a user