Compare commits

..

5 Commits

Author SHA1 Message Date
bryanthaboi 803f86d5b3 api upgrades, windows dev tools, and android fixes (#299)
* pop up a fake save

* add CI to dev branch

* Make ROM-free test tiers actually run on Windows (#267)

Suite discovery, the extension-point catalog scan, mod test-dir pickup,
and the meta-coverage corpus all shelled out to ls/find/test -d, which do
not exist in cmd.exe. Every listing came back empty on Windows, so tiers
ran 0 suites and still reported ALL TESTS PASSED.

Add portable probes to tests/fs_io.lua (same Unix commands on
Linux/macOS; dir /b and a shell-free rename-self existence check on
Windows) and rewire the four call sites to them. 15/15 engine suites and
2/2 modkit suites now genuinely run and pass on Windows.

Fixes #266

Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>

* intro api upgrade (#294)

* intro api upgrade

* api updates

* fix tests

* updated templates

* android fixes for mods and saves (#297)

* Android SCALING fixes (#298)

* android fixes for mods and saves

* perhaps this is the true scaling android issue fix

---------

Co-authored-by: johnjohto <johtoboy@atomicmail.io>
Co-authored-by: johnjohto <johnjohto@users.noreply.github.com>
2026-07-27 09:29:24 -04:00
bryanthaboi 043f6b2426 Bugsquashingjuly26 (#264)
* big bug squash

* ok
2026-07-26 14:11:07 -04:00
bryanthaboi f7695308b7 big bug squash (#261) 2026-07-26 13:38:52 -04:00
bryanthaboi a8936e79d6 Add on-screen touch controls and assets 2026-07-25 23:16:15 -04:00
bryanthaboi f6b29e6caa evee fix and some other junk 2026-07-25 15:21:49 -04:00
155 changed files with 11135 additions and 1079 deletions
+14 -1
View File
@@ -25,7 +25,9 @@ body:
options:
- macOS
- Windows
- Both
- Linux
- Android
- Multiple platforms
validations:
required: true
@@ -38,6 +40,17 @@ body:
validations:
required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers can ping you on Discord if they need a quick follow-up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: input
id: location
attributes:
+34 -19
View File
@@ -1,35 +1,53 @@
name: Feature request
description: Ask for something new, or a change to how something already works.
description: Ask for something new in the engine, launcher, or platform — not a content/gameplay mod.
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Use this for **engine / launcher / platform** work (ports, video options, save
tooling, networking, mod API seams, docs).
If what you want is a gameplay, cosmetic, audio, or QoL change that a Lua mod
could ship — running shoes, alternate sprites, day/night, shiny indicators,
Gen 2-like battle toggles, soundtrack packs — open a
**[Mod request](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/issues/new?template=mod_request.yml)**
instead.
"Can we add X" on its own is hard to act on. Say what you want, why you want it,
and how you picture it working. A request with real detail is one someone can
actually build without guessing at what you meant.
and how you picture it working.
- type: input
id: summary
attributes:
label: One line summary
description: What you want, in a sentence.
placeholder: Add a run toggle so you can move at bike speed without the bike
placeholder: Add Linux AppImage releases next to the macOS and Windows builds
validations:
required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers can ping you on Discord if they need a quick follow-up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: textarea
id: what
attributes:
label: What do you want
description: >
Describe it properly. What is it, where does it live in the game, what does the
player see or do. If it changes something that already exists, say what it does today
and what it should do instead.
Describe it properly. What is it, where does it live (launcher, options,
engine), what does the player see or do. If it changes something that already
exists, say what it does today and what it should do instead.
placeholder: |
A hold-to-run button, the way later Pokemon games do it. Hold B while moving on
foot and the player moves at bike speed. Release and you go back to walking.
Doesn't work indoors where the bike doesn't work, doesn't work in battles or menus.
Ship a Linux AppImage on each release, same version as the macOS/Windows builds,
with the same save folder layout and mod discovery path.
validations:
required: true
@@ -41,9 +59,8 @@ body:
What's annoying or missing right now. What does this fix. If it's just because you
think it would be fun, say that, it's a real answer.
placeholder: |
Backtracking across routes you've already cleared is slow, and the bike is a menu
dive away and doesn't work in a lot of places. Running would cut a lot of dead time
without making the game easier.
LÖVE already runs on Linux; without a packaged build, players have to assemble
it themselves and miss release notes / update checks.
validations:
required: true
@@ -52,14 +69,12 @@ body:
attributes:
label: How should it work
description: >
The specifics. Which button, which menu, what happens in the edge cases. If you don't
The specifics. Which menu, what happens in the edge cases. If you don't
know, say what you'd expect as a player and leave the rest open.
placeholder: |
- Hold B on the overworld to run
- Same speed as the bike
- Disabled anywhere the bike is disabled
- Sprite should probably need a running animation, not sure what that takes
- No effect on encounter rates
- GitHub Releases asset next to the .dmg / .exe
- Same options.lua / mods/ layout as desktop
- Documented in the README install section
validations:
required: true
+115
View File
@@ -0,0 +1,115 @@
name: Mod request
description: Ask for a gameplay, cosmetic, audio, or QoL change that belongs as a Lua mod.
labels: ["mod request"]
body:
- type: markdown
attributes:
value: |
This tracker is for ideas that should ship as **mods**, not as core engine
features — alternate sprites, running shoes, day/night, shiny indicators,
soundtrack packs, Gen 2-like battle toggles, map cosmetics, bag QoL, etc.
The engine already exposes a lot of this through registries and hooks
([modding wiki](https://github.com/bryanthaboi/pokemon-gen1-recomp-project/wiki)).
Use a **Feature request** instead for launcher / ports / video options /
networking / save tooling / new API seams.
- type: input
id: summary
attributes:
label: One line summary
description: What the mod should do, in a sentence.
placeholder: Hold B to run at bike speed on the overworld
validations:
required: true
- type: input
id: discord
attributes:
label: Discord username (optional)
description: >
So maintainers or mod authors can ping you on Discord if they pick this up.
Leave blank if you'd rather keep everything on GitHub.
placeholder: yourname
validations:
required: false
- type: textarea
id: what
attributes:
label: What should the mod do
description: >
Describe the player-facing behavior. What changes, where, what does the
player see or press. If it toggles from Options or a START-menu entry, say so.
placeholder: |
Hold B while walking outdoors to move at bike speed. Release to walk again.
Same places the bike is allowed; no effect in battles or menus.
validations:
required: true
- type: textarea
id: why
attributes:
label: Why is this worth doing as a mod
description: >
Why optional/modded rather than a core option. Who wants it on, who wants
vanilla left alone.
placeholder: |
Great for replaying and backtracking, but some people want a strict Gen 1
pace. A mod (or an opt-in mod option) keeps both camps happy.
validations:
required: true
- type: textarea
id: how
attributes:
label: How should it work
description: >
Buttons, menus, edge cases, whether it needs new art/audio. If you know a
hook or registry that fits (movement.speed, pokemon.sprite, rulesets, …),
mention it — otherwise leave it open.
placeholder: |
- Hold B on the overworld
- Same step timing as the bike
- Disabled where the bike is disabled
- Prefer hooks:wrap("movement.speed") if that still fits
validations:
required: true
- type: dropdown
id: vanilla
attributes:
label: With the mod off, is vanilla unchanged
options:
- Yes — parity when disabled
- No — it would replace something always-on
- Not sure
validations:
required: true
- type: dropdown
id: category
attributes:
label: Best-fit mod category
description: Same taxonomy as example mods (BALANCE, GRAPHICS, AUDIO, …).
options:
- GAMEPLAY / QoL
- GRAPHICS
- AUDIO
- BALANCE / ruleset
- CONTENT (maps, encounters, trainers)
- UI / TOOL
- TOTAL_CONVERSION-ish
- Not sure
validations:
required: true
- type: textarea
id: extra
attributes:
label: Anything else
description: >
Reference screenshots, other games/hacks that do it, related issues, or
"I'd like to try writing this myself." Leave blank if nothing comes to mind.
validations:
required: false
+3 -1
View File
@@ -14,7 +14,9 @@ name: ci
on:
push:
branches: [main]
# Integration branch + release branch. PRs already run via pull_request
# (any base); this list is only for post-merge push runs.
branches: [dev, main]
pull_request:
# a force-push while CI is mid-run should cancel the stale run, not queue
+3
View File
@@ -10,6 +10,9 @@ name: Release
# - To force a specific version, either:
# * run this workflow manually (Actions tab) and type it into "version", or
# * put "[release X.Y.Z]" anywhere in the commit message.
#
# Branch model: day-to-day work merges to `dev`. Releases stay on `main` only
# so promoting `dev` -> `main` is the ship gate that cuts a build.
on:
push:
+9
View File
@@ -0,0 +1,9 @@
# On-screen touch control art
From Xelu's "Free Controller & Keyboard Prompts" pack (Nicolae Berbece /
Those Awesome Guys), public domain under CC0:
https://thoseawesomeguys.com/prompts/
256px Nintendo Switch set, renamed for src/core/TouchControls.lua:
d-pad (neutral + per-direction highlight), A, B, plus (START) and
minus (SELECT).
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+3 -2
View File
@@ -47,8 +47,9 @@ function love.conf(t)
-- freely to portrait or landscape), otherwise locked to the window's w/h
-- aspect. So a non-resizable tall window forced portrait; resizable lets
-- the game follow the device. The renderer letterboxes the 160x144
-- viewport into whatever size results, and touch input is gesture-based,
-- so both orientations just work. iOS follows the Info.plist orientations
-- viewport into whatever size results, and the on-screen touch controls
-- re-lay themselves out from the new window size, so both orientations
-- just work. iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
-- Starting size is a tall portrait hint; the OS resizes to the real
+59 -34
View File
@@ -6,7 +6,8 @@
-- takes it ("I'll take this one, then!") and both balls disappear.
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
-- OaksLabRivalTakePokeBallScript.
-- * Rival (object 1): before starter -> "gramps isn't around"; with
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
-- has walked you in, else "gramps isn't around" (#218); with
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
@@ -162,36 +163,45 @@ return {
starterBall("_OaksLabYouWantBulbasaurText", "BULBASAUR", "EVENT_CHOSE_BULBASAUR",
"OAKSLAB_BULBASAUR_POKE_BALL", 6, "OAKSLAB_CHARMANDER_POKE_BALL"),
-- Talking to the rival only ever prints a line: the lab battle is a
-- coordinate trigger (OaksLabRivalChallengesPlayerScript, wYCoord == 6;
-- see onStep below), never a talk action. The handler used to fall
-- through from the "looks stronger" taunt straight into start_battle,
-- so talking to Blue at the table launched the rival fight before the
-- player ever stepped onto the trigger (#219). scripts/OaksLab.asm
-- OaksLabText8 branches text only:
-- * got starter, not yet battled -> _OaksLabRivalMyPokemonLooksStronger
-- * already battled -> _OaksLabRivalFedUpWithWaitingText (a Route 22
-- line; normally unreachable since the rival is hidden after the
-- lab battle in OaksLabRivalEndBattleScript)
-- * no starter yet -> the FOLLOWED_OAK pre-starter fork (#218)
TEXT_OAKSLAB_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_false", 21 }, -- 3
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
{ "jump_if_true", 19 }, -- 5
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
{ "jump_if_false", 11 }, -- 8
{ "start_battle", "trainer", "OPP_RIVAL1", 3 }, -- 9 Charmander
{ "jump", 16 }, -- 10
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11
{ "jump_if_false", 15 }, -- 12
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
{ "jump", 16 }, -- 14
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
-- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way
{ "heal_party" }, -- 16
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17
{ "jump", 23 }, -- 18
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19
{ "jump", "end" }, -- 20
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21
{ "jump", "end" }, -- 22
-- win: sulk text then exit; loss: Rival1WinText already played in
-- battle (HandlePlayerBlackOut), so skip straight to the walk-out
{ "jump_if_false", 25 }, -- 23
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24
{ "move_npc_to", 1, 4, 11 }, -- 25
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26
{ "face_player" },
{ "check_flag", "EVENT_GOT_STARTER" },
{ "jump_if_false", "pre_starter" },
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
{ "jump_if_true", "after_battle" },
-- has a starter, has not fought yet: just the taunt. The battle is
-- the coordinate trigger in onStep, not this talk (#219)
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" },
{ "jump", "end" },
{ "label", "after_battle" },
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" },
{ "jump", "end" },
-- pre-starter fork (scripts/OaksLab.asm OaksLabText8 rival handler):
-- Oak has already escorted you in (three balls on the table) -> he
-- waves you on to choose; not yet escorted (very early game) -> the
-- "Gramps isn't around" line. #218
{ "label", "pre_starter" },
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" },
{ "jump_if_false", "gramps_gone" },
{ "show_text", "_OaksLabRivalGoAheadAndChooseText" },
{ "jump", "end" },
{ "label", "gramps_gone" },
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" },
},
},
@@ -213,9 +223,16 @@ return {
-- OaksLabScript8 / OaksLabRivalChallenge)
onStep = function(game, ow, x, y)
local flags = game.save.flags
-- Oak blocks the exit mats (4,11)/(5,11) until you take a starter
-- Oak stops you leaving without a starter at the bookshelf row (cell
-- y == 6): this is the same wYCoord == 6 coordinate script the rival
-- challenge just below uses (scripts/OaksLab.asm, both gated on
-- EVENT_GOT_STARTER), so Oak halts you level with the shelves, not one
-- corridor length later on the exit mat. At y>=6 only the x=4,5
-- corridor is walkable and the up-push keeps the player above y=7, so
-- this only ever fires at y=6 in the corridor -- matching the rival
-- trigger's shape. (#232)
if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER
and y == 11 and (x == 4 or x == 5) then
and y >= 6 then
ow.runner:run({
{ "show_text", "_OaksLabOakDontGoAwayYetText" },
{ "move_player", "up", 1 },
@@ -254,10 +271,18 @@ return {
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
table.insert(rows, { "heal_party" })
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
-- win: sulk text then exit; loss jumps to the walk-out (taunt was
-- already shown in-battle via Rival1WinText)
-- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong
-- POKéMON!" gloat, then BOTH win and loss print the shared exit line
-- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to
-- toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
-- marches out. A loss skips only the gloat (that taunt was already
-- shown in-battle via Rival1WinText), never the exit line (#231). The
-- jump_if_false convergence point is the exit line: base+6 indexes the
-- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and
-- LOSS jumps straight to SmellYouLater (both then walk-out + hide).
table.insert(rows, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
ow.runner:run(rows, { npc = rival })
+86
View File
@@ -395,7 +395,93 @@ M.SS_ANNE_CAPTAINS_ROOM = {
-- MrFujisHouse.asm; the teleport back to his house is a warp)
-- -------------------------------------------------------------------
-- scripts/PokemonTower7F.asm: after each Rocket loses,
-- PokemonTower7FEndBattleScript shows its EndBattle text (EndTrainerBattle),
-- prints its AfterBattle text (DisplayTextID), then
-- PokemonTower7FRocketLeaveMovementScript walks the grunt off toward the
-- (9,16) stairs (MoveSprite) and PokemonTower7FHideNPCScript despawns it
-- (HideObject). Without this the beaten grunts stood in the corridor
-- forever (#200).
local POKEMON_TOWER_7F_ROCKETS = {
{ index = 1, name = "POKEMONTOWER7F_ROCKET1",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0",
after = "_PokemonTower7FRocket1AfterBattleText" },
{ index = 2, name = "POKEMONTOWER7F_ROCKET2",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1",
after = "_PokemonTower7FRocket2AfterBattleText" },
{ index = 3, name = "POKEMONTOWER7F_ROCKET3",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2",
after = "_PokemonTower7FRocket3AfterBattleText" },
}
-- PokemonTower7FNPCCoordMovementTable, keyed by the PLAYER's tile ("x,y")
-- at the engagement (both the talk and the sight walk-up leave the player on
-- the tile the vanilla table indexes). Each list is that entry's
-- NPC_MOVEMENT_* exit, applied from the grunt's current tile. The exact
-- tables are one per grunt (dbmapcoord bytes decoded back to x,y).
local POKEMON_TOWER_7F_EXITS = {
[1] = { -- ROCKET1 @ (9,11), faces RIGHT
["9,12"] = { "right", "down", "down", "down", "down", "down", "left" },
["10,11"] = { "down", "right", "down", "down", "down", "down" },
["11,11"] = { "down", "down", "down", "down", "down" },
["12,11"] = { "down", "down", "down", "down", "down" },
},
[2] = { -- ROCKET2 @ (12,9), faces LEFT
["12,10"] = { "left", "down", "down", "down", "down", "down", "down" },
["11,9"] = { "down", "down", "down", "left", "down", "down" },
["10,9"] = { "down", "down", "down", "down", "down" },
["9,9"] = { "down", "down", "down", "down", "down" },
},
[3] = { -- ROCKET3 @ (9,7), faces RIGHT
["9,8"] = { "right", "down", "down", "down", "down", "down", "down" },
["10,7"] = { "down", "down", "down", "down", "down" },
["11,7"] = { "down", "down", "down", "down", "down" },
["12,7"] = { "down", "down", "down", "down", "down" },
},
}
-- The vanilla table only lists the sight/talk tiles the map geometry allows;
-- a plain down-walk keeps any other engagement tile (e.g. talking from the
-- side) safe -- it still clears the corridor and despawns the grunt.
local POKEMON_TOWER_7F_EXIT_FALLBACK =
{ "down", "down", "down", "down", "down" }
M.POKEMON_TOWER_7F = {
-- Repair saves that beat a grunt before the exit walk was ported: in
-- vanilla HideObject persists (wMissableObjectFlags), so a beaten grunt is
-- still gone on re-entry. hide_object below writes objectToggles for new
-- wins; this hides any grunt whose BEAT flag is already set.
onEnter = function(game, ow)
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
if game.save.flags[r.beat] then
Commands.hide_object(ctx, "POKEMON_TOWER_7F", r.name)
end
end
end,
-- runVictoryHook fires after every trainer win on the map (both the sight
-- and talk paths route through engageTrainer -> checkVictoryRewards), so
-- this walks off whichever grunt was just beaten but is still standing.
-- The AfterBattle text + walk queue UNDER the EndBattle box engageTrainer
-- pushes right after, giving the vanilla order: EndBattle, AfterBattle,
-- walk, despawn.
onVictory = function(game, ow)
if ow.runner:isRunning() then return end
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
local npc = ow:npcByIndex(r.index)
if npc and game.save.flags[r.beat] then
local key = ow.player.cellX .. "," .. ow.player.cellY
local dirs = (POKEMON_TOWER_7F_EXITS[r.index] or {})[key]
or POKEMON_TOWER_7F_EXIT_FALLBACK
ow.runner:run({
{ "show_text", r.after }, -- DisplayTextID
{ "walk_npc", r.index, dirs }, -- MoveSprite
{ "hide_object", "POKEMON_TOWER_7F", r.name }, -- HideObject
}, { npc = npc })
return
end
end
end,
talk = {
TEXT_POKEMONTOWER7F_MR_FUJI = {
{ "face_player" }, -- 1
+4 -2
View File
@@ -528,7 +528,9 @@ M.MT_MOON_B2F = {
}
-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text):
-- Y50, once; declining at the rope walks you back out.
-- Y50, once. Declining at the rope shoves the player one tile SOUTH back off
-- the exhibit rope they crossed heading north (#151); the museum floor has no
-- ledges, so a plain scriptMove("down",1) is the correct primitive.
local function museumClerk(game, ow, done, onDecline)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
@@ -563,7 +565,7 @@ M.MUSEUM_1F = {
if y == 4 and (x == 9 or x == 10)
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
museumClerk(game, ow, nil, function()
ow:scriptMove(ow.player, "right", 1)
ow:scriptMove(ow.player, "down", 1)
end)
return true
end
+62 -31
View File
@@ -524,8 +524,17 @@ M.GAME_CORNER = {
game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function()
hideRocket()
done()
-- #198: GameCornerRocketExitScript (scripts/GameCorner.asm)
-- ApplyMovementData walks the grunt one tile UP into the poster
-- (the hideout's secret entrance at 9,4) before HideObject, so
-- he leaves the floor rather than popping out of existence on
-- (9,5). scriptMove locks player input (#scriptMoves>0) and
-- ignores collision, so we despawn + unfreeze (done) only once
-- the step lands.
ow:scriptMove(npc, "up", 1, function()
hideRocket()
done()
end)
end))
end)
end,
@@ -606,40 +615,62 @@ local function activePrizes()
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
end
-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
-- with no case prints RequireCoinCaseText and returns without ever opening a
-- window; only with the case does it print ExchangeCoinsForPrizesText and then
-- show the prizes. #194: the port used to open the window unconditionally and
-- skip both text boxes.
local function prizeCounter(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands")
local items = {}
for _, p in ipairs(activePrizes()) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
end
table.insert(items, { label = label, right = tostring(p.cost), value = p })
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- IsItemInBag COIN_CASE: without the case, deny and open no window
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
return
end
local list
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
if (game.save.coins or 0) < p.cost then
list.footer = "Not enough coins!"
return
-- ExchangeCoinsForPrizesText plays before the prize window opens.
game.stack:push(TextBox.new(game,
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
function()
local items = {}
for _, p in ipairs(activePrizes()) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
end
table.insert(items,
{ label = label, right = tostring(p.cost), value = p })
end
game.save.coins = game.save.coins - p.cost
if p.kind == "mon" then
Commands.give_pokemon({ save = game.save, game = game },
p.species, p.level)
else
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
end
list.footer = ("Got it! COINS %d"):format(game.save.coins)
end,
onCancel = done,
})
game.stack:push(list)
local list
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
if (game.save.coins or 0) < p.cost then
list.footer = "Not enough coins!"
return
end
game.save.coins = game.save.coins - p.cost
if p.kind == "mon" then
Commands.give_pokemon({ save = game.save, game = game },
p.species, p.level)
else
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
end
list.footer = ("Got it! COINS %d"):format(game.save.coins)
end,
onCancel = done,
})
game.stack:push(list)
end))
end
M.GAME_CORNER_PRIZE_ROOM = {
+26 -2
View File
@@ -134,12 +134,19 @@ M.MT_MOON_POKECENTER = {
-- Karate Master, take HITMONLEE or HITMONCHAN (the other disappears)
-- -------------------------------------------------------------------
-- ownBall/otherBall keep their spots for signature symmetry; only ownBall
-- is ever hidden (Gen1 removes just the chosen ball).
local function dojoBall(species, ownBall, otherBall, askKey)
return function(game, ow, npc, done)
local t = text(game)
local flags = game.save.flags
-- Already took one prize: the OTHER ball is still on the mat, so
-- talking to it now gives the "Better not get greedy..." refusal
-- (FightingDojo.asm .gotThePokemon / _FightingDojoBetterNotGetGreedyText),
-- not a silent no-op (#197).
if flags.EVENT_GOT_HITMONLEE or flags.EVENT_GOT_HITMONCHAN then
done()
push(game, t._FightingDojoBetterNotGetGreedyText
or "Better not get\ngreedy...", done)
return
end
if not flags.EVENT_BEAT_KARATE_MASTER then
@@ -153,8 +160,10 @@ local function dojoBall(species, ownBall, otherBall, askKey)
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.give_pokemon(ctx, species, 30)
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
-- just the picked object's index) and routes to the greedy line above
-- when talked to (#197).
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
Commands.hide_object(ctx, "FIGHTING_DOJO", otherBall)
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
end)
end
@@ -171,6 +180,21 @@ M.FIGHTING_DOJO = {
"FIGHTINGDOJO_HITMONLEE_POKE_BALL",
"_FightingDojoHitmonchanPokeBallText"),
},
-- The two dojo posters on the north wall (FightingDojo.asm bg_events at
-- the top of the room, cells (4,0)/(5,0) directly above the prize balls)
-- print _EnemiesOnEverySideText. The map extractor dropped the dojo
-- bg_events (signs = {}), so wire the poster read here, the same
-- facing-up + coord shape the mansion switches use. Reachable only once
-- a claimed prize frees the ball cell below the poster (#197).
onInteract = function(game, ow, fx, fy)
if ow.player.facing ~= "up" then return false end
if fy == 0 and (fx == 4 or fx == 5) then
push(game, text(game)._EnemiesOnEverySideText
or "Enemies on every\nside!")
return true
end
return false
end,
}
-- -------------------------------------------------------------------
+13 -3
View File
@@ -196,14 +196,24 @@ local function inCoords(coords, x, y)
return false
end
-- coordinate block: show a line and push the player back one step
-- coordinate block: show a line and shove the player back one step. pokered
-- shoves with a SIMULATED JOYPAD press (scripts/ViridianCity.asm ViridianCity-
-- CheckGymOpenScript), which runs the normal overworld step pipeline including
-- HandleLedges (engine/overworld/ledges.asm), so the shove must HOP a ledge
-- when one sits in front: the tile below the Viridian Gym door is a down-ledge
-- (#151). Gates with no ledge in front (Cinnabar gym lock at (18,4)) get
-- checkLedgeHop == false and fall back to the plain forced step, unchanged.
local function stepGate(opts)
return function(game, ow, x, y)
if not inCoords(opts.coords, x, y) then return false end
if not opts.blocked(game) then return false end
require("src.core.Sound").play(game.data, "Denied")
push(game, text(game)[opts.text] or opts.fallback,
function() ow:scriptMove(ow.player, opts.push, 1) end)
push(game, text(game)[opts.text] or opts.fallback, function()
ow.player.facing = opts.push
if not ow:checkLedgeHop(opts.push) then
ow:scriptMove(ow.player, opts.push, 1)
end
end)
return true
end
end
+5 -2
View File
@@ -114,9 +114,12 @@ return {
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
-- only him (data/maps/objects/FightingDojo.asm).
-- only him (data/maps/objects/FightingDojo.asm). `dialogue` is the
-- concede + prize offer he speaks after the win (his header supplies the
-- "Hwa! Arrgh! Beaten!" won line first; #197).
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) },
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3),
dialogue = { "_FightingDojoKarateMasterIWillGiveYouAPokemonText" } },
-- Elite Four progress flags (their rooms' door logic isn't ported, but
-- the flags make the Hall of Fame checkable)
+47 -15
View File
@@ -5,19 +5,39 @@ that runs before `Game:load`. Besides ROM import (see the file's own header)
it hosts a tabbed shell covering per-game save slots and a mod manager. This
file documents the runtime model; the visual spec lives separately.
## Android multi-ROM import
## Android multi-ROM / mod / save import
On Android, `love.system.pickFile()` opens the Storage Access Framework
picker (`GameActivity.showRomFilePicker`); the chosen file is copied to
`picked_rom.gb` in the app save directory. `RomImporter` then imports on
refocus / Choose via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps
to a version that is **not** yet ready counts as pending. A leftover
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
After a successful import the consumed save-dir `.gb` is removed.
On Android, `love.system.pickFile([kind])` opens the Storage Access Framework
picker (`GameActivity.showFilePicker`); the chosen file is copied into the app
save directory as:
| `kind` | Destination |
| --- | --- |
| nil / `"rom"` | `picked_rom.gb` (open) |
| `"mod"` | `picked_mod.zip` (open) |
| `"sav"` / `"save"` | `picked_save.sav` (open) |
Export uses a separate API: `love.system.createFile(suggestedName)`
`GameActivity.showCreateDocument` (`ACTION_CREATE_DOCUMENT`), which copies
staged `pending_export.sav` to the user-chosen URI and writes `export_done.flag`
for the launcher to acknowledge on refocus.
`RomImporter` then imports on refocus / Choose:
- **ROMs** via `findPendingRom`: only a 1 MiB `.gb` whose SHA-1 maps to a
version that is **not** yet ready counts as pending. A leftover
`picked_rom.gb` from Red therefore cannot block Blue's Choose (issue #167).
- **Mods** via `findPendingMod`: Prefer `picked_mod.zip`, or (on Choose) any
other `.zip` at the save-dir root (USB copy).
- **Saves** via `findPendingSav`: Prefer `picked_save.sav`, or (on Choose) any
other `.sav` at the save-dir root.
After a successful import the consumed save-dir file is removed.
**Manual check (device/emulator):** import Red → switch to Blue → Choose →
system file picker must appear (not a silent Red re-extract) → pick Blue →
Blue becomes ready beside Red.
Blue becomes ready beside Red. On the MODS tab, Import mod .zip must open the
same system picker and install the chosen archive on return.
## Tab structure
@@ -78,6 +98,10 @@ The launcher-facing API:
- `SaveData.createSlot(version)` -> new slot id, registered but with **no
save file written**. An empty slot means the title screen offers NEW GAME
only, which needs no further changes.
- `SaveData.deleteSlot(version, slotId)` removes the slot's
main/`.bak`/`.tmp` files, drops it from the registry, and if it was active
points active at another remaining slot (or clears active when the list is
empty). The launcher's SAVE SLOT panel Delete control calls this.
## Launcher mod manager
@@ -114,6 +138,9 @@ before `Game:load`, so **it never loads a mod's entry chunk**; only
temp first (mount only reaches save-dir-relative paths), the same way
`RomImporter` handles a dropped ROM. A failed copy rolls its partial tree
back, and every path unmounts and clears the staged temp file.
- `LauncherMods.uninstall(id)` removes `mods/<id>/` and clears
`options.mods[id]` so a later reinstall starts from the loader's default
(enabled). The mods panel Delete control calls this and re-derives the list.
## Import / Export save
@@ -122,9 +149,10 @@ through `src/import/SaveFileIO.lua`, which sits on top of
`src/save_convert/SaveConvert.lua` and the slot API in `SaveData`.
- **Import save** is live once the game's ROM is imported (playable). It opens
a native `.sav` picker (`chooseSav`, the per-OS dialogs mirror `chooseZip`;
Android has no picker and shows a drop hint). `SaveFileIO.importToSlot`
reads the bytes (an absolute path, a dropped LOVE file, or raw bytes),
a native `.sav` picker (`chooseSav` on desktop; on Android,
`love.system.pickFile("sav")``picked_save.sav`, same SAF path as ROMs).
`SaveFileIO.importToSlot` reads the bytes (an absolute path, a save-dir
relative name, a dropped LOVE file, or raw bytes),
guards the 32768-byte size, runs `SaveConvert.importSav` (which also rejects
a bad main-data checksum), then registers a fresh slot (`SaveData.createSlot`),
writes it (`SaveData.writeSlot`), and makes it active (`SaveData.setActiveSlot`).
@@ -136,9 +164,13 @@ through `src/import/SaveFileIO.lua`, which sits on top of
slot, encodes it back with `SaveConvert.exportSav` (a slot never keeps
`rawImport`, so this is a zero-filled template export, which is valid), and
writes `exports/gen1recomp-<version>-<slotId>.sav` in the save directory
(`love.filesystem.createDirectory("exports")`). It returns the absolute path
(`love.filesystem.getSaveDirectory()`), which the notice line shows with a
desktop "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
(`love.filesystem.createDirectory("exports")`). On desktop it returns the
absolute path (`love.filesystem.getSaveDirectory()`), which the notice line
shows with an "Open folder" affordance (`love.system.openURL("file://" .. dir)`).
On Android the bytes are also staged as `pending_export.sav` and
`love.system.createFile(suggestedName)` opens `ACTION_CREATE_DOCUMENT` so the
player can save to Downloads / Drive / etc.; on return `export_done.flag`
makes focus show "Save exported."
- **Drag-drop.** `filedropped` routes a `.sav` to the import path for the
currently active game tab; when a non-game tab (mods, or the locked yellow
placeholder) is showing it defaults to red, the always-present first game
+15 -1
View File
@@ -179,4 +179,18 @@ Into-battle wipes still run the original eight styles inside the classic
blocks cascade outward from that square into the surrounding world so the
void outside the OG wipe fills in lockstep. Once the battle state is up,
letterbox voids around the battle canvas fill **white** instead of black
so the whole window reads as one continuous battle screen.
so the whole window reads as one continuous battle screen.
## On-screen touch controls (mobile)
On Android/iOS the game draws a translucent d-pad (bottom-left), A/B
buttons (bottom-right, Game Boy diagonal), and +/- START/SELECT (bottom
center) over the frame, using Xelu's CC0 controller prompts
(`assets/touch/`). Real buttons, not gestures: press lands the frame the
finger does, sliding on the d-pad changes direction without lifting, and
multi-touch chords (e.g. hold a direction + tap B) work. The overlay only
appears while no controller is being used: the first gamepad button or
stick push hides it, the next screen touch brings it back, and unplugging
the last controller restores it immediately. Layout re-derives from the
window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
the overlay on and lets the mouse act as a finger (`=0` forces it off).
+18
View File
@@ -18,6 +18,11 @@ local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
-- bot or screenshot run is not at the mercy of the player's last choice.
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
-- POKEPORT_TOUCH=1 forces the mobile on-screen controls on and lets the
-- mouse stand in for a finger, so the overlay can be exercised on desktop
-- (see src/core/TouchControls.lua).
local mouseTouch = os.getenv("POKEPORT_TOUCH") == "1"
-- How many times to run a scripted act+step loop per rendered frame. Only
-- scripted runs use this; interactive play fast-forwards through
-- Game.speedOverride / the GAME SPEED option instead.
@@ -274,6 +279,9 @@ function love.mousepressed(x, y, button)
if editorMode and EditorApp.mousepressed then
return EditorApp.mousepressed(x, y, button)
end
if mouseTouch and Game and button == 1 then
Game:touchpressed("mouse", x, y)
end
end
function love.mousereleased(x, y, button)
@@ -281,6 +289,16 @@ function love.mousereleased(x, y, button)
if editorMode and EditorApp.mousereleased then
return EditorApp.mousereleased(x, y, button)
end
if mouseTouch and Game and button == 1 then
Game:touchreleased("mouse", x, y)
end
end
function love.mousemoved(x, y)
if editorMode or Importer then return end
if mouseTouch and Game and love.mouse.isDown(1) then
Game:touchmoved("mouse", x, y)
end
end
function love.textinput(text)
+5 -5
View File
@@ -43,11 +43,11 @@ The embedded `game.love` deliberately excludes `data/generated/`,
`assets/generated/`, and any ROM. It contains the first-boot Lua importer and
`tools/rom_manifest.json`.
ROM import on Android uses `love.system.pickFile()`
`GameActivity.showRomFilePicker` (Storage Access Framework), which copies the
chosen file to `picked_rom.gb` under the app save directory. `RomImporter`
imports pending (not-yet-ready) `.gb` files from that folder on Choose /
refocus; see `docs/launcher.md` (Android multi-ROM import). The APK payload
ROM / mod / save import on Android uses `love.system.pickFile([kind])`
`GameActivity.showFilePicker` (Storage Access Framework), which copies the
chosen file under the app save directory as `picked_rom.gb`,
`picked_mod.zip`, or `picked_save.sav`. `RomImporter` imports pending files
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache).
### SDK / NDK
+1 -1
View File
@@ -18,4 +18,4 @@ android.useAndroidX=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=true
android.nonFinalResIds=true
app.name=Pokemon Red
app.name=gen1recomp
@@ -183,13 +183,37 @@ void vibrate(double seconds)
env->DeleteLocalRef(activity);
}
bool showFilePicker()
bool showFilePicker(const char *destFilename)
{
if (destFilename == nullptr || destFilename[0] == '\0')
destFilename = "picked_rom.gb";
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showRomFilePicker", "()Z");
jboolean result = env->CallStaticBooleanMethod(activity, method);
jmethodID method = env->GetStaticMethodID(activity, "showFilePicker",
"(Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(destFilename);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
env->DeleteLocalRef(jname);
env->DeleteLocalRef(activity);
return result;
}
bool showCreateDocument(const char *suggestedName)
{
if (suggestedName == nullptr || suggestedName[0] == '\0')
suggestedName = "export.sav";
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument",
"(Ljava/lang/String;)Z");
jstring jname = env->NewStringUTF(suggestedName);
jboolean result = env->CallStaticBooleanMethod(activity, method, jname);
env->DeleteLocalRef(jname);
env->DeleteLocalRef(activity);
return result;
@@ -63,9 +63,16 @@ void vibrate(double seconds);
* Shows the system's "pick a document" UI (Storage Access Framework).
* Returns true if the picker was launched; the picked file (if any) is
* copied asynchronously by GameActivity.onActivityResult into the app's
* external save directory, not returned here -- see src/import/RomImporter.lua.
* external save directory under destFilename (default picked_rom.gb), not
* returned here -- see src/import/RomImporter.lua.
**/
bool showFilePicker();
bool showFilePicker(const char *destFilename = nullptr);
/**
* Shows ACTION_CREATE_DOCUMENT so Lua can export a staged pending_export.sav
* to a user-chosen location. suggestedName is the dialog default filename.
**/
bool showCreateDocument(const char *suggestedName = nullptr);
/*
* Helper functions for the filesystem module
@@ -22,6 +22,8 @@
#include "common/config.h"
#include "System.h"
#include <cstring>
#if defined(LOVE_MACOSX)
#include <CoreServices/CoreServices.h>
#elif defined(LOVE_IOS)
@@ -180,11 +182,32 @@ void System::vibrate(double seconds) const
#endif
}
bool System::pickFile() const
bool System::pickFile(const char *kind) const
{
#ifdef LOVE_ANDROID
return love::android::showFilePicker();
const char *dest = "picked_rom.gb";
if (kind != nullptr)
{
if (strcmp(kind, "mod") == 0)
dest = "picked_mod.zip";
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav";
else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb";
}
return love::android::showFilePicker(dest);
#else
LOVE_UNUSED(kind);
return false;
#endif
}
bool System::createFile(const char *suggestedName) const
{
#ifdef LOVE_ANDROID
return love::android::showCreateDocument(suggestedName);
#else
LOVE_UNUSED(suggestedName);
return false;
#endif
}
@@ -111,9 +111,21 @@ public:
* Android only for now; the result (if any) is not returned here -- see
* love::android::showFilePicker and src/import/RomImporter.lua.
*
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav.
* @return Whether the picker was shown.
**/
virtual bool pickFile() const;
virtual bool pickFile(const char *kind = nullptr) const;
/**
* Shows the platform's native "create / save a file" UI (Android SAF
* ACTION_CREATE_DOCUMENT). Copies staged pending_export.sav from the app
* save directory to the user-chosen URI. See GameActivity.showCreateDocument.
*
* @param suggestedName Default filename shown in the dialog.
* @return Whether the create dialog was shown.
**/
virtual bool createFile(const char *suggestedName = nullptr) const;
/**
* Gets if the user is playing music on background.
@@ -97,7 +97,15 @@ int w_vibrate(lua_State *L)
int w_pickFile(lua_State *L)
{
luax_pushboolean(L, instance()->pickFile());
const char *kind = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->pickFile(kind));
return 1;
}
int w_createFile(lua_State *L)
{
const char *suggested = luaL_optstring(L, 1, nullptr);
luax_pushboolean(L, instance()->createFile(suggested));
return 1;
}
@@ -117,6 +125,7 @@ static const luaL_Reg functions[] =
{ "openURL", w_openURL },
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
@@ -22,12 +22,15 @@ package org.love2d.android;
import org.libsdl.app.SDLActivity;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -61,13 +64,24 @@ public class GameActivity extends SDLActivity {
protected final int[] recordAudioRequestDummy = new int[1];
public static final int EXTERNAL_STORAGE_REQUEST_CODE = 2;
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int ROM_PICKER_REQUEST_CODE = 4;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked ROM
public static final int FILE_PICKER_REQUEST_CODE = 4;
public static final int FILE_CREATE_REQUEST_CODE = 5;
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
// is dropped so RomImporter's existing folder scan finds it -- see
// src/import/RomImporter.lua and Filesystem::setIdentity (sets Android's
// save directory to getExternalFilesDir()/save/<identity>).
private static final String ROM_SAVE_IDENTITY = "pokemon-love2d";
private static final String PICKED_ROM_FILENAME = "picked_rom.gb";
private static final String PICKED_MOD_FILENAME = "picked_mod.zip";
private static final String PICKED_SAVE_FILENAME = "picked_save.sav";
private static final String PENDING_EXPORT_FILENAME = "pending_export.sav";
private static final String EXPORT_DONE_FILENAME = "export_done.flag";
// Destination basename for the in-flight SAF pick (set by showFilePicker).
private String pendingPickFilename = PICKED_ROM_FILENAME;
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
private String pendingCreateSuggestedName = "export.sav";
private static boolean immersiveActive = false;
private static boolean needToCopyGameInArchive = false;
private boolean storagePermissionUnnecessary = false;
@@ -341,59 +355,185 @@ public class GameActivity extends SDLActivity {
/**
* Shows the system document picker (Storage Access Framework) so the
* player can pick their ROM from anywhere (Downloads, Drive, etc.)
* without needing to know where the app's external files folder is.
* Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* player can pick a ROM / mod / save from anywhere (Downloads, Drive,
* etc.) without needing to know where the app's external files folder
* is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any)
* arrives later in onActivityResult, not synchronously here.
*
* @param destFilename basename under the app save identity (e.g.
* picked_rom.gb, picked_mod.zip, picked_save.sav)
*/
@Keep
public static boolean showRomFilePicker() {
public static boolean showFilePicker(String destFilename) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (destFilename == null || destFilename.length() == 0) {
destFilename = PICKED_ROM_FILENAME;
}
// 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);
return false;
}
self.pendingPickFilename = destFilename;
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
try {
self.startActivityForResult(intent, ROM_PICKER_REQUEST_CODE);
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open ROM file picker: " + e.getMessage());
Log.d("GameActivity", "could not open file picker: " + e.getMessage());
return false;
}
}
/** ROM convenience wrapper; prefer showFilePicker with an explicit name. */
@Keep
public static boolean showRomFilePicker() {
return showFilePicker(PICKED_ROM_FILENAME);
}
/** Mod .zip convenience wrapper used by love.system.pickFile("mod"). */
@Keep
public static boolean showModFilePicker() {
return showFilePicker(PICKED_MOD_FILENAME);
}
/** Battery .sav convenience wrapper used by love.system.pickFile("sav"). */
@Keep
public static boolean showSaveFilePicker() {
return showFilePicker(PICKED_SAVE_FILENAME);
}
/**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive /
* etc. Suggested name is the dialog's default filename.
*/
@Keep
public static boolean showCreateDocument(String suggestedName) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (suggestedName == null || suggestedName.length() == 0) {
suggestedName = "export.sav";
}
if (suggestedName.indexOf('/') >= 0 || suggestedName.indexOf('\\') >= 0) {
Log.d("GameActivity", "refusing unsafe create name: " + suggestedName);
return false;
}
File source = new File(
new File(self.getExternalFilesDir(null), "save"),
ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME);
if (!source.isFile()) {
Log.d("GameActivity", "no pending export at " + source);
return false;
}
self.pendingCreateSuggestedName = suggestedName;
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("application/octet-stream");
intent.putExtra(Intent.EXTRA_TITLE, suggestedName);
try {
self.startActivityForResult(intent, FILE_CREATE_REQUEST_CODE);
return true;
} catch (Exception e) {
Log.d("GameActivity", "could not open create-document picker: " + e.getMessage());
return false;
}
}
private File saveIdentityDir() {
return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
}
private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null;
OutputStream out = null;
try {
in = new BufferedInputStream(new FileInputStream(source));
out = getContentResolver().openOutputStream(destUri);
if (out == null) return false;
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
out.flush();
return true;
} catch (IOException e) {
Log.d("GameActivity", "copy to URI failed: " + e.getMessage());
return false;
} finally {
try { if (in != null) in.close(); } catch (IOException ignored) {}
try { if (out != null) out.close(); } catch (IOException ignored) {}
}
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode != ROM_PICKER_REQUEST_CODE) return;
if (requestCode == FILE_CREATE_REQUEST_CODE) {
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "create-document cancelled");
return;
}
File source = new File(saveIdentityDir(), PENDING_EXPORT_FILENAME);
if (!source.isFile()) {
Log.d("GameActivity", "pending export missing at result time");
return;
}
Uri uri = data.getData();
if (copyFileToUri(source, uri)) {
// Signal Lua on next focus that the SAF export finished.
File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME);
try {
FileOutputStream fos = new FileOutputStream(flag, false);
fos.write("ok".getBytes());
fos.close();
} catch (IOException e) {
Log.d("GameActivity", "could not write export_done flag: " + e.getMessage());
}
// Keep pending_export.sav so a retry still works; Lua may remove it.
} else {
Log.d("GameActivity", "could not write export to " + uri);
}
return;
}
if (requestCode != FILE_PICKER_REQUEST_CODE) return;
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
Log.d("GameActivity", "ROM picker returned no file (cancelled?)");
Log.d("GameActivity", "file picker returned no file (cancelled?)");
return;
}
Uri uri = data.getData();
File destDir = new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY);
File destDir = saveIdentityDir();
if (!destDir.exists() && !destDir.mkdirs()) {
Log.d("GameActivity", "could not create " + destDir);
return;
}
File destFile = new File(destDir, PICKED_ROM_FILENAME);
String destName = pendingPickFilename != null
? pendingPickFilename : PICKED_ROM_FILENAME;
File destFile = new File(destDir, destName);
InputStream source;
try {
source = getContentResolver().openInputStream(uri);
} catch (FileNotFoundException e) {
Log.d("GameActivity", "could not open picked ROM: " + e.getMessage());
Log.d("GameActivity", "could not open picked file: " + e.getMessage());
return;
}
if (source == null) {
Log.d("GameActivity", "ContentResolver returned no stream for picked ROM");
Log.d("GameActivity", "ContentResolver returned no stream for picked file");
return;
}
if (!copyAssetFile(source, destFile.getPath())) {
Log.d("GameActivity", "could not copy picked ROM to " + destFile);
Log.d("GameActivity", "could not copy picked file to " + destFile);
}
}
+5 -3
View File
@@ -1,6 +1,6 @@
# Example mod gallery
Seven reference mods, one per modder persona. Each is small enough to read
Eight reference mods, one per modder persona. Each is small enough to read
in one sitting, exercises a different slice of the mod API, and is a real,
runnable, tested mod — not a snippet.
@@ -16,6 +16,7 @@ Copy the one closest to what you want to build.
| 5 | [`example_weather`](example_weather) | Mechanic designer | `MECHANIC` | Rain that scales WATER and FIRE damage, behind a ruleset |
| 6 | [`example_dexnav`](example_dexnav) | Tool builder | `TOOL` | A START-menu dex overlay with an inter-mod API |
| 7 | [`example_mini_conversion`](example_mini_conversion) | TC team | `TOTAL_CONVERSION` | Sable Cove: one town, three species, one badge |
| 8 | [`example_silly_oak`](example_silly_oak) | Intro author | `UI` | Extra Oak questions, sprite swaps, answers in `mod.save` |
## None of these load by default
@@ -46,7 +47,8 @@ and `field`, and exercises:
- both buses — `events:on` / `events:emit` and `hooks:wrap` — across
`music.select`, `battle.damage`, `ui.start_menu.items`, `ui.options.rows`,
`battle.started`, `battle.turn_started`, `battle.ended`, `flag.changed`,
`game.ready` and `assets.transformed`
`game.ready`, `assets.transformed`, `intro.oak_speech.build`,
`intro.oak_speech.answered` and `intro.oak_speech.finished`
- `mod.save`, `mod.options`, `mod.exports`, `mod.commands`, `mod.ui`,
`mod:read`, `mod.log` and `mod.path`
- asset transforms, the `trueColor` opt-out, script labels and `choice`,
@@ -66,7 +68,7 @@ mods/examples/<id>/
.modkitignore keeps the suite out of the distributed package
```
`tests/mod_examples_tests.lua` in the engine's own suite loads all seven
`tests/mod_examples_tests.lua` in the engine's own suite loads all eight
together and asserts the above, so the gallery cannot rot.
These example mods arent perfect and are just examples to show you basics of wahts possible, but way more than just this is possible.
@@ -0,0 +1,12 @@
# Changelog
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
Version headings match `manifest.json`'s `version`.
## 1.0.0
### Added
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
- Answers written to `mod.save` via `intro.oak_speech.answered`.
- Custom `toast_kid.png` sprite shown mid-speech.
+43
View File
@@ -0,0 +1,43 @@
# Silly Oak Intro Example
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
## Try it (play through yourself)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
love .
```
Then **NEW GAME** and mash A / pick the menus. Disable or delete
`mods/example_silly_oak` when you're done so vanilla boots clean.
## Headless check
```sh
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
```
## Auto driver (screenshots + save asserts)
```sh
rm -rf mods/example_silly_oak
cp -r mods/examples/example_silly_oak mods/
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
```
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
## What it demonstrates
| Seam | Where |
|---|---|
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` — reshape the step list |
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` — anchored on vanilla step ids |
| step kinds `say` / `yesno` / `choice` | `main.lua` |
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
| `events:on("intro.oak_speech.answered")` | `main.lua``mod.save` |
| `events:on("intro.oak_speech.finished")` | `main.lua` |
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 B

+107
View File
@@ -0,0 +1,107 @@
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
-- swaps (oak / rival / player / pokemon / a custom image), and answers
-- that land in mod.save.
--
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
return function(mod)
local toastPic = mod.path .. "/assets/toast_kid.png"
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
steps = next(steps, speech)
-- after oak says hello, immediately derail
mod.ui.insertStepAfter(steps, "oak_welcome", {
id = "silly_quiz_intro",
kind = "say",
pic = "oak",
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
})
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
id = "silly_toast",
kind = "yesno",
pic = "oak",
saveKey = "likes_toast",
text = "Do you like\ntoast?",
})
-- brand new sprite mid-speech
mod.ui.insertStepAfter(steps, "silly_toast", {
id = "silly_toast_kid",
kind = "say",
pic = { type = "image", path = toastPic },
reveal = "fade",
saveKey = nil,
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
})
-- existing mon with a wipe + cry, parked after the real demo mon
mod.ui.insertStepAfter(steps, "demo_mon", {
id = "silly_mew",
kind = "say",
pic = { type = "pokemon", id = "MEW" },
reveal = "wipe",
cry = "MEW",
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
})
mod.ui.insertStepAfter(steps, "silly_mew", {
id = "silly_snack",
kind = "choice",
pic = "oak",
saveKey = "snack",
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
})
-- swap to rival pic for a loaded question before naming him
mod.ui.insertStepBefore(steps, "ask_rival_name", {
id = "silly_trust",
kind = "choice",
pic = "rival",
reveal = "fade",
saveKey = "trusts_rival",
text = "Look at this kid.\nTrustworthy?",
choices = { "SURE", "NO" },
values = { true, false },
})
-- player pic for one last bit after both names are set
mod.ui.insertStepAfter(steps, "name_rival", {
id = "silly_pineapple",
kind = "yesno",
pic = "player",
saveKey = "pineapple_on_pizza",
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
})
mod.ui.insertStepAfter(steps, "silly_pineapple", {
id = "silly_closing",
kind = "say",
pic = "oak",
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
})
return steps
end)
-- every answered step with a saveKey lands in mod.save (and therefore
-- save.modData[mod.id] once the slot is written)
mod.events:on("intro.oak_speech.answered", function(ev)
if not ev.saveKey then return end
mod.save:set(ev.saveKey, ev.value)
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
end)
mod.events:on("intro.oak_speech.finished", function(ev)
local answers = ev.answers or {}
for key, value in pairs(answers) do
if mod.save:get(key) == nil then
mod.save:set(key, value)
end
end
mod.save:set("quiz_done", true)
mod.log:info("silly oak quiz done")
end)
end
@@ -0,0 +1,15 @@
{
"id": "example_silly_oak",
"name": "Silly Oak Intro Example",
"version": "1.0.0",
"api": 2,
"entry": "main.lua",
"profile": "content",
"category": "UI",
"game_version": ">=0.0.0-0 <2.0.0",
"priority": 100,
"dependencies": [],
"optional_dependencies": [],
"conflicts": [],
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
}
+21
View File
@@ -0,0 +1,21 @@
-- Sharing metadata for the manager detail pane.
return {
summary = "Oak asks dumb questions during the intro and remembers your answers.",
author = "Pokemon Gen 1 Recompilation Project",
contact = "https://github.com/bryanthaboi/pokemon-gen1-recomp-project",
tags = { "intro", "ui", "oak", "hooks" },
differences = {
changed = {
"Oak's NEW GAME speech gains extra questions and sprite beats",
},
added = {
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
"Custom Toast Kid pic mid-intro",
},
known = { "vanilla naming and the shrink-away still run" },
},
credits = {
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
},
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
}
@@ -0,0 +1,159 @@
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
-- and answers landing in mod.save.
--
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
-- fresh checkout without a ROM skip cleanly -- the gallery is also
-- covered by tests/mod_examples_tests.lua when generated data is present.
package.path = "./?.lua;./?/init.lua;" .. package.path
local function hasGenerated()
local handle = io.open("data/generated/constants.lua", "r")
if handle then handle:close() return true end
return false
end
if not hasGenerated() then
print("example_silly_oak_test skipped (needs data/generated/)")
os.exit(0)
end
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local OakSpeech = require("src.ui.OakSpeech")
local Data = require("src.core.Data")
Data:load()
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
local mod = run.mod
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
local ModUI = require("src.ui.ModUI")
local bucket = function()
return run.loader.modSave.example_silly_oak or {}
end
local toastPath = (mod.path or "mods/examples/example_silly_oak")
.. "/assets/toast_kid.png"
-- ------- build hook injects every silly beat around vanilla anchors
local speech = OakSpeech.new({
data = Data,
save = { player = { name = "RED", rival = "BLUE" } },
stack = { push = function() end, pop = function() end },
}, nil)
local steps = speech:buildSteps()
local ids = {}
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
local function has(id)
for _, x in ipairs(ids) do if x == id then return true end end
return false
end
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
"vanilla anchors still present")
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
"toast quiz beats injected")
T.check(has("silly_mew") and has("silly_snack"),
"MEW reveal and snack choice injected")
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
"rival trust + pineapple beats injected")
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
local function indexOf(id)
for i, x in ipairs(ids) do if x == id then return i end end
return 0
end
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
"Toast Kid shows before the demo mon")
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
"MEW shows after the demo mon")
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
"trust question is before rival naming")
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
and indexOf("silly_pineapple") < indexOf("legend"),
"pineapple lands between rival name and the legend beat")
-- ------- step shapes cover choice / yesno / custom image / pokemon
local byId = {}
for _, step in ipairs(steps) do byId[step.id] = step end
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
"Toast Kid uses a custom image pic")
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
"MEW beat uses pokemon pic + cry")
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
{ type = "pokemon", id = "MEW", flip = true }, speech)
local customImg = OakSpeech.resolvePic({ data = Data },
{ type = "image", path = toastPath }, speech)
-- headless love stub may return nil images; the call itself must not throw
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
or type(oakImg) == "table",
"oak shorthand resolves without error")
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
or type(rivalImg) == "table",
"rival shorthand resolves without error")
T.check(playerImg == speech.playerPic or playerImg == nil
or type(playerImg) == "userdata" or type(playerImg) == "table",
"player shorthand resolves without error")
T.check(mewFlip == true, "pokemon flip flag is honored")
T.check(customImg ~= nil or true, "custom image path is accepted")
-- ------- answered event writes mod.save (loader.modSave bucket)
Runtime.emit("intro.oak_speech.answered", {
saveKey = "likes_toast", value = true, label = "YES", index = 1,
step = byId.silly_toast, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
step = byId.silly_snack, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
step = byId.silly_trust, speech = speech,
})
Runtime.emit("intro.oak_speech.answered", {
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
step = byId.silly_pineapple, speech = speech,
})
Runtime.emit("intro.oak_speech.finished", {
speech = speech, answers = speech.answers,
})
local saved = bucket()
T.eq(saved.likes_toast, true, "likes_toast saved")
T.eq(saved.snack, "OLD ROD", "snack saved")
T.eq(saved.trusts_rival, false, "trusts_rival saved")
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
-- ------- ModUI step helpers (public surface)
local tiny = {
{ id = "a", kind = "say" },
{ id = "b", kind = "say" },
}
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
ModUI.removeStep(tiny, "mid")
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
run.release()
T.finish("example_silly_oak")
+155 -26
View File
@@ -183,7 +183,22 @@ end
-- the image a battler pic actually draws with this frame
function BattleState:picImage(img)
if self.grayPics then return grayImage(img) end
local PaletteFX = require("src.render.PaletteFX")
-- #207: OG / OG INV / CLASSIC are forced-mono display modes. A battle that
-- exposes no SGB zones (sgbPalettes() == nil) has a whole-screen GRAYS zone
-- invented by PaletteFX.ensureZones, so Renderer:endFrame re-thresholds the
-- WHOLE finished frame through the shade shader a second time (keyed on the
-- red channel). A pic baked with the species' SGB color is then remapped
-- again and loses its warm mid shades -- REDMON's reds 1.0/0.839 both land in
-- the c0 bucket, collapsing CHARMANDER's body into the white paper and
-- leaving only the outline. Emit the raw DMG-gray build instead (exactly
-- what the SE_WAVY_SCREEN grayPics path already does) so the downstream remap
-- recolors 255->c0/170->c1/85->c2/0->c3 and all four shades survive; cool
-- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode
-- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync.
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
or PaletteFX.mode == "classic"
if self.grayPics or mono then return grayImage(img) end
return fadeImage(img, self:activeBgp())
end
@@ -284,8 +299,13 @@ local function makeBattler(data, mon, isPlayer, save)
curStats = mon.stats,
curTypes = def.types,
curMoves = mon.moves,
sprite = getImage(isPlayer and def.spriteBack or def.spriteFront,
monPalette(data, mon.species), def.trueColor),
sprite = (function()
local Sprites = require("src.pokemon.Sprites")
local path, tc = Sprites.path(data, mon.species,
isPlayer and "back" or "front",
{ mon = mon, kind = "battle" })
return getImage(path, monPalette(data, mon.species), tc)
end)(),
}
end
@@ -303,13 +323,16 @@ BattleState.makeBattler = makeBattler
function BattleState:speciesSprite(species, isPlayerSide)
local def = self.data.pokemon[species]
if not def then return nil end
local Sprites = require("src.pokemon.Sprites")
local path, tc = Sprites.path(self.data, species,
isPlayerSide and "back" or "front", { kind = "battle" })
local PaletteFX = require("src.render.PaletteFX")
local colors = PaletteFX.monPal(self.data, species, true)
local name = "GRAYMON"
if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end
return getImage(isPlayerSide and def.spriteBack or def.spriteFront,
return getImage(path,
colors and { name = name, colors = colors } or nil,
def.trueColor)
tc)
end
local function markSeen(game, species)
@@ -666,16 +689,53 @@ local function shownHP(b)
return math.floor(shown)
end
-- Parse a battle message into its rendered lines. The extractor marks
-- \n = next line and \v = CONT (home/text.asm ContText: draw the blinking
-- ▼, WaitForTextScrollButtonPress, then ScrollTextUpOneLine); the boosted /
-- EXP.ALL exp lines end in the CONT code in the ROM (data/generated/text.lua
-- _BoostedText/_WithExpAllText = "...\011"). Each entry is { codes, cont },
-- cont true when the line was preceded by \v. Splitting before Font.encode
-- keeps the control chars out of the glyph stream. The box then types into
-- a rolling 2-line window (self.shown) that scrolls when a 3rd line arrives
-- instead of drawing it off-screen at y=144 (#216).
function BattleState:startMessage(item)
self.current = item
self.lines = {}
self.total = 0
for chunk in (item.text .. "\n"):gmatch("(.-)\n") do
local text = item.text or ""
local pos, cont = 1, false
while true do
local npos = text:find("[\n\v]", pos)
local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos)
local codes = Font.encode(chunk)
table.insert(self.lines, codes)
self.lines[#self.lines + 1] = { codes = codes, cont = cont }
self.total = self.total + #codes
if not npos then break end
cont = text:sub(npos, npos) == "\v"
pos = npos + 1
end
self.shown = {} -- up to two visible lines of revealed glyph codes
self.lineIndex = 0
-- self.charIndex counts glyphs typed across the WHOLE message (drivers read
-- it against self.total); the current line's revealed count is #shown[last]
self.charIndex = 0
self.msgWaiting = nil
self.scrollPx = nil
self:beginMsgLine()
end
-- Start typing the next line into the rolling window. When the box already
-- shows two lines, drop the top one and set the pixel scroll-up
-- (ScrollTextUpOneLine), mirroring TextBox:beginLine.
function BattleState:beginMsgLine()
self.lineIndex = self.lineIndex + 1
local ln = self.lines[self.lineIndex]
self.codes = ln and ln.codes or {}
if #self.shown >= 2 then
table.remove(self.shown, 1)
self.scrollPx = 8
end
self.shown[#self.shown + 1] = {}
end
function BattleState:updateQueue()
@@ -823,8 +883,32 @@ function BattleState:updateQueue()
end
self:startMessage(item)
end
if self.charIndex < self.total then
self.charIndex = math.min(self.total, self.charIndex + 2)
local input = self.game.input
-- a \v CONT wait holds the box until A/B, then scrolls the next line in
-- (home/text.asm ContText); this keeps a 3rd line on-screen (#216)
if self.msgWaiting then
if input:wasPressed("a") or input:wasPressed("b") then
self.msgWaiting = nil
self:beginMsgLine()
end
return true
end
local cur = self.shown[#self.shown]
if #cur < #self.codes then
-- battle typewriter cadence: two glyphs per fixed step (as before)
for _ = 1, 2 do
if #cur >= #self.codes then break end
cur[#cur + 1] = self.codes[#cur + 1]
self.charIndex = self.charIndex + 1
end
elseif self.lineIndex < #self.lines then
-- current line finished, more lines remain: \v waits for A/B + ▼ before
-- scrolling, \n advances now (beginMsgLine scrolls if the box is full)
if self.lines[self.lineIndex + 1].cont then
self.msgWaiting = true
else
self:beginMsgLine()
end
else
local item = self.current
-- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO
@@ -841,7 +925,6 @@ function BattleState:updateQueue()
end))
return true
end
local input = self.game.input
if not (item and item.choice)
and (input:wasPressed("a") or input:wasPressed("b")) then
self.current = nil
@@ -2706,6 +2789,13 @@ function BattleState:enemyMonFainted()
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
self.enemy.mon.level, self.kind == "trainer",
split, mon.traded)
-- Track level-ups for EvolveAfterBattle (OverworldState:afterBattle ->
-- Evolution.checkParty). B-cancel leaves the mon at/above threshold;
-- without this gate it re-triggers after every later fight (#213).
if #levels > 0 then
self.leveledUp = self.leveledUp or {}
self.leveledUp[mon] = true
end
Runtime.emit("battle.exp_gained", {
battle = self, mon = mon, gained = gained, levels = levels,
})
@@ -2715,12 +2805,15 @@ function BattleState:enemyMonFainted()
-- _WithExpAllText / _BoostedText / _ExpPointsText; the EXP.ALL
-- pass beats the traded boost (wBoostExpByExpAll checks first),
-- and _ExpPointsText prints wExpAmountGained -- the raw share,
-- captured before the max-level cap (experience.asm:92-100)
-- captured before the max-level cap (experience.asm:92-100).
-- _BoostedText / _WithExpAllText end in the CONT code (\v, "...\011"
-- in data/generated/text.lua): the box waits for A/B + ▼ then scrolls
-- the amount line in, so it stays on-screen instead of at y=144 (#216).
local tail = "%d EXP. Points!"
if announce == "expAll" then
tail = "with EXP.ALL,\n" .. tail
tail = "with EXP.ALL,\v" .. tail
elseif mon.traded then
tail = "a boosted\n" .. tail
tail = "a boosted\v" .. tail
end
self:sayNext(("%s gained\n" .. tail):format(name, gained))
end
@@ -2733,6 +2826,17 @@ function BattleState:enemyMonFainted()
require("src.core.Sound").play(game.data, "Level_Up")
return StatBox.new(game, mon)
end)
-- After PrintStatsBox, experience.asm reloads the active battler's
-- wBattleMon and runs DrawHUDsAndHPBars, so its HP bar reflects the
-- higher current HP. Experience.lua:84 already raised mon.hp by
-- (newMaxHP - oldMaxHP); the party mon and the battler share one table
-- (makeBattler), so mon.stats.hp (the bar's denominator) jumps to the
-- new max instantly while the battler's shownHP numerator lags at the
-- old current HP -- the bar SHRINKS (#224). Animate shownHP up to the
-- new current HP (house convention: potions drain the bar too, see
-- itemUsed) so the bar grows instead. Only the active player battler
-- shares its table with the HUD; other party mons (EXP.ALL) have no bar.
if mon == self.player.mon then self:drainNext() end
for _, moveId in ipairs(Experience.movesLearnedAt(
self.data.pokemon[mon.species], lv)) do
self:learnMove(mon, moveId)
@@ -3772,8 +3876,11 @@ function BattleState:colorMode()
if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas
and love.image and PaletteFX.pack(self.data)
and PaletteFX.shader() then
local ok1, bg = pcall(g.newCanvas, 160, 144)
local ok2, wv = pcall(g.newCanvas, 160, 144)
-- 160x144 real pixels, not DPI units, or the colored battle background
-- resamples against the UI canvas on mobile (#208; PixelCanvas.lua)
local PixelCanvas = require("src.render.PixelCanvas")
local ok1, bg = pcall(PixelCanvas.new, 160, 144)
local ok2, wv = pcall(PixelCanvas.new, 160, 144)
if ok1 and ok2 and bg and wv then
self.bgCanvas, self.waveCanvas = bg, wv
ready = true
@@ -4114,7 +4221,12 @@ function BattleState:drawHUDs(slide)
-- the HUD clears with the send-out text (ClearScreenArea,
-- core.asm:1414-1417) and DrawEnemyHUDAndHPBar (1435) only redraws
-- it after the grow-in + cry
local barData = self:colorMode() and {} or self.data -- gray fill when zoned
-- In colorized modes the zone pass (drawZonePass over BATTLE_ZONES pal 0/1)
-- recolors the bar's DMG gray fill by region, so drawHPBar must skip its
-- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill
-- double-applies and the zone shade shader maps the whole bar to black (#229).
local grayFill = self:colorMode()
local barData = self.data
local fx = self.fx
local hudShake = (fx and fx.hudShakeX) or 0
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
@@ -4139,7 +4251,8 @@ function BattleState:drawHUDs(slide)
end
hudTile(0x73, 8, 16)
drawHPBar(barData, 2, 2,
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats })
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
nil, grayFill)
hudTile(0x74, 8, 24)
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
hudTile(0x78, 80, 24)
@@ -4187,7 +4300,7 @@ function BattleState:drawHUDs(slide)
end
drawHPBar(barData, 10, 9,
{ hp = shownHP(self.player), stats = self.player.mon.stats },
1) -- wHPBarType 1: the $6D cap
1, grayFill) -- wHPBarType 1: the $6D cap
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
hudTile(0x73, 144, 80)
hudTile(0x77, 144, 88)
@@ -4200,16 +4313,27 @@ function BattleState:drawTextArea()
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.phase == "messages" and self.current then
local shown = 0
for li, codes in ipairs(self.lines) do
-- battle text uses every other tile row (hlcoord *,14 / *,16)
local y = 112 + (li - 1) * 16
for i = 1, #codes do
if shown >= self.charIndex then break end
Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
shown = shown + 1
-- rolling 2-line window: shown[1] at row y=112, shown[2] at y=128 (battle
-- text uses every other tile row, hlcoord *,14 / *,16). scrollPx animates
-- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into
-- view instead of drawing off-screen at y=144 (#216).
if self.scrollPx and self.scrollPx > 0 then
self.scrollPx = self.scrollPx - 2
if self.scrollPx <= 0 then self.scrollPx = nil end
end
local off = self.scrollPx or 0
local ys = { 112, 128 }
for li, line in ipairs(self.shown or {}) do
local y = (ys[li] or 128) + off
for i = 1, #line do
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
end
end
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait holds the
-- box, bottom-right of the box like TextBox / home/text.asm
if self.msgWaiting and self.frame % 60 < 30 then
Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
end
elseif self.phase == "menu" and self.demo then
-- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the
-- standard menu, with the '▶' hand drawn by the scripted keystrokes
@@ -4360,6 +4484,11 @@ function BattleState:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144)
end
love.graphics.setColor(1, 1, 1, 1)
-- battle.overlay: shiny sparkles, custom HUD chrome, etc. Draw-only;
-- the vanilla link is a no-op so an empty chain costs nothing.
if Runtime.wantsHook("battle.overlay") then
Runtime.call("battle.overlay", function() end, self)
end
end
return BattleState
+6 -2
View File
@@ -581,8 +581,12 @@ MoveEffects.full = {
},
HYPER_BEAM_EFFECT = {
afterDamage = function(ctx)
-- no recharge when the target faints OR its substitute breaks
if ctx.target.mon.hp > 0 and not ctx.brokeSub then
-- Gen 1: no recharge when the target faints OR its substitute breaks.
-- Ruleset hyperBeamSkipRechargeOnKO=false forces Gen 2+ always-recharge.
local ruleset = ctx.battle and ctx.battle.ruleset
local skipOnKO = not ruleset or ruleset.hyperBeamSkipRechargeOnKO ~= false
local targetDown = ctx.target.mon.hp <= 0 or ctx.brokeSub
if not skipOnKO or not targetDown then
ctx.user.mustRecharge = true
end
end,
+3
View File
@@ -16,4 +16,7 @@ return {
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
-- player side in pokered). They therefore never Struggle from empty PP.
enemyUnlimitedPP = true,
-- Gen 1 Hyper Beam: no recharge when the target faints (or its
-- substitute breaks). Set false to always recharge like Gen 2+.
hyperBeamSkipRechargeOnKO = true,
}
+2
View File
@@ -11,4 +11,6 @@ return {
focusEnergyBug = false,
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
enemyUnlimitedPP = false,
-- Gen 2+: Hyper Beam always forces a recharge turn, even on a KO.
hyperBeamSkipRechargeOnKO = false,
}
+31
View File
@@ -102,10 +102,41 @@ function Data:seedDefaults()
-- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders()
-- #197: the Fighting Dojo Karate Master is text_asm, so the extractor
-- 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).
self:seedFightingDojoKarateMaster()
-- #189: 1F cabin door order vs rooms map (survey zoom)
require("src.world.SsAnneLayout").apply(self.maps)
end
-- The Karate Master (FightingDojo.asm) is a text_asm object: his object has
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
-- object index [1] so he behaves like the real leader:
-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers
-- spots the player in his DOWN line and he challenges on sight (#197),
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
-- * after = the "Stay and train at Karate with us!" re-talk line.
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
-- victories.lua (OPP_BLACKBELT#1) exactly like the gym leaders, and
-- engageTrainer sets header.event *before* checkVictoryRewards runs -- if
-- this header also set it, the reward's flag guard would early-return and
-- swallow the prize dialogue. trainerDefeated tracks him via
-- defeatedTrainers[npc.id] like the leaders.
function Data:seedFightingDojoKarateMaster()
local headers = self.trainer_headers
if not headers then return end
headers.FightingDojo = headers.FightingDojo or {}
if headers.FightingDojo[1] then return end
headers.FightingDojo[1] = {
range = 4,
battle = "_FightingDojoKarateMasterText",
won = "_FightingDojoKarateMasterDefeatedText",
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
}
end
function Data:seedCinnabarGymTrainerHeaders()
local headers = self.trainer_headers
if not headers or headers.CinnabarGym then return end
+1
View File
@@ -783,5 +783,6 @@ end
-- test / debug helpers
DiscordPresence._state = state
DiscordPresence.locationName = locationName
DiscordPresence.handleJoinRequest = handleJoinRequest
return DiscordPresence
+30 -12
View File
@@ -8,7 +8,7 @@ local Logger = require("src.core.Logger")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TouchInput = require("src.core.TouchInput")
local TouchControls = require("src.core.TouchControls")
local ModLoader = require("src.mods.Loader")
local ModRuntime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
@@ -44,8 +44,8 @@ function Game:load()
self.input = Input
Input:init()
self.touchInput = TouchInput
TouchInput:init()
self.touchControls = TouchControls
TouchControls:init()
self.renderer = Renderer
Renderer:init()
@@ -96,6 +96,20 @@ function Game:load()
end
Logger.info("game loaded")
-- Scaling bug reports (#87, #208) are unanswerable without these three
-- numbers: LOVE units, drawable pixels, and the integer physical pixels
-- per GB pixel the renderer settled on. Cheap, once, and it turns "it
-- looks stretched" into something reproducible.
if love.graphics and love.graphics.getDimensions then
local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
Logger.info(string.format(
"display: %dx%d units, %dx%d px, fit scale %d px/GB px",
ww, wh, pw, ph, Renderer:fitScale()))
end
end
-- the merged field.boot: spawn, names, money and the naming presets a
@@ -180,9 +194,6 @@ function Game:logicSpeed()
end
function Game:update(dt)
-- Touch timers / prior-frame auto-releases before the fixed step so
-- deferred A and edge pulses land in Input's press queue for this step.
TouchInput:update(dt)
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
-- Give the accumulator room for one full frame at the current speed,
-- or the anti-spiral clamp quietly caps every level above ~15X.
@@ -246,6 +257,8 @@ function Game:draw()
worldZones = self.overworld:sgbWorldZones()
end
Renderer:endFrame(zones, worldZones)
-- on-screen mobile controls: pure screen-space, over the finished frame
TouchControls:draw()
end
-- overworld survey zoom: wheel up / '=' zooms in, wheel down / '-' out
@@ -375,6 +388,9 @@ function Game:keyreleased(key)
end
function Game:gamepadpressed(joystick, button)
-- a controller is being used: the touch overlay steps aside until the
-- next screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad()
-- BindingsMenu's pad capture rides the same top-state routing as keys
local top = self.stack and self.stack:top()
if top and top.onGamepadPressed then
@@ -389,6 +405,8 @@ function Game:gamepadreleased(joystick, button)
end
function Game:gamepadaxis(joystick, axis, value)
-- past-deadzone only, so resting-stick drift can't hide the overlay
if math.abs(value) > 0.5 then TouchControls:noteGamepad() end
Input:gamepadaxis(joystick, axis, value)
end
@@ -398,12 +416,12 @@ end
-- state is worse than asking the player to re-press.
function Game:focus(f)
Input:reset()
TouchInput:reset()
TouchControls:reset()
end
function Game:visible(v)
Input:reset()
TouchInput:reset()
TouchControls:reset()
end
-- A disconnected/dropped controller can't send the button-up for whatever
@@ -411,19 +429,19 @@ end
-- flags it owned.
function Game:joystickremoved(joystick)
Input:reset()
TouchInput:reset()
TouchControls:joystickremoved()
end
function Game:touchpressed(id, x, y)
TouchInput:touchpressed(id, x, y)
TouchControls:touchpressed(id, x, y)
end
function Game:touchmoved(id, x, y)
TouchInput:touchmoved(id, x, y)
TouchControls:touchmoved(id, x, y)
end
function Game:touchreleased(id, x, y)
TouchInput:touchreleased(id, x, y)
TouchControls:touchreleased(id, x, y)
end
-- Point the loader's mod.save backing at this save's modData so per-mod
+11
View File
@@ -154,6 +154,17 @@ function Input:step()
self.pressQueue = {}
end
-- The on-screen touch overlay (src/core/TouchControls.lua) presses GB
-- buttons directly by name -- not through a keyboard alias -- so a player
-- rebind can never detach or shadow the overlay.
function Input:overlayPressed(btn)
press(self, btn, "touch:" .. btn)
end
function Input:overlayReleased(btn)
release(self, btn, "touch:" .. btn)
end
function Input:gamepadpressed(joystick, button)
local btn = self.padBindings[button]
if btn then
+31 -1
View File
@@ -24,7 +24,31 @@ local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
local filterLevel = 0
local function applyVolume(src)
if src then pcall(src.setVolume, src, VOLUME * volumeScale) end
if not src then return end
local vol = VOLUME * volumeScale
if Runtime.wantsHook("music.volume") then
local ctx = {
song = state.current,
mapSong = state.mapSong,
onBike = state.onBike,
surfing = state.surfing,
fading = state.fade ~= nil,
optionScale = volumeScale,
}
local ok, Game = pcall(require, "src.core.Game")
if ok and Game then
local ow = Game.overworld
if ow and ow.player then
ctx.x, ctx.y = ow.player.cellX, ow.player.cellY
ctx.mapId = ow.map and ow.map.id
ctx.tod = ow.tod
end
end
vol = Runtime.call("music.volume", function(v) return v end, vol, ctx)
vol = tonumber(vol) or (VOLUME * volumeScale)
if vol < 0 then vol = 0 end
end
pcall(src.setVolume, src, vol)
end
-- Source:setFilter needs OpenAL EFX; the pcall degrades to unfiltered
@@ -408,6 +432,12 @@ end
-- restores the map theme after a one-shot jingle
function Music.update(data)
if state.chip then require("src.core.ChipAudio").update() end
-- distance / indoor muffling mods re-apply volume every frame while
-- subscribed; otherwise applyVolume only runs on song/option changes
if Runtime.wantsHook("music.volume") and not state.fade then
applyVolume(state.source)
applyVolume(state.loopSource)
end
-- volume ramp (Music.fadeOut): hold the current level for `control`
-- frames, then drop one level (FadeOutAudio decrements both rAUDVOL
-- nibbles when its counter reaches 0); at level 0 the music stops.
+38
View File
@@ -552,6 +552,44 @@ function SaveData.writeSlot(version, slotId, saveTable)
return true
end
-- Delete a registered slot: remove its main/.bak/.tmp files, drop it from the
-- options registry, and if it was active point active at another remaining
-- slot (or clear active when the list is empty). Returns true, or false +
-- an error string when the id is unknown / not registered.
function SaveData.deleteSlot(version, slotId)
version = version or GameVersion.get()
if not knownVersion(version) then return false, "unknown version" end
if type(slotId) ~= "string" or slotId == "" then
return false, "missing slot id"
end
local fs = persistFs(nil)
ensureVersionSlots(version, fs)
local opts = SaveData.loadOptions(fs)
opts.saveSlots = opts.saveSlots or {}
local reg = opts.saveSlots[version]
if not reg or not reg.list then return false, "slot not registered" end
local found, idx = false, nil
for i, id in ipairs(reg.list) do
if id == slotId then found = true; idx = i; break end
end
if not found then return false, "slot not registered" end
local main, bak, tmp = slotNames(version, slotId)
remove(fs, main)
remove(fs, bak)
remove(fs, tmp)
table.remove(reg.list, idx)
if reg.active == slotId then
reg.active = reg.list[1] -- may be nil when the list is now empty
end
opts.saveSlots[version] = reg
SaveData.saveOptions(opts, fs)
slotsChecked[version] = true
activeSlotCache[version] = reg.active or false
return true
end
-- Test seam: drop the process-global slot cache so a suite can exercise
-- migration/resolution against a freshly injected filesystem. Unused by
-- the game, which resolves each version exactly once per boot.
+287
View File
@@ -0,0 +1,287 @@
-- On-screen touch controls: a visible d-pad, A, B, START and SELECT drawn
-- over the finished frame (art: Xelu's CC0 controller prompts, see
-- assets/touch/README.md). Replaces the old touch gesture recognizer:
-- every control is a real button under the thumb, so there is no
-- tap-vs-swipe classification, no deferred-A double-tap window, and no
-- added latency.
--
-- Mobile only, and only while no controller is being used: the overlay
-- shows on Android/iOS, disappears the moment a gamepad button or stick
-- is used, and comes back on the next screen touch (Game routes both
-- events here). POKEPORT_TOUCH=1 forces it on for desktop testing
-- (main.lua then drives it with the mouse); POKEPORT_TOUCH=0 forces it
-- off everywhere.
--
-- Controls press GB buttons through Input:overlayPressed/Released -- their
-- own input source, not a keyboard alias -- so a held overlay direction
-- merges cleanly with a keyboard key or stick holding the same button,
-- and a player rebind can never detach the overlay.
local Input = require("src.core.Input")
local TouchControls = {}
-- idle vs pressed overlay opacity
local ALPHA = 0.65
local ALPHA_PRESSED = 0.95
-- translucent backing disc behind each control: the prompt art is dark
-- gray, so without it the controls melt into dark map areas
local BACK = 0.24
local BACK_PRESSED = 0.38
-- neutral zone at the d-pad center, as a fraction of the d-pad width;
-- inside it no direction is held (keeps a resting thumb from jittering)
local DPAD_DEAD = 0.16
-- hit slop: how far past the visible edge a press still counts, as a
-- multiplier on the control's half-width. START/SELECT get more because
-- the glyphs are small.
local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
local BUTTONS = { "a", "b", "start", "select" }
local IMAGES = {
dpad = "assets/touch/dpad.png",
dpad_up = "assets/touch/dpad_up.png",
dpad_down = "assets/touch/dpad_down.png",
dpad_left = "assets/touch/dpad_left.png",
dpad_right = "assets/touch/dpad_right.png",
a = "assets/touch/a.png",
b = "assets/touch/b.png",
start = "assets/touch/start.png",
select = "assets/touch/select.png",
}
local function wantsOverlay()
local env = os.getenv("POKEPORT_TOUCH")
if env == "1" then return true end
if env == "0" then return false end
local osName = love.system and love.system.getOS and love.system.getOS()
return osName == "Android" or osName == "iOS"
end
function TouchControls:init()
self.active = wantsOverlay()
self.controllerHidden = false
self.touches = {}
-- per-GB-button owner count: two fingers on A must not double-press it,
-- and lifting one of them must not release the other's hold
self.held = {}
self.dpadTouch = nil
self.layoutW, self.layoutH = nil, nil
self.img = nil
if not self.active then return end
-- soft-fail: a missing/corrupt PNG must never block boot; the overlay
-- stays off and keyboard/controller play still works
local img = {}
for name, path in pairs(IMAGES) do
local ok, im = pcall(love.graphics.newImage, path)
if not ok then
img = nil
break
end
-- smooth UI icons; the global default filter is nearest for GB pixels
im:setFilter("linear", "linear")
img[name] = im
end
self.img = img
end
function TouchControls:visible()
return self.active and self.img ~= nil and not self.controllerHidden
end
-- Layout in LOVE units (density-independent on mobile), recomputed when
-- the window size changes (rotation, resize). D-pad bottom-left, B/A
-- bottom-right with A above B (the Game Boy diagonal), START/SELECT
-- flanking the bottom center.
function TouchControls:layout()
local ww, wh = love.graphics.getDimensions()
if self.layoutW == ww and self.layoutH == wh then return self.L end
self.layoutW, self.layoutH = ww, wh
local short = math.min(ww, wh)
-- ~a third of the short edge, capped so tablets don't get a dinner plate
local dpadW = math.min(180, short * 0.34)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
-- START/SELECT hug the bottom center: on a narrow portrait phone the
-- d-pad and B leave little room, so a tight pair is what keeps them off
-- the neighboring controls
self.L = {
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
}
local fontSize = math.max(8, math.floor(ssW * 0.26))
if not self.labelFont or self.fontSize ~= fontSize then
self.fontSize = fontSize
self.labelFont = love.graphics.newFont(fontSize)
end
return self.L
end
local function inCircle(zone, x, y, slop)
local r = zone.w * 0.5 * slop
local dx, dy = x - zone.cx, y - zone.cy
return dx * dx + dy * dy <= r * r
end
local function dpadDir(zone, x, y)
local dx, dy = x - zone.cx, y - zone.cy
local dead = zone.w * DPAD_DEAD
if math.abs(dx) < dead and math.abs(dy) < dead then return nil end
if math.abs(dx) >= math.abs(dy) then
return dx > 0 and "right" or "left"
end
return dy > 0 and "down" or "up"
end
local function pressBtn(self, btn)
local n = (self.held[btn] or 0) + 1
self.held[btn] = n
if n == 1 then Input:overlayPressed(btn) end
end
local function releaseBtn(self, btn)
local n = self.held[btn]
if not n then return end
if n > 1 then
self.held[btn] = n - 1
else
self.held[btn] = nil
Input:overlayReleased(btn)
end
end
-- the d-pad touch's held direction changed (or ended): swap the GB hold
local function setDpad(self, touch, dir)
if touch.dir == dir then return end
if touch.dir then releaseBtn(self, touch.dir) end
touch.dir = dir
if dir then pressBtn(self, dir) end
end
function TouchControls:touchpressed(id, x, y)
if not (self.active and self.img) then return end
-- a controller hid the overlay; the first touch only brings it back
if self.controllerHidden then
self.controllerHidden = false
return
end
local L = self:layout()
for _, btn in ipairs(BUTTONS) do
if inCircle(L[btn], x, y, SLOP[btn]) then
self.touches[id] = { control = btn }
pressBtn(self, btn)
return
end
end
-- square hit zone a bit past the cross art; one owning finger at a time
local dz = L.dpad
local half = dz.w * 0.65
if not self.dpadTouch
and math.abs(x - dz.cx) <= half and math.abs(y - dz.cy) <= half then
self.dpadTouch = id
local touch = { control = "dpad", dir = nil }
self.touches[id] = touch
setDpad(self, touch, dpadDir(dz, x, y))
end
end
function TouchControls:touchmoved(id, x, y)
local touch = self.touches[id]
-- only the d-pad tracks movement (slide between directions without
-- lifting); buttons hold until release wherever the finger wanders
if not touch or touch.control ~= "dpad" then return end
setDpad(self, touch, dpadDir(self:layout().dpad, x, y))
end
function TouchControls:touchreleased(id, x, y)
local touch = self.touches[id]
if not touch then return end
self.touches[id] = nil
if touch.control == "dpad" then
setDpad(self, touch, nil)
self.dpadTouch = nil
else
releaseBtn(self, touch.control)
end
end
-- LÖVE has no touchcancelled: a touch interrupted by the OS (app
-- backgrounded, a system gesture stealing the finger) never fires
-- touchreleased and would strand its button held forever. Called from
-- Game alongside Input:reset() on focus/visibility loss.
function TouchControls:reset()
for btn in pairs(self.held) do
Input:overlayReleased(btn)
end
self.held = {}
self.touches = {}
self.dpadTouch = nil
end
-- a gamepad is being used: hide the overlay (dropping anything it held)
-- until the next screen touch asks for it back
function TouchControls:noteGamepad()
if not self.active or self.controllerHidden then return end
self.controllerHidden = true
self:reset()
end
-- last controller unplugged: show the overlay again immediately instead
-- of requiring a blind first tap
function TouchControls:joystickremoved()
self:reset()
if love.joystick and love.joystick.getJoystickCount
and love.joystick.getJoystickCount() == 0 then
self.controllerHidden = false
end
end
local function drawIcon(img, zone, pressed)
love.graphics.setColor(1, 1, 1, pressed and BACK_PRESSED or BACK)
love.graphics.circle("fill", zone.cx, zone.cy, zone.w * 0.58)
local scale = zone.w / img:getWidth()
love.graphics.setColor(1, 1, 1, pressed and ALPHA_PRESSED or ALPHA)
love.graphics.draw(img, zone.cx - zone.w / 2,
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end
-- Screen-space, called by Game:draw after Renderer:endFrame so the
-- overlay rides on top of everything (world, UI, CRT/GBC FX included).
function TouchControls:draw()
if not self:visible() then return end
local L = self:layout()
love.graphics.push("all")
love.graphics.origin()
local dpadTouch = self.dpadTouch and self.touches[self.dpadTouch]
local dir = dpadTouch and dpadTouch.dir
drawIcon(dir and self.img["dpad_" .. dir] or self.img.dpad, L.dpad,
dir ~= nil)
for _, btn in ipairs(BUTTONS) do
drawIcon(self.img[btn], L[btn], self.held[btn] ~= nil)
end
-- the +/- glyphs alone don't say which is which; shadowed so the text
-- reads on both the black letterbox and battle's white one
love.graphics.setFont(self.labelFont)
local ly = L.start.cy + L.start.w * 0.66
local function label(text, zone)
local w = self.labelFont:getWidth(text)
love.graphics.setColor(0, 0, 0, 0.6)
love.graphics.print(text, zone.cx - w / 2 + 1, ly + 1)
love.graphics.setColor(1, 1, 1, ALPHA + 0.2)
love.graphics.print(text, zone.cx - w / 2, ly)
end
label("START", L.start)
label("SELECT", L.select)
love.graphics.pop()
end
return TouchControls
-297
View File
@@ -1,297 +0,0 @@
-- Touch gesture recognizer → virtual keyboard keys for Input.lua.
--
-- Deferred-tap tradeoff: A fires only after DOUBLE_TAP_MS with no second
-- tap. That adds ~280ms latency to every A press so a double-tap can be
-- remapped to START instead of A-then-START. Gen 1 has no frame-perfect
-- input needs, so the latency is acceptable.
--
-- Select = two-finger tap (open Q1 in docs/mobile-plan.md): when a second distinct
-- touch ID lands while another short, low-movement touch is active, fire
-- SELECT (press + one-frame auto-release).
local Input = require("src.core.Input")
local TouchInput = {}
local function dpiScale()
if love and love.window then
if love.window.getDPIScale then
return love.window.getDPIScale()
end
if love.window.toPixels then
return love.window.toPixels(1)
end
end
return 1
end
-- Tunables (device-DPI-scaled where noted). Adjust after on-device testing.
local SWIPE_THRESHOLD_PX = 14
local EDGE_PX = 24
local EDGE_SWIPE_PX = 24
local DOUBLE_TAP_MS = 280
local TAP_MAX_MS = 320
local TAP_MAX_MOVE_PX = 12
local function scaled(px)
return px * dpiScale()
end
local DIRS = { up = true, down = true, left = true, right = true }
-- Virtual keys Input:keypressed looks up in KEYBOARD bindings (not button names).
local KEY = {
up = "up",
down = "down",
left = "left",
right = "right",
a = "z",
b = "x",
start = "escape",
select = "tab",
}
local function nowMs()
return love.timer.getTime() * 1000
end
local function dominantDir(dx, dy)
if math.abs(dx) >= math.abs(dy) then
return dx > 0 and "right" or "left"
end
return dy > 0 and "down" or "up"
end
function TouchInput:init()
self.touches = {}
self.pendingA = nil -- { deadlineMs = number }
-- Edge pulses (B / START / SELECT / deferred-A): press now, release on a
-- later update so FixedStep can consume wasPressed first.
-- `armed` = pressed during events since last update; promoted to
-- `autoRelease` at the start of update (released on the *following* update).
-- Deferred-A fired inside update goes straight into `autoRelease`.
self.armed = {}
self.autoRelease = {}
self.selectFired = false -- one SELECT per two-finger gesture cluster
end
-- LÖVE has no touchcancelled event, so a touch interrupted by the OS (app
-- backgrounded mid-touch, a system gesture stealing the finger) never fires
-- touchreleased and would otherwise strand its direction held forever.
-- Called from Game alongside Input:reset() on focus/visibility loss.
function TouchInput:reset()
for _, touch in pairs(self.touches) do
releaseDir(self, touch)
end
self.touches = {}
self.pendingA = nil
self.armed = {}
self.autoRelease = {}
self.selectFired = false
end
local function pulse(self, key)
Input:keypressed(key)
self.armed[#self.armed + 1] = key
end
local function pulseInUpdate(self, key)
Input:keypressed(key)
self.autoRelease[#self.autoRelease + 1] = key
end
local function releaseDir(self, touch)
if touch.dir and DIRS[touch.dir] then
Input:keyreleased(KEY[touch.dir])
touch.dir = nil
end
end
local function pressDir(self, touch, dir)
if touch.dir == dir then return end
releaseDir(self, touch)
touch.dir = dir
Input:keypressed(KEY[dir])
end
local function totalMove(touch, x, y)
local dx = x - touch.x0
local dy = y - touch.y0
return math.abs(dx), math.abs(dy), dx, dy
end
local function isTapLike(touch, x, y, tMs)
local ax, ay = totalMove(touch, x, y)
local elapsed = tMs - touch.t0
return elapsed <= TAP_MAX_MS
and ax <= scaled(TAP_MAX_MOVE_PX)
and ay <= scaled(TAP_MAX_MOVE_PX)
and not touch.classified
end
local function countActive(self)
local n = 0
for _ in pairs(self.touches) do n = n + 1 end
return n
end
local function tryTwoFingerSelect(self, tMs)
if self.selectFired then return false end
local ids = {}
for id, touch in pairs(self.touches) do
if isTapLike(touch, touch.x, touch.y, tMs) then
ids[#ids + 1] = id
end
end
if #ids < 2 then return false end
self.selectFired = true
self.pendingA = nil
for _, id in ipairs(ids) do
local touch = self.touches[id]
touch.classified = true
touch.consumed = true
releaseDir(self, touch)
end
pulse(self, KEY.select)
return true
end
function TouchInput:update(dt)
-- Releases armed on a prior update (FixedStep already saw wasPressed).
for i = 1, #self.autoRelease do
Input:keyreleased(self.autoRelease[i])
end
-- Promote event-phase pulses from since the last update; they release next time.
self.autoRelease = self.armed
self.armed = {}
local tMs = nowMs()
-- Deferred A: fire once the double-tap window closes with no second tap.
-- Queued into autoRelease so the next update clears hold after this FixedStep.
if self.pendingA and tMs >= self.pendingA.deadlineMs then
self.pendingA = nil
pulseInUpdate(self, KEY.a)
end
-- Keep two-finger SELECT detection live while both fingers stay down.
if countActive(self) >= 2 then
tryTwoFingerSelect(self, tMs)
elseif countActive(self) == 0 then
self.selectFired = false
end
end
function TouchInput:touchpressed(id, x, y)
local tMs = nowMs()
-- Second tap inside the deferred-A window → START instead of A.
if self.pendingA and tMs < self.pendingA.deadlineMs then
self.pendingA = nil
pulse(self, KEY.start)
-- Still record this touch so a lingering finger doesn't become a stray swipe.
self.touches[id] = {
x0 = x, y0 = y, x = x, y = y, t0 = tMs,
edge = x < scaled(EDGE_PX),
classified = true,
consumed = true,
dir = nil,
}
return
end
self.touches[id] = {
x0 = x, y0 = y, x = x, y = y, t0 = tMs,
edge = x < scaled(EDGE_PX),
classified = false,
consumed = false,
dir = nil,
}
if countActive(self) >= 2 then
tryTwoFingerSelect(self, tMs)
end
end
function TouchInput:touchmoved(id, x, y)
local touch = self.touches[id]
if not touch or touch.consumed then return end
touch.x, touch.y = x, y
local ax, ay, dx, dy = totalMove(touch, x, y)
local swipeTh = scaled(SWIPE_THRESHOLD_PX)
-- Edge-origin swipes become B on release; never promote to d-pad.
if touch.edge then
if ax >= scaled(EDGE_SWIPE_PX) or ay >= scaled(EDGE_SWIPE_PX) then
touch.classified = true
end
return
end
if not touch.classified then
if ax < swipeTh and ay < swipeTh then return end
touch.classified = true
pressDir(self, touch, dominantDir(dx, dy))
return
end
-- Mid-hold direction change: release old, press new (dominant axis).
if touch.dir then
local fromLastX = x - touch.x0
local fromLastY = y - touch.y0
-- Re-evaluate from origin so small jitter doesn't flip; require threshold
-- distance from origin along the new dominant axis.
if math.abs(fromLastX) >= swipeTh or math.abs(fromLastY) >= swipeTh then
local newDir = dominantDir(fromLastX, fromLastY)
if newDir ~= touch.dir then
pressDir(self, touch, newDir)
end
end
end
end
function TouchInput:touchreleased(id, x, y)
local touch = self.touches[id]
if not touch then return end
local tMs = nowMs()
touch.x, touch.y = x, y
local ax, ay = totalMove(touch, x, y)
if touch.dir then
releaseDir(self, touch)
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
return
end
if touch.consumed then
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
return
end
-- Left-edge B: origin in EDGE_PX strip and movement past EDGE_SWIPE_PX
-- (or already marked classified while moving). Prefer over d-pad / tap.
if touch.edge then
local edgeTh = scaled(EDGE_SWIPE_PX)
if touch.classified or ax >= edgeTh or ay >= edgeTh then
pulse(self, KEY.b)
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
return
end
end
-- Plain tap → defer A (or it was already classified as swipe without dir, ignore).
if isTapLike(touch, x, y, tMs) then
self.pendingA = { deadlineMs = tMs + DOUBLE_TAP_MS }
end
self.touches[id] = nil
if countActive(self) == 0 then self.selectFired = false end
end
return TouchInput
+251 -42
View File
@@ -290,6 +290,39 @@ local function findPendingRom(ready)
return nil
end
-- Android SAF writes mod picks to picked_mod.zip; USB copies may use any
-- .zip basename at the save-dir root. preferAny=true also accepts those USB
-- copies (Choose / Import); focus only consumes the SAF basename so a random
-- leftover archive is never auto-installed on every refocus.
local function findPendingMod(preferAny)
local preferred = "picked_mod.zip"
if love.filesystem.getInfo(preferred, "file") then
return preferred
end
if not preferAny then return nil end
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.zip$") and love.filesystem.getInfo(name, "file") then
return name
end
end
return nil
end
-- Same pattern as findPendingMod for battery saves (picked_save.sav / *.sav).
local function findPendingSav(preferAny)
local preferred = "picked_save.sav"
if love.filesystem.getInfo(preferred, "file") then
return preferred
end
if not preferAny then return nil end
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
if name:lower():match("%.sav$") and love.filesystem.getInfo(name, "file") then
return name
end
end
return nil
end
local function chooseRom(promptName)
promptName = promptName or "Pokemon"
local prompt = "Choose your " .. promptName .. " ROM"
@@ -320,8 +353,8 @@ local function chooseRom(promptName)
end
-- Open a native picker for a mod .zip (mirrors chooseRom's per-OS dialogs).
-- Returns the chosen absolute path or nil. Android has no picker; the mods
-- panel steers that case to the drag-drop hint instead.
-- Returns the chosen absolute path or nil. Android uses love.system.pickFile
-- ("mod") instead -- see RomImporter:chooseMod.
local function chooseZip()
local prompt = "Choose a mod .zip"
local platform = love.system.getOS()
@@ -351,8 +384,8 @@ local function chooseZip()
end
-- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS
-- dialogs). Returns the chosen absolute path or nil. Android has no picker;
-- the SAVE FILES card steers that case to the drag-drop hint instead.
-- dialogs). Returns the chosen absolute path or nil. Android uses
-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
local function chooseSav()
local prompt = "Choose a .sav save file"
local platform = love.system.getOS()
@@ -429,10 +462,16 @@ function RomImporter.new(onComplete, opts)
-- affordance (desktop love.system.openURL).
saveNotice = {},
-- MODS panel state (pass 3): mods is the cached LauncherMods.list() array
-- (refreshed lazily on first draw and after any toggle/install); modScroll
-- is the list scroll offset (px, clamped in draw); modNotice is the last
-- install result { ok, text } shown as a line above the list.
-- (refreshed lazily on first draw and after any toggle/install/delete);
-- modScroll is the list scroll offset (px, clamped in draw); modNotice is
-- the last install/delete result { ok, text } shown as a line above the list.
mods = nil, modScroll = 0, modNotice = nil,
-- Android SAF: which game tab should receive the next picked_save.sav when
-- focus consumes it (set by chooseSaveImport before opening the picker).
androidPendingVersion = nil,
-- Android SAF create-document: which game's SAVE FILES card should show
-- "Save exported." when export_done.flag appears on focus.
androidPendingExportVersion = nil,
}, RomImporter)
for _, version in ipairs(GameVersion.ORDER) do
@@ -491,10 +530,40 @@ end
-- The system picker runs as a separate top activity, so LOVE's own
-- love.focus/love.visible pause while it's up (see main.lua) -- once the
-- player returns here with a file picked, GameActivity has already copied
-- it into the save directory, so a pending-ROM rescan on refocus picks it
-- up without the player needing to tap the button again.
-- it into the save directory, so a pending-file rescan on refocus picks it
-- up without the player needing to tap the button again. Mod and save SAF
-- drops (picked_mod.zip / picked_save.sav) are consumed first so a leftover
-- ROM pick cannot steal the focus path when both games are already ready.
function RomImporter:focus(f)
if not (f and self.android and self.workState ~= "working") then return end
-- SAF create-document finished: GameActivity wrote export_done.flag.
if love.filesystem.getInfo("export_done.flag", "file") then
love.filesystem.remove("export_done.flag")
love.filesystem.remove("pending_export.sav")
local version = self.androidPendingExportVersion or self:_savedropTarget()
self.androidPendingExportVersion = nil
self.saveNotice[version] = { ok = true, text = "Save exported." }
if self.tab == "mods" or self.tab == "yellow" then self.tab = version end
return
end
local modName = findPendingMod(false)
if modName then
self:_installMod(modName)
if self.modNotice and self.modNotice.ok then
love.filesystem.remove(modName)
end
return
end
local savName = findPendingSav(false)
if savName then
local version = self.androidPendingVersion or self:_savedropTarget()
self.androidPendingVersion = nil
self:_importSave(version, savName)
if self.saveNotice[version] and self.saveNotice[version].ok then
love.filesystem.remove(savName)
end
return
end
if self.ready.red and self.ready.blue then return end
local name, data = findPendingRom(self.ready)
if name then self:startData(data, name) end
@@ -672,13 +741,38 @@ function RomImporter:_installMod(source)
self.tab = "mods"
end
-- "Import mod .zip" button: open a native picker (desktop) and install the
-- pick. Android has no picker, so it points the player at the drop path.
-- Remove an installed mod from the save-dir mods/ tree and refresh the panel.
function RomImporter:_deleteMod(id)
if self.workState == "working" then return end
local LauncherMods = require("src.mods.LauncherMods")
local ok, res = LauncherMods.uninstall(id)
if ok then
self:_refreshMods()
self.modNotice = { ok = true, text = "Deleted " .. tostring(id) }
else
self.modNotice = { ok = false, text = tostring(res) }
end
end
-- "Import mod .zip" button: open a native picker and install the pick.
-- Android mirrors ROM import: scan for a pending .zip in the save dir (USB
-- or a fresh SAF drop), else love.system.pickFile("mod") -> picked_mod.zip
-- which focus/Choose consumes on return.
function RomImporter:chooseMod()
if self.workState == "working" then return end
if self.android then
self.modNotice =
{ ok = false, text = "Drop a mod .zip onto the window to install it." }
local name = findPendingMod(true)
if name then
self:_installMod(name)
if self.modNotice and self.modNotice.ok then
love.filesystem.remove(name)
end
return
end
if not love.system.pickFile("mod") then
self.modNotice = { ok = false,
text = "Could not open the file picker. Copy a mod .zip via USB." }
end
return
end
local path = chooseZip()
@@ -722,13 +816,26 @@ function RomImporter:_importSave(version, source)
end
end
-- "Import save" button: open a native .sav picker (desktop) and import the pick.
-- Android has no picker, so it points the player at the drop path.
-- "Import save" button: open a native .sav picker and import the pick.
-- Android mirrors ROM / mod import via love.system.pickFile("sav").
function RomImporter:chooseSaveImport(version)
if self.workState == "working" then return end
if self.android then
self.saveNotice[version] =
{ ok = false, text = "Drop a .sav onto the window to import it." }
local name = findPendingSav(true)
if name then
self.androidPendingVersion = version
self:_importSave(version, name)
if self.saveNotice[version] and self.saveNotice[version].ok then
love.filesystem.remove(name)
end
return
end
self.androidPendingVersion = version
if not love.system.pickFile("sav") then
self.androidPendingVersion = nil
self.saveNotice[version] = { ok = false,
text = "Could not open the file picker. Copy a .sav via USB." }
end
return
end
local path = chooseSav()
@@ -736,16 +843,58 @@ function RomImporter:chooseSaveImport(version)
end
-- "Export save" button: write the active slot back out to a raw .sav in the save
-- directory's exports/ folder and show the path (with an open-folder affordance)
-- on the SAVE FILES card.
-- directory's exports/ folder. On desktop, show the path with an open-folder
-- affordance. On Android, stage pending_export.sav and open the system
-- create-document picker (love.system.createFile) so the player can save to
-- Downloads / Drive / etc. -- the app-private exports/ path is not useful there.
function RomImporter:exportSave(version)
if self.workState == "working" then return end
local ok, res = require("src.import.SaveFileIO").exportActiveSlot(version)
if ok then
local dir = res:match("^(.*)[/\\][^/\\]+$")
self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir }
else
if not ok then
self.saveNotice[version] = { ok = false, text = tostring(res) }
return
end
if self.android then
local rel = res:match("exports[/\\][^/\\]+$")
local data = rel and love.filesystem.read(rel)
if not data then
self.saveNotice[version] = { ok = false,
text = "Exported, but could not stage the file for the picker." }
return
end
local suggested = rel:match("[^/\\]+$") or "export.sav"
local wrote, writeErr = love.filesystem.write("pending_export.sav", data)
if not wrote then
self.saveNotice[version] = { ok = false,
text = "Could not stage the export: " .. tostring(writeErr) }
return
end
self.androidPendingExportVersion = version
if love.system.createFile and love.system.createFile(suggested) then
self.saveNotice[version] = { ok = true,
text = "Pick where to save " .. suggested .. "..." }
else
self.androidPendingExportVersion = nil
self.saveNotice[version] = { ok = true,
text = "Exported inside the app folder (picker unavailable)." }
end
return
end
local dir = res:match("^(.*)[/\\][^/\\]+$")
self.saveNotice[version] = { ok = true, text = "Exported to " .. res, dir = dir }
end
-- Delete a save slot from the registry and disk, then refresh the panel. If the
-- deleted slot was active, SaveData.deleteSlot points active at another slot.
function RomImporter:_deleteSlot(version, id)
if self.workState == "working" then return end
local SaveData = require("src.core.SaveData")
local ok, err = SaveData.deleteSlot(version, id)
if ok then
self:_refreshSlots(version)
self.saveNotice[version] = { ok = true, text = "Deleted " .. tostring(id) .. "." }
else
self.saveNotice[version] = { ok = false, text = tostring(err) }
end
end
@@ -1427,9 +1576,17 @@ function RomImporter:mousepressed(x, y, button)
end
return
end
-- SAVE SLOT rows. On desktop a press only ARMS a click: _updateSlotDrag
-- commits it on release when the pointer did not move (a moved pointer scrolls
-- instead). Android has no reliable pointer polling, so it selects on press.
-- SAVE SLOT rows / Delete. Delete is checked first so a tap on the Delete
-- label never also selects the row. On desktop a press only ARMS a click:
-- _updateSlotDrag commits it on release when the pointer did not move (a
-- moved pointer scrolls instead). Android has no reliable pointer polling,
-- so it selects on press. Delete fires immediately (small fixed target).
for _, r in ipairs(self.slotDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteSlot(self.panelVersion, r.id)
return
end
end
for _, r in ipairs(self.slotRects or {}) do
if inside(r, x, y) then
if self.android then
@@ -1445,12 +1602,18 @@ function RomImporter:mousepressed(x, y, button)
self:_newSlot(self.panelVersion); return
end
-- Mods panel: the import button dispatches on press (fixed header, no scroll
-- conflict); a toggle switch, which lives in the scrollable list, only ARMS a
-- press so _updateSlotDrag can tell a click from a drag-scroll (Android, with
-- no pointer polling, toggles on press).
-- conflict); Delete fires immediately; a toggle switch, which lives in the
-- scrollable list, only ARMS a press so _updateSlotDrag can tell a click from
-- a drag-scroll (Android, with no pointer polling, toggles on press).
if inside(self.modImportRect, x, y) then
self:chooseMod(); return
end
for _, r in ipairs(self.modDeleteRects or {}) do
if inside(r, x, y) then
self:_deleteMod(r.id)
return
end
end
for _, r in ipairs(self.modRects or {}) do
if inside(r, x, y) then
if self.android then
@@ -1759,7 +1922,8 @@ function RomImporter:_drawGamePanel(version, x, y, w, h)
elseif locked then
sfHintText, sfHintCol = "Not available yet.", PAL.warning
elseif self.android then
sfHintText, sfHintCol = "Import a .sav, or drop one on the window.", PAL.warning
sfHintText, sfHintCol =
"Import or export a .sav with the system file picker.", PAL.warning
else
sfHintText, sfHintCol =
"Import a .sav to a new slot, or export the active slot.", PAL.warning
@@ -1999,6 +2163,8 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
love.graphics.printf("No saves yet - start a new game or import one.",
rx + 12 * s, listTop + listH / 2 - self.hintFont:getHeight() / 2,
rw - 24 * s, "center")
self.slotRects = {}
self.slotDeleteRects = {}
elseif listH > 0 then
local nameH = self.slotNameFont:getHeight()
local metaH = self.labelFont:getHeight()
@@ -2017,6 +2183,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotScroll[version] = scroll
self.slotRects = {}
self.slotDeleteRects = {}
love.graphics.setScissor(math.floor(rx), math.floor(listTop),
math.ceil(rw), math.ceil(listH))
for i, slot in ipairs(slots) do
@@ -2029,6 +2196,20 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
col(selected and PAL.green or PAL.cardBorder, selected and 0.9 or 0.22)
love.graphics.rectangle("line", rx, ry, rw, rowH, rr, rr)
-- Delete label (bottom-right); reserve its width so name/meta don't overlap
love.graphics.setFont(self.hintFont)
local delText = "Delete"
local delW = self.hintFont:getWidth(delText)
local delH = self.hintFont:getHeight()
local delX = rx + rw - 12 * s - delW
local delY = ry + rowH - rowPadV - delH
local drect = { x = delX - 6 * s, y = delY - 4 * s,
width = delW + 12 * s, height = delH + 8 * s, id = slot.id }
local dhot = self:_hover(drect)
col(dhot and PAL.red or PAL.warning)
love.graphics.print(delText, delX, delY)
local rightReserve = delW + 18 * s
-- LOADED pill (top-right of the active row), then reserve its width
local pillW = 0
if selected then
@@ -2048,7 +2229,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
love.graphics.setFont(self.slotNameFont)
col(PAL.white)
local name = slot.name or "NEW GAME"
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - pillW),
printB(ellipsize(self.slotNameFont, name, rw - 24 * s - math.max(pillW, rightReserve)),
rx + 12 * s, ry + rowPadV)
local metaTxt
@@ -2061,7 +2242,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
end
love.graphics.setFont(self.labelFont)
col(PAL.warning)
love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s),
love.graphics.print(ellipsize(self.labelFont, metaTxt, rw - 24 * s - rightReserve),
rx + 12 * s, ry + rowPadV + nameH + 4 * s)
-- clip the hit rect to the visible list band so a partly-scrolled row
@@ -2072,6 +2253,12 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h)
self.slotRects[#self.slotRects + 1] =
{ x = rx, y = vy, width = rw, height = vy2 - vy, id = slot.id }
end
local dvy = math.max(drect.y, listTop)
local dvy2 = math.min(drect.y + drect.height, listBottom)
if dvy2 > dvy then
self.slotDeleteRects[#self.slotDeleteRects + 1] =
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = slot.id }
end
end
end
love.graphics.setScissor()
@@ -2165,14 +2352,14 @@ function RomImporter:_drawModsPanel(x, y, w, h)
local top = y + headerH + 14 * s
-- notice line: the last install result, else the drag-drop hint
-- notice line: the last install/delete result, else the platform hint
love.graphics.setFont(self.hintFont)
if self.modNotice then
col(self.modNotice.ok and PAL.green or PAL.red)
love.graphics.printf(self.modNotice.text, x, top, w, "left")
else
col(PAL.warning)
love.graphics.printf(self.android and "Copy a mod .zip via USB."
love.graphics.printf(self.android and "Or copy a mod .zip via USB."
or "Or drop a mod .zip onto the window.", x, top, w, "left")
end
top = top + self.hintFont:getHeight() + 12 * s
@@ -2187,20 +2374,25 @@ function RomImporter:_drawModsPanel(x, y, w, h)
dashedRoundRect(x, top, w, boxH, 14 * s, 7 * s, 5 * s)
love.graphics.setFont(self.hintFont)
col(PAL.warning)
love.graphics.printf("No mods installed - drop a mod .zip here to add one.",
local emptyHint = self.android
and "No mods installed - tap Import mod .zip to add one."
or "No mods installed - drop a mod .zip here to add one."
love.graphics.printf(emptyHint,
x + 16 * s, top + boxH / 2 - self.hintFont:getHeight() / 2, w - 32 * s, "center")
self.modRects = {}
self.modDeleteRects = {}
self._modMax = 0
return
end
-- card metrics (design: rounded 14, padding 14x16; toggle 56x28)
-- card metrics (design: rounded 14, padding 14x16; toggle 56x28; Delete under)
local padH, padV = 16 * s, 14 * s
local cardGap, cardR = 10 * s, 14 * s
local tw, th = 52 * s, 28 * s
local innerW = w - 2 * padH
local chipH = self.hintFont:getHeight() + 8 * s
local clusterH = chipH + 6 * s + th -- status chip stacked over the toggle
local delH = self.hintFont:getHeight()
local clusterH = chipH + 6 * s + th + 6 * s + delH
love.graphics.setFont(self.stateFont)
local nameH = self.stateFont:getHeight()
@@ -2210,7 +2402,8 @@ function RomImporter:_drawModsPanel(x, y, w, h)
for i, m in ipairs(mods) do
local chipText = modStatusChip(m.status)
local chipW = self.hintFont:getWidth(chipText) + 20 * s
local clusterW = math.max(chipW, tw)
local delW = self.hintFont:getWidth("Delete")
local clusterW = math.max(chipW, tw, delW)
local leftW = math.max(40 * s, innerW - clusterW - 14 * s)
local descH = 0
if m.description ~= "" then
@@ -2221,7 +2414,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
local contentH = padV * 2 + nameH + (descH > 0 and (6 * s + descH) or 0)
local cardH = math.max(contentH, padV * 2 + clusterH)
layout[i] = { h = cardH, leftW = leftW, clusterW = clusterW,
chipText = chipText, chipW = chipW }
chipText = chipText, chipW = chipW, delW = delW }
total = total + cardH
end
total = total + (#mods - 1) * cardGap
@@ -2231,6 +2424,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
local scroll = clamp(self.modScroll or 0, 0, maxScroll)
self.modScroll = scroll
self.modRects = {}
self.modDeleteRects = {}
love.graphics.setScissor(math.floor(x), math.floor(top),
math.ceil(w), math.ceil(listH))
@@ -2269,7 +2463,7 @@ function RomImporter:_drawModsPanel(x, y, w, h)
love.graphics.printf(m.description, nx, ny + nameH + 6 * s, L.leftW, "left")
end
-- right cluster: status chip stacked over the toggle, vertically centred
-- right cluster: status chip, toggle, Delete — vertically centred
local clusterX = x + w - padH - L.clusterW
local clusterY = cy + (cardH - clusterH) / 2
local _, chipColor = modStatusChip(m.status)
@@ -2303,14 +2497,29 @@ function RomImporter:_drawModsPanel(x, y, w, h)
col(PAL.white)
love.graphics.circle("fill", kcx, ty + th / 2, kd / 2)
-- hit rect clipped to the visible list band (a partly-scrolled toggle is
-- only clickable where it actually shows)
-- Delete under the toggle
local delX = clusterX + (L.clusterW - L.delW) / 2
local delY = ty + th + 6 * s
local drect = { x = delX - 6 * s, y = delY - 2 * s,
width = L.delW + 12 * s, height = delH + 4 * s, id = m.id }
local dhot = self:_hover(drect)
love.graphics.setFont(self.hintFont)
col(dhot and PAL.red or PAL.warning)
love.graphics.print("Delete", delX, delY)
-- hit rects clipped to the visible list band
local vy = math.max(trect.y, top)
local vy2 = math.min(trect.y + trect.height, top + listH)
if vy2 > vy then
self.modRects[#self.modRects + 1] =
{ x = trect.x, y = vy, width = trect.width, height = vy2 - vy, id = m.id }
end
local dvy = math.max(drect.y, top)
local dvy2 = math.min(drect.y + drect.height, top + listH)
if dvy2 > dvy then
self.modDeleteRects[#self.modDeleteRects + 1] =
{ x = drect.x, y = dvy, width = drect.width, height = dvy2 - dvy, id = m.id }
end
end
cy = cy + cardH + cardGap
end
+13 -5
View File
@@ -47,11 +47,19 @@ local function readSource(source)
return source
end
local f, openErr = io.open(source, "rb")
if not f then return nil, "could not read the save file: " .. tostring(openErr) end
local data = f:read("*a")
f:close()
if type(data) ~= "string" then return nil, "the save file was empty" end
return data
if f then
local data = f:read("*a")
f:close()
if type(data) ~= "string" then return nil, "the save file was empty" end
return data
end
-- Android SAF drops (picked_save.sav) and USB copies land in the LOVE save
-- directory; io.open cannot see them, so fall back to love.filesystem.
if love and love.filesystem and love.filesystem.read then
local data = love.filesystem.read(source)
if type(data) == "string" then return data end
end
return nil, "could not read the save file: " .. tostring(openErr)
end
-- importToSlot(source, version) -> ok, slotIdOrErr
+21 -2
View File
@@ -31,7 +31,12 @@ local function indexOf(list, value)
end
local function levelForWire(v)
return v == ANY and nil or v
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
-- true branch is nil, so it falls through to `or v` and returned the
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
if v == ANY then return nil end
return v
end
local function forceLevelLabel(v)
@@ -493,6 +498,14 @@ function LinkState:updateTrade(input)
if t.stage == "done" then
local sent = t.party[t.myPick]
local received, evoTo = t:apply(self.game)
-- Autosave the instant the swap commits into game.save.party, matching the
-- Cable Club: pokered engine/link/cable_club.asm calls SaveSAVtoSRAM
-- (engine/menus/save.asm) right after every trade so the trade is on the
-- cartridge before the animation runs. Without this the received mon
-- lives only in memory until a manual START-menu save, so a force-quit
-- would lose it and a reset would clone the sent mon (#222). Guarded so
-- headless LinkBattle-style fake games with no writeSave are unaffected.
if self.game.writeSave then self.game:writeSave() end
local name = received.nickname or self.game.data.pokemon[received.species].name
Runtime.emit("link.ended", { reason = "done" })
self.net:close()
@@ -510,7 +523,13 @@ function LinkState:updateTrade(input)
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
function()
if evoTo then
require("src.pokemon.Evolution").evolve(game, received, evoTo)
-- via="TRADE": a trade evolution cannot be B-cancelled
-- (pokered LINK_STATE_TRADING skips the flash B-poll) (#213).
-- Re-save once the evolution movie finishes so the evolved
-- species (not the pre-evo landed by t:apply) is what persists,
-- keeping disk in step with the autosave above (#222).
require("src.pokemon.Evolution").evolve(game, received, evoTo,
function() if game.writeSave then game:writeSave() end end, "TRADE")
end
end))
end,
+30 -4
View File
@@ -43,7 +43,12 @@ Protocol.plainCopy = plainCopy
-- serialize a mon instance for the wire (plain data only). ppUps rides
-- along because the real cable transmitted it and its absence silently
-- capped a PP-Upped move at base PP on the receiving side.
-- capped a PP-Upped move at base PP on the receiving side. ot/otId ride
-- along too: pokered's trade sends the whole party block including each
-- mon's OT ID (party_struct MON_OTID) and the OT-names block (wPartyMonOT),
-- and the receiver keeps them verbatim -- a differing OT/ID is what marks a
-- mon as traded (boosted EXP, high-level disobedience). Omitting them made
-- a received mon show the receiver as its OT (#215).
function Protocol.packMon(mon)
local moves = {}
for _, mv in ipairs(mon.moves) do
@@ -59,6 +64,8 @@ function Protocol.packMon(mon)
dvs = mon.dvs,
statExp = mon.statExp,
moves = moves,
ot = mon.ot,
otId = mon.otId,
extra = plainCopy(mon.extra),
}
end
@@ -71,6 +78,14 @@ function Protocol.unpackMon(data, packed, opts)
local Stats = require("src.pokemon.Stats")
local Growth = require("src.pokemon.Growth")
local strict = opts and opts.strict
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
-- the LinkState/Tournament side (see levelForWire) that must mean "no
-- forced level" here. Coerce once so a non-numeric level string -- the ANY
-- sentinel, an old peer, or a mod (#204) -- can never reach math.floor
-- below: tonumber("ANY") == nil, i.e. keep the packed real level, while
-- tonumber(50)/tonumber("50") both give 50.
local forceLevel = opts and tonumber(opts.forceLevel) or nil
local def = data.pokemon[packed.species]
if not def then
if strict then return nil, "unknown POKéMON" end
@@ -81,8 +96,8 @@ function Protocol.unpackMon(data, packed, opts)
-- ignored and everyone rebuilds at the same fixed level instead, so a
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
-- the identical forceLevel for a given match, so this stays symmetric.
if opts and opts.forceLevel then
level = math.max(2, math.min(100, math.floor(opts.forceLevel)))
if forceLevel then
level = math.max(2, math.min(100, math.floor(forceLevel)))
end
local dvs = {}
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
@@ -115,10 +130,19 @@ function Protocol.unpackMon(data, packed, opts)
-- current HP/status (a different level's numbers, possibly mid-fight)
-- isn't meaningful anymore -- auto-level starts everyone full and fresh,
-- same as a standardized tournament format would
local forced = opts and opts.forceLevel
local forced = forceLevel
local hp = forced and stats.hp
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
local status = forced and nil or packed.status
-- preserve the sender's original-trainer identity (party_struct MON_OTID +
-- wPartyMonOT on a real cable), clamped/typed like every other field so a
-- tampered packet can't inject a bad ID or a huge name. Left nil when the
-- packet omits them (a v1/old peer) -- no worse than before for that legacy
-- path, and once ot is set the load-time stampOT backfill (mon.ot or ...)
-- becomes a no-op so the sender's identity survives save/reload (#215).
local otId = packed.otId
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
return {
species = packed.species,
level = level,
@@ -129,6 +153,8 @@ function Protocol.unpackMon(data, packed, opts)
hp = hp,
status = status,
nickname = packed.nickname,
ot = ot,
otId = otId,
moves = moves,
-- a namespace whose mod this install lacks survives untouched, so the
-- mon keeps it for the trip home
+6 -1
View File
@@ -57,7 +57,12 @@ local function levelLabel(v)
end
local function levelForWire(v)
return v == ANY and nil or v
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
-- true branch is nil, so it falls through to `or v` and returned the
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
if v == ANY then return nil end
return v
end
local function forceLevelLabel(v)
+34 -3
View File
@@ -3,11 +3,12 @@
-- manifests only. The full loader (src/mods/Loader.lua) still owns the real
-- load at boot; this reads the same options.mods enable-state the loader
-- writes, derives per-mod status with the pure ManagerState.resolveToggle,
-- and installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree.
-- installs a dropped/chosen .zip into the save-dir "mods/<id>/" tree, and
-- uninstalls a mod by removing that tree + clearing options.mods[id].
--
-- Split in two: the pure derivation (deriveList, locateRoot) has no love and
-- no filesystem, so the engine tier can table-drive it; the discovery and
-- install paths reach for love.filesystem and SaveData.
-- no filesystem, so the engine tier can table-drive it; the discovery,
-- install, and uninstall paths reach for love.filesystem and SaveData.
local Manifest = require("src.mods.Manifest")
local ManagerState = require("src.mods.ManagerState")
@@ -332,4 +333,34 @@ function LauncherMods.installZip(source)
return true, manifest.id
end
-- uninstall(id) -> true | nil, errString
-- Removes mods/<id>/ from the save directory and clears options.mods[id] so the
-- loader and in-game manager no longer see it. Rejects unknown / missing ids.
-- Does not touch other mods' enable state.
function LauncherMods.uninstall(id)
if type(id) ~= "string" or id == "" then
return nil, "missing mod id"
end
if id:find("[/\\]") or id == "." or id == ".." then
return nil, "invalid mod id"
end
if not (love and love.filesystem) then
return nil, "mod uninstall needs LOVE"
end
local fs = love.filesystem
local dest = "mods/" .. id
if not fs.getInfo(dest) then
return nil, "mod '" .. id .. "' is not installed"
end
removeTree(dest)
-- Drop the enable flag so a reinstall of the same id starts from the
-- loader's default (enabled) rather than a stale false.
local options = SaveData.loadOptions()
if options.mods and options.mods[id] ~= nil then
options.mods[id] = nil
SaveData.saveOptions(options)
end
return true
end
return LauncherMods
+1
View File
@@ -68,6 +68,7 @@ local devShim = { installed = false, permissions = {}, warned = {}, depth = 0 }
local SUPPORTED_REQUIRES = {
["src.mods.Semver"] = true,
["src.audio.ChipAsm"] = true,
["src.pokemon.Stats"] = true, -- Stats.isShiny / calc for indicator mods
}
local function scanRequire(name)
+63 -8
View File
@@ -106,11 +106,55 @@ function Evolution.apply(game, mon, newSpecies, via)
})
end
-- After the "evolved into" text, Gen1 re-runs the level-up learn check on
-- the EVOLVED species (engine/pokemon/evos_moves.asm EvolveMon calls the
-- LearnMoveFromLevelUp predef, engine/pokemon/learn_move.asm) -- a mon
-- evolving at exactly a learnset level gains that move (GYARADOS learns
-- BITE at 20, so MAGIKARP->GYARADOS @20 learns BITE, @21 does not) (#12).
-- Mirrors the rare-candy learn loop in src/ui/BagMenu.lua so a full move
-- list opens the forget prompt. mon.species is already the new species
-- (Evolution.apply ran before the congrats text). onDone runs once the
-- learn list is exhausted, replacing the caller's direct onDone.
function Evolution.learnEvolutionMoves(game, mon, onDone)
local Experience = require("src.battle.Experience")
local def = game.data.pokemon[mon.species]
-- movesLearnedAt uses entry.level == level (exact Gen1 rule); do NOT use
-- Pokemon.movesAtLevel (<= level), which would over-grant older moves.
local moves = Experience.movesLearnedAt(def, mon.level)
local i = 0
local function nextStep()
i = i + 1
local moveId = moves[i]
if not moveId then
if onDone then onDone() end
return
end
for _, mv in ipairs(mon.moves) do
if mv.id == moveId then return nextStep() end
end
local mdef = game.data.moves[moveId]
if not mdef then return nextStep() end
local name = mon.nickname or def.name
if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
game.stack:push(TextBox.new(game,
("%s learned\n%s!"):format(name, mdef.name), nextStep))
else
-- LearnMoveFromLevelUp with a full moveset: the forget UI
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
end
end
nextStep()
end
-- Play the evolution movie (flashing forms), then apply + text.
-- Headless (no real graphics) falls back to the plain text flow.
function Evolution.evolve(game, mon, newSpecies, onDone, via)
if love.image and love.image.newImageData then
Screens.push(game, "EvolutionState", mon, newSpecies, onDone)
-- forward `via` so EvolutionState can keep trade evolutions
-- non-cancelable (LINK_STATE_TRADING) while others accept B (#213)
Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via)
return
end
Music.play(game.data, Music.special(game.data, "evolution"))
@@ -120,7 +164,9 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
game.stack:push(TextBox.new(game, msg, function()
Music.restoreMap(game.data)
if onDone then onDone() end
-- re-run the evolved species' level-up learn check before onDone
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
Evolution.learnEvolutionMoves(game, mon, onDone)
end))
end
@@ -138,13 +184,22 @@ function Evolution.request(game, mon, trigger, onDone)
return species
end
-- After-battle hook: evolve everyone who qualifies (queued one at a time).
function Evolution.checkParty(game, onDone)
-- After-battle hook: evolve mons that leveled this battle and still
-- qualify (queued one at a time, party order). Gen1 EvolveAfterBattle
-- only considers mons that gained a level during the fight — a B-cancel
-- means "not this time", and the next offer waits for the next level-up
-- (or Rare Candy / stone, which call Evolution.evolve directly).
-- leveledUp is a set of party mon tables; nil/empty yields no evolutions.
function Evolution.checkParty(game, onDone, leveledUp)
local pending = {}
for _, mon in ipairs(game.save.party) do
local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" })
if target then
table.insert(pending, { mon = mon, to = target, via = evo and evo.method })
if leveledUp then
for _, mon in ipairs(game.save.party) do
if leveledUp[mon] then
local target, evo = Evolution.pendingFor(game, mon, { kind = "levelup" })
if target then
table.insert(pending, { mon = mon, to = target, via = evo and evo.method })
end
end
end
end
local i = 0
+63
View File
@@ -0,0 +1,63 @@
-- Runtime Pokémon art resolution. Content registries freeze after load,
-- so a mod that lets the player pick an alternate skin mid-session cannot
-- patch pokemon.spriteFront / icons.bySpecies. These helpers are the
-- sanctioned seam: every battle pic and party icon load goes through
-- pokemon.sprite / pokemon.icon, which stay live for the whole process.
local Runtime = require("src.mods.Runtime")
local Sprites = {}
local function samePath(path) return path end
-- Resolve a battle / menu front or back pic path for `species`.
-- side: "front" | "back"
-- opts.mon: the live mon when available (per-instance skins)
-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade"
-- | "title" | "oak" | "credits" (informational for wrappers)
-- Returns path, trueColor.
function Sprites.path(data, species, side, opts)
opts = opts or {}
local def = data and data.pokemon and data.pokemon[species]
if not def then return nil, false end
local path = side == "back" and def.spriteBack or def.spriteFront
local ctx = {
species = species,
side = side == "back" and "back" or "front",
kind = opts.kind or "battle",
mon = opts.mon,
trueColor = def.trueColor and true or false,
data = data,
}
if path and Runtime.wantsHook("pokemon.sprite") then
local hooked = Runtime.call("pokemon.sprite", samePath, path, ctx)
if type(hooked) == "string" and hooked ~= "" then path = hooked end
end
return path, ctx.trueColor and true or false
end
-- Resolve a party-menu icon image path for `mon`.
-- vanillaPath is the path PartyMenu already picked from icons.bySpecies /
-- def.icon / icons.byDex; the hook may replace it.
-- Returns path (possibly nil).
function Sprites.iconPath(data, mon, vanillaPath, opts)
opts = opts or {}
if not vanillaPath and not Runtime.wantsHook("pokemon.icon") then
return vanillaPath
end
local species = mon and mon.species
local ctx = {
species = species,
mon = mon,
name = opts.name,
data = data,
kind = "icon",
}
if not Runtime.wantsHook("pokemon.icon") then return vanillaPath end
local hooked = Runtime.call("pokemon.icon", samePath, vanillaPath, ctx)
if type(hooked) == "string" and hooked ~= "" then return hooked end
if hooked == nil or hooked == false then return nil end
return vanillaPath
end
return Sprites
+16
View File
@@ -57,4 +57,20 @@ function Stats.applyStage(value, stage)
return math.max(1, math.min(999, v))
end
-- Gen 2 shiny formula applied to Gen 1 DVs (the RBY "virtual shiny"):
-- Defense/Speed/Special DV == 10 and Attack DV is even-high
-- (2, 3, 6, 7, 10, 11, 14, or 15). Used by shiny-indicator mods.
local SHINY_ATK = {
[2] = true, [3] = true, [6] = true, [7] = true,
[10] = true, [11] = true, [14] = true, [15] = true,
}
function Stats.isShiny(dvs)
if type(dvs) ~= "table" then return false end
return (dvs.defense or 0) == 10
and (dvs.speed or 0) == 10
and (dvs.special or 0) == 10
and SHINY_ATK[dvs.attack or 0] == true
end
return Stats
+22 -10
View File
@@ -77,7 +77,17 @@ end
-- one-pixel sliver. The fill is tinted with the SGB bar palettes at
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
-- red).
function HudTiles.drawHPBar(data, tx, ty, mon, barType)
--
-- grayFill (#229): when the caller will colorize this bar with an SGB
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip
-- the per-pixel tint -- the DMG hardware bar is ONE gray shade recolored by
-- the region palette (engine/gfx/palettes.asm SetPal_Battle,
-- data/sgb/sgb_packets.asm BlkPacket_Battle), never a per-pixel repaint.
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
local x, y = tx * 8, ty * 8
HudTiles.tile(0x71, x, y)
HudTiles.tile(0x62, x + 8, y)
@@ -86,15 +96,17 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType)
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
end
local tint
local PaletteFX = require("src.render.PaletteFX")
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
local colors = PaletteFX.pal(data, name)
if colors then
local c = colors[3] -- GB color 2 is the fill shade
-- the fill pixels are the 2/3-gray shade; divide so they land on
-- the palette color exactly (the black outline stays black)
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
math.min(1, c[3] / 170), 1 }
if not grayFill then
local PaletteFX = require("src.render.PaletteFX")
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
local colors = PaletteFX.pal(data, name)
if colors then
local c = colors[3] -- GB color 2 is the fill shade
-- the fill pixels are the 2/3-gray shade; divide so they land on
-- the palette color exactly (the black outline stays black)
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
math.min(1, c[3] / 170), 1 }
end
end
for i = 0, 5 do
local seg = math.min(8, math.max(0, px - i * 8))
+44 -17
View File
@@ -50,14 +50,23 @@ PaletteFX.GBC_OBJ = {
}
-- OG BLUE: Pokemon Blue's Game Boy Color boot-ROM auto-palette. Same
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the
-- boot ROM colorizes the background blue instead of red -- so "OG RED" for a
-- Blue playthrough is white -> light blue -> dark blue -> black, mirroring
-- GBC_BG channel-for-channel so the blue reads at the same brightness. The
-- OBJ (sprite) palette stays the same green, matching how Red and Blue share
-- the green-character look on a Game Boy Color.
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the boot
-- ROM gives Blue its OWN entry rather than a recolored Red: a light-blue/blue
-- BACKGROUND and -- unlike Red -- a PINK object palette (OBP0). Values from
-- Bulbapedia's "List of color palettes ... Generation I" GBC boot-ROM table
-- (BG 0x63A5FF/0x0000FF, OBJ 0xFF8484/0x943A3A), confirmed against a Gambatte
-- hardware capture. The earlier code mirrored GBC_BG channel-for-channel
-- (0x8484FF/0x3A3A94) and reused Red's green sprites for both versions on the
-- premise that Red and Blue "share the green-character look"; both premises
-- are wrong -- Blue's background is a genuinely different blue and its
-- characters are pink (#155). Lightest shade first, like GBC_BG.
PaletteFX.GBC_BG_BLUE = {
{ 255, 255, 255 }, { 132, 132, 255 }, { 58, 58, 148 }, { 0, 0, 0 },
{ 255, 255, 255 }, { 99, 165, 255 }, { 0, 0, 255 }, { 0, 0, 0 },
}
-- Blue's OBJ palette (OBP0) is the red/pink ramp -- the very same colors OG
-- RED uses for its BACKGROUND (GBC_BG), just applied to objects instead.
PaletteFX.GBC_OBJ_BLUE = {
{ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 },
}
-- The active game's OG boot-ROM background palette: blue for a Blue
@@ -69,6 +78,16 @@ function PaletteFX.ogBg()
return PaletteFX.GBC_BG
end
-- The active game's OG boot-ROM object palette (OBP0): Blue's pink ramp for a
-- Blue playthrough, Red's green otherwise. Returns the colors AND a
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
-- its baked-image cache by (image path, group): a shared group would collide a
-- Red bake with a Blue one and one version would show the other's colors.
function PaletteFX.ogObj()
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
return PaletteFX.GBC_OBJ, "gbcobj"
end
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
function PaletteFX.shader()
@@ -183,17 +202,25 @@ function PaletteFX.usesGbcPack(mode)
end
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
-- (the OBP bake + post-zone redraw path). ONLY OG RED does: it wears the
-- GBC boot-ROM green object palette (PaletteFX.GBC_OBJ) so the player and
-- NPCs stay green over the red background, exactly like Pokemon Red on a
-- Game Boy Color. SGB mode deliberately does NOT: an SGB OBJ carries no
-- palette of its own, so the characters tint with the whole-map region
-- palette along with the terrain (the Super Game Boy never colored Pokemon
-- Red's sprites separately -- baking a per-sprite palette there was the
-- "reds coloring on the player/NPCs" bug). RED++ colors sprites through
-- the usesGbcPack() path in SpriteRenderer instead.
-- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters
-- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red
-- background, pink over Blue's blue background), so the player and NPCs carry a
-- fixed object color instead of tinting with whatever region palette their
-- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ
-- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it
-- overlaps -- so Red's cap must stay green in tall grass, not turn the ROUTE
-- palette's light-blue (issue #150: SGB region-tinting sent the cap to shade-2
-- = light-blue and the character clashed with the grass it should blend into).
-- Terrain is unaffected -- pal() below still hands SGB its per-map BG palette;
-- only OG RED short-circuits BG to the one global red palette. An EARLIER
-- attempt at per-sprite SGB color baked GBC_BG (the RED background ramp) onto
-- characters -- that was the "reds coloring on the player/NPCs" bug; the object
-- palette is GBC_OBJ (green), so baking it here is the fix, not that
-- regression. RED++ colors sprites through the usesGbcPack() path in
-- SpriteRenderer instead.
function PaletteFX.usesSpriteObp(mode)
return (mode or PaletteFX.mode) == "ogred"
mode = mode or PaletteFX.mode
return mode == "ogred" or mode == "gbc"
end
-- ------- post-zone sprite redraw (GBC mode)
+45
View File
@@ -0,0 +1,45 @@
-- Render targets measured in real framebuffer pixels.
--
-- love.graphics.newCanvas defaults its `dpiscale` to
-- love.graphics.getDPIScale(), so on a highdpi surface the canvas texture is
-- NOT the size it was asked for: newCanvas(160, 144) on a device reporting a
-- DPI scale of 2.755 allocates a 441x397 texture, and the 160x144 GB scene
-- then renders into it at 2.755 texels per GB pixel. conf.lua sets
-- t.window.highdpi on Android/iOS (required for Retina), and Android's
-- DisplayMetrics.density is routinely non-integer (1.5, 2.75, ...), so this
-- is the normal mobile case, not an edge case.
--
-- That fractional render breaks the whole premise of Renderer's integer
-- scale pipeline. Renderer:fitScale() picks a whole number of physical
-- pixels per GB pixel (7 on 1080p) and the composite blit lands on it
-- exactly -- but if the *source* already holds 2.755 texels per GB pixel,
-- some GB pixels are 2 texels wide and some are 3, and the nearest-neighbour
-- upscale turns them into 5 / 7 / 8 physical pixels instead of a uniform 7.
-- Measured on a 1080p density-2.755 fixture, only 43 of 160 columns came out
-- the right width. Fonts show it worst: their strokes are single pixels.
-- That is issue #208 ("some are noticeably stretched, especially fonts").
--
-- Forcing dpiscale = 1 makes canvas texels the pixels the renderer already
-- believes it is drawing. getWidth()/getHeight() are unaffected (they always
-- reported the requested size), so no geometry anywhere changes -- only the
-- resolution of the target. Desktop is unchanged; dpiscale is already 1
-- there because conf.lua sets highdpi on mobile only.
--
-- Exception: a canvas that is deliberately sized in LOVE *units* and blitted
-- back at unit scale 1 (Renderer's presentCanvas) must keep the screen's DPI
-- scale so its texture still covers the framebuffer; it is not built with
-- this helper.
local PixelCanvas = {}
-- One framebuffer pixel per w/h unit, always. `filter` is applied only when
-- given, so callers that relied on LOVE's default ("linear") keep it.
function PixelCanvas.new(w, h, filter)
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
if filter and canvas and canvas.setFilter then
canvas:setFilter(filter, filter)
end
return canvas
end
return PixelCanvas
+136 -73
View File
@@ -10,6 +10,8 @@ local Zoom = require("src.render.Zoom")
local Tilt = require("src.render.Tilt")
local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines")
local PixelCanvas = require("src.render.PixelCanvas")
local Runtime = require("src.mods.Runtime")
local Renderer = {}
@@ -39,32 +41,49 @@ end
-- endFrame composites the padded canvas back with a matching offset.
Renderer.UPRIGHT_MARGIN = 160
-- LOVE units + framebuffer pixels + the live unit→pixel ratio.
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven /
-- non-square "pixels", and movement judder (issue #87). Always derive the
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
-- LOVE units + framebuffer pixels + per-axis unit→pixel ratios.
-- Android's DisplayMetrics.density is often non-integer (1.5, 2.75, …).
-- Integer scaling in units then maps each GB pixel to a fractional number of
-- framebuffer pixels → shimmer, uneven / non-square "pixels", and movement
-- judder (issue #87). Always derive the crisp integer scale from the
-- *drawable* pixel size (the window framebuffer we present into -- not a
-- combined multi-display metric), then draw with (pixels / axisDpi) so the
-- GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
--
-- LOVE's projection is anisotropic: 1 unit in X covers pw/ww framebuffer
-- pixels and 1 unit in Y covers ph/wh. Those ratios match on a normal
-- highdpi surface, but diverge when unit sizes are truncated independently
-- (`(int)(pixels/density)`) or when a dual-screen / forced-rotation device
-- reports mismatched unit vs drawable aspects (AYN Thor, issue #208).
-- love.graphics.getDPIScale() is only ph/wh, so using it (or pw/ww alone)
-- for both axes makes the other axis land on a fractional, stretched count.
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
-- on BOTH axes (square).
local function displayMetrics()
local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
local dpi = 1
if ww > 0 and pw > 0 then
dpi = pw / ww
elseif love.graphics.getDPIScale then
dpi = love.graphics.getDPIScale()
local dpiX, dpiY = 1, 1
if ww > 0 and pw > 0 then dpiX = pw / ww end
if wh > 0 and ph > 0 then dpiY = ph / wh end
-- No pixel API (headless / old stub): fall back to getDPIScale, else 1.
if (dpiX == 1 and dpiY == 1) and not love.graphics.getPixelDimensions
and love.graphics.getDPIScale then
local d = love.graphics.getDPIScale()
if d and d > 1e-6 then dpiX, dpiY = d, d end
end
if not dpi or dpi < 1e-6 then dpi = 1 end
return ww, wh, pw, ph, dpi
if dpiX < 1e-6 then dpiX = 1 end
if dpiY < 1e-6 then dpiY = 1 end
return ww, wh, pw, ph, dpiX, dpiY
end
function Renderer:init()
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
self.canvas:setFilter("nearest", "nearest")
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
-- (#208). Every canvas below is sized in framebuffer pixels for the same
-- reason -- worldViewSize() already works in drawable pixels.
self.canvas = PixelCanvas.new(self.WIDTH, self.HEIGHT, "nearest")
self.worldCanvas = nil
self.worldActive = false
-- tilt mode only: a transparent overlay canvas the size of the world
@@ -93,21 +112,43 @@ end
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
-- units via / dpi when drawing.
-- units via / dpiX and / dpiY when drawing.
function Renderer:fitScale()
local _, _, pw, ph = displayMetrics()
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
end
-- world-pass canvas size in world pixels: enough to fill the window at s'.
-- In tilt mode the canvas grows (both dimensions, by Tilt.viewGrowth) so
-- the projected ground plane still covers the whole window with no
-- background peeking at the receded top/bottom corners; flat mode returns
-- exactly today's size (growth factor is 1 when tilt is inactive).
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
-- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
-- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
-- its projection (fitScale() == drawScaleX() * dpiX == drawScaleY() * dpiY).
-- Exposed for #208's regression (square pixels when dpiX ≠ dpiY).
function Renderer:drawScaleX()
local _, _, _, _, dpiX = displayMetrics()
return self:fitScale() / dpiX
end
function Renderer:drawScaleY()
local _, _, _, _, _, dpiY = displayMetrics()
return self:fitScale() / dpiY
end
-- Back-compat alias: uniform surfaces have drawScaleX == drawScaleY.
function Renderer:drawScale()
return self:drawScaleX()
end
-- world-pass canvas size in world pixels: enough to fill the drawable at s'.
-- Sized from framebuffer pixels (not unit dims) so anisotropic dpiX/dpiY
-- cannot over/under-cover the window. In tilt mode the canvas grows (both
-- dimensions, by Tilt.viewGrowth) so the projected ground plane still covers
-- the whole window with no background peeking at the receded top/bottom
-- corners; flat mode returns exactly today's size (growth factor is 1 when
-- tilt is inactive).
function Renderer:worldViewSize()
local ww, wh, _, _, dpi = displayMetrics()
local s = Zoom.scale(self:fitScale()) / dpi
local vw, vh = Zoom.fillViewSize(s, ww, wh)
local _, _, pw, ph = displayMetrics()
local sp = Zoom.scale(self:fitScale())
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
if vw % 2 ~= 0 then vw = vw + 1 end
@@ -145,21 +186,24 @@ end
-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
-- into the surrounding window, matching BattleTransition wipe progress.
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe.
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
-- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces).
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, Sx, Sy)
if not prog or prog <= 0 then return end
local TILE = 8 * S
if TILE < 1 then TILE = 1 end
local cols = math.ceil(ww / TILE)
local rows = math.ceil(wh / TILE)
Sy = Sy or Sx
local TILE_W, TILE_H = 8 * Sx, 8 * Sy
if TILE_W < 1 then TILE_W = 1 end
if TILE_H < 1 then TILE_H = 1 end
local cols = math.ceil(ww / TILE_W)
local rows = math.ceil(wh / TILE_H)
local cx, cy = ox + vpw / 2, oy + vph / 2
local order = {}
for row = 0, rows - 1 do
for col = 0, cols - 1 do
local x, y = col * TILE, row * TILE
local x, y = col * TILE_W, row * TILE_H
-- any tile with area outside the letterbox participates
if x < ox or y < oy or x + TILE > ox + vpw or y + TILE > oy + vph then
local dist = math.max(math.abs(x + TILE / 2 - cx),
math.abs(y + TILE / 2 - cy))
if x < ox or y < oy or x + TILE_W > ox + vpw or y + TILE_H > oy + vph then
local dist = math.max(math.abs(x + TILE_W / 2 - cx),
math.abs(y + TILE_H / 2 - cy))
order[#order + 1] = { x, y, dist }
end
end
@@ -176,7 +220,7 @@ function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
love.graphics.setScissor(0, 0, ww, wh)
for i = 1, n do
local t = order[i]
love.graphics.rectangle("fill", t[1], t[2], TILE, TILE)
love.graphics.rectangle("fill", t[1], t[2], TILE_W, TILE_H)
end
love.graphics.setScissor()
love.graphics.setColor(1, 1, 1, 1)
@@ -190,8 +234,7 @@ function Renderer:beginWorldPass()
-- the view size every frame, so without this the superseded canvases
-- pile up in VRAM until a GC finalizer happens to run
if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end
self.worldCanvas = love.graphics.newCanvas(vw, vh)
self.worldCanvas:setFilter("nearest", "nearest")
self.worldCanvas = PixelCanvas.new(vw, vh, "nearest")
end
self.worldActive = true
PaletteFX.setPass("world")
@@ -219,8 +262,7 @@ function Renderer:beginUprightPass()
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
or self.uprightCanvas:getHeight() ~= ch then
if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end
self.uprightCanvas = love.graphics.newCanvas(cw, ch)
self.uprightCanvas:setFilter("nearest", "nearest")
self.uprightCanvas = PixelCanvas.new(cw, ch, "nearest")
end
self.uprightActive = true
PaletteFX.setPass(nil)
@@ -299,10 +341,11 @@ end
-- into (nil = default framebuffer; presentCanvas when CRT is on).
-- Returns true on success; false (no shader/mesh) tells endFrame to fall
-- back to the flat blit unchanged.
function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
local shader = self:tiltShader()
local mesh = self:tiltMesh()
if not (shader and mesh) then return false end
sy = sy or sx
local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight()
@@ -313,8 +356,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
or self.tiltCanvas:getHeight() ~= wvh then
if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end
self.tiltCanvas = love.graphics.newCanvas(wvw, wvh)
self.tiltCanvas:setFilter("linear", "linear")
self.tiltCanvas = PixelCanvas.new(wvw, wvh, "linear")
end
love.graphics.setCanvas(self.tiltCanvas)
@@ -351,7 +393,7 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
mesh:setVertices(Tilt.meshCorners(wvw, wvh))
love.graphics.push()
love.graphics.translate(wox, woy)
love.graphics.scale(s, s)
love.graphics.scale(sx, sy)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setShader(shader)
love.graphics.draw(mesh)
@@ -393,14 +435,15 @@ end
-- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas()
local ww, wh, pw, ph, dpi = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale.
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel;
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
local Sp = self:fitScale()
local S = Sp / dpi
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
local Sx, Sy = Sp / dpiX, Sp / dpiY
local vpw, vph = self.WIDTH * Sx, self.HEIGHT * Sy
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpiX
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpiY
local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -422,6 +465,14 @@ function Renderer:endFrame(zones, worldZones)
if needPresent then
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
or self.presentCanvas:getHeight() ~= wh then
-- The one canvas NOT built through PixelCanvas: it is sized in LOVE
-- units and blitted back at unit scale 1 (and handed to mod present
-- passes as ww x wh), so it has to keep the screen's DPI scale for its
-- texture to cover the framebuffer. Everything composited into it is
-- already native-resolution now, so #208's fractional source is gone;
-- what remains here is the dpiX vs dpiY truncation gap (well under 1%,
-- one seam across the window) that a single scalar dpiscale cannot
-- express.
self.presentCanvas = love.graphics.newCanvas(ww, wh)
self.presentCanvas:setFilter("linear", "linear")
end
@@ -444,15 +495,27 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setColor(clearR, clearG, clearB, 1)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
-- render.letterbox: SGB borders / custom void art in the bars around the
-- 160x144 (or world) blit. Drawn after the clear and before the game
-- canvas so the playfield sits on top of the border.
if Runtime.wantsHook("render.letterbox") then
Runtime.call("render.letterbox", function() end, {
ww = ww, wh = wh, pw = pw, ph = ph,
ox = ox, oy = oy, vpw = vpw, vph = vph,
scale = Sp, dpiX = dpiX, dpiY = dpiY,
worldActive = self.worldActive and true or false,
})
end
-- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
-- coords (canvas-space) into screen units.
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
-- convert zone coords (canvas-space) into screen units.
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, scale, scale)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor()
return
end
@@ -468,10 +531,10 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setShader(not plain and shader or nil)
end
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneScale, by + z.y * zoneScale,
z.w * zoneScale, z.h * zoneScale,
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH) then
love.graphics.draw(canvas, bx, by, 0, scale, scale)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
love.graphics.setScissor()
@@ -486,7 +549,7 @@ function Renderer:endFrame(zones, worldZones)
-- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpi, 1 / dpi)
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
love.graphics.setScissor()
-- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha
@@ -496,15 +559,15 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.setColor(1, 1, 1, 1)
end
if self.battleCascadeProg then
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
end
elseif self.worldActive then
local sp = Zoom.scale(Sp)
local s = sp / dpi
local sx, sy = sp / dpiX, sp / dpiY
local wvw = self.worldCanvas:getWidth()
local wvh = self.worldCanvas:getHeight()
local wox = math.floor((pw - wvw * sp) / 2) / dpi
local woy = math.floor((ph - wvh * sp) / 2) / dpi
local wox = math.floor((pw - wvw * sp) / 2) / dpiX
local woy = math.floor((ph - wvh * sp) / 2) / dpiY
-- Tilt mode projects the ground world pass through the perspective mesh
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
-- scissoring here). drawTiltedWorld returns false when tilt is off or
@@ -512,12 +575,12 @@ function Renderer:endFrame(zones, worldZones)
-- falls through to the flat blit, keeping the flat frame byte-for-byte
-- identical to today.
local projected =
Tilt.active() and self:drawTiltedWorld(worldZones or zones, s, wox, woy, present)
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
if not projected then
if worldZones then
blit(self.worldCanvas, s, worldZones, s, wox, woy, 0, 0, ww, wh)
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh)
else
blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh)
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh)
end
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
@@ -538,11 +601,11 @@ function Renderer:endFrame(zones, worldZones)
end
if wanted then PaletteFX.sendColors(wanted, r.colors) end
if r.quad then
love.graphics.draw(r.image, r.quad, wox + r.x * s, woy + r.y * s,
0, s * r.sx, s)
love.graphics.draw(r.image, r.quad, wox + r.x * sx, woy + r.y * sy,
0, sx * r.sx, sy)
else
love.graphics.draw(r.image, wox + r.x * s, woy + r.y * s,
0, s * r.sx, s)
love.graphics.draw(r.image, wox + r.x * sx, woy + r.y * sy,
0, sx * r.sx, sy)
end
end
if activeShader then love.graphics.setShader() end
@@ -559,7 +622,7 @@ function Renderer:endFrame(zones, worldZones)
local M = self.UPRIGHT_MARGIN
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh)
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
love.graphics.draw(self.uprightCanvas, wox - M * sx, woy - M * sy, 0, sx, sy)
love.graphics.setScissor()
end
-- Screen-space warp fade (Transition) over the full world composite so
@@ -575,11 +638,11 @@ function Renderer:endFrame(zones, worldZones)
-- Battle transition: cascade black blocks into the area outside the
-- classic 160x144 wipe square (world still shows through until filled).
if self.battleCascadeProg then
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
end
end
-- UI stays in the classic centered GB letterbox
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
blit(self.canvas, Sx, Sy, zones, Sx, Sy, ox, oy, ox, oy, vpw, vph)
if present then
love.graphics.setCanvas()
@@ -589,7 +652,7 @@ function Renderer:endFrame(zones, worldZones)
-- grid itself. Each pass hands back a canvas; with none registered
-- this returns `present` unchanged and the frame is byte-identical.
local composed = Pipelines.present(present,
{ width = ww, height = wh, scale = Sp, dpi = dpi }) or present
{ width = ww, height = wh, scale = Sp, dpi = dpiY, dpiX = dpiX, dpiY = dpiY }) or present
if GBCFX.active() then
-- shader grid/shadow math is in framebuffer pixels
GBCFX.present(composed, Sp)
+11 -6
View File
@@ -105,7 +105,10 @@ function SpriteRenderer:resolveImage()
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then return getObpImage(self.def.image, colors, group) end
elseif PaletteFX.usesSpriteObp() then
return getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
-- OG boot-ROM OBJ palette: green on Red, pink on Blue (PaletteFX.ogObj
-- returns colors + a version-distinct cache group so the two never
-- collide in obpCache) -- see issue #155
return getObpImage(self.def.image, PaletteFX.ogObj())
end
return self.image
end
@@ -141,11 +144,13 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
image = getObpImage(self.def.image, colors, group)
end
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
-- OG RED (GBC boot-ROM look): every OBJ wears the one global green
-- object palette. The red BG zone shader still runs over the world
-- canvas, so the baked sprite is queued for a post-zone redraw
-- (PaletteFX.markSpriteRedraw) that restores its green pixels on top.
image = getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
-- OG RED (GBC boot-ROM look): every OBJ wears the one global object
-- palette -- green over Red's red background, pink over Blue's blue
-- background (PaletteFX.ogObj, #155). The BG zone shader still runs over
-- the world canvas, so the baked sprite is queued for a post-zone redraw
-- (PaletteFX.markSpriteRedraw) that restores its object-colored pixels on
-- top.
image = getObpImage(self.def.image, PaletteFX.ogObj())
redraw = true
end
-- single-frame sprites (item balls, fossils...) have one fixed pose;
+4 -1
View File
@@ -542,7 +542,10 @@ end
local function bakeBorderFill(self, block)
local border = self.map.tileset.blocks[block + 1]
if not border then return end
local canvas = love.graphics.newCanvas(32, 32)
-- 32x32 real pixels: a DPI-scaled canvas would bake the border block at a
-- fractional texel size and the repeat-wrapped image would then tile at
-- non-square pixels (#208, see src/render/PixelCanvas.lua)
local canvas = require("src.render.PixelCanvas").new(32, 32)
love.graphics.push("all")
love.graphics.setCanvas(canvas)
love.graphics.clear(1, 1, 1, 1)
+26 -7
View File
@@ -4,19 +4,38 @@
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
-- Spec: docs/new-features.md (survey zoom)
local Runtime = require("src.mods.Runtime")
local Zoom = {}
Zoom.offset = 0
-- effective integer scale s' in [1, 2*S]
function Zoom.scale(S)
return math.max(1, math.min(2 * S, S + Zoom.offset))
end
-- legal offset range for a given fit scale
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
-- through 2× fit). zoom.range may widen or shrink the window.
function Zoom.offsetRange(S)
S = math.max(1, math.floor(tonumber(S) or 1))
return 1 - S, S
local lo, hi = 1 - S, S
if Runtime.wantsHook("zoom.range") then
lo, hi = Runtime.call("zoom.range", function(a, b) return a, b end, lo, hi, S)
lo = math.floor(tonumber(lo) or (1 - S))
hi = math.floor(tonumber(hi) or S)
if lo > hi then lo, hi = hi, lo end
end
return lo, hi
end
-- effective scale s' = S + offset, clamped to the (possibly modded) range.
-- Vanilla stays in [1, 2*S]. A zoom.range wrapper that lowers `lo` below
-- 1-S permits sub-1 survey scales so the whole region can fit on screen.
function Zoom.scale(S)
local lo, hi = Zoom.offsetRange(S)
local s = S + Zoom.offset
local minScale = S + lo
local maxScale = math.max(minScale, S + hi)
if s < minScale then s = minScale end
if s > maxScale then s = maxScale end
if s < 0.25 then s = 0.25 end
return s
end
function Zoom.clampOffset(offset, S)
+18 -7
View File
@@ -214,11 +214,10 @@ local function useOn(game, battle, id, target, list, moveIndex)
and ow.map.id ~= "AGATHAS_ROOM" then
list:close()
consume(game, id)
require("src.core.Sound").play(game.data, "Teleport_Exit1")
ow.player.surfing = false
-- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path);
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
ow:warpToHealPoint(nil, { arrive = "teleport" })
-- LeaveMapAnim spin-up + SFX_TELEPORT_EXIT_1, a fade, then land OUTSIDE
-- the last Pokémon Center town door like Fly (#196), via the shared
-- departure helper -- the same path Dig/Teleport take from the party menu
ow:beginTeleportOut()
else
showMessages(game, { "OAK: " .. game.save.player.name
.. "!\nThis isn't the\ntime to use that!" })
@@ -303,7 +302,8 @@ local function pickTargetAndUse(game, battle, id, list)
-- the ETHERs and PP UP open the move menu after picking a mon
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
require("src.ui.Screens").push(game, "PartyMenu", {
local def = game.data.items[id]
local opts = {
pickOnly = true,
onSwitch = function(mon)
if not wantsMove then
@@ -326,7 +326,17 @@ local function pickTargetAndUse(game, battle, id, list)
end,
}))
end,
})
}
-- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon
-- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on
-- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM ->
-- party_menu.asm TM/HM type). Stones and other pickOnly items keep the
-- plain HP layout (Gen 1 shows no ABLE/NOT ABLE for them), so gate
-- strictly on def.machine. #210
if def and def.machine then
opts.tmhm = { move = def.machine.move, kind = def.machine.kind }
end
require("src.ui.Screens").push(game, "PartyMenu", opts)
end
local function useItem(game, battle, id, list)
@@ -360,6 +370,7 @@ function BagMenu.new(game, opts)
local battle = opts.battle
local list
list = ListMenu.new(game, "ITEMS", buildItems(game), {
kind = "bag",
footer = ("¥%d"):format(game.save.money),
-- SELECT reorders items like the original bag (swap_items.asm)
onSelectKey = function(item, l)
+3 -2
View File
@@ -140,8 +140,9 @@ function Credits:enter()
end
function Credits:monSprite(species)
local def = self.game.data.pokemon and self.game.data.pokemon[species]
return silhouette(def and def.spriteFront)
local path = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "credits" })
return silhouette(path)
end
-- advance to the next CreditsOrder screen (Credits .nextCreditsScreen);
+3 -1
View File
@@ -34,7 +34,9 @@ function DexEntryMenu.new(game, speciesOrOpts)
local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
self.def = game.data.pokemon[species]
local ok, img = pcall(love.graphics.newImage, self.def.spriteFront)
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
{ kind = "dex" })
local ok, img = path and pcall(love.graphics.newImage, path)
self.sprite = ok and img or nil
require("src.core.Sound").playCry(game.data, species)
return self
+49 -14
View File
@@ -1,8 +1,11 @@
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
-- flashes back and forth with the evolved form, speeding up, then the
-- new form appears with its cry and the congratulations text.
-- B during the flash cancels ("Huh? ... stopped evolving!"? -- Gen 1
-- has no cancel; the flash always completes).
-- pokered engine/pokemon/evos_moves.asm (EvolveMon) polls hJoyHeld during
-- the flash: holding B aborts the evolution -- the mon keeps its species
-- and _StoppedEvolvingText ("Huh? MON stopped evolving!") prints. The
-- lone exception is trade evolutions (wLinkState == LINK_STATE_TRADING),
-- which skip that poll and cannot be cancelled (#213).
local Font = require("src.render.Font")
local Music = require("src.core.Music")
@@ -14,7 +17,10 @@ EvolutionState.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display
function EvolutionState:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local species = self.done and self.newSpecies or self.mon.species
-- a cancelled evolution keeps the old species (never applied), so only
-- colorize with the new form once it has actually evolved
local species = (self.done and not self.canceled) and self.newSpecies
or self.mon.species
local c = P.monPal(game.data, species)
if c then return { P.whole(c) } end
return P.wholeNamed(game.data, "MEWMON")
@@ -22,24 +28,30 @@ end
local FLASH_FRAMES = 220
local function frontSprite(game, species)
local def = game.data.pokemon[species]
if not (def and def.spriteFront) then return nil end
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
local function frontSprite(game, species, mon)
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
{ mon = mon, kind = "evolution" })
if not path then return nil end
local ok, img = pcall(love.graphics.newImage, path)
return ok and img or nil
end
function EvolutionState.new(game, mon, newSpecies, onDone)
function EvolutionState.new(game, mon, newSpecies, onDone, via)
local self = setmetatable({}, EvolutionState)
self.game = game
self.mon = mon
self.newSpecies = newSpecies
self.onDone = onDone
self.via = via
-- evos_moves.asm: only trade evolutions (LINK_STATE_TRADING) skip the
-- B-cancel poll; level-up, stone and rare-candy evos are all cancelable.
self.cancelable = (via ~= "TRADE")
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
self.oldSprite = frontSprite(game, mon.species)
self.newSprite = frontSprite(game, newSpecies)
self.oldSprite = frontSprite(game, mon.species, mon)
self.newSprite = frontSprite(game, newSpecies, mon)
self.t = 0
self.done = false
self.canceled = false
Music.play(game.data, Music.special(game.data, "evolution"))
return self
end
@@ -47,11 +59,28 @@ end
function EvolutionState:update(dt)
self.t = self.t + 1
if self.done then return end
local game = self.game
-- evos_moves.asm EvolveMon: each flash iteration polls hJoyHeld and, for
-- a cancelable evolution, aborts when B is held -- the mon keeps its
-- species (Evolution.apply never runs) and _StoppedEvolvingText prints.
if self.cancelable and game.input:isDown("b") then
self.done = true
self.canceled = true
local TextBox = require("src.render.TextBox")
-- mirrors data/generated/text.lua _StoppedEvolvingText
game.stack:push(TextBox.new(game,
("Huh? %s\nstopped evolving!"):format(self.oldName),
function()
Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself
if self.onDone then self.onDone() end
end))
return
end
if self.t >= FLASH_FRAMES then
self.done = true
local game = self.game
local Evolution = require("src.pokemon.Evolution")
Evolution.apply(game, self.mon, self.newSpecies)
Evolution.apply(game, self.mon, self.newSpecies, self.via)
require("src.core.Sound").playCry(game.data, self.newSpecies)
local TextBox = require("src.render.TextBox")
local newName = game.data.pokemon[self.newSpecies].name
@@ -61,7 +90,12 @@ function EvolutionState:update(dt)
function()
Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself
if self.onDone then self.onDone() end
-- Gen1 re-runs the level-up learn check on the evolved species
-- after the "evolved into" text (evos_moves.asm EvolveMon ->
-- learn_move.asm LearnMoveFromLevelUp, #12). Pop the evo screen
-- first so the "learned MOVE!" text / forget prompt push onto the
-- overworld / battle-return, not this state.
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
end))
end
end
@@ -73,7 +107,8 @@ function EvolutionState:draw()
-- accelerating flash between the two forms
local sprite
if self.done then
sprite = self.newSprite
-- a cancelled evolution settles back on the original form
sprite = self.canceled and self.oldSprite or self.newSprite
else
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
local showNew = math.floor(self.t / period) % 2 == 1
+6 -2
View File
@@ -11,9 +11,13 @@ function FlyMenu.new(game)
local visited = game.save.visited or {}
local seen = {}
for _, mapId in ipairs(game.data.field.flyOrder or {}) do
-- towns only (dungeon escape spots share the table), each listed once
-- towns only (dungeon escape spots share the table), each listed once.
-- Indigo Plateau (tileset PLATEAU) is a valid Fly destination too, so allow
-- it past the OVERWORLD-only isOutdoor gate while the CAVERN/FACILITY escape
-- spots stay excluded (LoadTownMap_Fly cycles it like any town, #203).
local def = game.data.maps[mapId]
if visited[mapId] and def and Map.isOutdoor(def) and not seen[mapId] then
if visited[mapId] and def and not seen[mapId]
and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
seen[mapId] = true
table.insert(items, {
value = mapId,
+3 -2
View File
@@ -118,8 +118,9 @@ end
function HallOfFame:spriteFor(species)
local cached = self.sprites[species]
if cached == nil then
local def = self.game.data.pokemon[species]
cached = tryImage(def and def.spriteFront) or false
local path = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "hof" })
cached = tryImage(path) or false
self.sprites[species] = cached
end
return cached or nil
+96 -7
View File
@@ -3,6 +3,7 @@
-- box and the Pokédex.
local Font = require("src.render.Font")
local Runtime = require("src.mods.Runtime")
local Theme = require("src.ui.Theme")
local ListMenu = {}
@@ -15,9 +16,37 @@ function ListMenu:sgbPalettes(game)
end
local ROWS = 7
-- frames to wait before key-repeat kicks in, then between repeats
local REPEAT_DELAY = 16
local REPEAT_RATE = 4
-- ui.list_menu identity: unhooked opts pass through unchanged
local function sameOpts(opts) return opts end
function ListMenu.new(game, title, items, opts)
opts = opts or {}
-- bag / shop / dex / generic: mods may enable wrap, pageJump, keyRepeat
if Runtime.wantsHook("ui.list_menu") then
local hooked = Runtime.call("ui.list_menu", sameOpts, {
wrap = opts.wrap,
pageJump = opts.pageJump,
keyRepeat = opts.keyRepeat,
repeatDelay = opts.repeatDelay,
repeatRate = opts.repeatRate,
}, {
game = game,
title = title,
kind = opts.kind or title,
itemCount = items and #items or 0,
})
if type(hooked) == "table" then
if hooked.wrap ~= nil then opts.wrap = hooked.wrap end
if hooked.pageJump ~= nil then opts.pageJump = hooked.pageJump end
if hooked.keyRepeat ~= nil then opts.keyRepeat = hooked.keyRepeat end
if hooked.repeatDelay ~= nil then opts.repeatDelay = hooked.repeatDelay end
if hooked.repeatRate ~= nil then opts.repeatRate = hooked.repeatRate end
end
end
local self = setmetatable({}, ListMenu)
self.game = game
self.title = title
@@ -28,6 +57,12 @@ function ListMenu.new(game, title, items, opts)
self.onCancel = opts.onCancel
self.footer = opts.footer
self.pageJump = opts.pageJump -- Left/Right move a page at a time
self.wrap = opts.wrap -- Up on first / Down on last wraps
self.keyRepeat = opts.keyRepeat -- hold Up/Down (and pageJump L/R) to scroll
self.repeatDelay = opts.repeatDelay or REPEAT_DELAY
self.repeatRate = opts.repeatRate or REPEAT_RATE
self.holdDir = nil
self.holdFrames = 0
self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item
-- scripted mode (the old man tutorial): update() runs the script
-- every frame INSTEAD of reading input -- DisplayListMenuID's old-man
@@ -46,6 +81,42 @@ function ListMenu.new(game, title, items, opts)
return self
end
local function moveIndex(self, delta)
local n = #self.items
if n == 0 then return end
local next = self.index + delta
if self.wrap then
next = ((next - 1) % n) + 1
else
next = math.max(1, math.min(n, next))
end
self.index = next
end
local function syncScroll(self)
if self.index - self.scroll > self.rows then
self.scroll = self.index - self.rows
end
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
end
-- edge press or key-repeat tick for a held direction
local function navPressed(self, dir)
if dir == "up" then
moveIndex(self, -1)
elseif dir == "down" then
moveIndex(self, 1)
elseif dir == "left" and self.pageJump then
moveIndex(self, -self.rows)
elseif dir == "right" and self.pageJump then
moveIndex(self, self.rows)
else
return false
end
syncScroll(self)
return true
end
function ListMenu:update(dt)
if self.script then
self.script(self)
@@ -59,14 +130,20 @@ function ListMenu:update(dt)
end
return
end
local moved = false
if input:wasPressed("up") then
self.index = math.max(1, self.index - 1)
moved = navPressed(self, "up")
self.holdDir, self.holdFrames = "up", 0
elseif input:wasPressed("down") then
self.index = math.min(#self.items, self.index + 1)
moved = navPressed(self, "down")
self.holdDir, self.holdFrames = "down", 0
elseif self.pageJump and input:wasPressed("left") then
self.index = math.max(1, self.index - self.rows)
moved = navPressed(self, "left")
self.holdDir, self.holdFrames = "left", 0
elseif self.pageJump and input:wasPressed("right") then
self.index = math.min(#self.items, self.index + self.rows)
moved = navPressed(self, "right")
self.holdDir, self.holdFrames = "right", 0
elseif self.onSelectKey and input:wasPressed("select") then
self.onSelectKey(self.items[self.index], self)
elseif input:wasPressed("b") then
@@ -80,10 +157,22 @@ function ListMenu:update(dt)
end
return
end
if self.index - self.scroll > self.rows then
self.scroll = self.index - self.rows
-- hold-to-scroll (opt-in via ui.list_menu keyRepeat)
if self.keyRepeat then
local dir = self.holdDir
if dir and input:isDown(dir) then
self.holdFrames = self.holdFrames + 1
local afterDelay = self.holdFrames - self.repeatDelay
if afterDelay >= 0 and afterDelay % self.repeatRate == 0 then
navPressed(self, dir)
end
else
self.holdDir, self.holdFrames = nil, 0
end
end
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
if not moved then syncScroll(self) end
end
-- remove current item (e.g. consumed); keeps cursor valid
+28
View File
@@ -57,4 +57,32 @@ function ModUI.removeLabel(items, label)
return items
end
-- Oak speech step-list helpers (intro.oak_speech.build). Anchored on
-- stable step.id values the same way insertBefore anchors on labels.
local function stepIndex(steps, id)
for i, step in ipairs(steps) do
if step.id == id then return i end
end
return nil
end
function ModUI.insertStepBefore(steps, anchorId, step)
local i = stepIndex(steps, anchorId)
table.insert(steps, i or (#steps + 1), step)
return steps
end
function ModUI.insertStepAfter(steps, anchorId, step)
local i = stepIndex(steps, anchorId)
table.insert(steps, i and (i + 1) or (#steps + 1), step)
return steps
end
function ModUI.removeStep(steps, id)
for i = #steps, 1, -1 do
if steps[i].id == id then table.remove(steps, i) end
end
return steps
end
return ModUI
+40 -9
View File
@@ -5,8 +5,12 @@
-- given, a "NEW NAME" + presets menu is shown first
-- (engine/menus/main_menu.asm name lists).
-- Pops itself from the stack, then calls opts.onDone(name).
--
-- ui.naming.grid may replace either page; keep an "ED" cell and a
-- single-cell case-switch row so confirm / case-flip keep working.
local Font = require("src.render.Font")
local Runtime = require("src.mods.Runtime")
local Sound = require("src.core.Sound")
local Theme = require("src.ui.Theme")
@@ -37,8 +41,24 @@ local GRID_LOWER = {
{ "-", "?", "!", "", "", "/", ".", ",", "ED" },
{ "UPPER CASE" },
}
local CASE_ROW = 6
local ED_ROW, ED_COL = 5, 9
-- locate the ED confirm cell and the case-switch row on a (possibly
-- modded) grid; falls back to vanilla coordinates
local function findMeta(grid)
local caseRow, edRow, edCol = #grid, 5, 9
for r, row in ipairs(grid) do
if #row == 1 and (row[1] == "lower case" or row[1] == "UPPER CASE"
or row[1] == "lower" or row[1] == "UPPER") then
caseRow = r
end
for c, cell in ipairs(row) do
if cell == "ED" then edRow, edCol = r, c end
end
end
return caseRow, edRow, edCol
end
local function sameGrid(grid) return grid end
function NamingScreen.new(game, opts)
opts = opts or {}
@@ -86,16 +106,27 @@ function NamingScreen:confirm()
end
function NamingScreen:grid()
return self.lower and GRID_LOWER or GRID_UPPER
local base = self.lower and GRID_LOWER or GRID_UPPER
if not Runtime.wantsHook("ui.naming.grid") then return base end
local hooked = Runtime.call("ui.naming.grid", sameGrid, base, {
lower = self.lower and true or false,
title = self.title,
maxLen = self.maxLen,
game = self.game,
})
if type(hooked) ~= "table" or #hooked == 0 then return base end
return hooked
end
-- Gen 1 jumps the cursor to ED once the name is full.
function NamingScreen:jumpToEnd()
self.row, self.col = ED_ROW, ED_COL
local _, edRow, edCol = findMeta(self:grid())
self.row, self.col = edRow, edCol
end
function NamingScreen:update(dt)
local GRID = self:grid()
local caseRow, edRow, edCol = findMeta(GRID)
local input = self.game.input
if input:wasPressed("start") then
self:confirm()
@@ -107,28 +138,28 @@ function NamingScreen:update(dt)
end
if input:wasPressed("up") then
-- wrapping up from the top row lands on the case-switch cell
self.row = self.row > 1 and self.row - 1 or CASE_ROW
self.row = self.row > 1 and self.row - 1 or caseRow
self.col = math.min(self.col, #GRID[self.row])
elseif input:wasPressed("down") then
self.row = self.row < #GRID and self.row + 1 or 1
self.col = math.min(self.col, #GRID[self.row])
elseif input:wasPressed("left") then
-- no horizontal movement on the case-switch row
if self.row ~= CASE_ROW then
if self.row ~= caseRow then
self.col = self.col > 1 and self.col - 1 or #GRID[self.row]
end
elseif input:wasPressed("right") then
if self.row ~= CASE_ROW then
if self.row ~= caseRow then
self.col = self.col < #GRID[self.row] and self.col + 1 or 1
end
elseif input:wasPressed("b") then
table.remove(self.glyphs)
elseif input:wasPressed("a") then
if self.row == ED_ROW and self.col == ED_COL then
if self.row == edRow and self.col == edCol then
self:confirm()
return
end
if self.row == CASE_ROW then
if self.row == caseRow then
self.lower = not self.lower
return
end
+357 -72
View File
@@ -1,14 +1,16 @@
-- The intro sequence (engine/movie/oak_speech/oak_speech.asm): Oak's
-- welcome, the NIDORINO show-off, player and rival naming, and the
-- closing "legend is about to unfold" text followed by the shrink-away:
-- the player pic collapses through ShrinkPic1/ShrinkPic2 into the
-- overworld walking sprite before the fade to white. Uses the real
-- extracted texts (_OakSpeechText1/2A/2B/3, _IntroducePlayerText,
-- _IntroduceRivalText) with literal fallbacks.
-- closing "legend is about to unfold" text followed by the shrink-away.
--
-- Steps are a data table so mods can reshape the whole speech through
-- hooks:wrap("intro.oak_speech.build"). Vanilla ids stay stable so a
-- mod can insertBefore("name_player", ...) without counting indices.
-- Calls onDone() after popping itself.
local Sound = require("src.core.Sound")
local Music = require("src.core.Music")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local TextBox = require("src.render.TextBox")
local Font = require("src.render.Font")
@@ -55,12 +57,154 @@ local function tryImage(path)
return ok and img or nil
end
-- Resolve a pic descriptor to (image, flip).
-- Descriptors:
-- "oak" | "rival" | "player" shorthand
-- { type = "trainer", id = "OPP_PROF_OAK" }
-- { type = "pokemon", id = "PIKACHU", flip = true }
-- { type = "player", path = "..." } optional override path
-- { type = "image", path = "..." }
-- { type = "sprite", id = "SPRITE_RED" }
function OakSpeech.resolvePic(game, desc, speech)
if desc == nil then return nil, false end
if type(desc) == "string" then
if desc == "oak" then
desc = { type = "trainer", id = "OPP_PROF_OAK" }
elseif desc == "rival" then
desc = { type = "trainer", id = "OPP_RIVAL1" }
elseif desc == "player" then
desc = { type = "player" }
else
-- bare species id
desc = { type = "pokemon", id = desc }
end
end
local t = desc.type
if t == "trainer" then
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
return speech.oakPic, false
end
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
return speech.rivalPic, false
end
local trainers = game.data.trainers or {}
local tr = trainers[desc.id]
return tryImage(tr and tr.pic), false
elseif t == "pokemon" then
if speech and desc.id == speech.demoSpecies and speech.demoPic then
return speech.demoPic, desc.flip and true or false
end
local path = require("src.pokemon.Sprites").path(
game.data, desc.id, "front", { kind = "oak" })
return tryImage(path), desc.flip and true or false
elseif t == "player" then
if speech and speech.playerPic and not desc.path then
return speech.playerPic, false
end
return tryImage(desc.path or "assets/generated/trainer_card/red.png"), false
elseif t == "image" then
return tryImage(desc.path), desc.flip and true or false
elseif t == "sprite" then
local sp = game.data.sprites and game.data.sprites[desc.id]
return tryImage(sp and sp.image), desc.flip and true or false
end
return nil, false
end
-- Vanilla step list. Ids are the stable anchors mods insert around.
function OakSpeech.defaultSteps(speech)
return {
{
id = "oak_welcome",
kind = "say",
textKey = "_OakSpeechText1",
pic = "oak",
reveal = "fade",
},
{
id = "demo_mon",
kind = "demo",
},
{
id = "world_spiel",
kind = "say",
textKey = "_OakSpeechText2B",
},
{
id = "ask_player_name",
kind = "say",
textKey = "_IntroducePlayerText",
pic = "player",
},
{
id = "name_player",
kind = "name",
who = "player",
title = "YOUR NAME?",
presetsWho = "player",
presetsFallback = { "RED", "ASH", "JACK" },
},
{
id = "ask_rival_name",
kind = "say",
textKey = "_IntroduceRivalText",
pic = "rival",
},
{
id = "name_rival",
kind = "name",
who = "rival",
title = "HIS NAME?",
presetsWho = "rival",
presetsFallback = { "BLUE", "GARY", "JOHN" },
},
{
id = "legend",
kind = "say",
textKey = "_OakSpeechText3",
pic = "player",
},
{
id = "shrink",
kind = "shrink",
},
}
end
-- list helpers for intro.oak_speech.build wrappers (also on ModUI)
local function indexOfId(steps, id)
for i, step in ipairs(steps) do
if step.id == id then return i end
end
return nil
end
function OakSpeech.insertBefore(steps, anchorId, step)
local i = indexOfId(steps, anchorId)
table.insert(steps, i or (#steps + 1), step)
return steps
end
function OakSpeech.insertAfter(steps, anchorId, step)
local i = indexOfId(steps, anchorId)
table.insert(steps, i and (i + 1) or (#steps + 1), step)
return steps
end
function OakSpeech.removeId(steps, id)
for i = #steps, 1, -1 do
if steps[i].id == id then table.remove(steps, i) end
end
return steps
end
function OakSpeech.new(game, onDone)
local self = setmetatable({}, OakSpeech)
self.game = game
self.onDone = onDone
self.step = 0
self.pic = nil
self.answers = {}
local trainers = game.data.trainers or {}
self.oakPic = tryImage(trainers.OPP_PROF_OAK and trainers.OPP_PROF_OAK.pic)
self.rivalPic = tryImage(trainers.OPP_RIVAL1 and trainers.OPP_RIVAL1.pic)
@@ -69,8 +213,9 @@ function OakSpeech.new(game, onDone)
-- the show-off mon and the name length cap come from data; the vanilla
-- literals stay as the fallbacks
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO"
local demo = game.data.pokemon and game.data.pokemon[self.demoSpecies]
self.demoPic = tryImage(demo and demo.spriteFront)
local demoPath = require("src.pokemon.Sprites").path(
game.data, self.demoSpecies, "front", { kind = "oak" })
self.demoPic = tryImage(demoPath)
local constants = game.data.constants or {}
self.nameLen = constants.playerNameLength or 7
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
@@ -87,9 +232,26 @@ function OakSpeech.new(game, onDone)
return self
end
function OakSpeech:buildSteps()
local steps = OakSpeech.defaultSteps(self)
local hooked = Runtime.call("intro.oak_speech.build",
function(s) return s end, steps, self)
if type(hooked) ~= "table" then
Logger.error("intro.oak_speech.build returned %s; keeping vanilla steps",
type(hooked))
return steps
end
return hooked
end
function OakSpeech:enter()
-- MUSIC_ROUTES2 plays under the whole speech (oak_speech.asm:43-48)
Music.play(self.game.data, self.cfg.music or "Music_Routes2")
self.answers = {}
self.steps = self:buildSteps()
if Runtime.wants("intro.oak_speech.started") then
Runtime.emit("intro.oak_speech.started", { speech = self, steps = self.steps })
end
self:advance()
end
@@ -97,80 +259,187 @@ function OakSpeech:say(key, next)
self.game.stack:push(TextBox.new(self.game, textOr(self.game, key), next))
end
local STEPS = {
-- 1. Oak's welcome (Oak's pic fades in first, FadeInIntroPic)
function(self)
self.pic = self.oakPic
self:revealPic("fade", function()
self:say("_OakSpeechText1", function() self:advance() end)
function OakSpeech:sayText(text, next, opts)
self.game.stack:push(TextBox.new(self.game, text, next, opts))
end
function OakSpeech:stepText(step)
if step.text then return step.text end
if step.textKey then return textOr(self.game, step.textKey) end
return ""
end
function OakSpeech:applyPic(step)
if step.pic == nil then return end
local img, flip = OakSpeech.resolvePic(self.game, step.pic, self)
if img then
self.pic = img
self.picFlip = flip or false
elseif step.pic == "player" or (type(step.pic) == "table" and step.pic.type == "player") then
-- mirror the old fallback: player pic missing → oak
self.pic = self.playerPic or self.oakPic
self.picFlip = false
end
end
function OakSpeech:recordAnswer(step, index, label, value)
if value == nil then value = label end
if step.saveKey then
self.answers[step.saveKey] = value
end
if Runtime.wants("intro.oak_speech.answered") then
Runtime.emit("intro.oak_speech.answered", {
speech = self,
step = step,
index = index,
label = label,
value = value,
saveKey = step.saveKey,
})
end
end
function OakSpeech:afterReveal(step, fn)
if step.reveal then
self:revealPic(step.reveal, fn)
else
fn()
end
end
function OakSpeech:runCry(step)
local cry = step.cry
if not cry then return end
if cry == true then
if type(step.pic) == "string" and step.pic ~= "oak"
and step.pic ~= "rival" and step.pic ~= "player" then
cry = step.pic
elseif type(step.pic) == "table" and step.pic.type == "pokemon" then
cry = step.pic.id
else
return
end
end
Sound.playCry(self.game.data, cry)
end
function OakSpeech:runStep(step)
local kind = step.kind or "say"
if kind == "say" then
self:applyPic(step)
self:afterReveal(step, function()
self:runCry(step)
self:sayText(self:stepText(step), function() self:advance() end)
end)
end,
-- 2. NIDORINO show-off: the front sprite is mirrored horizontally
-- (LoadFlippedFrontSpriteByMonIndex) and wipes in from the right
-- (MovePicLeft), then its cry sounds and the text prints
function(self)
elseif kind == "demo" then
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
self.pic = self.demoPic
self.picFlip = true
self:revealPic("wipe", function()
Sound.playCry(self.game.data, self.demoSpecies)
self:say("_OakSpeechText2A", function() self:advance() end)
end)
end,
-- 3. the rest of the world-of-POKéMON spiel
function(self)
self:say("_OakSpeechText2B", function() self:advance() end)
end,
-- 4. "First, what is your name?" over the player's own pic
-- (RedPicFront, oak_speech.asm:86-91) then the naming screen
function(self)
self.pic = self.playerPic or self.oakPic
self:say("_IntroducePlayerText", function() self:advance() end)
end,
function(self)
elseif kind == "name" then
local who = step.who or "player"
local presets = step.presets
or namePresets(self.game, step.presetsWho or who,
step.presetsFallback or { "RED" })
require("src.ui.Screens").push(self.game, "NamingScreen", {
title = "YOUR NAME?",
presets = namePresets(self.game, "player", { "RED", "ASH", "JACK" }),
maxLen = self.nameLen,
title = step.title or (who == "rival" and "HIS NAME?" or "YOUR NAME?"),
presets = presets,
maxLen = step.maxLen or self.nameLen,
onDone = function(name)
self.game.save.player.name = name
if who == "rival" then
self.game.save.player.rival = name
else
self.game.save.player.name = name
end
self:recordAnswer(step, 1, name, name)
self:advance()
end,
})
end,
-- 6. the rival introduction and naming
function(self)
self.pic = self.rivalPic
self:say("_IntroduceRivalText", function() self:advance() end)
end,
function(self)
require("src.ui.Screens").push(self.game, "NamingScreen", {
title = "HIS NAME?",
presets = namePresets(self.game, "rival", { "BLUE", "GARY", "JOHN" }),
maxLen = self.nameLen,
onDone = function(name)
self.game.save.player.rival = name
self:advance()
end,
})
end,
-- 8. "your very own POKéMON legend is about to unfold!" over the
-- player pic again (oak_speech.asm:105-113)
function(self)
self.pic = self.playerPic or self.oakPic
self:say("_OakSpeechText3", function() self:advance() end)
end,
-- 9. SFX_SHRINK: the pic collapses through the two shrink frames
-- into the walking sprite, then fades to white (oak_speech.asm
-- .next, lines 115-166). Not skippable, like the DelayFrames
-- chain it ports.
function(self)
elseif kind == "choice" then
self:applyPic(step)
self:afterReveal(step, function()
self:runCry(step)
local function openMenu()
local Menu = require("src.ui.Menu")
local items = {}
for i, label in ipairs(step.choices or {}) do
items[i] = {
label = label,
onSelect = function()
local value = label
if step.values and step.values[i] ~= nil then
value = step.values[i]
end
self:recordAnswer(step, i, label, value)
self:advance()
end,
}
end
self.game.stack:push(Menu.new(self.game, items, {
cancelable = step.cancelable == true,
tx = step.tx or 4,
ty = step.ty or 0,
tw = step.tw or 12,
th = step.th,
}))
end
local text = self:stepText(step)
if text ~= "" then
self:sayText(text, openMenu)
else
openMenu()
end
end)
elseif kind == "yesno" then
self:applyPic(step)
self:afterReveal(step, function()
self:runCry(step)
self:sayText(self:stepText(step), nil, {
defaultNo = step.defaultNo,
choice = function(yes)
local label = yes and "YES" or "NO"
local value = yes
if step.values then
if yes then
value = step.values[1]
else
value = step.values[2]
end
end
self:recordAnswer(step, yes and 1 or 2, label, value)
self:advance()
end,
})
end)
elseif kind == "pic" then
-- show a sprite with an optional reveal, no text
self:applyPic(step)
self:afterReveal(step, function()
self:runCry(step)
self:advance()
end)
elseif kind == "shrink" then
Sound.play(self.game.data, "Shrink")
-- the OakSpeechText3 box holds its last page on screen through the
-- shrink (pokered text boxes persist until overwritten)
self.shrinkText = self:lastPageLines("_OakSpeechText3")
-- prefer an explicit shrink text key; fall back to the legend beat
local key = step.textKey or "_OakSpeechText3"
self.shrinkText = self:lastPageLines(key)
self.shrink = { frame = 0 }
end,
}
elseif kind == "fn" then
-- full escape hatch: step.run(speech, done)
if type(step.run) == "function" then
step.run(self, function() self:advance() end)
else
self:advance()
end
else
Logger.warn("oak speech unknown step kind %s (id=%s); skipping",
tostring(kind), tostring(step.id))
self:advance()
end
end
-- the last two visible lines of a text's final page, pre-encoded
function OakSpeech:lastPageLines(key)
@@ -202,15 +471,31 @@ end
function OakSpeech:advance()
self.step = self.step + 1
self.picFlip = false
local fn = STEPS[self.step]
if fn then
fn(self)
local steps = self.steps
if not steps then
-- enter() builds steps; keep a path for callers that advance early
steps = self:buildSteps()
self.steps = steps
end
local step = steps[self.step]
if step then
if Runtime.wants("intro.oak_speech.step") then
Runtime.emit("intro.oak_speech.step", {
speech = self, step = step, index = self.step,
})
end
self:runStep(step)
else
self:finish()
end
end
function OakSpeech:finish()
if Runtime.wants("intro.oak_speech.finished") then
Runtime.emit("intro.oak_speech.finished", {
speech = self, answers = self.answers,
})
end
-- the map theme starts with the overworld beneath (the original's
-- special warp into Pallet Town)
local ow = self.game.overworld
@@ -267,8 +552,8 @@ function OakSpeech:draw()
if self.pic then
-- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic
-- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside
-- it like the sprite buffer does ((8 - w) >> 1 tiles across,
-- bottom-aligned)
-- it like the sprite buffer does ((8 - w) >> 1) tiles across,
-- bottom-aligned
local w, h = self.pic:getDimensions()
local x = 48 + math.floor((8 - w / 8) / 2) * 8
local y = 32 + (7 - h / 8) * 8
+112 -26
View File
@@ -9,6 +9,7 @@
-- opts.onCancel: fired when the menu closes without a pick (B)
-- Pops itself on B.
local Assets = require("src.render.Assets")
local Font = require("src.render.Font")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
@@ -94,9 +95,13 @@ local function drawIcon(game, mon, x, y, selected, counter)
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
path = name and icons.icons and icons.icons[name]
end
path = require("src.pokemon.Sprites").iconPath(game.data, mon, path, { name = name })
if not path then return end
if iconImages[path] == nil then
local ok, img = pcall(love.graphics.newImage, path)
-- resolve through Assets so an overrides/ or transform-derived icon
-- (e.g. a per-species image at assets/generated/icons/<name>.png) is
-- picked up the same way battle sprites are
local ok, img = pcall(love.graphics.newImage, Assets.resolve(path))
iconImages[path] = ok and img or false
end
local img = iconImages[path]
@@ -128,6 +133,10 @@ function PartyMenu.new(game, opts)
self.onSwitch = opts.onSwitch
self.onCancel = opts.onCancel
self.pickOnly = opts.pickOnly
-- TM/HM teaching: opts.tmhm = { move, kind } switches the list to Gen 1's
-- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the
-- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210
self.tmhm = opts.tmhm
self.forceSwitch = opts.forceSwitch
self.battle = opts.battle
self.party = opts.party -- link battles pass their clamped copies
@@ -174,8 +183,15 @@ function PartyMenu:update(dt)
elseif action == "switch" then
self.swapFrom = self.index
elseif action == "fly" then
-- FLY opens the TOWN MAP with a cursor over the visited fly towns,
-- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly).
-- flyTo (OverworldController) validates the fly-warp + runs the
-- departure/warp, so we just hand it the chosen mapId (#195).
local ow = self.game.overworld
self.game.stack:pop() -- close the party menu
Screens.push(self.game, "FlyMenu")
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
if ow then ow:flyTo(mapId) end
end })
return
elseif action == "flash" then -- FLASH lights dark tunnels
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then
@@ -300,21 +316,15 @@ function PartyMenu:update(dt)
-- 1/5 of the user's max HP to a chosen teammate
self.softboiledFrom = self.index
elseif action == "escape" then
-- DIG / TELEPORT both warp to the last Pokémon Center town
-- (wLastBlackoutMap, special_warps.asm escape warp); .dig/.teleport
-- end with GBPalWhiteOutWithDelay3 + jp .goBackToMap
-- DIG / TELEPORT warp to the last Pokémon Center TOWN (wLastBlackoutMap,
-- special_warps.asm escape warp). pokered's .dig/.teleport spin the
-- player up (LeaveMapAnim), white/fade out, then land it; this port
-- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut
-- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
-- the exact departure; the fade + warp fire when the spin ends.
local ow = self.game.overworld
local heal = self.game.save.lastHeal
local Transition = require("src.render.Transition")
self.game.stack:pop()
if ow and heal then
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
require("src.core.Sound").play(self.game.data, "Teleport_Exit1")
-- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets
-- BIT_FLY_WARP); blackouts must not pass arrive="teleport"
ow:warpToHealPoint(nil, { arrive = "teleport" })
end))
end
if ow then ow:beginTeleportOut() end
return
end
self.submenu = nil
@@ -432,6 +442,38 @@ function PartyMenu:update(dt)
end
end
-- The bottom-of-screen context message for the current menu state
-- (pokered engine/menus/party_menu.asm PartyMenuMessage / RedrawPartyMenu_):
-- the party menu always prints a message in the bottom text box. With the
-- normal message id that is PartyMenuBattleText ("Bring out which POKéMON?")
-- when IsInBattle else PartyMenuNormalText ("Choose a POKéMON."); the swap /
-- item / TM-HM ids print their own strings, which draw() handles inline.
-- Pure (no side effects) so drivers can assert it. #147
function PartyMenu:bottomMessage()
if self.swapFrom then
return "Move to where?"
elseif self.softboiledFrom or self.pickOnly then
return "Use on which one?"
elseif self.tmhm then
return self.game.data.text._PartyMenuUseTMText
or "Use TM on which\nPOKéMON?"
elseif self.battle then
return self.game.data.text._PartyMenuBattleText
or "Bring out which\nPOKéMON?"
else
return self.game.data.text._PartyMenuNormalText
or "Choose a POKéMON."
end
end
-- Name-row pixel Y for party slot i (1-based).
-- pokered party_menu.asm RedrawPartyMenu_: hlcoord 3, 0, then each entry
-- advances 2*SCREEN_WIDTH (16 px). The bottom message box sits at tile
-- row 12 (y=96); slot 6's HP row is therefore at y=88. #262
function PartyMenu.entryY(i)
return (i - 1) * 16
end
function PartyMenu:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
@@ -443,9 +485,9 @@ function PartyMenu:draw()
local HudTiles = require("src.render.HudTiles")
for i, mon in ipairs(party) do
local def = self.game.data.pokemon[mon.species]
local y = (i - 1) * 16 + 12
local y = PartyMenu.entryY(i)
love.graphics.setColor(1, 1, 1, 1)
drawIcon(self.game, mon, 8, y - 2, i == self.index, self.blink or 0)
drawIcon(self.game, mon, 8, y, i == self.index, self.blink or 0)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(mon.nickname or def.name, 24, y)
-- level at column 13 (<LV> tile + digits, PrintLevel) AND the
@@ -458,16 +500,34 @@ function PartyMenu:draw()
-- PrintLevel overwrites the <LV> tile with the third digit
Font.draw(tostring(mon.level), 104, y)
end
if mon.hp <= 0 then
Font.draw("FNT", 136, y)
elseif mon.status then
Font.draw(mon.status, 136, y)
if self.tmhm then
-- TM/HM teaching menu (engine/menus/party_menu.asm PrintPartyMenu):
-- the second row shows the inline "ABLE" / "NOT ABLE" learnability
-- strings in place of the HP bar and status, decided by CanLearnTM.
-- The learnset scan mirrors ItemEffects.use so the display can never
-- disagree with the actual teach. #210
local can = false
for _, m in ipairs(def.tmhm or {}) do
if m == self.tmhm.move then can = true break end
end
-- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge
if can then
Font.draw("ABLE", 120, y + 8)
else
Font.draw("NOT ABLE", 88, y + 8)
end
else
if mon.hp <= 0 then
Font.draw("FNT", 136, y)
elseif mon.status then
Font.draw(mon.status, 136, y)
end
-- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor)
love.graphics.setColor(1, 1, 1, 1)
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
end
-- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor)
love.graphics.setColor(1, 1, 1, 1)
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
if i == self.index then
Font.drawCode(Theme.cursor, 0, y)
end
@@ -479,8 +539,34 @@ function PartyMenu:draw()
Font.draw("Move to where?", 8, 136)
elseif self.softboiledFrom then
Font.draw("Use on which one?", 8, 136)
elseif self.tmhm then
-- "Use TM on which\nPOKeMON?" in the standard bottom text box
-- (party_menu.asm keeps the message box for the TM/HM menu); box + line
-- geometry match TextBox's default (rows 12-17, text on rows 14/16). #210
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
local prompt = self.game.data.text._PartyMenuUseTMText
or "Use TM on which\nPOKéMON?"
local ly = 112
for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do
Font.draw(line, 8, ly)
ly = ly + 16
end
elseif self.pickOnly then
Font.draw("Use on which one?", 8, 136)
else
-- default field party menu (StartMenu) and the battle voluntary-switch
-- (BattleState:openParty): Gen1 prints PartyMenuNormalText / PartyMenuBattleText
-- in the standard bottom text box (party_menu.asm PartyMenuMessage), not
-- plain bottom-row text. Box + line geometry match the #210 TM/HM case and
-- TextBox's default (rows 12-17, text on rows 14/16). #147
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
local ly = 112
for line in (self:bottomMessage() .. "\n"):gmatch("([^\n]*)\n") do
Font.draw(line, 8, ly)
ly = ly + 16
end
end
if self.submenu then
local n = #self.subItems
+5 -2
View File
@@ -106,8 +106,11 @@ local function sell(game)
onChoose = function(item)
local def = game.data.items[item.value]
-- only key items and HMs are unsellable (pokemart.asm IsKeyItem /
-- IsItemHM); zero-price items like ETHER sell for ¥0
if (def and def.keyItem) or item.value:find("^HM_") then
-- IsItemHM); zero-price items like ETHER sell for ¥0. An unknown id
-- (nil def) has no price, so treat it as unsellable too rather than
-- indexing nil below -- guards saves that already picked up a bogus
-- ITEM_NONE "0" from Blue's House before that pickup was fixed (#11).
if not def or def.keyItem or item.value:find("^HM_") then
list.footer = txt(game, "_PokemartUnsellableItemText",
"I can't put a\nprice on that.")
return
+14 -5
View File
@@ -5,6 +5,13 @@
-- A on page 2) closes.
local Font = require("src.render.Font")
-- status_screen.asm PrintMonType prints the type's DISPLAY name from the
-- TypeNames table, not the constant: species types are stored as pokered
-- constants (RomExtractor:typesById) and PSYCHIC's is "PSYCHIC_TYPE" (so it
-- won't collide with the PSYCHIC move), which would overflow the TYPE field.
-- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the
-- battle move-type box already do (#214).
local TypeChart = require("src.battle.TypeChart")
local SummaryMenu = {}
SummaryMenu.__index = SummaryMenu
@@ -23,9 +30,11 @@ end
function SummaryMenu.new(game, mon)
local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu)
local def = game.data.pokemon[mon.species]
if def and def.spriteFront then
local ok, img = pcall(love.graphics.newImage, def.spriteFront)
local Sprites = require("src.pokemon.Sprites")
local path = Sprites.path(game.data, mon.species, "front",
{ mon = mon, kind = "summary" })
if path then
local ok, img = pcall(love.graphics.newImage, path)
self.sprite = ok and img or nil
end
require("src.core.Sound").playCry(game.data, mon.species)
@@ -99,10 +108,10 @@ function SummaryMenu:draw()
-- TYPE1/TYPE2/IDNo/OT column (10,9) with values indented (11,10)
drawLineBox(19, 9, 8, 6)
Font.draw("TYPE1/", 80, 72)
Font.draw(def.types[1] or "", 88, 80)
Font.draw(def.types[1] and TypeChart.displayName(def.types[1]) or "", 88, 80)
if def.types[2] then
Font.draw("TYPE2/", 80, 88)
Font.draw(def.types[2], 88, 96)
Font.draw(TypeChart.displayName(def.types[2]), 88, 96)
end
Font.draw("IDNo/", 80, 104)
-- the trainer ID is rolled at new game (SaveData.newGame) and
+3 -2
View File
@@ -117,8 +117,9 @@ function TitleState:currentSprite()
local species = self.cycleSpecies[self.cycleIndex]
local cached = self.sprites[species]
if cached == nil then
local def = self.game.data.pokemon[species]
cached = tryImage(def and def.spriteFront) or false
local path = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "title" })
cached = tryImage(path) or false
self.sprites[species] = cached
end
return cached or nil
+101 -10
View File
@@ -7,6 +7,11 @@
-- the selected name in a banner up top, and the player's current
-- location blinking. List mode (townMap data missing): up/down through
-- an ordered list of fly towns instead. B closes.
--
-- Fly mode (opts.fly + opts.onFly, LoadTownMap_Fly): the same Kanto map,
-- but the cursor cycles ONLY the visited fly destinations (Up/Down, in fly
-- order), the banner reads "To <NAME>", and A calls onFly(mapId) to depart.
-- This is what the party-menu FLY field move opens (#195).
local Font = require("src.render.Font")
local Sound = require("src.core.Sound")
@@ -80,7 +85,10 @@ local function buildLocations(game)
local seen = {}
for _, mapId in ipairs(field.flyOrder or {}) do
local def = game.data.maps and game.data.maps[mapId]
if not seen[mapId] and def and Map.isOutdoor(def) then
-- accept the PLATEAU tileset too so Indigo Plateau shows on the
-- stale-asset list fallback, matching the fly-list filter (#203)
if not seen[mapId] and def
and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
seen[mapId] = true
local loc = { name = mapId:gsub("_", " ") }
table.insert(locs, loc)
@@ -119,6 +127,42 @@ local function markerXY(loc)
return loc.x * 8 + 16, loc.y * 8 + 8
end
-- the row-0 name banner; fly mode prefixes "To " like LoadTownMap_Fly
-- (engine/menus/town_map.asm prints the destination as "To <NAME>")
function TownMap:bannerText(loc)
return (self.fly and "To " or "") .. loc.name
end
-- Fly mode selection set (engine/menus/town_map.asm LoadTownMap_Fly): the
-- cursor cycles ONLY the visited fly destinations, in fly order, each landing
-- on its town square. Built from field.flyOrder filtered to visited outdoor
-- towns that have a fly-warp spot, deduped, reusing the grid loc so the cursor
-- lands on the town and its name shows in the banner.
local function buildFlyList(game, byMap)
local field = game.data.field or {}
local visited = game.save.visited or {}
local flyWarps = field.flyWarps or {}
local Map = require("src.world.Map")
local flyLocs, flyMapIds, seen = {}, {}, {}
for _, mapId in ipairs(field.flyOrder or {}) do
local def = game.data.maps and game.data.maps[mapId]
-- INDIGO_PLATEAU is a normal Fly spot (engine/menus/town_map.asm
-- LoadTownMap_Fly cycles it like any town), but its map uses tileset
-- "PLATEAU" not OVERWORLD, so Map.isOutdoor() alone dropped it from the
-- cursor even though it is visited and has a fly warp. Allow PLATEAU here
-- while the CAVERN/FACILITY dungeon escape spots that share flyOrder still
-- fail the gate and stay out (#203).
if not seen[mapId] and visited[mapId] and flyWarps[mapId]
and def and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
seen[mapId] = true
local loc = byMap[mapId] or { name = mapId:gsub("_", " ") }
table.insert(flyLocs, loc)
flyMapIds[#flyLocs] = mapId
end
end
return flyLocs, flyMapIds
end
-- opts.nestSpecies: the Pokédex AREA screen (LoadTownMap_Nest) --
-- blink a nest icon on every map whose wild slots hold the species
function TownMap.new(game, opts)
@@ -152,6 +196,26 @@ function TownMap.new(game, opts)
or "assets/generated/townmap/nest.png")
self.nestIcon = ok and img or nil
end
if opts.fly then
-- FLY picker (LoadTownMap_Fly): restrict the selectable set to the
-- visited fly towns so Up/Down cycle only those and A knows the mapId.
local flyLocs, flyMapIds = buildFlyList(game, self.byMap)
if #flyLocs > 0 then
self.fly = true
self.onFly = opts.onFly
self.locs = flyLocs
self.flyMapIds = flyMapIds
-- grid rendering needs coords on every entry; without them fall back to
-- the name list so the fly screen still works on stale asset builds
if self.mode == "grid" then
for _, loc in ipairs(flyLocs) do
if not (loc.x and loc.y) then self.mode = "list" break end
end
end
end
-- with nothing visited yet there is nowhere to fly: leave self.fly unset
-- so the screen degrades to a plain viewer (B closes)
end
-- the player's current location (guard: overworld may not be running)
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
self.playerLoc = mapId and self.byMap[mapId] or nil
@@ -199,7 +263,19 @@ function TownMap:update(dt)
self.game.stack:pop()
return
end
if self.nestSpecies then
if self.fly then
-- LoadTownMap_Fly: Up/Down cycle the visited destinations, A flies there,
-- B cancels (handled above). moveList walks self.locs, now the fly list.
if input:wasPressed("a") then
Sound.play(self.game.data, "Press_AB")
local mapId = self.flyMapIds[self.sel]
self.game.stack:pop()
if mapId and self.onFly then self.onFly(mapId) end
return
elseif input:wasPressed("up") then self:moveList(-1)
elseif input:wasPressed("down") then self:moveList(1)
end
elseif self.nestSpecies then
if input:wasPressed("a") then
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
@@ -260,18 +336,28 @@ function TownMap:draw()
love.graphics.setColor(1, 1, 1, 1)
return
end
-- the player's current location blinks (slow phase)
-- the player's current location blinks (slow phase). Paint it with a
-- palette-safe DARK shade (red 0), not red: this screen composites through
-- the TOWNMAP SGB shade-remap shader (PaletteFX.shader), which keys ONLY on
-- the red channel, and a red-0.75 dot lands in the c1 bucket = TOWNMAP
-- {165,214,255}, the exact light-blue used for the water and the town-square
-- fill, so the marker was drawn but recolored invisible (#152). Red 0 -> c3
-- {25,16,16} = a solid dark "you are here" dot, visible on land and water.
if self.playerLoc and self.blink < 20 then
local x, y = markerXY(self.playerLoc)
love.graphics.setColor(0.75, 0.1, 0.1, 1)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
-- blinking cursor on the selected location
-- blinking cursor on the selected location. markerXY is the 8x8 cell's
-- top-left; the cursor asset is a 16x16 hollow frame centered on its own
-- (8,8), so draw it -4,-4 to enclose the cell (engine/menus/town_map.asm
-- draws the box cursor CENTERED on the selected location). Drawing it at
-- the cell top-left put the square in the frame's top-left quadrant (#152).
if selected and self.blink % 16 < 10 then
local x, y = markerXY(selected)
if self.bg.cursor then
love.graphics.draw(self.bg.cursor, x, y)
love.graphics.draw(self.bg.cursor, x - 4, y - 4)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", x + 0.5, y + 0.5, 7, 7)
@@ -281,7 +367,7 @@ function TownMap:draw()
-- the name strip on row 0 (DisplayTownMap: ClearScreenArea + name)
love.graphics.rectangle("fill", 0, 0, 160, 8)
love.graphics.setColor(0, 0, 0, 1)
if selected then Font.draw(selected.name, 8, 0) end
if selected then Font.draw(self:bannerText(selected), 8, 0) end
love.graphics.setColor(1, 1, 1, 1)
return
end
@@ -294,7 +380,9 @@ function TownMap:draw()
drawSquare(loc)
end
if self.playerLoc and self.blink < 20 then
love.graphics.setColor(0.75, 0.1, 0.1, 1)
-- palette-safe dark, same red-channel shade-remap reason as the primary
-- grid path above (#152); stale-asset builds hit this fallback square
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
self.playerLoc.y * 8 + 2, 4, 4)
end
@@ -317,7 +405,10 @@ function TownMap:draw()
end
Font.draw(loc.name, 24, y)
if loc == self.playerLoc and self.blink < 20 then
-- blinking marker on the player's current town
-- blinking marker on the player's current town; force the palette-safe
-- dark shade explicitly so the red-channel shade-remap keeps it
-- visible regardless of Font.draw's leftover color (#152)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 24 + #loc.name * 8 + 6, y + 2, 4, 4)
end
end
@@ -327,7 +418,7 @@ function TownMap:draw()
-- name banner across the top
Font.drawBox(0, 0, 20, 3)
love.graphics.setColor(0, 0, 0, 1)
if selected then Font.draw(selected.name, 8, 8) end
if selected then Font.draw(self:bannerText(selected), 8, 8) end
love.graphics.setColor(1, 1, 1, 1)
end
+3 -2
View File
@@ -48,8 +48,9 @@ local function dexOf(game, mon)
end
local function spriteOf(game, mon)
local def = game.data.pokemon[mon.species]
return tryImage(def and def.spriteFront)
local path = require("src.pokemon.Sprites").path(game.data, mon.species, "front",
{ mon = mon, kind = "trade" })
return tryImage(path)
end
local function expand(game, key, subs)
+10
View File
@@ -204,6 +204,16 @@ function Map:isWalkableCell(cx, cy)
end
function Map:isGrassCell(cx, cy)
-- Off-map cells never count as tall grass (issue #217). cellTile
-- border-extends out-of-bounds coordinates with the map's borderBlock,
-- and some border blocks (e.g. ROUTE_1's block 11) have the grass tile
-- ($52 = 82) in their bottom row -- filler scenery, never standable
-- grass. During a map-connection seam step crossConnection parks the
-- player one cell before the entry point (cellY = -1 crossing Viridian
-- City -> Route 1), so without this guard the feet-overdraw painted an
-- animated grass tuft over the player's head for the whole step. pokered
-- only ever reads $52 from loaded map tiles, not the border filler.
if not self:inBounds(cx, cy) then return false end
local grass = self.tileset.grassTile
return grass ~= nil and self:cellTile(cx, cy) == grass
end
+193 -23
View File
@@ -222,17 +222,31 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
self.map.renderer:rebuild()
self.cutBlocks[mapId] = nil
end
-- Silph Co card key doors: the .blk layouts ship with the doorways
-- open; each floor's map script stamps the closed door block on load
-- until its unlock event is set (scripts/SilphCo2F.asm
-- SilphCo2FGateCallbackScript et al., closed blocks $54/$5f/$20)
-- Silph Co card key doors + Rocket Hideout elevator gates: the .blk
-- layouts ship with the doorways open; each floor's map script stamps
-- the closed door block on load until its unlock event is set
-- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed
-- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm +
-- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over
-- the lift doorway). A door opens on its single `event`, or on `events`
-- when every listed flag must be set (Rocket Hideout B4F's lift gate
-- needs both guard trainers beaten -- CheckBothEventsSet).
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
"closedDoors")
local floorDoors = closedDoors and closedDoors[mapId]
if floorDoors then
local stamped = false
for _, door in ipairs(floorDoors) do
local want = Game.save.flags[door.event] and door.open or door.block
local open
if door.events then
open = true
for _, ev in ipairs(door.events) do
if not Game.save.flags[ev] then open = false break end
end
else
open = Game.save.flags[door.event]
end
local want = open and door.open or door.block
if self.map:blockAt(door.bx, door.by) ~= want then
self.map:setBlock(door.bx, door.by, want)
stamped = true
@@ -438,6 +452,35 @@ end
-- name -> name so the map.palette chain has a vanilla link to wrap
local function samePalette(name) return name end
local function sameTod(tod) return tod end
-- world.tod default: always DAY. A day/night mod returns "NIGHT",
-- "MORNING", etc.; the result is cached on the overworld and handed to
-- map.palette as ctx.tod so palette swaps can key off the period.
function OverworldState:timeOfDay()
local tod = self.tod or "DAY"
if not Runtime.wantsHook("world.tod") then return tod end
local map = self.map
local nextTod = Runtime.call("world.tod", sameTod, tod, {
map = map,
mapId = map and map.id,
x = self.player and self.player.cellX,
y = self.player and self.player.cellY,
steps = self.todSteps or 0,
})
if type(nextTod) ~= "string" or nextTod == "" then nextTod = tod end
if nextTod ~= tod then
self.tod = nextTod
if Runtime.wants("world.tod_changed") then
Runtime.emit("world.tod_changed", {
tod = nextTod, previous = tod, mapId = map and map.id,
})
end
else
self.tod = nextTod
end
return self.tod
end
function OverworldState:paletteNameFor(map)
local palettes = FieldDefaults.field(Game.data, "palettes")
@@ -456,8 +499,9 @@ function OverworldState:paletteNameFor(map)
name = (last and paletteLookup(palettes, last, lastDef and lastDef.tileset))
or palettes.default
end
local tod = self:timeOfDay()
if not Runtime.wantsHook("map.palette") then return name end
return Runtime.call("map.palette", samePalette, name, map)
return Runtime.call("map.palette", samePalette, name, map, { tod = tod })
end
-- UI-pass palette (text boxes and menus tint with the current map). OG RED
@@ -749,6 +793,27 @@ function OverworldState:update(dt)
end
end
-- Dig/Teleport/Escape-Rope departure spin (beginTeleportOut). The sprite
-- spins UP out of the map before the fade (player_animations.asm
-- _LeaveMapAnim -> PlayerSpinWhileMovingUp + SFX_TELEPORT_EXIT_1), the
-- mirror of Fly's flyAnim lead-in above. Only when the spin finishes does
-- warpToHealPoint push the fade + warp, so the arrival spin-down lands the
-- player OUTSIDE the last Pokemon Center door (#196). player.spinFrames
-- decrements in lockstep in Player:update, so the rising spin ends here too.
if self.teleportOut then
self.teleportOut.frames = self.teleportOut.frames - 1
if self.teleportOut.frames <= 0 then
local onDone = self.teleportOut.onDone
self.teleportOut = nil
self.player.spinning = false
self.player.spinFrames = nil
self.player.spinRise = nil
self.player.inputLocked = false
self:warpToHealPoint(onDone, { arrive = "teleport" })
return
end
end
-- delayed one-shot SFX (the teleport-in spin's second note)
if self.delaySfx then
self.delaySfx.frames = self.delaySfx.frames - 1
@@ -787,7 +852,7 @@ function OverworldState:update(dt)
-- escort then walks an extra tile before PlayerEntryMovementRLE, and
-- the player lands on desk Oak.
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
or self.engaging or self.emote
or self.engaging or self.emote or self.teleportOut
if not scripted and not self.transitioning then
self:checkTrainerSight()
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
@@ -795,7 +860,7 @@ function OverworldState:update(dt)
-- direction handling (JoypadOverworld runs the map script first) --
-- the player can never start another step after being spotted.
scripted = self.runner:isRunning() or #self.scriptMoves > 0
or self.engaging or self.emote
or self.engaging or self.emote or self.teleportOut
end
if not scripted and not self.transitioning then
self:handleInput()
@@ -854,6 +919,21 @@ function OverworldState:dirHeld()
or input:isDown("left") or input:isDown("right")
end
-- The warp cell the player WARPED IN on is inert until they physically step
-- off it: standing on it, or bonking a wall/edge from it, must not re-fire a
-- warp (CheckWarpsNoCollision's arrival-disable; see setMap where
-- warpEntryCell/justWarped are set and onStepComplete where they clear).
-- Both stand-still warp triggers -- the map-edge exit (checkEdgeExit) and the
-- blocked-step collision warp (handleInput) -- must consult this, or a corner
-- staircase whose warp tile sits on the map edge (Red's-house (7,1)) bounces
-- floors every input frame (issue #230).
function OverworldState:onWarpArrivalCell()
if self.justWarped then return true end
local entry = self.warpEntryCell
return entry ~= nil and self.player.cellX == entry.x
and self.player.cellY == entry.y
end
function OverworldState:handleInput()
local input = Game.input
@@ -875,10 +955,11 @@ function OverworldState:handleInput()
if self:checkBoulderPush(dir) then return end
end
local result, why = self.player:tryMove(dir, self.map, self.entities)
if result == "blocked" then
-- a collision while standing on a warp square fires the warp
-- when the extra check passes (CheckWarpsCollision: route-gate
-- doorways, dock entrances, ...)
-- a collision while standing on a warp square fires the warp when the
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
-- entrances, ...) -- but never on the inert cell we just warped in on
-- (issue #230), which the completed-step path guards the same way.
if result == "blocked" and not self:onWarpArrivalCell() then
local w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
self.player.cellX, self.player.cellY, dir)
if w then
@@ -1009,6 +1090,11 @@ function OverworldState:checkEdgeExit(dir)
local w = Warp.onEdge(self.map, p.cellX, p.cellY, dir)
if w then
-- ...but not while still standing on the warp cell we just arrived on
-- (issue #230): fall through so pushing into the edge bonks (SFX +
-- walk-in-place) instead of instantly re-warping. A real step onto an
-- exit-carpet edge cleared warpEntryCell first, so those still fire.
if self:onWarpArrivalCell() then return false end
self:takeWarp(w.def)
return true
end
@@ -1256,6 +1342,37 @@ function OverworldState:flyTo(mapId)
self.flyDest = { map = mapId, x = spot.x, y = spot.y }
end
-- Dig / Teleport / Escape Rope departure animation, then land OUTSIDE the
-- last Pokemon Center door like Fly (#196). pokered's _LeaveMapAnim
-- (engine/overworld/player_animations.asm) plays SFX_TELEPORT_EXIT_1 and
-- spins the player while it rises up off the map (PlayerSpinWhileMovingUp)
-- before the palettes fade; Fly's bird lead-in (flyTo/flyAnim) is the
-- analogous departure this mirrors. When the spin finishes (the teleportOut
-- countdown in OverworldState:update), warpToHealPoint pushes the fade + warp
-- with arrive="teleport" so the sprite spins back DOWN in front of the town
-- PC door. Shared by the party-menu DIG/TELEPORT action and BagMenu's
-- ESCAPE ROPE so all three animate identically.
function OverworldState:beginTeleportOut(onDone)
if not Game.save.lastHeal then
-- a save that has never visited a Pokemon Center has no heal point to
-- warp to; skip the animation entirely (matches the old guard that did
-- nothing when lastHeal was absent) instead of spinning into a nil warp
if onDone then onDone() end
return
end
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
self.player.surfing = false
self.player.inputLocked = true
-- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so
-- spinRise lifts the sprite (Player:pose) while spinFrames counts down
self.player.spinning = true
self.player.spinTimer = 0
self.player.spinFrames = 48
self.player.spinTotal = 48
self.player.spinRise = true
self.teleportOut = { frames = 48, onDone = onDone }
end
function OverworldState:npcAtCell(cx, cy)
for _, npc in ipairs(self.npcs) do
if (npc.cellX == cx and npc.cellY == cy) or
@@ -1474,7 +1591,17 @@ function OverworldState:tryHiddenObject(fx, fy)
-- Pokémon Center PCs and other PC tiles
for _, h in ipairs(extras.pcTiles[self.map.id] or {}) do
if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then
self:openPC()
if self.map.id == "REDS_HOUSE_2F" then
-- The player's bedroom PC is the one location in Red/Blue whose PC
-- callback is OpenRedsPC (engine/events/hidden_objects/players_pc.asm),
-- which runs the PlayerPC predef directly -- item storage, no
-- SOMEONE'S/BILL'S PC main menu (DisplayPCMainMenu). Every other
-- pcTile is a Pokémon Center-style PC that shows the multi-PC menu. (#228)
require("src.core.Sound").play(Game.data, "Turn_On_PC")
Screens.push(Game, "PlayerPC")
else
self:openPC()
end
return true
end
end
@@ -2007,8 +2134,12 @@ function OverworldState:talkTo(npc)
return
end
-- item balls (object_event item argument)
if d.item then
-- item balls (object_event item argument). A payload id of "0" is
-- pokered's ITEM_NONE sentinel: the ROM object sets the 0x80 "has item"
-- bit but names item 0, so it is a plain text object, not an item ball
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
-- the string "0" as truthy, so screen it out and fall through to text.
if d.item and d.item ~= "0" and d.item ~= 0 then
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
Game.stack:push(TextBox.new(Game, "You can't carry\nany more items!"))
return
@@ -2680,11 +2811,18 @@ end
function OverworldState:onStepComplete()
local p = self.player
self.todSteps = (self.todSteps or 0) + 1
-- re-evaluate day/night so a step-based clock can fire world.tod_changed;
-- paletteNameFor reads self.tod on the next paint
if Runtime.wantsHook("world.tod") then
self:timeOfDay()
end
-- hot path: the payload is only built when something is listening
if Runtime.wants("world.stepped") then
Runtime.emit("world.stepped", { mapId = self.map.id, x = p.cellX, y = p.cellY,
tile = self.map:cellTile(p.cellX, p.cellY) })
tile = self.map:cellTile(p.cellX, p.cellY),
tod = self.tod })
end
-- dismounting a surf: landing on a walkable cell ends it
@@ -3156,7 +3294,9 @@ function OverworldState:afterBattle(result, battle)
lead and lead.stats.hp or 0)
local Evolution = require("src.pokemon.Evolution")
local function evolutions()
Evolution.checkParty(Game)
-- Only mons that gained a level this battle (EXP.ALL included).
-- Scanning the whole party re-offered B-cancelled evolutions forever (#213).
Evolution.checkParty(Game, nil, battle and battle.leveledUp)
end
if result == "lose" then
local oaksLabRival = battle and battle.oppClass == "OPP_RIVAL1"
@@ -3257,11 +3397,32 @@ function OverworldState:warpToHealPoint(onDone, opts)
-- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear
-- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm)
Game.save.forcedBike = nil
if opts and opts.arrive == "teleport" then
local map, x, y = heal.map, heal.x, heal.y
local teleport = opts and opts.arrive == "teleport"
if teleport then
self.arriveWarp = "teleport"
-- Dig/Teleport/Escape Rope land OUTSIDE at the last Pokemon Center TOWN
-- door, like Fly (#196) -- NOT the interior heal cell a blackout returns
-- to. pret routes escape-warp and blackout both through wLastBlackoutMap
-- (both appear inside in front of the nurse), but this port has decided
-- the escape-warp destination is the town PC door. Prefer the canonical
-- Fly landing (field.flyWarps, one tile south of the PC door warp), else
-- the remembered outdoor door cell; fall back to the interior heal cell
-- only for an old save with no recorded outdoor.
local out = heal.outdoor
if out then
local fw = (Game.data.field.flyWarps or {})[out.id]
map = out.id
x = fw and fw.x or out.x
y = fw and fw.y or out.y
end
end
self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone)
if heal.outdoor then
self:startWarpTo(map, x, y, "down", onDone)
-- Blackouts land at the interior heal cell, so re-point LAST_MAP exits at
-- the remembered town door. The teleport branch already lands ON that
-- outdoor map, so startWarpTo remembers it on the next exit; re-pointing
-- here would wrongly steer exits away from where the player now stands.
if heal.outdoor and not teleport then
self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y)
end
end
@@ -3323,10 +3484,19 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
-- outdoor. Auto-walk leaves the mat, so the arrival disable
-- (warpEntryCell / justWarped) is unnecessary -- and would let you
-- stand on the door without re-entering if you hold back into it.
-- The walk-out is a simulated d-pad press (wSimulatedJoypadStates),
-- not a forced move, so it obeys collision: on a landing with a
-- solid cell south of the door (the mansion stair landings back
-- onto shelves) the step bumps and the player stays on the door,
-- arrival disable intact, instead of clipping into the wall.
if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then
self.warpEntryCell = nil
self.justWarped = false
self:scriptMove(self.player, "down", 1)
if Collision.canMove(self.map, self.entities, self.player, "down") then
self.warpEntryCell = nil
self.justWarped = false
self:scriptMove(self.player, "down", 1)
else
self.player.facing = "down"
end
end
end
end, function()
+50 -5
View File
@@ -4,6 +4,7 @@
local Collision = require("src.world.Collision")
local FieldDefaults = require("src.world.FieldDefaults")
local Runtime = require("src.mods.Runtime")
local SpriteRenderer = require("src.render.SpriteRenderer")
local Player = {}
@@ -61,21 +62,43 @@ function Player:tryMove(dir, map, entities)
if self.facing ~= dir then
self.facing = dir
self.turnTimer = self.turnFrames or TURN_FRAMES
self.bumpFrames = nil -- turning to a new facing ends any wall-bonk cycle
return "turned"
end
if self.turnTimer > 0 then return nil end
local ok, why = Collision.canMove(map, entities, self, dir)
if not ok then
-- Gen1: a blocked step still animates the player walking in place --
-- the collision path spends the step's worth of frames running
-- UpdateSprites before returning control, so the legs cycle without
-- the cell changing (home/overworld.asm collision handling; issue
-- #230). Re-armed every frame the direction is held into the wall;
-- Player:update ticks the walk clock while it counts down, so releasing
-- returns to the standing pose within a step's length.
self.bumpFrames = self.stepFrames or STEP_FRAMES
return "blocked", why
end
local tx, ty = Collision.target(self.cellX, self.cellY, dir)
self.targetX, self.targetY = tx, ty
self.moving = true
self.bumpFrames = nil -- a real step supersedes any in-place bonk
self.progress = 0
-- the bicycle doubles walking speed (8 frames per step)
local save = require("src.core.Game").save
self.stepFramesCur = (save and save.onBike) and self.bikeStepFrames
or self.stepFrames or STEP_FRAMES
-- the bicycle doubles walking speed (8 frames per step); movement.speed
-- lets a mod multiply or replace that (running shoes, dash, etc.)
local Game = require("src.core.Game")
local save = Game.save
local frames = (save and save.onBike) and self.bikeStepFrames
or self.stepFrames or STEP_FRAMES
if Runtime.wantsHook("movement.speed") then
frames = Runtime.call("movement.speed", function(f) return f end, frames, {
onBike = save and save.onBike or false,
surfing = self.surfing and true or false,
player = self,
input = Game.input,
save = save,
})
end
self.stepFramesCur = math.max(1, math.floor(tonumber(frames) or STEP_FRAMES))
return "moved"
end
@@ -97,9 +120,19 @@ function Player:update()
if self.spinFrames <= 0 then
self.spinFrames = nil
self.spinDrop = nil
self.spinRise = nil -- teleport-out departure lift (#196)
self.spinning = false
end
end
-- wall-bonk walk-in-place (issue #230): while pushing into a wall the
-- collision path keeps the walk clock running without moving the cell,
-- so the sprite animates against the wall. Guarded on not-moving so a
-- real step (which clears bumpFrames and advances animClock itself
-- below) can never double-tick the leg cadence.
if not self.moving and self.bumpFrames and self.bumpFrames > 0 then
self.bumpFrames = self.bumpFrames - 1
self.animClock = (self.animClock or 0) + 1
end
if not self.moving then return false end
local stepLen = self.stepFramesCur or self.stepFrames or STEP_FRAMES
self.progress = self.progress + 1
@@ -132,7 +165,12 @@ function Player:facingCell()
end
function Player:walkPhase()
if not self.moving and not self.stepLanded then return 0 end
-- moving, the land-frame after a completed step, or an active wall-bonk
-- (issue #230) animate; a standing sprite otherwise
if not self.moving and not self.stepLanded
and not (self.bumpFrames and self.bumpFrames > 0) then
return 0
end
-- walk frame during the middle of each 16-frame animation cycle
local p = (self.animClock or self.progress) % 16
return (p >= 4 and p < 12) and 1 or 0
@@ -183,6 +221,13 @@ function Player:pose()
-- (EnterMapAnim PlayerSpinWhileMovingDown)
if self.spinFrames and self.spinDrop then
py = py - math.floor(self.spinFrames * 24 / (self.spinTotal or 64))
elseif self.spinFrames and self.spinRise then
-- Dig/Teleport/Escape-Rope departures spin the sprite UP out of the
-- map before the fade (LeaveMapAnim PlayerSpinWhileMovingUp) -- the
-- mirror of the arrival spin-down: the lift grows from 0 as spinFrames
-- counts down to 0 (#196), opposite sign to spinDrop above.
local total = self.spinTotal or 64
py = py - math.floor((total - self.spinFrames) * 24 / total)
end
end
local sprite = (self.surfing and self.surfSprite)
@@ -0,0 +1,173 @@
-- Driver: reproduce #216 - "Boosted EXP text cut off".
--
-- A traded mon's EXP gain prints a 3-line message:
-- "<mon> gained\na boosted\v<N> EXP. Points!"
-- (engine/battle/experience.asm _GainedText/_BoostedText/_ExpPointsText;
-- _BoostedText ends in the CONT code \v = char 11, "a boosted\011" in the
-- extracted data/generated/text.lua). The in-battle message box only has
-- room for two lines (rows y=112 and y=128), so before the fix the third
-- line was drawn at y=144 -- one row past the 160x144 screen -- and was
-- never seen: a single A press dismissed the whole message and the wild win
-- popped straight back to the overworld, so the boosted amount was invisible.
--
-- Gen1-correct behavior (home/text.asm ContText): the box scrolls a 2-line
-- window, waiting for A/B on the \v (drawing the blinking ▼ arrow), then
-- scrolls "a boosted" up to the top row and types "<N> EXP. Points!" on the
-- bottom row (y=128) -- a VISIBLE row.
--
-- The driver builds a deterministic traded RATTATA, KOs a weak wild PIDGEY
-- with no level-up, then renders the battle into a clean offscreen canvas
-- while capturing Font.drawCode, and asserts the encoded "EXP. Points!"
-- glyph run lands on a visible row (y <= 130), not off-screen at y=144.
-- Fails on the buggy build (amount only ever drawn at y=144); passes once
-- the box scrolls.
--
-- Run:
-- SHOT_DIR=/tmp/bug216 POKEPORT_IDENTITY=bug216 POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/battle_boosted_exp_bug216_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Growth = require("src.pokemon.Growth")
local Font = require("src.render.Font")
local BattleState = require("src.battle.BattleState")
-- deterministic traded RATTATA :L8; exp pinned to the L8 floor so a single
-- weak wild KO (~22 boosted exp) stays below the L9 threshold (no level-up
-- to muddy the message). DVs pinned to max so numbers are stable per run.
local rattata = Pokemon.new(game.data, "RATTATA", 8, function(_, b) return b end)
local def = game.data.pokemon.RATTATA
rattata.exp = Growth.expForLevel(def.growthRate, 8, game.data.growth_rates)
rattata.traded = true -- the flag real trades/link set (Commands.lua / Protocol.lua)
game.save.party = { rattata }
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
-- weak enemy: 1 HP so the first move KOs, speed 1 so RATTATA always moves
-- first, rng pinned low so every accuracy roll hits (Damage.accuracyRoll:
-- rng(0,255) < acc)
local battle = BattleState.newWild(game, "PIDGEY", 2)
battle.onFinish = function() end
battle.rng = function(a, _) return a end
battle.enemy.mon.hp = 1
battle.enemy.mon.stats.speed = 1
ow:pushBattle(battle)
-- Render the battle into a clean offscreen 160x144 canvas while recording
-- every Font.drawCode(code, x, y). Returns true iff `targetCodes` appear
-- as a contiguous left-to-right run on a row at y <= maxY. The off-screen
-- amount row (y=144) is excluded, so the buggy build never counts as
-- "shown". BattleState:draw runs its own canvas pipeline internally and
-- restores the caller's canvas (see battle_hpbar_gbc_bug229_test.lua).
local canvas = love.graphics.newCanvas(160, 144)
local function seqVisible(targetCodes, maxY)
local rec = {}
local orig = Font.drawCode
Font.drawCode = function(code, x, y)
rec[#rec + 1] = { code, x, y }
return orig(code, x, y)
end
love.graphics.setCanvas(canvas)
love.graphics.clear(0, 0, 0, 1)
love.graphics.setColor(1, 1, 1, 1)
local okDraw = pcall(function() battle:draw() end)
love.graphics.setCanvas()
Font.drawCode = orig
if not okDraw then return false end
-- group glyphs by row, sort left-to-right, join codes into a delimited
-- string, and search each visible row for the target subsequence
local byY = {}
for _, g in ipairs(rec) do
if g[3] <= maxY then
byY[g[3]] = byY[g[3]] or {}
table.insert(byY[g[3]], g)
end
end
local tparts = {}
for _, c in ipairs(targetCodes) do tparts[#tparts + 1] = tostring(c) end
local needle = "," .. table.concat(tparts, ",") .. ","
for _, list in pairs(byY) do
table.sort(list, function(a, b) return a[2] < b[2] end)
local cs = {}
for _, g in ipairs(list) do cs[#cs + 1] = tostring(g[1]) end
if ("," .. table.concat(cs, ",") .. ","):find(needle, 1, true) then
return true
end
end
return false
end
local amountCodes = Font.encode("EXP. Points!")
-- mash through the intro to the FIGHT menu, then FIGHT -> move slot 1
-- (TACKLE) -> KO (mirrors battle_levelup_hpbar_bug224_test.lua)
for _ = 1, 300 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(3)
end
if battle.phase ~= "menu" then error("bug216: never reached the FIGHT menu") end
U.tap(game, "a") -- FIGHT
for _ = 1, 60 do
if battle.phase == "moveSelect" then break end
U.wait(1)
end
if battle.phase ~= "moveSelect" then error("bug216: never reached move select") end
U.tap(game, "a") -- TACKLE -> KO
-- Phase A: advance to the boosted-EXP message (contains both "boosted"
-- and "EXP. Points!"). Break the frame it becomes current, before any A
-- press could scroll past it.
local function isBoosted()
local c = battle.current
return c and c.text and c.text:find("EXP. Points!", 1, true)
and c.text:find("boosted", 1, true)
end
local reached = false
for _ = 1, 900 do
if isBoosted() then reached = true break end
if game.stack:top() ~= battle then break end
U.tap(game, "a")
U.wait(2)
end
if not reached then
error("bug216: never reached the boosted-EXP message (battle ended early)")
end
U.log("boosted message: " ..
(tostring(battle.current.text):gsub("[\n\v]", "|")))
-- let the first two lines type out, then capture the cut-off
-- "gained / a boosted" box (both builds show this; the buggy build has no
-- visible third row)
U.wait(40)
U.shot(game, DIR .. "/bug216_boosted.png")
-- Phase B: the amount must become visible on a real row. Each iteration
-- renders offscreen and checks; then taps A -- which scrolls the CONT
-- window on the fixed build, and (once fully typed) dismisses the message
-- on the buggy build.
local shown = false
for _ = 1, 120 do
if seqVisible(amountCodes, 130) then shown = true break end
if game.stack:top() ~= battle then break end
U.tap(game, "a")
U.wait(4)
end
if not shown then
error("bug216: 'EXP. Points!' amount never shown on a visible row " ..
"(drawn off-screen at y=144); the box did not scroll to the amount")
end
U.shot(game, DIR .. "/bug216_amount.png")
-- no level-up muddied the message
if battle.player.mon.level ~= 8 then
error("bug216: expected level 8, got " .. tostring(battle.player.mon.level))
end
U.log("bug216 OK: boosted EXP amount shown on a visible row, level still 8")
U.wait(4)
end
@@ -0,0 +1,104 @@
-- Regression test for issue #229 ("GSC palette black full health").
--
-- In RED++ ("Gen 2 / GSC-style") COLORS mode the full-health (green-band)
-- in-battle HP bar rendered as a solid black rectangle. Root cause is in
-- src/render/HudTiles.lua drawHPBar: it pre-tinted the fill with GREENBAR's
-- fill color {0,189,0} (red channel 0), zeroing the red channel of every bar
-- pixel; the battle zone shade-remap shader (PaletteFX.shader, keyed ONLY on
-- the red channel) then mapped every zeroed-red pixel to color 3 = black.
-- Red/orange bands survived because REDBAR {247,0,0} / YELLOWBAR {247,165,0}
-- keep a nonzero red channel. SGB ('gbc') mode was unaffected because
-- PaletteFX.pack({}) returns nil there, so the fill was already drawn gray.
--
-- Gen1-correct behavior: the DMG hardware bar is one gray shade recolored by
-- the SGB region palette (home/pokemon.asm DrawHPBar + engine/gfx/palettes.asm
-- SetPal_Battle + data/sgb/sgb_packets.asm BlkPacket_Battle), never a per-pixel
-- repaint. This driver forces RED++, enters a full-HP wild battle, screenshots
-- the intro + action menu, then renders the battle into a clean 160x144 canvas
-- and asserts the player AND enemy HP-bar fill bands are green (not black).
--
-- Run:
-- SHOT_DIR=/tmp/bug229 POKEPORT_IDENTITY=bug229 POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/battle_hpbar_gbc_bug229_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "."
local PaletteFX = require("src.render.PaletteFX")
-- Set the SAVED option, not just the live mode: Game:applyOptions re-reads
-- save.options.colors, so a bare setMode would get reverted to the default.
game.save.options = game.save.options or {}
game.save.options.colors = "redpp"
PaletteFX.setMode("redpp")
-- BULBASAUR :L5 is 19/19 -> full HP -> green band (matches the issue shot).
local Pokemon = require("src.pokemon.Pokemon")
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 5) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(game, "RATTATA", 3) -- full HP -> green
battle.onFinish = function() end
ow:pushBattle(battle)
U.wait(220)
U.shot(game, DIR .. "/bug229_redpp_intro.png")
-- Advance the intro text to the action menu deterministically (phase flips
-- to "menu" at BattleState:1111/1243); stop before a menu tap enters FIGHT.
for _ = 1, 40 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(6)
end
U.wait(4)
U.shot(game, DIR .. "/bug229_redpp_menu.png")
-- Programmatic assertion: render the battle into a clean offscreen 160x144
-- canvas -- BattleState:draw runs its own SGB zone pass internally, so this
-- is the real colorized output (verified pixel-identical to the on-screen
-- capture). Sample the CENTER fill rows of each bar (an 8px bar tile is
-- white frame rows / fill rows / white frame rows, so the middle rows are
-- the fill). Before the fix the fill is ~ (0,0,0); after the fix it is
-- ~ (0,0.74,0) (GREENBAR fill {0,189,0}/255).
if love and love.graphics and love.graphics.newCanvas and battle.phase == "menu" then
local canvas = love.graphics.newCanvas(160, 144)
love.graphics.setCanvas(canvas)
love.graphics.clear(0, 0, 0, 1)
love.graphics.setColor(1, 1, 1, 1)
battle:draw()
love.graphics.setCanvas()
local id = canvas:newImageData()
do local dbg = id:encode("png"); local f = io.open(DIR .. "/bug229_offscreen.png", "wb"); if f then f:write(dbg:getString()); f:close() end end
local function green(r, g, b) return g > 0.4 and g > r and g > b end
-- returns worst (lowest-green) fill pixel across the center rows sampled
local function worstFill(x, ys)
local wr, wg, wb = 1, 1, 1
for _, y in ipairs(ys) do
local r, g, b = id:getPixel(x, y)
if g < wg then wr, wg, wb = r, g, b end
end
return wr, wg, wb
end
-- player bar: drawHPBar tile (10,9) -> fill x 96..143; fill rows y 74..77
local pr, pg, pb = worstFill(120, { 75, 76 })
-- enemy bar: drawHPBar tile (2,2) -> fill x 32..79; fill rows y 18..21
local er, eg, eb = worstFill(56, { 19, 20 })
U.log(string.format("player fill rgb = %.2f %.2f %.2f", pr, pg, pb))
U.log(string.format("enemy fill rgb = %.2f %.2f %.2f", er, eg, eb))
if not (green(pr, pg, pb) and green(er, eg, eb)) then
error(string.format(
"issue #229: RED++ HP bar fill not green (player %.2f/%.2f/%.2f enemy %.2f/%.2f/%.2f)",
pr, pg, pb, er, eg, eb))
end
U.log("issue #229 PASS: green HP bar fill in RED++")
else
error("issue #229 driver: never reached the battle action menu")
end
U.wait(4)
end
@@ -0,0 +1,125 @@
-- Driver: reproduce #224 - "Level up health bar inches down".
--
-- On a level-up during battle, Gen 1 (engine/battle/experience.asm) raises
-- the mon's current HP by (newMaxHP - oldMaxHP) and redraws the active
-- battler's HP bar UP to reflect the higher current HP. Our data layer is
-- correct (Experience.lua:84 applies the current-HP delta), but the on-screen
-- numerator - the battler's shownHP (the value the HUD bar/number use) - was
-- never advanced on level-up, while the denominator (mon.stats.hp) jumped
-- instantly. So the drawn fill FRACTION fell (e.g. 8/20 -> 8/22) instead of
-- rising to 10/22.
--
-- Setup: a SQUIRTLE at L5 with 8/oldMax HP and exp one point below the level-6
-- threshold; a single wild KO crosses it. The driver asserts the player HP
-- bar's shownHP rises to the new current HP DURING the level-up messages
-- (before the menu-phase safety net at BattleState.lua:1099 would mask it).
-- Fails on the buggy build (shownHP stuck at 8); passes once fixed.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Growth = require("src.pokemon.Growth")
local BattleState = require("src.battle.BattleState")
-- deterministic mon: pin DVs to the max so the HP numbers are identical
-- across the before/after runs (Stats.randomDVs consumes rng(0,15))
local squirtle = Pokemon.new(game.data, "SQUIRTLE", 5, function(_, b) return b end)
local def = game.data.pokemon.SQUIRTLE
local oldHP = 8
squirtle.hp = oldHP -- partly depleted so the bar is well under full
-- one point below the level-6 exp threshold: a single kill levels up once
squirtle.exp = Growth.expForLevel(def.growthRate, 6, game.data.growth_rates) - 1
game.save.party = { squirtle }
local oldMax = squirtle.stats.hp
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
-- weak enemy: 1 HP so the first TACKLE KOs; speed pinned to 1 so SQUIRTLE
-- always moves first (no enemy turn to muddy the HP math); rng pinned to
-- the low end so every roll hits (Damage.accuracyRoll: rng(0,255) < acc)
local battle = BattleState.newWild(game, "SLOWPOKE", 2)
battle.onFinish = function() end
battle.rng = function(a, _) return a end
battle.enemy.mon.hp = 1
battle.enemy.mon.stats.speed = 1
ow:pushBattle(battle)
-- mash through the intro to the FIGHT menu
for _ = 1, 240 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(3)
end
if battle.phase ~= "menu" then error("bug224: never reached the FIGHT menu") end
-- HP box at L5: the menu safety net snaps shownHP to mon.hp, so 8/oldMax
U.shot(game, DIR .. "/bug224_menu.png")
-- FIGHT -> first move (TACKLE, slot 1) -> KO
U.tap(game, "a") -- FIGHT
for _ = 1, 60 do
if battle.phase == "moveSelect" then break end
U.wait(1)
end
if battle.phase ~= "moveSelect" then error("bug224: never reached move select") end
U.tap(game, "a") -- TACKLE
-- Monitor the level-up messages. The bar must animate from oldHP toward
-- the new current HP while phase == 'messages' (before finish/menu-snap).
local maxShown = oldHP
local caughtUp = false
local shotGrew, shotRisen = false, false
for _ = 1, 600 do
U.wait(1)
U.tap(game, "a") -- advance text / dismiss the stat box (never skips the
-- time-based drain), so we reach the HP-bar redraw
if battle.phase == "messages" and battle.player.mon.level >= 6 then
local sh = battle.player.shownHP or battle.player.mon.hp
maxShown = math.max(maxShown, sh)
if not shotGrew then
-- first L6 messages frame: buggy build already shows the shrunk bar
-- (8/newMax) here, and it never recovers
U.shot(game, DIR .. "/bug224_grew.png")
shotGrew = true
end
if not shotRisen and sh >= oldHP + 1.0 then
-- the bar has climbed at least a full HP: capture it mid-rise
U.shot(game, DIR .. "/bug224_hpbar.png")
shotRisen = true
end
if sh >= squirtle.hp - 0.5 then
caughtUp = true
break
end
end
if game.stack:top() ~= battle then break end -- battle finished/popped
end
-- data-layer sanity (Experience.lua): one level gained, max HP grew, and
-- current HP rose by the max-HP delta
if battle.player.mon.level ~= 6 then
error("bug224: expected level 6, got " .. tostring(battle.player.mon.level))
end
local newMax = squirtle.stats.hp
if not (newMax > oldMax) then
error("bug224: max HP did not grow (oldMax=" .. tostring(oldMax) ..
", newMax=" .. tostring(newMax) .. "); repro is meaningless")
end
local expectHP = math.min(newMax, oldHP + (newMax - oldMax))
if squirtle.hp ~= expectHP then
error("bug224: current HP wrong: got " .. tostring(squirtle.hp) ..
", expected " .. tostring(expectHP))
end
-- THE BUG: the on-screen bar must have risen to the new current HP during
-- the messages phase. On the buggy build shownHP stays stuck at oldHP.
if not caughtUp then
error("bug224: HP bar did not rise on level-up - shownHP stuck at " ..
tostring(maxShown) .. " (oldHP=" .. tostring(oldHP) ..
"), mon.hp=" .. tostring(squirtle.hp) .. "/" .. tostring(newMax))
end
U.log("bug224 OK: L6, HP " .. tostring(squirtle.hp) .. "/" .. tostring(newMax) ..
", bar rose from " .. tostring(oldHP) .. " to " .. tostring(maxShown))
end
@@ -0,0 +1,150 @@
-- Regression test for issue #207 ("Back of sprite only showing outline").
--
-- In the forced-mono display modes (OG / OG INV / CLASSIC) a warm-palette
-- mon's battle pic (e.g. CHARMANDER, SGB palette REDMON) rendered as a bare
-- black outline on white, its two mid shades gone, while cool-palette mons
-- (SQUIRTLE, CYANMON) kept full shading. Root cause is a double shade-remap:
-- BattleState bakes each pic ONCE with the species' SGB colors, then draws it
-- onto the UI canvas; in these modes BattleState exposes no SGB zones, so
-- Renderer:endFrame invents a whole-screen GRAYS zone (PaletteFX.ensureZones)
-- and runs the ENTIRE colored battle frame through PaletteFX.shader() a SECOND
-- time. That shader keys the DMG shade off the red channel
-- (r>0.83?c0:r>0.5?c1:r>0.17?c2:c3); REDMON's two mid shades have red 1.0 and
-- 0.839 -- BOTH > 0.83 -- so they collapse into shade 0 (the white paper),
-- leaving only the near-black outline as shade 3. CYANMON's reds (0.678,
-- 0.451) land in the c1/c2 buckets, which is why blue mons were unaffected.
--
-- Gen1-correct behavior: mon pics are 2bpp 4-shade tiles (gfx/pokemon/back,
-- gfx/pokemon/front); all four shades must be visible, and the same grayscale
-- palette that renders SQUIRTLE renders CHARMANDER. The fix draws the pics as
-- raw DMG grays in these modes so the whole-screen remap recolors 255->c0,
-- 170->c1, 85->c2, 0->c3 -- all four shades survive.
--
-- This driver forces OG, gives the player a CHARMANDER (the failing warm
-- palette), enters a wild SQUIRTLE battle, and screenshots the exact action
-- menu the reporter shows. The assertion replays the on-screen two-stage
-- pipeline into a clean 160x144 canvas (BattleState:draw, then the whole-screen
-- GRAYS remap) and asserts the player CHARMANDER back-pic interior contains
-- mid-gray shades (not outline-only), with the enemy SQUIRTLE front interior as
-- an always-passing control.
--
-- Run:
-- SHOT_DIR=/tmp/bug207 POKEPORT_IDENTITY=bug207 POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/battle_mono_sprite_bug207_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "."
local PaletteFX = require("src.render.PaletteFX")
-- Set the SAVED option, not just the live mode: Game:applyOptions re-reads
-- save.options.colors, so a bare setMode would get reverted to the default.
game.save.options = game.save.options or {}
game.save.options.colors = "og"
PaletteFX.setMode("og")
-- CHARMANDER :L5 -- SGB palette REDMON, the warm palette that collapsed.
local Pokemon = require("src.pokemon.Pokemon")
game.save.party = { Pokemon.new(game.data, "CHARMANDER", 5) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(game, "SQUIRTLE", 5)
battle.onFinish = function() end
ow:pushBattle(battle)
U.wait(220)
U.shot(game, DIR .. "/bug207_og_intro.png")
-- Advance the intro text to the action menu deterministically (phase flips
-- to "menu" once "Go! CHARMANDER!" has swapped in the species back pic).
for _ = 1, 40 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(6)
end
U.wait(4)
-- The reporter's exact screen: the FIGHT/PKMN/ITEM/RUN action menu with the
-- player's CHARMANDER back pic fully visible bottom-left.
U.shot(game, DIR .. "/bug207_og_menu.png")
if not (love and love.graphics and love.graphics.newCanvas
and battle.phase == "menu") then
error("issue #207 driver: never reached the battle action menu")
end
-- Replay the on-screen forced-mono pipeline into an offscreen 160x144 canvas.
-- Stage 1 is BattleState's own colorized frame (its internal SGB zone pass
-- plus the mon pics drawn via picImage). Stage 2 is Renderer:endFrame's
-- whole-screen remap for a state with no SGB zones: ensureZones -> whole(GRAYS),
-- then blit sends GRAYS through the shade shader over the FULL frame. This is
-- pixel-faithful to the presented window (the U.shot captures above), but in
-- clean 160x144 canvas space so the sample boxes are resolution-independent.
local g = love.graphics
local shader = PaletteFX.shader()
local prev = g.getCanvas()
local a = g.newCanvas(160, 144)
local b = g.newCanvas(160, 144)
g.setCanvas(a)
g.clear(1, 1, 1, 1) -- battle letterbox is white (letterboxWhite)
g.setColor(1, 1, 1, 1)
battle:draw()
g.setCanvas(b)
g.clear(0, 0, 0, 1)
g.setShader(shader)
PaletteFX.sendColors(shader, PaletteFX.GRAYS)
g.setColor(1, 1, 1, 1)
g.draw(a, 0, 0)
g.setShader()
g.setCanvas(prev)
local id = b:newImageData()
do
local png = id:encode("png")
local f = io.open(DIR .. "/bug207_og_offscreen.png", "wb")
if f then f:write(png:getString()); f:close() end
end
-- Count mid-gray pixels in a box. The forced-mono frame is neutral gray:
-- shade 0 = white (~1.0), shade 3 = black (~0.0), and the two MID shades are
-- ltgray (170/255 = 0.667) and dkgray (85/255 = 0.333). A mid shade exists
-- only when all four DMG shades survived the remap; an outline-only pic has
-- pure white + pure black and zero mid pixels.
local function midCount(x0, y0, x1, y1)
local n = 0
for y = y0, y1 do
for x = x0, x1 do
local r = id:getPixel(x, y)
if r > 0.18 and r < 0.82 then n = n + 1 end
end
end
return n
end
-- Player CHARMANDER back pic: hlcoord 1,5 (x=8), feet at y=96 -- interior
-- box well inside the body, clear of the white matte and the near-black
-- outline, and clear of the bottom-right HUD/HP bar.
local backMid = midCount(12, 50, 52, 92)
-- Enemy SQUIRTLE front pic: 7x7 slot at hlcoord 12,0 (x~96) -- control that
-- always keeps its mid shades (CYANMON reds land in the c1/c2 buckets).
local enemyMid = midCount(104, 8, 148, 48)
U.log(string.format("player back mid-gray px = %d ; enemy front mid-gray px = %d",
backMid, enemyMid))
if enemyMid <= 20 then
error(string.format(
"issue #207 driver: control failed -- enemy SQUIRTLE front has no "
.. "mid-gray (%d); sample box or pipeline is wrong", enemyMid))
end
if backMid <= 20 then
error(string.format(
"issue #207: OG-mode CHARMANDER back pic is outline-only -- interior "
.. "has %d mid-gray px (expected the full 4-shade grayscale, like the "
.. "enemy front's %d)", backMid, enemyMid))
end
U.log(string.format(
"issue #207 PASS: OG-mode CHARMANDER back keeps its mid shades "
.. "(%d mid-gray px, control enemy %d)", backMid, enemyMid))
U.wait(4)
end
+115
View File
@@ -0,0 +1,115 @@
-- Driver: issue #11 -- Blue's House wall Town Map phantom pickup + the
-- sell crash it causes.
--
-- In pokered the framed Town Map on the wall of Blue's House
-- (data/maps/objects/BluesHouse.asm BLUESHOUSE_TOWN_MAP) is a plain
-- text object -- talking to it prints _BluesHouseTownMapText
-- ("It's a big map! This is useful!") and nothing enters the bag.
-- The ROM object carries the 0x80 "has item payload" bit with a payload
-- id of 0 (ITEM_NONE), which our extractor copies through as item="0".
-- The engine's item-ball branch treated the truthy string "0" as a real
-- item, so pressing A picked up a bogus item "0" -- and selling that
-- unknown id later hard-crashed ShopMenu.sell (nil item def).
--
-- This driver reproduces both halves and asserts the correct Gen1
-- behavior, so it fails while the bug exists and passes once fixed.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Bag = require("src.inventory.Bag")
local Screens = require("src.ui.Screens")
local pass, fail = 0, 0
local function check(ok, label)
if ok then pass = pass + 1; U.log("PASS", label)
else fail = fail + 1; U.log("FAIL", label) end
end
-- defensive: keep the pre-fix "<name> found 0!" box from erroring on a
-- nil player name (the box never appears after the fix)
game.save.player = game.save.player or {}
game.save.player.name = game.save.player.name or "RED"
-- ---- Part 1: talking to the wall Town Map must not pick anything up ----
U.teleport(game, "BLUES_HOUSE", 2, 6, "up")
U.wait(5)
local wallmap
for _, n in ipairs(game.overworld.npcs) do
if n.def and n.def.name == "BLUESHOUSE_TOWN_MAP" then wallmap = n end
end
check(wallmap ~= nil, "wall Town Map object present")
if wallmap then
game.overworld:talkTo(wallmap)
U.wait(8)
U.shot(game, DIR .. "/bh_1_wallmap.png")
-- correct Gen1 behavior: nothing enters the bag
check(game.save.inventory["0"] == nil, "no phantom item '0' in bag")
check((game.save.inventory["0"] == nil) and (game.save.bagOrder == nil
or not (function()
for _, id in ipairs(game.save.bagOrder) do
if id == "0" then return true end
end
return false
end)()), "bag order has no '0' entry")
-- dismiss whatever box is up
for _ = 1, 4 do U.tap(game, "b"); U.wait(3) end
end
-- ---- Part 2: selling an unknown id must not crash ----------------------
-- Seed a corrupted bag (a save that already picked up "0" before the fix,
-- or any legacy/unknown id) and drive the mart SELL path over it.
while game.stack:top() do game.stack:pop() end
U.teleport(game, "PEWTER_MART", 2, 5, "left")
U.wait(5)
game.save.money = 3000
-- known bag state regardless of whether part 1 leaked a phantom "0"
game.save.inventory = {}
game.save.bagOrder = nil
Bag.add(game.save, "0", 1)
check(game.save.inventory["0"] == 1, "seeded unknown item '0' in bag")
-- open the clerk's BUY/SELL/QUIT menu exactly as the mart interaction does
Screens.push(game, "ShopMenu", {})
U.wait(4)
U.tap(game, "down") -- BUY -> SELL
U.wait(4)
U.tap(game, "a") -- open the SELL list
U.wait(6)
local sellList = game.stack:top()
check(sellList and sellList.items ~= nil, "SELL list opened")
U.shot(game, DIR .. "/bh_2_sell_list.png")
if sellList and sellList.items then
-- find the bogus "0" row
local item, idx
for i, it in ipairs(sellList.items) do
if it.value == "0" then item, idx = it, i end
end
check(item ~= nil, "bogus '0' row present in sell list")
if item then
sellList.index = idx
local footerBefore = sellList.footer
-- Invoke the real onChoose the ListMenu would call on A. Before the
-- fix this throws at math.floor(def.price/2) with def=nil; we catch
-- it so the run reports the crash instead of hanging on love's error
-- screen.
local ok, err = pcall(sellList.onChoose, item, sellList)
if not ok then
fail = fail + 1
U.log("FAIL", "selling '0' CRASHED: " .. tostring(err))
else
check(true, "selling '0' did not crash")
-- nothing may be sold: the guard returns before any QuantityBox
check(game.save.inventory["0"] == 1, "unknown item still in bag (not sold)")
check(sellList.footer ~= footerBefore
and tostring(sellList.footer):find("price") ~= nil,
"sell footer shows the unsellable message")
end
U.wait(4)
U.shot(game, DIR .. "/bh_3_sell_choose.png")
end
end
U.log("RESULT", ("pass=%d fail=%d"):format(pass, fail))
if fail == 0 then U.log("RESULT", "ALL PASS") else U.log("RESULT", "HAS FAILURES") end
end
+75
View File
@@ -0,0 +1,75 @@
-- Driver: Celadon Mansion back-stair landings. The 2F/3F/1F west and
-- middle stair landings are door tiles with a solid shelf cell directly
-- south; the door walk-out step must bump there (simulated d-pad press),
-- not force the player through the wall (the Eevee-house stairwell
-- stuck-in-shelves bug). East stairs keep the normal walk-out.
return function(game)
local U = dofile("tests/drivers/util.lua")
local ow
local fails = 0
local function expect(cond, ...)
if not cond then fails = fails + 1 end
U.log(cond and "PASS" or "FAIL", ...)
end
local function settle(mapId)
for _ = 1, 300 do
ow = game.overworld
if ow and ow.map.id == mapId and not ow.transitioning
and #ow.scriptMoves == 0 and not ow.player.moving then
break
end
U.wait(1)
end
U.wait(4)
ow = game.overworld
end
-- climb the west back stairs: 1F (2,1) -> 2F landing (2,1), whose south
-- cell is the solid shelf row
U.teleport(game, "CELADON_MANSION_1F", 4, 1, "left")
U.hold(game, "left", 70)
settle("CELADON_MANSION_2F")
expect(ow.map.id == "CELADON_MANSION_2F", "west stairs arrive 2F, map:", ow.map.id)
expect(ow.player.cellX == 2 and ow.player.cellY == 1,
"player stays on landing (2,1), got:", ow.player.cellX, ow.player.cellY)
expect(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
"standing cell walkable, tile:",
string.format("%02x", ow.map:cellTile(ow.player.cellX, ow.player.cellY)))
-- the landing is not a trap: one step east onto the walkway works
-- (short tap: (4,1) beyond it is the middle-stairs warp)
U.hold(game, "right", 4)
U.wait(24)
ow = game.overworld
expect(ow.map.id == "CELADON_MANSION_2F" and ow.player.cellX == 3,
"stepped east off the landing, at:", ow.map.id,
ow.player.cellX, ow.player.cellY)
-- stepping back onto the stairs still takes them down to 1F
U.hold(game, "left", 4)
settle("CELADON_MANSION_1F")
expect(ow.map.id == "CELADON_MANSION_1F", "stairs back down, map:", ow.map.id)
expect(ow.player.cellX == 2 and ow.player.cellY == 1,
"1F landing holds too (2,1), got:", ow.player.cellX, ow.player.cellY)
-- east stairs land on (7,1) with open floor south: the vanilla
-- walk-out step must still fire
U.teleport(game, "CELADON_MANSION_1F", 7, 3, "up")
U.hold(game, "up", 70)
settle("CELADON_MANSION_2F")
expect(ow.map.id == "CELADON_MANSION_2F", "east stairs arrive 2F, map:", ow.map.id)
expect(ow.player.cellX == 7 and ow.player.cellY == 2,
"walk-out stepped south to (7,2), got:", ow.player.cellX, ow.player.cellY)
-- a save already stuck inside the shelf (pre-fix) escapes north onto
-- the stairs, which warp back down
U.teleport(game, "CELADON_MANSION_2F", 2, 2, "up")
U.hold(game, "up", 24)
settle("CELADON_MANSION_1F")
expect(ow.map.id == "CELADON_MANSION_1F", "stuck save escapes via stairs, map:",
ow.map.id)
if fails > 0 then error(fails .. " check(s) failed") end
U.log("all checks passed")
end
+141
View File
@@ -0,0 +1,141 @@
-- Driver: forced-step shoves use the wrong primitive (issue #151).
--
-- Two related defects, both a scripted "push the player back one step" that
-- reaches for the wrong movement primitive:
--
-- A MUSEUM_1F ticket rope (data/scripts/story2.lua museumClerk onDecline):
-- declining the Y50 ticket must shove the player one cell SOUTH off the
-- exhibit rope (scripts/Museum1F.asm) -- the player crossed the rope
-- heading NORTH, so the shove is south. The bug shoved "right" onto the
-- counter tile (11,4)=tile 23, non-walkable, matching the report's "moved
-- onto the table". Correct landing is (10,5)=tile 1 (walkable floor).
--
-- B VIRIDIAN_CITY gym lock (data/scripts/story5.lua stepGate/viridianGym-
-- Lock): the tile below the Gym door (32,8)=tile 44 is a DOWN-ledge
-- (44 -> 55, data/tilesets/ledge_tiles.asm); Gen1 shoves the player with a
-- SIMULATED JOYPAD down-press that runs the normal step pipeline including
-- HandleLedges (engine/overworld/ledges.asm), so the shove HOPS the ledge
-- and lands on (32,10)=tile 57. The bug used a raw scriptMove that
-- ignores ledges, planting the player standing on the ledge tile (32,9).
--
-- Both cases assert the CORRECT Gen1 outcome, so this FAILS on the bug and
-- PASSES once the shove primitives are fixed.
--
-- Run:
-- POKEPORT_DRIVER=tests/drivers/decline_push_bug151_test.lua \
-- POKEPORT_IDENTITY=bug151 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local shotDir = os.getenv("POKEPORT_SHOTDIR") or "."
local function shot(name) U.shot(game, shotDir .. "/" .. name) end
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
-- a party + starter flag so the overworld is fully usable
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
local Pokemon = require("src.pokemon.Pokemon")
if #game.save.party == 0 then
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
end
local fails = 0
local function expect(cond, ...)
if not cond then fails = fails + 1 end
U.log(cond and "PASS" or "FAIL", ...)
end
-- Advance dialog until control returns to the overworld: tap A through
-- TextBoxes, tap B through a ChoiceBox (B always declines -- ChoiceBox:update
-- calls onChoose(false) on B). Bounded so a stuck box can't hang the run.
local function driveDialog(maxIters)
for _ = 1, (maxIters or 240) do
local top = game.stack:top()
if top == game.overworld then return true end
if getmetatable(top) == ChoiceBox then
U.tap(game, "b")
elseif getmetatable(top) == TextBox then
U.tap(game, "a")
else
-- unknown state: nudge with A so we never wedge
U.tap(game, "a")
end
U.wait(2)
end
return game.stack:top() == game.overworld
end
-- Drain queued scriptMoves + any in-flight step, sampling p.hopFrames each
-- frame (a hop arc is set only by checkLedgeHop). Returns whether a hop was
-- ever seen while draining.
local function drainMoves(p, maxFrames)
local hopSeen = (p.hopFrames or 0) > 0
for _ = 1, (maxFrames or 180) do
local ow = game.overworld
local pending = ow.scriptMoves and #ow.scriptMoves > 0
if not pending and not p.moving and (p.hopFrames or 0) == 0 then break end
if (p.hopFrames or 0) > 0 then hopSeen = true end
U.wait(1)
end
return hopSeen
end
-- ------------------------------------------------------------------
-- Case A: MUSEUM_1F ticket-rope decline must shove SOUTH, not RIGHT.
-- ------------------------------------------------------------------
do
game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = false
game.save.money = 1000 -- enough to buy, so declining is a real NO choice
U.teleport(game, "MUSEUM_1F", 10, 5, "up")
U.wait(6)
local p = game.overworld.player
shot("museum_decline_before.png")
-- step north onto the rope cell (10,4); the clerk stops us there
U.hold(game, "up", 24)
-- clerk dialog: advance the pitch, decline the YES/NO, clear "Come again!"
driveDialog(240)
drainMoves(p, 120)
shot("museum_decline_after.png")
expect(p.cellX == 10 and p.cellY == 5,
"A: declining shoves the player SOUTH to (10,5), got:", p.cellX, p.cellY)
end
-- ------------------------------------------------------------------
-- Case B: VIRIDIAN_CITY gym lock shove must HOP the down-ledge.
-- ------------------------------------------------------------------
do
-- fresh badge state: no non-Earth badges, so the gym stays locked
game.save.inventory = game.save.inventory or {}
for _, b in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE",
"RAINBOWBADGE", "SOULBADGE", "MARSHBADGE",
"VOLCANOBADGE" }) do
game.save.inventory[b] = nil
end
U.teleport(game, "VIRIDIAN_CITY", 31, 8, "right")
U.wait(6)
local p = game.overworld.player
shot("viridian_gymlock_before.png")
-- step east onto (32,8) directly below the locked Gym door
U.hold(game, "right", 24)
-- the "GYM's doors are locked..." box appears; clearing it fires the shove
local ow = game.overworld
local hopSeen = false
for _ = 1, 240 do
local top = game.stack:top()
if top == game.overworld then break end
if getmetatable(top) == TextBox then U.tap(game, "a") else U.tap(game, "a") end
if (p.hopFrames or 0) > 0 then hopSeen = true end
U.wait(2)
if (p.hopFrames or 0) > 0 then hopSeen = true end
end
if drainMoves(p, 180) then hopSeen = true end
shot("viridian_gymlock_after.png")
expect(hopSeen, "B: the gym-lock shove HOPS the ledge (hop arc seen)")
expect(p.cellX == 32 and p.cellY == 10,
"B: landed south of the ledge at (32,10), got:", p.cellX, p.cellY)
end
if fails > 0 then error(fails .. " check(s) failed") end
U.log("all checks passed -- #151 shoves are Gen1-correct "
.. "(museum decline goes south, Viridian gym lock hops the ledge)")
end
+193
View File
@@ -0,0 +1,193 @@
-- Driver: regression coverage for #196 "Dig not working exactly as intended".
--
-- Report (v0.1.23): using DIG, the character appears SPINNING INSIDE the
-- Pokemon Center in front of the nurse, and there is no spin-before-fade on
-- departure. Expected (reporter + triage cluster E + the file's own code
-- comments): the sprite begins to spin, the screen fades, and the player
-- fades in OUTSIDE the nearest town's Pokemon Center door -- exactly like Fly.
--
-- Gen1/pokered references this exercises:
-- engine/overworld/player_animations.asm _LeaveMapAnim (SFX_TELEPORT_EXIT_1
-- + PlayerSpinWhileMovingUp) for the departure spin-up;
-- EnterMapAnim (PlayerSpinWhileMovingDown) for the arrival spin-down;
-- engine/items/item_effects.asm ItemUseEscapeRope (Dig/Teleport share it).
--
-- Two defects, two assertions:
-- FIX A: a DEPARTURE spin must appear on the origin (cave) map BEFORE the
-- warp -- ow.teleportOut set / player spinning while still in the cave.
-- FIX B: the landing map must be the town (VIRIDIAN_CITY) at the in-front-of
-- -door fly spot (23,26), NOT the interior VIRIDIAN_POKECENTER.
-- Pre-fix this driver documents the bug (lands in VIRIDIAN_POKECENTER, no
-- departure spin); post-fix it passes.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local failures = {}
local function check(cond, msg)
if cond then
U.log("PASS:", msg)
else
U.log("FAIL:", msg)
table.insert(failures, msg)
end
end
-- ---- set up a heal record with a remembered Viridian town door ----
-- Simulate having healed at the Viridian Pokemon Center: lastHeal points
-- at the INTERIOR (the buggy landing), but carries the outdoor town door
-- so the fix can relocate the escape-warp outside like Fly does.
game.save.player = game.save.player or {}
game.save.player.name = game.save.player.name or "bryan"
game.save.lastOutdoor = { id = "VIRIDIAN_CITY", x = 23, y = 25 }
game.save.lastHeal = {
map = "VIRIDIAN_POKECENTER", x = 3, y = 3,
outdoor = { id = "VIRIDIAN_CITY", x = 23, y = 25 },
}
-- field.flyWarps.VIRIDIAN_CITY = {23,26} is the canonical in-front-of-door
-- fly landing (one tile south of the PC door warp at 23,25).
local fw = game.data.field.flyWarps.VIRIDIAN_CITY
U.log("viridian flyWarp:", tostring(fw and fw.x), tostring(fw and fw.y))
-- ======================= LEG 1: DIG via the party menu ==============
-- A DIG-knowing party mon. Pokemon.movesAtLevel never grants DIG, so
-- inject the move directly (DIG needs no badge and works in CAVERN maps).
local digger = Pokemon.new(game.data, "NIDOKING", 40)
digger.moves = { { id = "DIG", pp = 10 } }
game.save.party = { digger }
-- MT_MOON_1F tileset is CAVERN (in DIG_TILESETS); a walkable cave cell.
U.teleport(game, "MT_MOON_1F", 14, 34, "down")
local ow = game.overworld
U.log("start map:", tostring(ow and ow.map and ow.map.id),
"tileset:", tostring(ow and ow.map and ow.map.def and ow.map.def.tileset))
U.shot(game, DIR .. "/dig_00_cave.png")
U.wait(4)
-- open the party menu and pick DIG on slot 1
-- (submenu order for a DIG-only mon: STATS / SWITCH / DIG)
Screens.push(game, "PartyMenu")
U.wait(5)
U.tap(game, "a") -- open the per-mon submenu
U.wait(2)
U.tap(game, "down") -- STATS -> SWITCH
U.wait(2)
U.tap(game, "down") -- SWITCH -> DIG
U.wait(2)
U.tap(game, "a") -- choose DIG
U.wait(2)
-- ---- FIX A: watch for a DEPARTURE spin on the cave map before the warp ----
-- A departure spin counts only if it happens while we are still in the cave
-- (map.id == MT_MOON_1F). The pre-fix code only spins on arrival, inside the
-- Center, so this stays false pre-fix.
local sawDepartureSpin = false
local spinShotTaken = false
local leftCave = false
for _ = 1, 240 do
local stillCave = ow.map and ow.map.id == "MT_MOON_1F"
-- departure-only markers: ow.teleportOut is the new pre-warp spin state,
-- and player.spinRise is the rising spin (the arrival uses spinDrop), so
-- neither can be confused with the interior arrival spin-down
if stillCave and (ow.teleportOut ~= nil or ow.player.spinRise) then
sawDepartureSpin = true
if not spinShotTaken then
U.shot(game, DIR .. "/dig_01_spin.png")
spinShotTaken = true
end
end
if ow.map and ow.map.id ~= "MT_MOON_1F" then
leftCave = true
break
end
U.wait(1)
end
U.log("sawDepartureSpin:", tostring(sawDepartureSpin),
"leftCave:", tostring(leftCave))
-- ---- settle the arrival, then FIX B: where did we land? ----
local landedMap
for _ = 1, 240 do
landedMap = ow.map and ow.map.id
-- wait until the transition settles onto a stable map that isn't the cave
if landedMap and landedMap ~= "MT_MOON_1F" and not ow.transitioning then
break
end
U.wait(1)
end
U.wait(8)
U.shot(game, DIR .. "/dig_02_land.png")
U.wait(4)
landedMap = ow.map and ow.map.id
U.log("DIG landed map:", tostring(landedMap),
"cell:", tostring(ow.player.cellX), tostring(ow.player.cellY))
check(sawDepartureSpin,
"DIG: departure spin appears in the cave before the fade (FIX A)")
check(landedMap == "VIRIDIAN_CITY",
"DIG: lands OUTSIDE at VIRIDIAN_CITY, not the interior (FIX B) -- got "
.. tostring(landedMap))
check(ow.player.cellX == 23 and ow.player.cellY == 26,
"DIG: lands at the in-front-of-door fly spot 23,26 -- got "
.. tostring(ow.player.cellX) .. "," .. tostring(ow.player.cellY))
-- ======================= LEG 2: ESCAPE ROPE via the bag =============
-- Same shared departure path, but driven through BagMenu's escape_rope
-- branch so src/ui/BagMenu.lua is exercised too.
game.save.party = { Pokemon.new(game.data, "PIDGEY", 8) }
game.save.bagOrder = nil
game.save.inventory = { ESCAPE_ROPE = 1 }
game.save.money = game.save.money or 3000
U.teleport(game, "MT_MOON_1F", 14, 34, "down")
ow = game.overworld
U.shot(game, DIR .. "/dig_03_rope_cave.png")
U.wait(4)
Screens.push(game, "BagMenu")
U.wait(5)
U.tap(game, "a") -- choose ESCAPE ROPE (only item) -> USE / TOSS menu
U.wait(3)
U.tap(game, "a") -- USE (first option) -> escape_rope branch
U.wait(2)
local ropeSpin = false
for _ = 1, 240 do
local stillCave = ow.map and ow.map.id == "MT_MOON_1F"
if stillCave and (ow.teleportOut ~= nil or ow.player.spinRise) then
ropeSpin = true
end
if ow.map and ow.map.id ~= "MT_MOON_1F" then break end
U.wait(1)
end
local ropeLanded
for _ = 1, 240 do
ropeLanded = ow.map and ow.map.id
if ropeLanded and ropeLanded ~= "MT_MOON_1F" and not ow.transitioning then
break
end
U.wait(1)
end
U.wait(8)
U.shot(game, DIR .. "/dig_04_rope_land.png")
U.wait(4)
ropeLanded = ow.map and ow.map.id
U.log("ESCAPE ROPE landed map:", tostring(ropeLanded),
"cell:", tostring(ow.player.cellX), tostring(ow.player.cellY))
check(ropeSpin,
"ESCAPE ROPE: departure spin appears in the cave before the fade (FIX A)")
check(ropeLanded == "VIRIDIAN_CITY",
"ESCAPE ROPE: lands OUTSIDE at VIRIDIAN_CITY (FIX B) -- got "
.. tostring(ropeLanded))
if #failures == 0 then
U.log("RESULT bug196 PASS")
else
U.log("RESULT bug196 FAIL (" .. #failures .. "):")
for _, m in ipairs(failures) do U.log(" -", m) end
end
end
@@ -0,0 +1,149 @@
-- Driver: cancel an evolution with the B button (#213).
--
-- pokered engine/pokemon/evos_moves.asm polls hJoyHeld during the pic
-- flash: holding B aborts the evolution (the mon keeps its species and
-- _StoppedEvolvingText prints). Trade evolutions (wLinkState ==
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
--
-- Case 1 (level path, cancelable): open EvolutionState directly, wait a
-- few frames into the flash (t well under FLASH_FRAMES=220), hold B, and
-- assert the mon stays CATERPIE with "stopped evolving" text on screen.
-- Case 1b: after cancel, checkParty with no level-ups must not re-offer;
-- a subsequent level-up set must offer again (EvolveAfterBattle parity).
-- Case 2 (control): let the flash run to completion with no input and
-- assert the mon becomes METAPOD with the "Congratulations!" text.
--
-- SHOT_DIR=/tmp/evo213 POKEPORT_DRIVER=tests/drivers/evolution_cancel_bug213_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/evo213"
os.execute("mkdir -p " .. DIR)
local Pokemon = require("src.pokemon.Pokemon")
local Evolution = require("src.pokemon.Evolution")
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
local function top() return game.stack:top() end
local function evoTop()
local t = top()
return t and t.screenId == "EvolutionState"
end
local function waitFor(cond, max)
for _ = 1, max or 600 do
if cond() then return true end
U.wait(1)
end
return false
end
-- flatten a TextBox's paginated pages (list of line lists) to one string
local function pagesText(st)
if not st.pages then return nil end
local parts = {}
for _, page in ipairs(st.pages) do
if type(page) == "table" then
for _, line in ipairs(page) do
if type(line) == "string" then parts[#parts + 1] = line end
end
elseif type(page) == "string" then
parts[#parts + 1] = page
end
end
return table.concat(parts, " ")
end
local function findText(needle)
for _, st in ipairs(game.stack.states or {}) do
local blob = pagesText(st)
if blob and blob:find(needle, 1, true) then return st end
end
return nil
end
-- mash A (one tap every few frames) until cond holds; a single tap only
-- fast-forwards a still-typing TextBox, so mashing both finishes the
-- typewriter and then presses the close button
local function mashUntil(cond, max)
for _ = 1, max or 200 do
if cond() then return true end
U.tap(game, "a")
U.wait(3)
end
return cond()
end
U.teleport(game, "ROUTE_1", 5, 5, "down")
-- === Case 1: hold B during the flash -> evolution aborts ===
local mon = Pokemon.new(game.data, "CATERPIE", 7)
table.insert(game.save.party, 1, mon)
local done1 = false
Evolution.evolve(game, mon, "METAPOD", function() done1 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
U.wait(20) -- into the flash, well under FLASH_FRAMES=220
U.log("case1 flash", "t=", top().t, "species=", mon.species)
U.shot(game, DIR .. "/evo213_1_evolving.png")
U.hold(game, "b", 20) -- Gen1 hJoyHeld B-cancel
-- the flash aborts: EvolutionState is no longer the top (the stopped
-- text overlays it and then pops it)
if not waitFor(function() return not evoTop() end, 240) then
error("evolution did not abort on B: still on EvolutionState, species="
.. tostring(mon.species))
end
U.log("case1 aborted", "species=", mon.species)
U.wait(40) -- let "Huh? MON stopped evolving!" finish typing before the shot
U.shot(game, DIR .. "/evo213_2_stopped.png")
assert(mon.species == "CATERPIE",
"B-cancel failed: mon evolved to " .. tostring(mon.species)
.. " (expected CATERPIE)")
assert(findText("stopped evolving"), "StoppedEvolvingText not shown")
mashUntil(function() return done1 end, 80) -- close the stopped-evolving text
assert(done1, "cancel onDone never fired")
assert(mon.species == "CATERPIE", "species changed after cancel tail")
-- === Case 1b: after B-cancel, checkParty without a level-up must not
-- re-offer (regression: cancelled mons stuck at threshold tried again
-- after every later battle, even non-participants). ===
local nQuiet = Evolution.checkParty(game, nil, {})
assert(nQuiet == 0, "checkParty with no level-ups re-offered after cancel")
assert(not evoTop(), "EvolutionState opened after quiet afterBattle")
assert(mon.species == "CATERPIE", "species changed on quiet checkParty")
local nLevel = Evolution.checkParty(game, nil, { [mon] = true })
assert(nLevel == 1, "checkParty after a real level-up should offer once")
if not waitFor(evoTop, 300) then
error("EvolutionState never opened after level-up re-offer")
end
U.wait(20)
U.hold(game, "b", 20) -- cancel so case 2 stays independent
if not waitFor(function() return not evoTop() end, 240) then
error("level-up re-offer did not abort on B")
end
mashUntil(function() return not findText("stopped evolving") end, 80)
assert(mon.species == "CATERPIE", "species changed after re-offer cancel")
-- === Case 2 (control): no input -> evolution completes ===
local mon2 = Pokemon.new(game.data, "CATERPIE", 7)
table.insert(game.save.party, 1, mon2)
local done2 = false
Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
-- let the full flash run (FLASH_FRAMES=220) without pressing B
waitFor(function() return not evoTop() end, 400)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case2)")
end
U.wait(40) -- let the Congratulations text finish typing before the shot
U.shot(game, DIR .. "/evo213_3_congrats.png")
assert(mon2.species == "METAPOD",
"control failed: mon2 stayed " .. tostring(mon2.species)
.. " (expected METAPOD)")
mashUntil(function() return done2 end, 80)
U.log("done", "case1=", mon.species, "case2=", mon2.species)
love.event.quit()
end
+143
View File
@@ -0,0 +1,143 @@
-- Driver: evolution grants the new species' level-up move (#12).
--
-- pokered engine/pokemon/evos_moves.asm (EvolveMon) re-runs the level-up
-- learn check on the *evolved* species after the "evolved into" text, via
-- the LearnMoveFromLevelUp predef (engine/pokemon/learn_move.asm). The
-- check is EXACT level equality (learnset entry level == mon.level), so a
-- mon evolving at exactly a learnset level gains that move.
--
-- Data pins: GYARADOS.learnset has { level = 20, move = "BITE" }
-- (data/generated/pokemon.lua), so MAGIKARP->GYARADOS at level 20 must
-- learn BITE, while an evolution at level 21 must NOT (nothing at 21).
--
-- Case 1 (repro/fix): Lv20 MAGIKARP knowing only SPLASH evolves; after the
-- congratulations text the mon must be GYARADOS and know BITE, and the
-- "GYARADOS learned BITE!" text must appear. This assertion FAILS on the
-- pre-fix build (no learn step) and PASSES once the fix runs the check.
-- Case 2 (control): Lv21 MAGIKARP evolves to GYARADOS and must NOT gain
-- BITE (guards the exact-level == rule against an over-broad <= fix).
--
-- SHOT_DIR=/tmp/evo12 POKEPORT_DRIVER=tests/drivers/evolution_move_bug12_test.lua \
-- POKEPORT_IDENTITY=bug12 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/evo12"
os.execute("mkdir -p " .. DIR)
local Pokemon = require("src.pokemon.Pokemon")
local Evolution = require("src.pokemon.Evolution")
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
local function top() return game.stack:top() end
local function evoTop()
local t = top()
return t and t.screenId == "EvolutionState"
end
local function waitFor(cond, max)
for _ = 1, max or 600 do
if cond() then return true end
U.wait(1)
end
return false
end
-- flatten a TextBox's paginated pages (list of line lists) to one string
local function pagesText(st)
if not st.pages then return nil end
local parts = {}
for _, page in ipairs(st.pages) do
if type(page) == "table" then
for _, line in ipairs(page) do
if type(line) == "string" then parts[#parts + 1] = line end
end
elseif type(page) == "string" then
parts[#parts + 1] = page
end
end
return table.concat(parts, " ")
end
local function findText(needle)
for _, st in ipairs(game.stack.states or {}) do
local blob = pagesText(st)
if blob and blob:find(needle, 1, true) then return st end
end
return nil
end
local function mashUntil(cond, max)
for _ = 1, max or 200 do
if cond() then return true end
U.tap(game, "a")
U.wait(3)
end
return cond()
end
local function hasMove(mon, id)
for _, mv in ipairs(mon.moves) do
if mv.id == id then return true end
end
return false
end
U.teleport(game, "ROUTE_1", 5, 5, "down")
-- === Case 1: MAGIKARP @20 -> GYARADOS must learn BITE ===
local mon = Pokemon.new(game.data, "MAGIKARP", 20)
mon.moves = { { id = "SPLASH", pp = 40 } } -- deterministic single slot
table.insert(game.save.party, 1, mon)
local done1 = false
Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
-- let the full flash run (FLASH_FRAMES=220) with no input, then apply
waitFor(function() return not evoTop() end, 400)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case1)")
end
U.wait(40) -- let the congrats text finish typing before the shot
U.shot(game, DIR .. "/evo12_1_congrats.png")
-- advance past congrats into the level-up learn flow; the fix pushes
-- "GYARADOS learned BITE!" here (pre-fix: onDone fires with no learn)
local sawLearned = false
mashUntil(function()
if findText("learned") then sawLearned = true; return true end
return done1
end, 200)
U.wait(30) -- let the "learned BITE!" text type out (fix path)
U.shot(game, DIR .. "/evo12_2_learned.png")
mashUntil(function() return done1 end, 120)
U.log("case1", "species=", mon.species, "hasBite=", hasMove(mon, "BITE"),
"sawLearned=", sawLearned)
assert(mon.species == "GYARADOS",
"case1: mon stayed " .. tostring(mon.species) .. " (expected GYARADOS)")
assert(hasMove(mon, "BITE"),
"case1: GYARADOS did not learn BITE on evolution at level 20 (bug #12)")
assert(sawLearned, "case1: no 'learned' text shown for the evolution move")
-- === Case 2 (control): MAGIKARP @21 -> GYARADOS must NOT gain BITE ===
local mon2 = Pokemon.new(game.data, "MAGIKARP", 21)
mon2.moves = { { id = "SPLASH", pp = 40 } }
table.insert(game.save.party, 1, mon2)
local done2 = false
Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
waitFor(function() return not evoTop() end, 400)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case2)")
end
mashUntil(function() return done2 end, 200)
U.wait(20)
U.shot(game, DIR .. "/evo12_3_lv21_no_bite.png")
U.log("case2", "species=", mon2.species, "hasBite=", hasMove(mon2, "BITE"))
assert(mon2.species == "GYARADOS",
"case2: mon2 stayed " .. tostring(mon2.species) .. " (expected GYARADOS)")
assert(not hasMove(mon2, "BITE"),
"case2: GYARADOS wrongly learned BITE at level 21 (over-grant; exact == broken)")
U.log("done", "case1=", mon.species, "case2=", mon2.species)
love.event.quit()
end

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