mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-13 08:51:07 +02:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 323c59e54f | |||
| d26d63ed38 | |||
| 2009df3dd1 | |||
| f0f3e9634b | |||
| ca1beaefe7 | |||
| 24cf1b3317 | |||
| 88e2ec2042 | |||
| be60c7c1d5 | |||
| 9be21380c7 | |||
| 22fd196045 | |||
| d5293dffb8 | |||
| 9326b07583 | |||
| 90437a5d8d | |||
| c4ec5dc1c5 | |||
| 3b19826f1e | |||
| 95e4715bc3 | |||
| 81edc97d86 | |||
| 92c8bac714 | |||
| 1df04aa18d | |||
| 218b9249a2 | |||
| 50947eec2a | |||
| 2624bb0210 | |||
| 6e9685f302 | |||
| 898bf0c71e | |||
| 5a7add8eaa | |||
| 66334dfacd | |||
| a33f3b1ceb | |||
| 6e724cedf7 | |||
| e949c78639 | |||
| 9bcfdb1f0d | |||
| fccb122c59 | |||
| e2b2a5fb9f | |||
| f2a3e5f05b | |||
| 5f855568c8 | |||
| 24696e3be2 | |||
| b17dce232e | |||
| e1b20723fd | |||
| fdab15a6dc | |||
| 33899c237e | |||
| 0fad222569 | |||
| 9c5f33e187 | |||
| ddcce5b95a | |||
| 6fd9741fab | |||
| 4916fc8f4b | |||
| fc19e58678 | |||
| caa65182dc | |||
| 74eeb411ba | |||
| 040ca3f332 | |||
| c8e035d332 |
@@ -16,6 +16,7 @@ __pycache__/
|
||||
.*
|
||||
!.github/
|
||||
!.gitignore
|
||||
!.luacheckrc
|
||||
|
||||
# Android build outputs / local SDK path / packaged payload (keep love-android sources)
|
||||
mobile/android/app/build/
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
-- Static-analysis config for `luacheck` (https://luacheck.readthedocs.io).
|
||||
--
|
||||
-- Run it over the engine with: luacheck src (or scripts/lint.sh)
|
||||
--
|
||||
-- The point is a high-signal baseline: the categories left on are the ones
|
||||
-- that catch real defects -- undefined globals/locals (the class that hid a
|
||||
-- `music.volume` crash: applyVolume read a `state` that was still the nil
|
||||
-- global), unused values, unreachable code, redefinitions. The cosmetic
|
||||
-- categories the codebase deliberately lives with (a `self`/`dt` an
|
||||
-- interface requires but a given method ignores, documented empty
|
||||
-- fall-through branches, the odd long line) are muted so they don't drown
|
||||
-- the signal.
|
||||
|
||||
std = "luajit"
|
||||
|
||||
-- LÖVE exposes `love` as a mutable table: games assign their callbacks onto
|
||||
-- it (love.wheelmoved, love.run, ...), so it is a regular global, not
|
||||
-- read-only -- otherwise every callback registration reads as a violation.
|
||||
globals = { "love" }
|
||||
|
||||
read_globals = {
|
||||
"jit",
|
||||
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
|
||||
-- it; without this, every `table.unpack` reads as an undefined field.
|
||||
table = { fields = { "unpack" } },
|
||||
}
|
||||
|
||||
-- Vendored/native trees and the test suites have their own conventions.
|
||||
exclude_files = {
|
||||
"mobile/",
|
||||
"tests/",
|
||||
"tools/save-editor/",
|
||||
}
|
||||
|
||||
ignore = {
|
||||
"212", -- unused argument -- self/dt kept for a shared method signature
|
||||
"213", -- unused loop variable -- `for _, v in` where only v is wanted
|
||||
"421", -- shadowing a local -- deliberate re-use in a few tight scopes
|
||||
"431", -- shadowing an upvalue
|
||||
"432", -- shadowing an argument
|
||||
"542", -- empty if branch -- documented fall-throughs, not gaps
|
||||
"631", -- line is too long
|
||||
}
|
||||
@@ -108,6 +108,17 @@ supported out of the box.
|
||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||
and persist in `options.lua`.
|
||||
|
||||
### Low-end devices
|
||||
|
||||
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
|
||||
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
|
||||
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
|
||||
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
|
||||
normal desktops → HIGH, unchanged). It only scales presentation; the
|
||||
fixed-step game logic is identical on every tier, and a lower tier hides
|
||||
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
|
||||
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
|
||||
|
||||
### Rulesets
|
||||
|
||||
**OPTIONS → RULESET** picks which set of Gen 1 battle behaviors to run.
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
function love.conf(t)
|
||||
-- PhysFS ignores symlinks unless told otherwise, so a mod dev-linked into
|
||||
-- mods/ (ln -s, matching the mklink /J workflow on Windows) is invisible
|
||||
-- to love.filesystem.getDirectoryItems without this.
|
||||
love.filesystem.setSymlinksEnabled(true)
|
||||
|
||||
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
||||
local developer = os.getenv("POKEPORT_DEV") == "1"
|
||||
if arg then
|
||||
@@ -44,7 +49,7 @@ function love.conf(t)
|
||||
t.window.minwidth = 480
|
||||
t.window.minheight = 360
|
||||
end
|
||||
t.version = "11.5"
|
||||
t.version = love._os == "iOS" and "12.0" or "11.5"
|
||||
t.window.vsync = 1
|
||||
t.modules.joystick = true
|
||||
t.modules.physics = false
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
-- BikeShop (BIKE_SHOP) flavor dialogue
|
||||
-- Source: pokered/scripts/BikeShop.asm, pokered/text/BikeShop.asm
|
||||
--
|
||||
-- TEXT_BIKESHOP_CLERK is skipped: it drives the actual voucher-for-bicycle
|
||||
-- exchange (YesNoChoice purchase menu, GiveItem, RemoveItemByID, SetEvent
|
||||
-- EVENT_GOT_BICYCLE). That is a significant standalone feature outside the
|
||||
-- scope of these two flavor NPCs and is left unported here.
|
||||
-- TEXT_BIKESHOP_CLERK lives in data/scripts/story2.lua (M.BIKE_SHOP): the
|
||||
-- voucher exchange and the BICYCLE/CANCEL price window need more than
|
||||
-- command rows (#568).
|
||||
|
||||
return {
|
||||
BIKE_SHOP = {
|
||||
@@ -20,12 +19,15 @@ return {
|
||||
-- CheckEvent EVENT_GOT_BICYCLE ; jr nz, .gotBike
|
||||
-- before the player owns a bike -> TheseBikesAreExpensiveText
|
||||
-- after the player owns a bike -> CoolBikeText
|
||||
-- The check reads the bag, not the event: the port hands out the
|
||||
-- BICYCLE itself (a key item, so it cannot be tossed), and that also
|
||||
-- reads right on saves written before the clerk set the event (#567).
|
||||
TEXT_BIKESHOP_YOUNGSTER = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_BICYCLE" }, -- 2
|
||||
{ "jump_if_true", 5 }, -- 3
|
||||
{ "check_item", "BICYCLE" }, -- 2
|
||||
{ "jump_if_true", 6 }, -- 3
|
||||
{ "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4
|
||||
{ "jump", 6 }, -- 5
|
||||
{ "jump", "end" }, -- 5
|
||||
{ "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6
|
||||
},
|
||||
},
|
||||
|
||||
@@ -35,14 +35,25 @@ local function middleAgedMan(game, ow, npc, done)
|
||||
-- .loop: print WhichBadgeText, then show the badge list menu again
|
||||
push(game, t._CeruleanBadgeHouseMiddleAgedManWhichBadgeText, function()
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Strings = require("src.core.Strings")
|
||||
local items = {}
|
||||
for _, id in ipairs(BADGE_ORDER) do
|
||||
local idef = game.data.items[id]
|
||||
items[#items + 1] = { label = idef and idef.name or id, value = id }
|
||||
end
|
||||
-- PrintListMenuEntries prints ListMenuCancelText once it hits the
|
||||
-- list's $FF terminator (home/list_menu.asm), so every
|
||||
-- DisplayListMenuID list ends in an on-screen CANCEL row; picking it
|
||||
-- takes DisplayListMenuIDLoop's ExitListMenu path, the same .done
|
||||
-- exit as B (#569)
|
||||
items[#items + 1] = { label = Strings("CANCEL") }
|
||||
local menu = ListMenu.new(game, "", items, {
|
||||
onChoose = function(item)
|
||||
game.stack:pop()
|
||||
if not item.value then
|
||||
push(game, t._CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText, done)
|
||||
return
|
||||
end
|
||||
push(game, t[BADGE_TEXT[item.value]], loop)
|
||||
end,
|
||||
onCancel = function()
|
||||
|
||||
@@ -85,6 +85,47 @@ return {
|
||||
alreadyGotLabel = "_GameCornerGentlemanCloselyWatchTheReelsText",
|
||||
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
|
||||
}),
|
||||
|
||||
-- Yellow keeps all three giveaways with the same events and amounts
|
||||
-- but renames the objects and their text labels: FISHING_GURU ->
|
||||
-- FISHING_GURU1, CLERK2 -> MIDDLE_AGED_MAN2, GENTLEMAN ->
|
||||
-- FISHING_GURU2 (pokeyellow/scripts/GameCorner.asm). The Red/Blue
|
||||
-- keys above never match there, so Yellow needs its own three (#552).
|
||||
TEXT_GAMECORNER_FISHING_GURU1 = coinGiver({
|
||||
event = "EVENT_GOT_10_COINS",
|
||||
amount = 10,
|
||||
askLabel = "_GameCornerFishingGuru1WantToPlayText",
|
||||
askFallback = "Kid, do you want\nto play?",
|
||||
receivedLabel = "_GameCornerFishingGuru1Received10CoinsText",
|
||||
coinCaseFullLabel = "_GameCornerFishingGuru1DontNeedMyCoinsText",
|
||||
coinCaseFullFallback = "You don't need my\ncoins!",
|
||||
alreadyGotLabel = "_GameCornerFishingGuru1WinsComeAndGoText",
|
||||
alreadyGotFallback = "Wins seem to come\nand go.",
|
||||
}),
|
||||
|
||||
TEXT_GAMECORNER_MIDDLE_AGED_MAN2 = coinGiver({
|
||||
event = "EVENT_GOT_20_COINS_2",
|
||||
amount = 20,
|
||||
askLabel = "_GameCornerMiddleAgedMan2WantSomeCoinsText",
|
||||
askFallback = "What's up? Want\nsome coins?",
|
||||
receivedLabel = "_GameCornerMiddleAgedMan2Received20CoinsText",
|
||||
coinCaseFullLabel = "_GameCornerMiddleAgedMan2YouHaveLotsOfCoinsText",
|
||||
coinCaseFullFallback = "You have lots of\ncoins!",
|
||||
alreadyGotLabel = "_GameCornerMiddleAgedMan2INeedMoreCoinsText",
|
||||
alreadyGotFallback = "Darn! I need more\ncoins for the\vPOKéMON I want!",
|
||||
}),
|
||||
|
||||
TEXT_GAMECORNER_FISHING_GURU2 = coinGiver({
|
||||
event = "EVENT_GOT_20_COINS",
|
||||
amount = 20,
|
||||
askLabel = "_GameCornerFishingGuru2ThrowingMeOffText",
|
||||
askFallback = "Hey, what? You're\nthrowing me off!\vHere are some\vcoins, shoo!",
|
||||
receivedLabel = "_GameCornerFishingGuru2Received20CoinsText",
|
||||
coinCaseFullLabel = "_GameCornerFishingGuru2YouGotYourOwnCoinsText",
|
||||
coinCaseFullFallback = "You've got your\nown coins!",
|
||||
alreadyGotLabel = "_GameCornerFishingGuru2CloselyWatchTheReelsText",
|
||||
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Viridian School House's two readables (data/events/hidden_events.asm,
|
||||
-- hidden_events_for VIRIDIAN_SCHOOL_HOUSE):
|
||||
-- hidden_text_predef 3, 0 PrintBlackboardLinkCableText, ViridianSchoolBlackboard
|
||||
-- hidden_text_predef 3, 4 PrintNotebookText, ViridianSchoolNotebook
|
||||
-- tools/extract/field.py only parses `hidden_event` rows, so neither
|
||||
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
|
||||
-- were dead A presses (#503). Same hook shape, and the same sibling asm
|
||||
-- file, as the Celadon roof house in data/scripts/celadon_eevee.lua (#391);
|
||||
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither
|
||||
-- tile gates on facing.
|
||||
|
||||
local Menu = require("src.ui.Menu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm):
|
||||
-- StatusAilmentText1/2 are the two columns of the 12x8 box at the top left
|
||||
-- (hlcoord 0, 0 + `lb bc, 6, 10`); picking a status prints its
|
||||
-- ViridianBlackboardStatusPointers entry and jumps back to .blackboardLoop,
|
||||
-- QUIT or B falls through to .exitBlackboard.
|
||||
local STATUS_LABELS = {
|
||||
{ " SLP", "_ViridianBlackboardSleepText" },
|
||||
{ " PSN", "_ViridianBlackboardPoisonText" },
|
||||
{ " PAR", "_ViridianBlackboardPrlzText" },
|
||||
{ " BRN", "_ViridianBlackboardBurnText" },
|
||||
{ " FRZ", "_ViridianBlackboardFrozenText" },
|
||||
}
|
||||
|
||||
local function blackboard(game)
|
||||
local text = game.data.text or {}
|
||||
local items, showMenu, askHeading
|
||||
function showMenu()
|
||||
game.stack:push(Menu.new(game, items,
|
||||
{ tx = 0, ty = 0, tw = 12, th = 8, rowStep = 1 }))
|
||||
end
|
||||
-- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop
|
||||
-- pass, immediately before HandleMenuInput
|
||||
function askHeading()
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
|
||||
showMenu))
|
||||
end
|
||||
items = {}
|
||||
for i, row in ipairs(STATUS_LABELS) do
|
||||
local label, key = row[1], row[2]
|
||||
items[i] = { label = label, onSelect = function()
|
||||
game.stack:push(TextBox.new(game, text[key] or label, askHeading))
|
||||
end }
|
||||
end
|
||||
-- no onSelect: Menu's own pop closes the box, matching .exitBlackboard
|
||||
items[#items + 1] = { label = " QUIT" }
|
||||
game.stack:push(TextBox.new(game,
|
||||
text._ViridianSchoolBlackboardText1
|
||||
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
|
||||
askHeading))
|
||||
end
|
||||
|
||||
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
|
||||
-- pages 1-3 each end in TurnPageSchoolNotebook (TurnPageText + YesNoChoice)
|
||||
-- and NO stops the read; page 4 turns without asking and runs straight into
|
||||
-- page 5, the girl catching you at it.
|
||||
local function notebook(game)
|
||||
local text = game.data.text or {}
|
||||
local function page(n, after)
|
||||
return TextBox.new(game, text["_ViridianSchoolNotebookText" .. n] or "", after)
|
||||
end
|
||||
local function turnPage(nextPage)
|
||||
return function()
|
||||
game.stack:push(TextBox.new(game, text._TurnPageText or "Turn the page?",
|
||||
nil, { choice = function(yes)
|
||||
if yes then game.stack:push(nextPage()) end
|
||||
end }))
|
||||
end
|
||||
end
|
||||
local function page5() return page(5) end
|
||||
local function page4() return page(4, function() game.stack:push(page5()) end) end
|
||||
local function page3() return page(3, turnPage(page4)) end
|
||||
local function page2() return page(2, turnPage(page3)) end
|
||||
game.stack:push(page(1, turnPage(page2)))
|
||||
end
|
||||
|
||||
return {
|
||||
VIRIDIAN_SCHOOL_HOUSE = {
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
if fx == 3 and fy == 0 then
|
||||
blackboard(game)
|
||||
return true
|
||||
end
|
||||
if fx == 3 and fy == 4 then
|
||||
notebook(game)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
},
|
||||
}
|
||||
@@ -60,6 +60,7 @@ local files = {
|
||||
"data.scripts.flavor.victory_road_2f",
|
||||
"data.scripts.flavor.viridian_city",
|
||||
"data.scripts.flavor.viridian_nickname_house",
|
||||
"data.scripts.flavor.viridian_school_house", -- #503
|
||||
"data.scripts.flavor.wardens_house",
|
||||
}
|
||||
|
||||
|
||||
@@ -143,17 +143,25 @@ return {
|
||||
-- OaksLabRivalExclamationScript: "!" over the rival
|
||||
{ "emote", RIVAL, "shock" },
|
||||
}
|
||||
-- .RivalPushesPlayerAwayFromEeveeBall + the PAD_RIGHT x2 shove:
|
||||
-- the rival cuts across to the ball WHILE the player standing
|
||||
-- below it is bumped two tiles right (both movements run in the
|
||||
-- same beat, so the walk overlaps the shove like the original)
|
||||
-- .RivalPushesPlayerAwayFromEeveeBall is DOWN then RIGHT x3 (the $07
|
||||
-- bytes are Yellow's own step-right encoding, decoded by
|
||||
-- engine/overworld/movement.asm Func_5288 -> Func_532b), and the
|
||||
-- PAD_RIGHT x2 shove is NOT queued alongside it:
|
||||
-- OaksLabRivalTakesPokeballScript .asm_1c564 polls every frame and
|
||||
-- only simulates the pair once wNPCNumScriptedSteps reads 1 -- i.e.
|
||||
-- as the rival begins the LAST byte, the step onto the tile the
|
||||
-- player is standing on. Starting both on one row had Red stroll
|
||||
-- off the Eevee while the rival was still at the top of the table
|
||||
-- (#559).
|
||||
if py == 4 then
|
||||
rows[#rows + 1] = { "walk_npc", RIVAL,
|
||||
{ "down", "right", "right", "right" }, { wait = false } }
|
||||
rows[#rows + 1] = { "walk_npc", RIVAL, { "down", "right", "right" } }
|
||||
-- this one runs concurrently with the shove below
|
||||
rows[#rows + 1] = { "walk_npc", RIVAL, { "right" }, { wait = false } }
|
||||
rows[#rows + 1] = { "face_player_dir", "left" }
|
||||
rows[#rows + 1] = { "move_player", "right", 2 }
|
||||
-- let the rival finish the last stretch to (7,4)
|
||||
rows[#rows + 1] = { "wait", 40 }
|
||||
-- move_player blocks for both tiles, so the rival has already
|
||||
-- landed on (7,4); this is just the beat before he turns up
|
||||
rows[#rows + 1] = { "wait", 20 }
|
||||
else
|
||||
rows[#rows + 1] = { "move_npc_to", RIVAL, 7, 4 }
|
||||
end
|
||||
|
||||
+14
-3
@@ -647,7 +647,12 @@ M.WARDENS_HOUSE = {
|
||||
TEXT_WARDENSHOUSE_WARDEN = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
|
||||
{ "jump_if_true", 13 }, -- 3
|
||||
-- #535: previously jumped to the same silent-end target as the
|
||||
-- give-then-thank fallthrough (row 13), so the warden said nothing
|
||||
-- on every visit after the trade. pokered's .got_item branch
|
||||
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText,
|
||||
-- so route here to the new row 17 that does the same.
|
||||
{ "jump_if_true", 17 }, -- 3
|
||||
{ "check_item", "GOLD_TEETH" }, -- 4
|
||||
{ "jump_if_false", 15 }, -- 5
|
||||
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
|
||||
@@ -658,9 +663,15 @@ M.WARDENS_HOUSE = {
|
||||
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
|
||||
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
|
||||
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
|
||||
{ "jump", 16 }, -- 13 (already got it)
|
||||
{ "jump", 16 }, -- 14 (unused)
|
||||
{ "jump", 18 }, -- 13 (already got it this convo; jp .done)
|
||||
{ "jump", 18 }, -- 14 (unused)
|
||||
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
|
||||
{ "jump", 18 }, -- 16 (#535: skip the new explanation row below)
|
||||
-- #535: pokered .got_item branch (scripts/WardensHouse.asm) --
|
||||
-- printed on every subsequent talk once EVENT_GOT_HM04 is set.
|
||||
-- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm):
|
||||
-- HM04 teaches Strength, and hints at the Safari Zone secret house.
|
||||
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 17
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+130
-11
@@ -413,22 +413,141 @@ M.POKEMON_FAN_CLUB = {
|
||||
},
|
||||
}
|
||||
|
||||
-- BikeShopClerkText (scripts/BikeShop.asm) runs three ways: the BICYCLE is
|
||||
-- already yours, you are carrying the BIKE VOUCHER, or you get the sales
|
||||
-- pitch. The pitch draws its own window (TextBoxBorder hlcoord 0,0, b=4
|
||||
-- c=15) holding BikeShopMenuText and BikeShopMenuPrice, and leaves it up
|
||||
-- while the clerk keeps talking in the bottom box: the original never
|
||||
-- erases it before TextScriptEnd (#568).
|
||||
local BikeShopWindow = {}
|
||||
BikeShopWindow.__index = BikeShopWindow
|
||||
|
||||
function BikeShopWindow.new(game, footer, onChoose)
|
||||
local self = setmetatable({}, BikeShopWindow)
|
||||
self.game = game
|
||||
self.onChoose = onChoose
|
||||
self.index = 1
|
||||
self.active = true
|
||||
-- BikeShopClerkDoYouLikeItText stays on screen under the window for as
|
||||
-- long as the menu is up. The text box pushed on top types it out and
|
||||
-- pops itself; this copy of its last page takes over from there, so the
|
||||
-- bottom box never blanks between the pitch and the answer. Paginated
|
||||
-- with the themed column budget so the copy breaks where the box did.
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local box = require("src.ui.Theme").textBox or {}
|
||||
local pages = TextBox.paginate(TextBox.substitute(game, footer), box.maxCols)
|
||||
self.footer = pages[#pages]
|
||||
return self
|
||||
end
|
||||
|
||||
function BikeShopWindow:update()
|
||||
-- one answer only: the boxes pushed by onChoose sit on top of this
|
||||
-- state, but a second A on the same frame must not fire it twice
|
||||
if not self.active then return end
|
||||
local input = self.game.input
|
||||
-- HandleMenuInput with wMenuWrappingEnabled clear: the two rows clamp
|
||||
if input:wasPressed("up") then
|
||||
self.index = 1
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = 2
|
||||
elseif input:wasPressed("a") or input:wasPressed("b") then
|
||||
local cancelled = input:wasPressed("b") -- bit B_PAD_B -> .cancel
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
self.active = false
|
||||
self.onChoose(not cancelled and self.index == 1)
|
||||
end
|
||||
end
|
||||
|
||||
function BikeShopWindow:draw()
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Theme = require("src.ui.Theme")
|
||||
Font.drawBox(0, 0, 17, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local bike = self.game.data.items.BICYCLE
|
||||
Font.draw(bike and bike.name or "BICYCLE", 16, 16) -- hlcoord 2, 2
|
||||
Font.draw("¥1000000", 64, 24) -- hlcoord 8, 3
|
||||
Font.draw(Strings("CANCEL"), 16, 32) -- `next` skips a row
|
||||
-- wTopMenuItemX 1, wTopMenuItemY 2, rows two apart
|
||||
Font.drawCode(Theme.cursor, 8, self.index == 1 and 16 or 32)
|
||||
if self.footer then
|
||||
-- same geometry TextBox resolves against, so a themed box matches
|
||||
local box = Theme.textBox or {}
|
||||
local tx, ty = box.tx or 0, box.ty or 12
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.drawBox(tx, ty, box.tw or 20, box.th or 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, line in ipairs(self.footer) do
|
||||
Font.draw(line, (tx + 1) * 8, (ty + 2 * i) * 8)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
M.BIKE_SHOP = {
|
||||
talk = {
|
||||
TEXT_BIKESHOP_CLERK = function(game, ow, npc, done)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Flags = require("src.script.Flags")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
if (game.save.inventory.BICYCLE or 0) > 0 then
|
||||
game.stack:push(TextBox.new(game, "How's the\nBICYCLE treating\nyou?", done))
|
||||
elseif (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
|
||||
game.save.inventory.BIKE_VOUCHER = nil
|
||||
game.save.inventory.BICYCLE = 1
|
||||
game.stack:push(TextBox.new(game,
|
||||
("Oh, that's a\nBIKE VOUCHER!\f%s exchanged\nit for a BICYCLE!")
|
||||
:format(game.save.player.name), done))
|
||||
else
|
||||
game.stack:push(TextBox.new(game,
|
||||
"A BICYCLE costs\n¥1000000. Sorry,\nno instalments!", done))
|
||||
local t = game.data.text
|
||||
local function say(text, after, opts)
|
||||
game.stack:push(TextBox.new(game, text, after, opts))
|
||||
end
|
||||
|
||||
-- CheckEvent EVENT_GOT_BICYCLE. Saves made before the clerk started
|
||||
-- setting the event still have the bike in the bag, so either counts.
|
||||
if (game.save.inventory.BICYCLE or 0) > 0
|
||||
or Flags.get(game.save, "EVENT_GOT_BICYCLE") then
|
||||
say(t._BikeShopClerkHowDoYouLikeYourBicycleText, done)
|
||||
return
|
||||
end
|
||||
|
||||
-- .dontHaveBike: IsItemInBag BIKE_VOUCHER
|
||||
if (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
|
||||
say(t._BikeShopClerkOhThatsAVoucherText, function()
|
||||
-- GiveItem's `jr nc, .BagFull`: the voucher is only spent once
|
||||
-- the BICYCLE is actually in the bag
|
||||
if not Bag.add(game.save, "BICYCLE", 1) then
|
||||
say(t._BikeShopBagFullText, done)
|
||||
return
|
||||
end
|
||||
Bag.remove(game.save, "BIKE_VOUCHER", 1)
|
||||
Flags.set(game.save, "EVENT_GOT_BICYCLE")
|
||||
-- BikeShopExchangedVoucherText carries sound_get_key_item; the
|
||||
-- map runs EnableAutoTextBoxDrawing, so the box still waits for
|
||||
-- a button once the jingle has played (auto.wait, #247)
|
||||
say(t._BikeShopExchangedVoucherText, done, {
|
||||
auto = { wait = true, sound = function()
|
||||
return require("src.core.Sound").play(game.data, "Get_Key_Item")
|
||||
end },
|
||||
})
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
-- .dontHaveVoucher: welcome, then the BICYCLE/CANCEL window
|
||||
say(t._BikeShopClerkWelcomeText, function()
|
||||
local pitch = t._BikeShopClerkDoYouLikeItText
|
||||
game.stack:push(BikeShopWindow.new(game, pitch, function(bought)
|
||||
local function comeAgain()
|
||||
say(t._BikeShopComeAgainText, function()
|
||||
game.stack:pop() -- the window, still up under the text
|
||||
done()
|
||||
end)
|
||||
end
|
||||
if bought then
|
||||
-- a million is out of anyone's reach: BikeShopCantAffordText
|
||||
say(t._BikeShopCantAffordText, comeAgain)
|
||||
else
|
||||
comeAgain()
|
||||
end
|
||||
end))
|
||||
-- PrintText BikeShopClerkDoYouLikeItText, then straight into
|
||||
-- HandleMenuInput: the box types out and hands over without
|
||||
-- waiting, leaving the window's copy of the line on screen
|
||||
say(pitch, nil, { auto = { delay = 0 } })
|
||||
end)
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
+72
-15
@@ -111,8 +111,9 @@ M.POKEMON_TOWER_5F = {
|
||||
--
|
||||
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
|
||||
-- Silph Scope check at the trigger -- the scope only decides whether the
|
||||
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
|
||||
-- balls dodged). An earlier version of this port turned the player back
|
||||
-- disguise sticks (IsGhostBattle -> makeGhost: "too scared to move", balls
|
||||
-- dodged) or comes off in the unveil (makeUnveiledGhost, #492). An earlier
|
||||
-- version of this port turned the player back
|
||||
-- without the scope and never opened the battle, which made 6F
|
||||
-- impassable on any route that skips Rocket Hideout; vanilla lets the
|
||||
-- battle open and a POKE_DOLL end it (see wBattleResult below).
|
||||
@@ -133,7 +134,13 @@ M.POKEMON_TOWER_6F = {
|
||||
-- or not the scope revealed it, so the "can't be caught" state rides
|
||||
-- the battle instead of IsGhostBattle alone (#444)
|
||||
battle.noCatch = true
|
||||
if not game.save.inventory.SILPH_SCOPE then
|
||||
-- InitWildBattle enters disguised for the RESTLESS SOUL either way
|
||||
-- (core.asm:6698-6700). The scope does not skip the disguise, it
|
||||
-- buys the unveil PrintBeginningBattleText .isMarowak plays over it
|
||||
-- before the battle proceeds as an ordinary wild one (#492).
|
||||
if game.save.inventory.SILPH_SCOPE then
|
||||
battle:makeUnveiledGhost()
|
||||
else
|
||||
battle:makeGhost()
|
||||
end
|
||||
battle.onFinish = function(result)
|
||||
@@ -353,6 +360,38 @@ M.ROCKET_HIDEOUT_B4F = {
|
||||
end))
|
||||
end,
|
||||
|
||||
-- Yellow swaps Rocket1/Rocket2 for Jessie & James and keeps a single
|
||||
-- grunt, spelled ROCKETHIDEOUTB4F_ROCKET / TEXT_ROCKETHIDEOUTB4F_ROCKET
|
||||
-- (pokeyellow/scripts/RocketHideoutB4F.asm), so the Red/Blue key above
|
||||
-- never matched there and the LIFT KEY never dropped (#552, a
|
||||
-- regression of #105). Yellow also moves the drop off the second
|
||||
-- talk: RocketHideoutB4FRocketEndBattleText is text_promptbutton +
|
||||
-- text_asm, so its SetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
|
||||
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5 run the instant the battle ends.
|
||||
TEXT_ROCKETHIDEOUTB4F_ROCKET = function(game, ow, npc, done)
|
||||
if not ow:trainerDefeated(npc) then
|
||||
ow:engageTrainer(npc, function()
|
||||
-- engageTrainer records the win before it calls back, so this
|
||||
-- is the end-battle text's SetEvent + ShowObject
|
||||
if ow:trainerDefeated(npc)
|
||||
and not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
|
||||
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.show_object(
|
||||
{ game = game, save = game.save, overworld = ow },
|
||||
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
|
||||
end
|
||||
done()
|
||||
end)
|
||||
return
|
||||
end
|
||||
-- RocketHideoutB4FRocketAfterBattleText: later talks only reprint
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text._RocketHideoutB4FRocketAfterBattleText
|
||||
or "Oh no! I dropped\nthe LIFT KEY!", done))
|
||||
end,
|
||||
|
||||
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
|
||||
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
|
||||
-- owns both the engage and the BeatGiovanniScript aftermath.
|
||||
@@ -502,45 +541,56 @@ M.GAME_CORNER = {
|
||||
end))
|
||||
end)
|
||||
end,
|
||||
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
|
||||
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
|
||||
-- object const and from every one of his text labels
|
||||
-- (GameCornerClerkText, pokeyellow/scripts/GameCorner.asm) with an
|
||||
-- identical body, so each line resolves under both spellings and the
|
||||
-- handler is bound to both text ids just below (#552).
|
||||
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local t = game.data.text
|
||||
local function line(suffix, fallback)
|
||||
return t["_GameCornerClerk1" .. suffix]
|
||||
or t["_GameCornerClerk" .. suffix]
|
||||
or fallback
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._GameCornerClerk1DoYouNeedSomeGameCoinsText
|
||||
or "Do you need some\ngame coins?\f¥1000 for 50."), function()
|
||||
line("DoYouNeedSomeGameCoinsText",
|
||||
"Do you need some\ngame coins?\f¥1000 for 50."), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1PleaseComePlaySometimeText
|
||||
or "No? Please come\nplay sometime!", done))
|
||||
line("PleaseComePlaySometimeText",
|
||||
"No? Please come\nplay sometime!"), done))
|
||||
return
|
||||
end
|
||||
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
|
||||
-- the COIN CASE and room for at least 9 coins (Has9990Coins)
|
||||
if not game.save.inventory.COIN_CASE then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1DontHaveCoinCaseText
|
||||
or "You don't have a\nCOIN CASE!", done))
|
||||
line("DontHaveCoinCaseText",
|
||||
"You don't have a\nCOIN CASE!"), done))
|
||||
return
|
||||
end
|
||||
if (game.save.coins or 0) >= 9990 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1CoinCaseIsFullText
|
||||
or "Oops! Your COIN\nCASE is full.", done))
|
||||
line("CoinCaseIsFullText",
|
||||
"Oops! Your COIN\nCASE is full."), done))
|
||||
return
|
||||
end
|
||||
if game.save.money < 1000 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._GameCornerClerk1CantAffordTheCoinsText
|
||||
or "You can't afford\nthe coins!", done))
|
||||
line("CantAffordTheCoinsText",
|
||||
"You can't afford\nthe coins!"), done))
|
||||
return
|
||||
end
|
||||
game.save.money = game.save.money - 1000
|
||||
game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._GameCornerClerk1ThanksHereAre50CoinsText
|
||||
or "Thanks! Here are\nyour 50 coins!")
|
||||
line("ThanksHereAre50CoinsText",
|
||||
"Thanks! Here are\nyour 50 coins!")
|
||||
.. ("\fCOINS: %d"):format(game.save.coins), done))
|
||||
end))
|
||||
end))
|
||||
@@ -548,6 +598,13 @@ M.GAME_CORNER = {
|
||||
},
|
||||
}
|
||||
|
||||
-- Yellow's coin clerk is GAMECORNER_CLERK / TEXT_GAMECORNER_CLERK where Red
|
||||
-- and Blue spell him CLERK1, and the two text_asm bodies are identical, so
|
||||
-- one handler answers both ids. Without the alias the Yellow clerk fell
|
||||
-- through to his extracted offer line with no yes/no box behind it (#552).
|
||||
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK =
|
||||
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1
|
||||
|
||||
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
|
||||
-- The six mon prizes differ between Red and Blue; the three TM prizes are
|
||||
-- identical, so they are shared and appended to each version's mon list.
|
||||
|
||||
+12
-1
@@ -790,8 +790,19 @@ local function bikeGateGuard(coords, stopText, explainText)
|
||||
local t = text(game)
|
||||
push(game, t[stopText] or "Hey! Wait up!", function()
|
||||
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
|
||||
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript
|
||||
-- (scripts/Route16Gate1F.asm, Route18Gate1F.asm) simulate one
|
||||
-- PAD_RIGHT step after the refusal text, and only clear
|
||||
-- wJoyIgnore / hand control back once that step finishes
|
||||
-- (PlayerMovingRightScript). Without it the player was left
|
||||
-- parked beside the guard's counter with no way past. #518
|
||||
local function shoveRight()
|
||||
ow:scriptMove(ow.player, "right", 1)
|
||||
end
|
||||
if dist > 0 then
|
||||
ow:scriptMove(ow.player, "up", dist)
|
||||
ow:scriptMove(ow.player, "up", dist, shoveRight)
|
||||
else
|
||||
shoveRight()
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
@@ -65,6 +65,13 @@ display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
|
||||
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
|
||||
put the row back.
|
||||
|
||||
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
|
||||
to AUTO, which reads this device as an ARM Linux handheld and resolves to
|
||||
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
|
||||
so the overworld runs smoothly on the H700 out of the box. Bump it to
|
||||
BALANCED or HIGH from OPTIONS if you want the extras and your device keeps
|
||||
up; see [Performance tier](new-features.md#performance-tier-low-end-devices).
|
||||
|
||||
The pack bundles the LÖVE 11.5 aarch64 runtime from
|
||||
[PortMaster](https://portmaster.games/), so the device does not need a
|
||||
separate `love_11.5` runtime download on first launch. The launcher resolves
|
||||
|
||||
@@ -12,7 +12,7 @@ this port.
|
||||
|
||||
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) |
|
||||
| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
|
||||
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
|
||||
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
|
||||
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
|
||||
@@ -38,6 +38,10 @@ this port.
|
||||
|
||||
## Notes
|
||||
|
||||
- Mods may patch `constants.bagSize` through the public content registry. The
|
||||
native `save.lua` format keeps every existing item when the configured
|
||||
limit changes; exporting to a cartridge `.sav` still writes only the first
|
||||
20 bag slots because the original SRAM layout has no room for more.
|
||||
- PC Box **overflow handling** was deliberately changed even though the
|
||||
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
|
||||
or blocks the deposit," this port spills into the next box with room.
|
||||
|
||||
@@ -187,5 +187,18 @@ composited and before touch controls draw. The window-space viewport contains
|
||||
and `dpiY`, so a tool can use the letterbox margins without drawing over the
|
||||
playfield or pushing an updating game state.
|
||||
|
||||
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
|
||||
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
|
||||
the mod full control of the window, while calling `next` runs the engine's
|
||||
normal single-window composite so the mod can decorate around it. `ctx` carries
|
||||
the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
|
||||
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
|
||||
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
|
||||
palette-correct blit of either canvas into an arbitrary screen rect, and the
|
||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`)
|
||||
for driving a second physical display. This is what lets a mod lay the two
|
||||
passes out as two stacked Game Boy screens, or push one onto a second screen,
|
||||
without the engine knowing the layout.
|
||||
|
||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||
that reach outside their permission set.
|
||||
|
||||
+72
-2
@@ -143,6 +143,41 @@ effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
|
||||
its launcher because the device reports `"Linux"` while its GPU is in the
|
||||
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
|
||||
|
||||
## Performance tier (low-end devices)
|
||||
|
||||
The Options **PERFORMANCE** row scales the port's optional presentation
|
||||
extras down for weaker hardware. The extras it governs are the three
|
||||
heaviest things the port adds on top of the original -- the whole-screen 3D
|
||||
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
|
||||
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
|
||||
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
|
||||
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
|
||||
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
|
||||
only in how much eye-candy the renderer is allowed to do.
|
||||
|
||||
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
|
||||
| ------------ | ---- | ------ | ----------- | ----------------- |
|
||||
| **HIGH** | on | on | on | none |
|
||||
| **BALANCED** | off | off | on | none |
|
||||
| **LOW** | off | off | off | 60 |
|
||||
| **AUTO** | picks a default from the device (below) |||
|
||||
|
||||
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
|
||||
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
|
||||
desktops to **BALANCED**, and everything else -- a normal desktop, and
|
||||
every existing `options.lua` that predates this option -- to **HIGH**,
|
||||
so the common case is unchanged. See `src/core/Performance.detect`.
|
||||
- AUTO only chooses the *default*; all four tiers are selectable, so a
|
||||
wrong guess is one row away from being overridden.
|
||||
- The clamps are applied **live** against your stored options and never
|
||||
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
|
||||
FX / ZOOM without forgetting them -- raising the tier restores exactly
|
||||
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
|
||||
saved choice on a clamped tier: it's your preference, waiting for a tier
|
||||
that can afford it.)
|
||||
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
|
||||
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
|
||||
|
||||
## Peer-to-peer link play (lua-enet)
|
||||
|
||||
Trades and link battles connect two copies of the game directly over
|
||||
@@ -157,6 +192,13 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
|
||||
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
|
||||
also exercises real UDP pairing.
|
||||
|
||||
Red, Blue, and Yellow copies link with each other, as the real cable
|
||||
does. The compatibility fingerprint hashes only data a link mode can
|
||||
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
|
||||
only R/B/Y link-surface difference) no longer read as different games
|
||||
(issue #511). Moving the fingerprint is a link parity change: builds
|
||||
from before this fix will refuse to pair with builds after it.
|
||||
|
||||
## Fair play in link and online matches
|
||||
|
||||
A link session is decided by the battle and nothing else, so for its
|
||||
@@ -273,7 +315,7 @@ the overlay on and lets the mouse act as a finger (`=0` forces it off).
|
||||
|
||||
The launcher's **Touch Controls** button opens a drag editor: move each
|
||||
button freely, **Disable** to hide the overlay permanently (for
|
||||
controllers / emulation handhelds — distinct from the temporary
|
||||
controllers / emulation handhelds -- distinct from the temporary
|
||||
gamepad auto-hide), **Reset** for defaults, **Done** to save into
|
||||
`options.lua` as normalized window fractions so rotation keeps the
|
||||
relative placement. In-game, Options → **TOUCH PAD** toggles the same
|
||||
@@ -338,7 +380,8 @@ semantics - so the two windows read as one app. Six tabs:
|
||||
party dock, so deposit and withdraw live in one place. Empty slots are
|
||||
clickable and create a mon there.
|
||||
- **Items**: money, a searchable item picker (replacing the arrows that
|
||||
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
|
||||
cycled one id at a time through ~250 items), the configurable bag (20 slots
|
||||
by default), PC storage
|
||||
with no slot cap, and the eight badges as toggle chips.
|
||||
- **Events**: flags, defeated trainers, taken items and per-map object
|
||||
toggles, with a real filter field and a two-column paged grid.
|
||||
@@ -390,3 +433,30 @@ own repositories. No index ships with the launcher and none is ever added
|
||||
automatically, so the tab opens on an "Add an index" prompt until you name
|
||||
one; paste an index URL or its `owner/repo` and it is remembered in
|
||||
`options.lua`. More than one index can be added, and the listings merge.
|
||||
|
||||
## Soft reset (all versions)
|
||||
|
||||
Holding A, B, START and SELECT together restarts the game the way flicking
|
||||
a Game Boy's power switch did, dropping straight back to the title screen.
|
||||
It works from anywhere, including mid-battle, which the QUIT entry on the
|
||||
start menu cannot do: the original combo is how stationary and gift
|
||||
Pokemon get their stats rerolled without sitting through a full relaunch.
|
||||
Unsaved progress is discarded, exactly as on hardware.
|
||||
|
||||
As on the original, the four buttons have to stay held for 16 straight
|
||||
polls (better than a quarter of a second) and any direction in the mix
|
||||
cancels it, so it is hard to hit by accident -- including on the on-screen
|
||||
touch controls, where it would take four fingers held on four separate
|
||||
controls.
|
||||
|
||||
## Controls rebinding (CONTROLS screen)
|
||||
|
||||
OPTIONS -> CONTROLS lists every Game Boy button with its current keyboard
|
||||
key and controller button side by side (Z/A). Press A on a row, then press
|
||||
and release the key or pad button you want; the rebind commits on the
|
||||
release. If that input already belongs to another row, the two rows swap,
|
||||
so no button is ever stranded without an input and no input ever serves
|
||||
two buttons. Holding a second key or pad button while the first is still
|
||||
down backs out of the capture without touching a keyboard; Escape still
|
||||
cancels too. SELECT clears one row back to its default, and START resets
|
||||
every binding after a confirmation.
|
||||
|
||||
@@ -461,14 +461,31 @@ function love.wheelmoved(x, y)
|
||||
Game:wheelmoved(x, y)
|
||||
end
|
||||
|
||||
function love.mousepressed(x, y, button)
|
||||
function love.mousepressed(x, y, button, istouch)
|
||||
if TouchEditor then
|
||||
-- Android primary touch already arrived via love.touchpressed; a second
|
||||
-- mouse path would double-fire Done / begin a second drag.
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousepressed(x, y, button)
|
||||
end
|
||||
if Importer then return Importer:mousepressed(x, y, button) end
|
||||
if Importer then
|
||||
-- The same double-fire TouchEditor guards against, which the launcher was
|
||||
-- missing: love.touchpressed above forwards the primary touch to the
|
||||
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that
|
||||
-- same touch, so one tap ran every launcher button twice. On Import that
|
||||
-- meant two choose() calls and two stacked SAF picker activities: the
|
||||
-- player picked their ROM, the top picker closed, and the second was still
|
||||
-- underneath asking for it again, which is the "import the file twice"
|
||||
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
|
||||
-- USB mouse) working, which an Android-wide return would have broken.
|
||||
--
|
||||
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
|
||||
-- returns early on iOS and never forwards, so there the synthesized mouse
|
||||
-- press is the ONLY event the launcher gets. Filtering istouch on both
|
||||
-- killed every tap on iOS outright.
|
||||
if istouch and love.system.getOS() == "Android" then return end
|
||||
return Importer:mousepressed(x, y, button)
|
||||
end
|
||||
if editorMode and EditorApp.mousepressed then
|
||||
return EditorApp.mousepressed(x, y, button)
|
||||
end
|
||||
|
||||
@@ -50,6 +50,29 @@ chosen file under the app save directory as `picked_rom.gb`,
|
||||
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
|
||||
itself remains data-free (no embedded ROM or generated cache).
|
||||
|
||||
### Step bridge (Pokéwalker mod)
|
||||
|
||||
`love.system.syncHealthSteps()` → `GameActivity.syncHealthSteps` (same
|
||||
JNI route as the picker: `common/android.cpp` →
|
||||
`modules/system/System.cpp` → `wrap_System.cpp`). The Java side does a
|
||||
one-shot read of the hardware `TYPE_STEP_COUNTER` sensor (cumulative
|
||||
since boot, counted by the OS whether or not any app runs), anchors the
|
||||
reading in `SharedPreferences` so a walk is never credited twice
|
||||
(a reading below the anchor means the phone rebooted → re-anchor without
|
||||
crediting), and stages the delta as `steps_pending.json` in the save
|
||||
identity dir — the same contract as the iOS `GRHealthBridge`. Nothing in
|
||||
the base game calls it; the consumer is the
|
||||
[Pokéwalker mod](https://github.com/mresnick67/Gen1ReComp-Pokewalker),
|
||||
installed as a mod `.zip` at runtime (its SYNC STEPS option defaults
|
||||
off).
|
||||
|
||||
Android 10+ gates the sensor behind the `ACTIVITY_RECOGNITION` runtime
|
||||
permission (declared in `app/src/main/AndroidManifest.xml`; keep it out
|
||||
of the build script's permission trim). The first
|
||||
`syncHealthSteps()` call shows the system prompt; on grant the sensor
|
||||
read runs immediately (`onRequestPermissionsResult`,
|
||||
`STEP_PERMISSION_REQUEST_CODE`).
|
||||
|
||||
### SDK / NDK
|
||||
|
||||
love-android 11.5a expects:
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
the link screen shows as "(Operation not permitted)" (issue #287).
|
||||
scripts/build_android.sh must not strip this again. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
|
||||
counter, which Android 10+ gates behind this runtime permission.
|
||||
Requested only on the first sync call (the Pokéwalker mod's SYNC
|
||||
STEPS option); scripts/build_android.sh must not strip it. -->
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||
<!-- OpenGL ES 2.0 -->
|
||||
<uses-feature android:glEsVersion="0x00020000" />
|
||||
<!-- Touchscreen support -->
|
||||
|
||||
@@ -219,6 +219,40 @@ bool showCreateDocument(const char *suggestedName)
|
||||
return result;
|
||||
}
|
||||
|
||||
bool syncHealthSteps()
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
jmethodID method = env->GetStaticMethodID(activity, "syncHealthSteps", "()Z");
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool restartApp()
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
|
||||
// Old APK / new liblove skew: fail soft so HostShell.restart can fall
|
||||
// back to a clean quit instead of aborting on a missing method (#575).
|
||||
jmethodID method = env->GetStaticMethodID(activity, "restartApp", "()Z");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Does not return on success: the Java side exits the process.
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method);
|
||||
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
* Helper functions for the filesystem module
|
||||
*/
|
||||
@@ -851,4 +885,51 @@ const char *getArg0()
|
||||
} // android
|
||||
} // love
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
int love_android_secondary_ready()
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID m = env->GetStaticMethodID(activity, "hasSecondaryDisplay", "()Z");
|
||||
jboolean ready = JNI_FALSE;
|
||||
if (m)
|
||||
ready = env->CallStaticBooleanMethod(activity, m);
|
||||
else
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return ready ? 1 : 0;
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
void love_android_push_secondary(const void *rgba, int w, int h)
|
||||
{
|
||||
if (!rgba || w <= 0 || h <= 0)
|
||||
return;
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID m = env->GetStaticMethodID(activity, "updateSecondaryFrame", "(Ljava/nio/ByteBuffer;II)V");
|
||||
if (m)
|
||||
{
|
||||
jobject buf = env->NewDirectByteBuffer((void*) rgba, (jlong) w * (jlong) h * 4);
|
||||
env->CallStaticVoidMethod(activity, m, buf, w, h);
|
||||
env->DeleteLocalRef(buf);
|
||||
}
|
||||
else
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
void love_android_secondary_enable(int on)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID m = env->GetStaticMethodID(activity, "setSecondaryEnabled", "(Z)V");
|
||||
if (m)
|
||||
env->CallStaticVoidMethod(activity, m, on ? JNI_TRUE : JNI_FALSE);
|
||||
else
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
#endif // LOVE_ANDROID
|
||||
|
||||
@@ -74,6 +74,22 @@ bool showFilePicker(const char *destFilename = nullptr);
|
||||
**/
|
||||
bool showCreateDocument(const char *suggestedName = nullptr);
|
||||
|
||||
/**
|
||||
* Pokéwalker step bridge: asks GameActivity to read the hardware step
|
||||
* counter and stage steps_pending.json in the save identity dir (see
|
||||
* GameActivity.syncHealthSteps). Returns whether a sync could start.
|
||||
*/
|
||||
bool syncHealthSteps();
|
||||
|
||||
/**
|
||||
* Full process relaunch (GameActivity.restartApp): schedules the app's
|
||||
* launch intent and kills the process, because the in-process
|
||||
* quit("restart") loop double-inits physfs and crashes (#575). On success
|
||||
* the process dies inside the Java call and this never returns; false
|
||||
* means the relaunch could not be scheduled.
|
||||
**/
|
||||
bool restartApp();
|
||||
|
||||
/*
|
||||
* Helper functions for the filesystem module
|
||||
*/
|
||||
|
||||
@@ -212,6 +212,24 @@ bool System::createFile(const char *suggestedName) const
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::syncHealthSteps() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::syncHealthSteps();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::restartApp() const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::restartApp();
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::hasBackgroundMusic() const
|
||||
{
|
||||
#if defined(LOVE_ANDROID)
|
||||
|
||||
@@ -127,6 +127,20 @@ public:
|
||||
**/
|
||||
virtual bool createFile(const char *suggestedName = nullptr) const;
|
||||
|
||||
/**
|
||||
* Pokéwalker: stage pending real-world steps (steps_pending.json in the
|
||||
* save dir) from the platform step source. Android-only; false elsewhere.
|
||||
*/
|
||||
virtual bool syncHealthSteps() const;
|
||||
|
||||
/**
|
||||
* Relaunches the whole app with a fresh process (Android only; false
|
||||
* elsewhere). The in-process love.event.quit("restart") double-inits
|
||||
* physfs on Android and crashes, so src/core/HostShell.lua calls this
|
||||
* instead (#575). Does not return on success -- the process exits.
|
||||
**/
|
||||
virtual bool restartApp() const;
|
||||
|
||||
/**
|
||||
* Gets if the user is playing music on background.
|
||||
* Throws an exception on unsupported platforms.
|
||||
|
||||
@@ -109,6 +109,20 @@ int w_createFile(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_syncHealthSteps(lua_State *L)
|
||||
{
|
||||
luax_pushboolean(L, instance()->syncHealthSteps());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_restartApp(lua_State *L)
|
||||
{
|
||||
// Does not return on success: GameActivity.restartApp exits the process
|
||||
// after scheduling the relaunch (#575).
|
||||
luax_pushboolean(L, instance()->restartApp());
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_hasBackgroundMusic(lua_State *L)
|
||||
{
|
||||
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
||||
@@ -126,6 +140,8 @@ static const luaL_Reg functions[] =
|
||||
{ "vibrate", w_vibrate },
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "createFile", w_createFile },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
{ "hasBackgroundMusic", w_hasBackgroundMusic },
|
||||
{ 0, 0 }
|
||||
};
|
||||
|
||||
@@ -37,17 +37,26 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.AlarmManager;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.res.AssetManager;
|
||||
import android.hardware.Sensor;
|
||||
import android.hardware.SensorEvent;
|
||||
import android.hardware.SensorEventListener;
|
||||
import android.hardware.SensorManager;
|
||||
import android.media.AudioManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.Vibrator;
|
||||
import android.util.Log;
|
||||
import android.util.DisplayMetrics;
|
||||
@@ -67,6 +76,8 @@ public class GameActivity extends SDLActivity {
|
||||
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
|
||||
public static final int FILE_PICKER_REQUEST_CODE = 4;
|
||||
public static final int FILE_CREATE_REQUEST_CODE = 5;
|
||||
public static final int STEP_PERMISSION_REQUEST_CODE = 6;
|
||||
public static final int RESTART_REQUEST_CODE = 7;
|
||||
/** @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
|
||||
@@ -83,8 +94,27 @@ public class GameActivity extends SDLActivity {
|
||||
// basename as its body, so RomImporter:focus can say so in the launcher
|
||||
// instead of leaving the player on "No ROM imported" (issue #442).
|
||||
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
|
||||
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
|
||||
// consumed by the Pokéwalker mod, same contract as the iOS
|
||||
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
|
||||
// (cumulative since boot, counted by the OS whether or not any app is
|
||||
// running), anchored in SharedPreferences so a walk is never credited
|
||||
// twice.
|
||||
private static final String PENDING_STEPS_FILENAME = "steps_pending.json";
|
||||
private static final String STEP_PREFS = "pokewalker_steps";
|
||||
private static final String STEP_PREF_ANCHOR = "anchor";
|
||||
private static final String STEP_PREF_ANCHOR_WALLTIME = "anchor_walltime";
|
||||
private static final long STEP_MAX_PER_SYNC = 50000;
|
||||
// Destination basename for the in-flight SAF pick (set by showFilePicker).
|
||||
// Saved/restored across instance state: the picker is a separate activity
|
||||
// and Android may destroy this one while it is up (memory pressure, or
|
||||
// "Don't keep activities"). A recreated instance still receives
|
||||
// onActivityResult, so without this a mod or save pick came back with the
|
||||
// field reset and was filed as picked_rom.gb, which Lua then rejected as a
|
||||
// bad ROM instead of installing it (#553).
|
||||
private String pendingPickFilename = PICKED_ROM_FILENAME;
|
||||
private static final String STATE_PENDING_PICK = "pendingPickFilename";
|
||||
private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName";
|
||||
// Suggested download name for the in-flight SAF create (set by showCreateDocument).
|
||||
private String pendingCreateSuggestedName = "export.sav";
|
||||
private static boolean immersiveActive = false;
|
||||
@@ -149,6 +179,14 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
if (savedInstanceState != null) {
|
||||
// Restore the in-flight SAF destinations, so a pick that returns to
|
||||
// a recreated activity still lands under the basename it asked for.
|
||||
String pick = savedInstanceState.getString(STATE_PENDING_PICK);
|
||||
if (pick != null) pendingPickFilename = pick;
|
||||
String create = savedInstanceState.getString(STATE_PENDING_CREATE);
|
||||
if (create != null) pendingCreateSuggestedName = create;
|
||||
}
|
||||
metrics = getResources().getDisplayMetrics();
|
||||
|
||||
// Set low-latency audio values
|
||||
@@ -296,12 +334,14 @@ public class GameActivity extends SDLActivity {
|
||||
Log.d("GameActivity", "Cancelling vibration");
|
||||
vibrator.cancel();
|
||||
}
|
||||
teardownSecondaryDisplay();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
setupSecondaryDisplay();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -410,15 +450,23 @@ public class GameActivity extends SDLActivity {
|
||||
* Shows the system document picker (Storage Access Framework) so the
|
||||
* 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.
|
||||
* is. The picked file (if any) arrives later in onActivityResult, not
|
||||
* synchronously here.
|
||||
*
|
||||
* API 21+ uses ACTION_OPEN_DOCUMENT; API 16-20 uses an ACTION_GET_CONTENT
|
||||
* chooser instead. Below 19 OPEN_DOCUMENT does not exist, and on 19/20
|
||||
* the stock DocumentsUI is unreliable -- it launches and then hands back
|
||||
* RESULT_CANCELED with no data, which onActivityResult cannot tell apart
|
||||
* from the player cancelling (#584). GET_CONTENT lets any installed file
|
||||
* manager serve the pick, and both intents return the same content:// or
|
||||
* file:// URI shapes, so the result path in onActivityResult stays
|
||||
* picker-agnostic and unchanged.
|
||||
*
|
||||
* @param destFilename basename under the app save identity (e.g.
|
||||
* picked_rom.gb, picked_mod.zip, picked_save.sav)
|
||||
*/
|
||||
@Keep
|
||||
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) {
|
||||
@@ -432,11 +480,26 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
|
||||
self.pendingPickFilename = destFilename;
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
if (android.os.Build.VERSION.SDK_INT >= 21) {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
try {
|
||||
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
// Some OEM / TV builds ship without DocumentsUI; fall through
|
||||
// to the GET_CONTENT chooser below instead of giving up (#584).
|
||||
Log.d("GameActivity", "could not open document picker: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("*/*");
|
||||
try {
|
||||
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
|
||||
self.startActivityForResult(
|
||||
Intent.createChooser(intent, "Choose a file"),
|
||||
FILE_PICKER_REQUEST_CODE);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not open file picker: " + e.getMessage());
|
||||
@@ -462,14 +525,60 @@ public class GameActivity extends SDLActivity {
|
||||
return showFilePicker(PICKED_SAVE_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relaunches the whole app for love.system.restartApp, used by
|
||||
* src/core/HostShell.lua when a mod toggle needs a cold boot (#575).
|
||||
* love.event.quit("restart") re-runs LOVE's boot inside the same
|
||||
* process, and the second love.filesystem.init throws once physfs
|
||||
* failed to deinit ("already initialized"), killing the app. Instead
|
||||
* we hand our launch intent to AlarmManager and then exit the process:
|
||||
* the alarm lives in system_server, so it survives our death and
|
||||
* cannot race the exit the way a plain startActivity right before
|
||||
* Runtime.exit can on some OEMs, and the dead process guarantees no
|
||||
* native (physfs / SDL / JNI) state leaks into the fresh run.
|
||||
*/
|
||||
@Keep
|
||||
public static boolean restartApp() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
try {
|
||||
Context context = self.getApplicationContext();
|
||||
Intent intent = context.getPackageManager()
|
||||
.getLaunchIntentForPackage(context.getPackageName());
|
||||
if (intent == null) return false;
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
int pendingFlags = PendingIntent.FLAG_CANCEL_CURRENT;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 23) {
|
||||
// Mandatory mutability flag on API 31+; harmless from 23 up.
|
||||
pendingFlags |= PendingIntent.FLAG_IMMUTABLE;
|
||||
}
|
||||
PendingIntent pending = PendingIntent.getActivity(
|
||||
context, RESTART_REQUEST_CODE, intent, pendingFlags);
|
||||
AlarmManager alarm = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
|
||||
if (alarm == null) return false;
|
||||
alarm.set(AlarmManager.RTC, System.currentTimeMillis() + 250, pending);
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not schedule restart: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
Runtime.getRuntime().exit(0);
|
||||
return true; // unreachable, but keeps the JNI signature honest
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* CREATE_DOCUMENT does not exist below API 19 and has no pre-SAF
|
||||
* equivalent, so unlike showFilePicker this stays 19+ (#584); the false
|
||||
* return degrades on the Lua side (RomImporter export) to "Exported
|
||||
* inside the app folder", which is the correct pre-KitKat behavior.
|
||||
*/
|
||||
@Keep
|
||||
public static boolean showCreateDocument(String suggestedName) {
|
||||
if (android.os.Build.VERSION.SDK_INT < 19) return false;
|
||||
// (see showFilePicker for why the import side got a pre-19 path)
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (suggestedName == null || suggestedName.length() == 0) {
|
||||
@@ -516,6 +625,160 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step sync, called from Lua as love.system.syncHealthSteps()
|
||||
* (see modules/system/wrap_System.cpp). Asynchronous like the picker:
|
||||
* returns whether a sync could be started; the result lands later as
|
||||
* steps_pending.json in the save identity dir, where the Pokéwalker
|
||||
* mod's poll consumes it.
|
||||
*
|
||||
* Android 10+ gates the step counter behind the ACTIVITY_RECOGNITION
|
||||
* runtime permission; the first call shows the system prompt and a later
|
||||
* sync (the mod retries on save load / option change) delivers.
|
||||
*/
|
||||
@Keep
|
||||
public static boolean syncHealthSteps() {
|
||||
final GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 29
|
||||
&& ActivityCompat.checkSelfPermission(self,
|
||||
Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
self.runOnUiThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
ActivityCompat.requestPermissions(self,
|
||||
new String[]{Manifest.permission.ACTIVITY_RECOGNITION},
|
||||
STEP_PERMISSION_REQUEST_CODE);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
self.startStepSensorRead();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot read of the cumulative hardware step counter. The sensor
|
||||
* usually reports its cached value moments after registration; some
|
||||
* devices hold the event until the next physical step, so the listener
|
||||
* is given 20 seconds before being torn down (the next sync retries).
|
||||
*/
|
||||
private void startStepSensorRead() {
|
||||
final SensorManager manager =
|
||||
(SensorManager) getSystemService(Context.SENSOR_SERVICE);
|
||||
if (manager == null) return;
|
||||
Sensor counter = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
|
||||
if (counter == null) {
|
||||
Log.d("GameActivity", "no step counter sensor on this device");
|
||||
return;
|
||||
}
|
||||
final SensorEventListener listener = new SensorEventListener() {
|
||||
private boolean delivered = false;
|
||||
|
||||
@Override
|
||||
public void onSensorChanged(SensorEvent event) {
|
||||
if (delivered || event.values.length == 0) return;
|
||||
delivered = true;
|
||||
manager.unregisterListener(this);
|
||||
deliverSteps((long) event.values[0]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAccuracyChanged(Sensor sensor, int accuracy) {
|
||||
}
|
||||
};
|
||||
if (!manager.registerListener(listener, counter,
|
||||
SensorManager.SENSOR_DELAY_NORMAL)) {
|
||||
Log.d("GameActivity", "step counter listener registration failed");
|
||||
return;
|
||||
}
|
||||
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
// No-op if the listener already delivered and unregistered.
|
||||
manager.unregisterListener(listener);
|
||||
}
|
||||
}, 20000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a cumulative counter reading into pending steps. The counter
|
||||
* resets to zero on reboot: a reading below the stored anchor re-anchors
|
||||
* without crediting (steps walked between the reboot and this sync are
|
||||
* lost, which errs on the honest side).
|
||||
*/
|
||||
private void deliverSteps(long counterNow) {
|
||||
SharedPreferences prefs = getSharedPreferences(STEP_PREFS, MODE_PRIVATE);
|
||||
long anchor = prefs.getLong(STEP_PREF_ANCHOR, -1);
|
||||
long now = System.currentTimeMillis();
|
||||
if (anchor < 0 || counterNow < anchor) {
|
||||
prefs.edit()
|
||||
.putLong(STEP_PREF_ANCHOR, counterNow)
|
||||
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
|
||||
.apply();
|
||||
Log.d("GameActivity", "step anchor set at " + counterNow);
|
||||
return;
|
||||
}
|
||||
long steps = Math.min(counterNow - anchor, STEP_MAX_PER_SYNC);
|
||||
long fromWalltime = prefs.getLong(STEP_PREF_ANCHOR_WALLTIME, now);
|
||||
if (steps <= 0) return;
|
||||
prefs.edit()
|
||||
.putLong(STEP_PREF_ANCHOR, counterNow)
|
||||
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
|
||||
.apply();
|
||||
|
||||
File dir = saveIdentityDir();
|
||||
if (!dir.isDirectory() && !dir.mkdirs()) {
|
||||
Log.d("GameActivity", "cannot create save dir for steps: " + dir);
|
||||
return;
|
||||
}
|
||||
File pending = new File(dir, PENDING_STEPS_FILENAME);
|
||||
long total = steps;
|
||||
// Merge with an unconsumed earlier delivery so steps are never lost
|
||||
// (same contract as the iOS bridge).
|
||||
if (pending.isFile()) {
|
||||
try {
|
||||
byte[] raw = new byte[(int) Math.min(pending.length(), 4096)];
|
||||
FileInputStream in = new FileInputStream(pending);
|
||||
int read = in.read(raw);
|
||||
in.close();
|
||||
if (read > 0) {
|
||||
org.json.JSONObject old =
|
||||
new org.json.JSONObject(new String(raw, 0, read, "UTF-8"));
|
||||
total += Math.max(0, old.optLong("steps", 0));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "ignoring unreadable pending steps: " + e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
org.json.JSONObject payload = new org.json.JSONObject();
|
||||
payload.put("steps", total);
|
||||
payload.put("from", isoTime(fromWalltime));
|
||||
payload.put("to", isoTime(now));
|
||||
File tmp = new File(dir, PENDING_STEPS_FILENAME + ".tmp");
|
||||
FileOutputStream out = new FileOutputStream(tmp);
|
||||
out.write(payload.toString().getBytes("UTF-8"));
|
||||
out.close();
|
||||
if (!tmp.renameTo(pending)) {
|
||||
tmp.delete();
|
||||
Log.d("GameActivity", "could not publish pending steps");
|
||||
return;
|
||||
}
|
||||
Log.d("GameActivity", total + " steps pending for the Pokewalker mod");
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "could not write pending steps: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String isoTime(long millis) {
|
||||
java.text.SimpleDateFormat format =
|
||||
new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US);
|
||||
format.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
|
||||
return format.format(new java.util.Date(millis));
|
||||
}
|
||||
|
||||
private boolean copyFileToUri(File source, Uri destUri) {
|
||||
InputStream in = null;
|
||||
OutputStream out = null;
|
||||
@@ -539,6 +802,13 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putString(STATE_PENDING_PICK, pendingPickFilename);
|
||||
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
@@ -739,6 +1009,17 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case STEP_PERMISSION_REQUEST_CODE: {
|
||||
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
Log.d("GameActivity", "Step permission granted");
|
||||
// Deliver right away so the sync the player just
|
||||
// opted into doesn't wait for the next launch.
|
||||
startStepSensorRead();
|
||||
} else {
|
||||
Log.d("GameActivity", "Did not get step permission.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
}
|
||||
@@ -921,4 +1202,182 @@ public class GameActivity extends SDLActivity {
|
||||
return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
|
||||
}
|
||||
}
|
||||
|
||||
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
|
||||
// physical display. Driven from the engine through love_android_secondary_*
|
||||
// in src/jni/love/src/common/android.cpp.
|
||||
private static volatile SecondaryPresentation secondaryPresentation;
|
||||
private static volatile boolean secondaryEnabled = false;
|
||||
|
||||
@Keep
|
||||
public static void setSecondaryEnabled(final boolean on) {
|
||||
secondaryEnabled = on;
|
||||
final GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return;
|
||||
self.runOnUiThread(new Runnable() {
|
||||
@Override public void run() {
|
||||
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void setupSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
|
||||
try {
|
||||
android.hardware.display.DisplayManager dm =
|
||||
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
|
||||
if (dm == null) return;
|
||||
Display chosen = null;
|
||||
for (Display d : dm.getDisplays()) {
|
||||
android.graphics.Point size = new android.graphics.Point();
|
||||
d.getRealSize(size);
|
||||
Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName()
|
||||
+ " size=" + size.x + "x" + size.y);
|
||||
if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) {
|
||||
chosen = d;
|
||||
}
|
||||
}
|
||||
if (chosen == null) {
|
||||
Display[] pres =
|
||||
dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
|
||||
if (pres != null && pres.length > 0) chosen = pres[0];
|
||||
}
|
||||
if (chosen == null) {
|
||||
Log.d("GameActivity", "no secondary display found");
|
||||
return;
|
||||
}
|
||||
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
|
||||
p.show();
|
||||
secondaryPresentation = p;
|
||||
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
|
||||
} catch (Throwable t) {
|
||||
Log.d("GameActivity", "secondary display setup failed: " + t);
|
||||
secondaryPresentation = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void teardownSecondaryDisplay() {
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
secondaryPresentation = null;
|
||||
if (p != null) {
|
||||
try { p.dismiss(); } catch (Throwable t) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean hasSecondaryDisplay() {
|
||||
return secondaryPresentation != null;
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
if (p != null && buf != null && w > 0 && h > 0) {
|
||||
p.updateFrame(buf, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
private static class SecondaryPresentation extends android.app.Presentation {
|
||||
private final FrameView frameView;
|
||||
|
||||
SecondaryPresentation(Context context, Display display) {
|
||||
super(context, display);
|
||||
frameView = new FrameView(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
android.view.Window w = getWindow();
|
||||
if (w != null) {
|
||||
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
|
||||
w.setLayout(WindowManager.LayoutParams.MATCH_PARENT,
|
||||
WindowManager.LayoutParams.MATCH_PARENT);
|
||||
}
|
||||
setContentView(frameView);
|
||||
applyImmersive();
|
||||
frameView.post(new Runnable() {
|
||||
@Override public void run() { applyImmersive(); }
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) applyImmersive();
|
||||
}
|
||||
|
||||
private void applyImmersive() {
|
||||
android.view.Window w = getWindow();
|
||||
if (w == null) return;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 30) {
|
||||
w.setDecorFitsSystemWindows(false);
|
||||
android.view.WindowInsetsController c = w.getInsetsController();
|
||||
if (c != null) {
|
||||
c.hide(android.view.WindowInsets.Type.systemBars());
|
||||
c.setSystemBarsBehavior(
|
||||
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||
}
|
||||
} else {
|
||||
w.getDecorView().setSystemUiVisibility(
|
||||
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
|
||||
}
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
frameView.updateFrame(buf, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
private static class FrameView extends View {
|
||||
private android.graphics.Bitmap bitmap;
|
||||
private final android.graphics.Rect dst = new android.graphics.Rect();
|
||||
private final android.graphics.Paint paint = new android.graphics.Paint();
|
||||
private final Object lock = new Object();
|
||||
private int fw, fh;
|
||||
|
||||
FrameView(Context context) {
|
||||
super(context);
|
||||
paint.setFilterBitmap(false);
|
||||
paint.setAntiAlias(false);
|
||||
setBackgroundColor(0xFF000000);
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
synchronized (lock) {
|
||||
if (bitmap == null || fw != w || fh != h) {
|
||||
if (bitmap != null) bitmap.recycle();
|
||||
bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888);
|
||||
fw = w; fh = h;
|
||||
}
|
||||
buf.rewind();
|
||||
bitmap.copyPixelsFromBuffer(buf);
|
||||
}
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(android.graphics.Canvas canvas) {
|
||||
synchronized (lock) {
|
||||
if (bitmap == null || fw == 0 || fh == 0) return;
|
||||
int vw = getWidth(), vh = getHeight();
|
||||
int s = Math.min(vw / fw, vh / fh);
|
||||
if (s < 1) s = 1;
|
||||
int dw = fw * s, dh = fh * s;
|
||||
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
|
||||
dst.set(dx, dy, dx + dw, dy + dh);
|
||||
canvas.drawColor(0xFF000000);
|
||||
canvas.drawBitmap(bitmap, null, dst, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Pokemon Red</string>
|
||||
<string>gen1recomp</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -49,10 +49,6 @@
|
||||
<false/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>Launch Screen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>opengles-2</string>
|
||||
</array>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<true/>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
|
||||
@@ -156,7 +156,7 @@ return function(mod)
|
||||
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
|
||||
mod.save:set("caught_areas", caughtAreas())
|
||||
end
|
||||
Bag.add(self.game.save, ball, 1)
|
||||
Bag.add(self.game.save, ball, 1, self.game.data)
|
||||
self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
|
||||
or "You already have\nthis POKéMON family!")
|
||||
return
|
||||
|
||||
@@ -35,6 +35,7 @@ TARGET="all"
|
||||
NOTARY_PROFILE="notary-profile"
|
||||
NOTARIZE=true
|
||||
IOS_RELEASE=false
|
||||
IOS_IPA=false
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
@@ -48,6 +49,7 @@ while [ $# -gt 0 ]; do
|
||||
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
||||
--no-notarize) NOTARIZE=false ;;
|
||||
--release) IOS_RELEASE=true ;;
|
||||
--ipa) IOS_IPA=true ;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
@@ -298,6 +300,9 @@ build_android() {
|
||||
if [ "$VERSION_EXPLICIT" = true ]; then
|
||||
args+=(--version "$VERSION")
|
||||
fi
|
||||
if [ "$IOS_IPA" = true ]; then
|
||||
args+=(--ipa)
|
||||
fi
|
||||
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +198,10 @@ pack_game_love() {
|
||||
rm -f "$LOVE_FILE"
|
||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||
# row opens it in-process, so it must be inside the archive (see build.sh).
|
||||
# Deliberately NO fused mods: a mod inside game.love sits in the read-only
|
||||
# APK, so the mod manager's Delete can't remove it and it reappears every
|
||||
# launch. Pokewalker ships as an importable .zip instead, which gives it
|
||||
# a real install/upgrade/delete lifecycle.
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
@@ -290,10 +294,32 @@ require_android_sdk() {
|
||||
# --------------------------------------------------------------- gradle
|
||||
run_gradle() {
|
||||
local task="assembleEmbedNoRecordDebug"
|
||||
say "building APK ($task)"
|
||||
local build_dir="$ANDROID_DIR"
|
||||
|
||||
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
|
||||
# the project path ("Your APP_BUILD_SCRIPT points to an unknown file").
|
||||
# When this checkout lives at a spaced path (e.g. "~/xCode Projects/..."),
|
||||
# shadow the android tree to a space-free location and build there; the
|
||||
# shadow persists across runs so gradle/ndk builds stay incremental.
|
||||
case "$ANDROID_DIR" in
|
||||
*" "*)
|
||||
build_dir="${TMPDIR:-/tmp}/gen1recomp-android-shadow"
|
||||
say "path contains spaces (ndk-build cannot handle them);"
|
||||
say "shadow-building in: $build_dir"
|
||||
mkdir -p "$build_dir"
|
||||
rsync -a --delete \
|
||||
--exclude=".gradle" --exclude="app/build" --exclude="love/build" \
|
||||
--exclude="local.properties" \
|
||||
"$ANDROID_DIR/" "$build_dir/"
|
||||
if [ -f "$ANDROID_DIR/local.properties" ]; then
|
||||
cp "$ANDROID_DIR/local.properties" "$build_dir/local.properties"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
say "building APK ($task)"
|
||||
if ! (
|
||||
cd "$ANDROID_DIR"
|
||||
cd "$build_dir"
|
||||
./gradlew --no-daemon "$task"
|
||||
); then
|
||||
fail "gradle $task failed.
|
||||
@@ -302,7 +328,7 @@ run_gradle() {
|
||||
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
|
||||
fi
|
||||
|
||||
local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
|
||||
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
|
||||
if [ -d "$out_dir" ]; then
|
||||
say "APK output:"
|
||||
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
||||
|
||||
+153
-4
@@ -50,7 +50,7 @@ DISPLAY_NAME="gen1recomp"
|
||||
# capabilities like HealthKit are involved), so a per-team default
|
||||
# lets anyone build without colliding with someone else's app
|
||||
# 4. simulator: the project default (no App ID registration involved)
|
||||
BUNDLE_ID="${GEN1_BUNDLE_ID:-}"
|
||||
BUNDLE_ID="${GEN1_BUNDLE_ID:-com.theboisclub.gen1recomp}"
|
||||
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
|
||||
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
|
||||
fi
|
||||
@@ -65,6 +65,11 @@ DEVICE=false
|
||||
RELEASE=false
|
||||
PACKAGE_ONLY=false
|
||||
INSTALL=false
|
||||
CREATE_IPA=false
|
||||
# Last resort for an incomplete source export, mirroring build_android.sh.
|
||||
MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}"
|
||||
MANIFESTS=""
|
||||
|
||||
VERSION=""
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
@@ -78,6 +83,7 @@ while [ $# -gt 0 ]; do
|
||||
--release) RELEASE=true ;;
|
||||
--package-only) PACKAGE_ONLY=true ;;
|
||||
--install) INSTALL=true ;;
|
||||
--ipa) CREATE_IPA=true ;;
|
||||
--version) VERSION="$2"; shift ;;
|
||||
-h|--help)
|
||||
sed -n '2,24p' "$0"
|
||||
@@ -88,6 +94,10 @@ while [ $# -gt 0 ]; do
|
||||
shift
|
||||
done
|
||||
|
||||
if $CREATE_IPA; then
|
||||
DEVICE=true
|
||||
fi
|
||||
|
||||
VERSION_CODE=""
|
||||
if [ -n "$VERSION" ]; then
|
||||
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
@@ -228,7 +238,93 @@ apply_ios_branding() {
|
||||
cp "$OVERLAY_PLIST" "$dest"
|
||||
}
|
||||
|
||||
apply_ios_icon() {
|
||||
local source="$ROOT/assets/logo/logo.png"
|
||||
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
|
||||
[ -f "$source" ] || fail "missing iOS icon source: $source"
|
||||
[ -d "$target" ] || fail "missing iOS app icon set: $target"
|
||||
local entry name size
|
||||
while IFS=: read -r name size; do
|
||||
sips -z "$size" "$size" "$source" --out "$target/$name" >/dev/null
|
||||
done <<'EOF'
|
||||
icon-1024pt@1x.png:1024
|
||||
icon-29pt@1x.png:29
|
||||
icon-29pt@2x.png:58
|
||||
icon-29pt@3x.png:87
|
||||
icon-40pt@1x.png:40
|
||||
icon-40pt@2x.png:80
|
||||
icon-40pt@3x.png:120
|
||||
icon-60pt@2x.png:120
|
||||
icon-60pt@3x.png:180
|
||||
icon-76pt@1x.png:76
|
||||
icon-76pt@2x.png:152
|
||||
icon-83.5pt@2x.png:167
|
||||
EOF
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- game.love
|
||||
# Every version's import manifest has to ship or that game's ROM import fails in
|
||||
# the built app: decodeManifest (src/import/RomImporter.lua) errors outright when
|
||||
# one is absent, and dev reads them off the source tree, so the miss only ever
|
||||
# shows up in a build. iOS shipped without the Yellow one in 0.1.45 to 0.1.47
|
||||
# for exactly that reason.
|
||||
#
|
||||
# The list is READ OUT OF src/core/GameVersion.lua rather than hand-kept here, so
|
||||
# a fourth version cannot silently ship without its manifest, and a missing file
|
||||
# is recovered from Git or the project repo the same way build_android.sh already
|
||||
# recovers Yellow's. Recovery is a last resort for an incomplete source export:
|
||||
# a manifest carries extraction metadata only, never a ROM or game data.
|
||||
manifest_paths() {
|
||||
python3 - "$ROOT/src/core/GameVersion.lua" <<'PY'
|
||||
import re, sys
|
||||
src = open(sys.argv[1]).read()
|
||||
print(" ".join(dict.fromkeys(re.findall(r'manifest\s*=\s*"([^"]+)"', src))))
|
||||
PY
|
||||
}
|
||||
|
||||
manifest_is_valid() {
|
||||
python3 - "$1" <<'PY'
|
||||
import json, pathlib, sys
|
||||
try:
|
||||
m = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
except (OSError, ValueError):
|
||||
raise SystemExit(1)
|
||||
sha = m.get("romSha1")
|
||||
raise SystemExit(0 if isinstance(sha, str) and len(sha) == 40 else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
ensure_manifests() {
|
||||
MANIFESTS="$(manifest_paths)"
|
||||
[ -n "$MANIFESTS" ] \
|
||||
|| fail "could not read any manifest path out of src/core/GameVersion.lua"
|
||||
local rel staged
|
||||
for rel in $MANIFESTS; do
|
||||
if manifest_is_valid "$ROOT/$rel"; then continue; fi
|
||||
warn "$rel is missing or invalid; recovering it before packaging"
|
||||
staged="$(mktemp)"
|
||||
if git -C "$ROOT" show "HEAD:$rel" > "$staged" 2>/dev/null \
|
||||
&& manifest_is_valid "$staged"; then
|
||||
mkdir -p "$ROOT/$(dirname "$rel")"
|
||||
mv "$staged" "$ROOT/$rel"
|
||||
say "restored $rel from this checkout's Git data"
|
||||
continue
|
||||
fi
|
||||
if command -v curl >/dev/null 2>&1 \
|
||||
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||
--output "$staged" "$MANIFEST_BASE_URL/$rel" \
|
||||
&& manifest_is_valid "$staged"; then
|
||||
mkdir -p "$ROOT/$(dirname "$rel")"
|
||||
mv "$staged" "$ROOT/$rel"
|
||||
say "downloaded $rel from the project repository"
|
||||
continue
|
||||
fi
|
||||
rm -f "$staged"
|
||||
fail "$rel is unavailable: Git recovery failed and $MANIFEST_BASE_URL/$rel could not be downloaded"
|
||||
done
|
||||
say "import manifests: $MANIFESTS"
|
||||
}
|
||||
|
||||
pack_game_love() {
|
||||
say "packing game.love for love-ios resources"
|
||||
mkdir -p "$RESOURCES_DIR"
|
||||
@@ -240,9 +336,10 @@ pack_game_love() {
|
||||
# it reappears every launch. Mods install as .zips at runtime instead
|
||||
# (launcher -> MODS -> Import mod .zip), the same lifecycle as every
|
||||
# other platform.
|
||||
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
$MANIFESTS \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
# NOTE: grep -q here would race pipefail — it exits on first match, unzip
|
||||
@@ -252,8 +349,19 @@ pack_game_love() {
|
||||
| grep -E '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' >/dev/null; then
|
||||
fail "game.love unexpectedly contains generated ROM data"
|
||||
fi
|
||||
unzip -Z1 "$LOVE_FILE" | grep -x 'tools/save-editor/App.lua' >/dev/null \
|
||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||
# Same required-file gate as scripts/build.sh and scripts/build_android.sh.
|
||||
# iOS only checked App.lua, which is why the Yellow manifest shipped missing
|
||||
# in 0.1.45 through 0.1.47: decodeManifest (src/import/RomImporter.lua) errors
|
||||
# outright when a version's manifest is absent, so Import ROM on Yellow died
|
||||
# in the built app while dev, which reads the source tree, stayed green.
|
||||
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
|
||||
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
|
||||
for required in src/update/Boot.lua tools/save-editor/App.lua \
|
||||
tools/save-editor/Kit.lua tools/save-editor/panels/Party.lua \
|
||||
$MANIFESTS; do
|
||||
printf '%s\n' "$archive_entries" | grep -qx "$required" \
|
||||
|| fail "game.love is missing $required"
|
||||
done
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||
}
|
||||
|
||||
@@ -348,6 +456,43 @@ PY
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- xcodebuild
|
||||
# love.system.pickFile and createFile are a native bridge compiled in by
|
||||
# mobile/ios/patch_love_src.py, not part of LÖVE. A build that skipped the
|
||||
# patch still links and still runs, then finds the field nil the moment anyone
|
||||
# taps Import ROM (#482). #539 made that degrade to the copy-into-Files flow
|
||||
# rather than crash, which is the right floor, but a build with no picker at all
|
||||
# is a silent downgrade, so fail here instead of shipping one.
|
||||
#
|
||||
# Checked against the built binary rather than the source, because patching
|
||||
# love-src proves nothing about what Xcode actually compiled: the shipped
|
||||
# 0.1.45/0.1.46/0.1.47 IPAs all DO carry the bridge, so the reports that blamed
|
||||
# a missing patch step were self-built IPAs, exactly the case this catches.
|
||||
verify_native_bridge() {
|
||||
local app="$1"
|
||||
local exe bin missing=""
|
||||
exe="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' \
|
||||
"$app/Info.plist" 2>/dev/null || true)"
|
||||
bin="$app/${exe:-love}"
|
||||
[ -f "$bin" ] || bin="$app/love"
|
||||
if [ ! -f "$bin" ]; then
|
||||
warn "no executable inside $(basename "$app"); skipping native bridge check"
|
||||
return 0
|
||||
fi
|
||||
# grep -q here would race pipefail the same way pack_game_love documents:
|
||||
# it exits on first match, strings dies of SIGPIPE, the pipeline "fails"
|
||||
# nondeterministically. >/dev/null keeps grep reading the whole stream.
|
||||
for sym in pickFile createFile; do
|
||||
strings -a "$bin" | grep -x "$sym" >/dev/null || missing="$missing $sym"
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
fail "built app has no native bridge (missing:$missing).
|
||||
Import ROM would fall back to copy-into-Files instead of opening the picker.
|
||||
mobile/ios/patch_love_src.py did not take. Re-run:
|
||||
scripts/build_ios.sh --fetch && scripts/build_ios.sh"
|
||||
fi
|
||||
say "native bridge present (pickFile, createFile)"
|
||||
}
|
||||
|
||||
run_xcodebuild() {
|
||||
local config sdk destination
|
||||
if $RELEASE; then
|
||||
@@ -455,6 +600,8 @@ run_xcodebuild() {
|
||||
cp "$LOVE_FILE" "$app/game.love"
|
||||
fi
|
||||
|
||||
verify_native_bridge "$app"
|
||||
|
||||
local dist_dir="$DIST/${config}-${sdk}"
|
||||
rm -rf "$dist_dir"
|
||||
mkdir -p "$dist_dir"
|
||||
@@ -524,8 +671,10 @@ install_to_device() {
|
||||
|
||||
# --------------------------------------------------------------- main
|
||||
apply_ios_branding
|
||||
apply_ios_icon
|
||||
say "applying iOS native bridge patches (picker/Files support)"
|
||||
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
|
||||
ensure_manifests
|
||||
pack_game_love
|
||||
ensure_game_love_in_xcode
|
||||
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Static analysis for the engine Lua (config: .luacheckrc).
|
||||
#
|
||||
# Complements scripts/test.sh: the tests prove behavior, luacheck catches the
|
||||
# defects that never run in a green test -- undefined globals/locals (the
|
||||
# class that hid a music.volume crash: a hook read a `state` that was still
|
||||
# the nil global), unused values, unreachable code. The .luacheckrc mutes the
|
||||
# cosmetic categories the codebase lives with, so what prints is worth a look.
|
||||
#
|
||||
# scripts/lint.sh lint src/
|
||||
# scripts/lint.sh src tools lint specific paths
|
||||
#
|
||||
# Install once with: luarocks install luacheck
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if ! command -v luacheck >/dev/null 2>&1; then
|
||||
echo "luacheck not found on PATH (install: luarocks install luacheck)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
luacheck "${@:-src}"
|
||||
+204
-43
@@ -291,10 +291,17 @@ function BattleState:picImage(img)
|
||||
or PaletteFX.mode == "classic"
|
||||
if self.grayPics or mono then return grayImage(img) end
|
||||
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go
|
||||
-- dark with the HP bars while the blackout text is up (#292). Below the
|
||||
-- mono check on purpose: the forced-mono modes re-threshold the whole
|
||||
-- frame downstream, and the DMG had no SGB darkening to begin with.
|
||||
if self.blackedOut then return blackImage(self.data, img) end
|
||||
-- dark with the HP bars while the blackout text is up (#292). The intro
|
||||
-- silhouette slide (SlidePlayerAndEnemySilhouettesOnScreen) darkens the
|
||||
-- same way: the original slides both pics in under the %11100100
|
||||
-- silhouette palette and only runs SET_PAL_BATTLE once they have landed,
|
||||
-- so a still-sliding pic reads as a black silhouette, exactly like the
|
||||
-- evolution movie's PAL_BLACK (#577). Below the mono check on purpose:
|
||||
-- the forced-mono modes re-threshold the whole frame downstream, and the
|
||||
-- DMG had no SGB darkening to begin with.
|
||||
if self.blackedOut or (self.introSlide or 0) > 0 then
|
||||
return blackImage(self.data, img)
|
||||
end
|
||||
return fadeImage(img, self:activeBgp())
|
||||
end
|
||||
|
||||
@@ -645,17 +652,63 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
|
||||
return self
|
||||
end
|
||||
|
||||
-- The disguise itself. InitWildBattle .isGhost (engine/battle/core.asm)
|
||||
-- swaps only the pic and the nick -- wEnemyMonSpecies2 still holds the real
|
||||
-- species, so the SGB palette stays the disguised mon's. ghostReal keeps
|
||||
-- what LoadEnemyMonData puts back when the scope unveils.
|
||||
local function disguiseAsGhost(self)
|
||||
self.ghostReal = { name = self.enemy.name, sprite = self.enemy.sprite }
|
||||
self.enemy.name = "GHOST"
|
||||
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
|
||||
monPalette(self.data, self.enemy.mon.species))
|
||||
self.introText = Strings("The GHOST\nappeared!")
|
||||
end
|
||||
|
||||
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
|
||||
-- the enemy is "GHOST", you're too scared to attack, and balls fail.
|
||||
function BattleState:makeGhost()
|
||||
self.ghost = true
|
||||
self.enemy.name = "GHOST"
|
||||
-- the ghost keeps the disguised mon's SGB palette: InitWildBattle
|
||||
-- swaps only the pic, wEnemyMonSpecies2 still holds the real species
|
||||
-- (engine/battle/core.asm InitWildBattle .isGhost)
|
||||
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
|
||||
monPalette(self.data, self.enemy.mon.species))
|
||||
self.introText = Strings("The GHOST\nappeared!")
|
||||
disguiseAsGhost(self)
|
||||
end
|
||||
|
||||
-- The RESTLESS SOUL with the SILPH SCOPE in the bag. Mechanically this is
|
||||
-- not a ghost battle at all -- IsGhostBattle (core.asm) returns false the
|
||||
-- moment the scope is in the bag, so the mon attacks, is attacked and rolls
|
||||
-- for flight normally -- but InitWildBattle still enters disguised: its
|
||||
-- .isGhost branch fires on wCurOpponent == RESTLESS_SOUL as well. Taking
|
||||
-- the disguise off is PrintBeginningBattleText .isMarowak's job
|
||||
-- (engine/battle/common_text.asm), which enter() queues through
|
||||
-- queueScopeReveal (#492).
|
||||
function BattleState:makeUnveiledGhost()
|
||||
self.scopeReveal = true
|
||||
disguiseAsGhost(self)
|
||||
end
|
||||
|
||||
-- MarowakAnim (engine/battle/ghost_marowak_anim.asm) counted in frames: the
|
||||
-- ghost pic is copied into OAM and flashed 8 times by xor-ing rOBP1 with
|
||||
-- $80 (10 frames a side), the BG pic underneath is swapped to the RESTLESS
|
||||
-- SOUL, rOBP1 is shifted left two bits every 10 frames until the ghost has
|
||||
-- faded into the paper (3 shifts), and the Marowak copy fades back in the
|
||||
-- same way (4 shifts). Alpha over the paper is what those OBP shifts look
|
||||
-- like on screen.
|
||||
BattleState.GHOST_FLASH_FRAMES = 80
|
||||
BattleState.GHOST_FADE_OUT_FRAMES = 30
|
||||
BattleState.GHOST_FADE_IN_FRAMES = 40
|
||||
BattleState.GHOST_REVEAL_FRAMES = 150
|
||||
|
||||
-- The rows .isMarowak prints over the disguise, after the "GHOST appeared!"
|
||||
-- box enter() has already queued: the unveil line, MarowakAnim, then "Wild
|
||||
-- MAROWAK appeared!" under the restored name. The fall-through into
|
||||
-- .playSFX at the end is the same SFX_TRAINER_APPEARED the port does not
|
||||
-- play for trainer intros either, so it is left out here too.
|
||||
function BattleState:queueScopeReveal()
|
||||
local unveiled = self.data.text and self.data.text._UnveiledGhostText
|
||||
self:say(unveiled
|
||||
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
|
||||
self:act(function() self.ghostReveal = { t = 0 } end)
|
||||
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
|
||||
self:say(Strings("Wild %s\nappeared!",
|
||||
self.ghostReal and self.ghostReal.name or self.enemy.name))
|
||||
end
|
||||
|
||||
-- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN,
|
||||
@@ -669,6 +722,12 @@ end
|
||||
function BattleState:makeOldManDemo(name)
|
||||
self.demo = true
|
||||
self.demoName = name or "OLD MAN"
|
||||
-- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType:
|
||||
-- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU
|
||||
-- gets .profOakName + ProfOakPicBack (pokeyellow core.asm). The thrower
|
||||
-- name the caller passes IS that distinction here, so it picks the pic too
|
||||
-- -- data/scripts/story2.lua is the only site that names PROF.OAK (#557).
|
||||
self.oakDemo = name == "PROF.OAK"
|
||||
-- Yellow's Pallet intro runs this before the player owns any mon
|
||||
-- (BATTLE_TYPE_PIKACHU precedes the lab gift), so newWild flagged the
|
||||
-- battle dead for lack of a party. The demo never sends out, draws, or
|
||||
@@ -1189,8 +1248,11 @@ function BattleState:enter()
|
||||
-- without a transition (link battles, scripted pushes)
|
||||
Music.playBattle(self.data, self.musicKind)
|
||||
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
|
||||
-- sides slide in; the trainer pics stay up until the send-outs
|
||||
self.introSlide = 40
|
||||
-- sides slide in as black silhouettes. The original scrolls SCX from
|
||||
-- $90 to 0 two pixels per frame (72 frames); the port covers the full
|
||||
-- 160px screen width, so 2px/frame is an 80-frame slide (slide offset is
|
||||
-- introSlide*2 below). The trainer pics stay up until the send-outs.
|
||||
self.introSlide = 80
|
||||
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
|
||||
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
|
||||
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
||||
@@ -1214,7 +1276,8 @@ function BattleState:enter()
|
||||
-- fade, the ground-padding measurement and battle_sprite_scales.
|
||||
local backPath, backTrueColor =
|
||||
require("src.pokemon.Sprites").playerPath(self.data, "back",
|
||||
{ kind = "battle", demo = self.demo, battle = self })
|
||||
{ kind = "battle", demo = self.demo, oakDemo = self.oakDemo,
|
||||
battle = self })
|
||||
self.playerBackPic = getImage(backPath,
|
||||
namedPalette(self.data, "MEWMON"), backTrueColor)
|
||||
self.showPlayerBack = self.playerBackPic ~= nil
|
||||
@@ -1229,11 +1292,16 @@ function BattleState:enter()
|
||||
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
|
||||
-- sounds with the "Wild X appeared!" box instead of waiting on the A
|
||||
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
|
||||
-- ghost gets no cry at all (common_text.asm:43-48).
|
||||
if self.kind ~= "trainer" and self.kind ~= "link" and not self.ghost then
|
||||
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the
|
||||
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
|
||||
if self.kind ~= "trainer" and self.kind ~= "link"
|
||||
and not self.ghost and not self.scopeReveal then
|
||||
queueEnemyCry()
|
||||
end
|
||||
self:say(self.introText)
|
||||
-- the unveil rides on that same box, before _InitBattleCommon clears the
|
||||
-- intro chrome below (#492)
|
||||
if self.scopeReveal then self:queueScopeReveal() end
|
||||
-- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is
|
||||
-- dismissed both HUD blocks are cleared and ClearSprites drops the
|
||||
-- pokeball OAM, so the intro chrome never returns for the rest of the
|
||||
@@ -2663,6 +2731,35 @@ function BattleState:updateFx()
|
||||
end
|
||||
end
|
||||
end
|
||||
-- The Silph Scope unveil (MarowakAnim; queueScopeReveal parks a wait row
|
||||
-- of GHOST_REVEAL_FRAMES over it): the ghost flashes, fades out, the pic
|
||||
-- and the nick swap back the way LoadEnemyMonData restores them, and the
|
||||
-- real mon fades in (#492).
|
||||
local gr = self.ghostReveal
|
||||
if gr and self.enemy then
|
||||
gr.t = gr.t + 1
|
||||
local pf = self:picFxFor(self.enemy)
|
||||
local flashEnd = BattleState.GHOST_FLASH_FRAMES
|
||||
local outEnd = flashEnd + BattleState.GHOST_FADE_OUT_FRAMES
|
||||
if gr.t <= flashEnd then
|
||||
pf.fade = (math.floor((gr.t - 1) / 10) % 2 == 1) and 0.5 or 1
|
||||
elseif gr.t <= outEnd then
|
||||
pf.fade = 1 - math.ceil((gr.t - flashEnd) / 10) / 3
|
||||
else
|
||||
if not gr.swapped then
|
||||
gr.swapped = true
|
||||
local real = self.ghostReal
|
||||
if real then
|
||||
self.enemy.name = real.name or self.enemy.name
|
||||
self.enemy.sprite = real.sprite or self.enemy.sprite
|
||||
end
|
||||
end
|
||||
pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4)
|
||||
end
|
||||
if gr.t >= BattleState.GHOST_REVEAL_FRAMES then
|
||||
self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil
|
||||
end
|
||||
end
|
||||
-- the send-out grow-in (AnimateSendingOutMon): 3+4+5 frames, then
|
||||
-- the pic draws at full size again
|
||||
if self.growIn then
|
||||
@@ -2677,10 +2774,24 @@ function BattleState:updateFx()
|
||||
-- so a sounding siren rides out the next hit's HP drain instead of
|
||||
-- dropping out mid-announcement (#293)
|
||||
self.lowHealthAlarmOn = self:lowHealthAlarmActive()
|
||||
if self.lowHealthAlarmOn then
|
||||
Sound.startLoop(self.data, "Low_Health_Alarm")
|
||||
-- battle.low_health_alarm: on/off toggle for the siren loop, ctx.on
|
||||
-- mirrors self.lowHealthAlarmOn. Vanilla just starts/stops the loop
|
||||
-- each frame; a mod can wrap this to reshape the toggle (e.g. force
|
||||
-- ctx.on false after some budget) before letting vanilla act on it.
|
||||
if Runtime.wantsHook("battle.low_health_alarm") then
|
||||
Runtime.call("battle.low_health_alarm", function(ctx)
|
||||
if ctx.on then
|
||||
Sound.startLoop(ctx.battle.data, "Low_Health_Alarm")
|
||||
else
|
||||
Sound.stopLoop("Low_Health_Alarm")
|
||||
end
|
||||
end, { on = self.lowHealthAlarmOn, battle = self })
|
||||
else
|
||||
Sound.stopLoop("Low_Health_Alarm")
|
||||
if self.lowHealthAlarmOn then
|
||||
Sound.startLoop(self.data, "Low_Health_Alarm")
|
||||
else
|
||||
Sound.stopLoop("Low_Health_Alarm")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3216,7 +3327,10 @@ function BattleState:onFaint(battler)
|
||||
end
|
||||
end
|
||||
|
||||
function BattleState:enemyMonFainted()
|
||||
-- Exp for the defeated enemy, shared by the faint path (enemyMonFainted)
|
||||
-- and, when a mod's battle.catch_exp hook says so, the catch path
|
||||
-- (storeCaughtMon).
|
||||
function BattleState:awardExp()
|
||||
-- exp is split among the mons that fought this enemy
|
||||
-- (engine/battle/experience.asm); traded mons earn x1.5; each
|
||||
-- participant gets the full stat exp
|
||||
@@ -3294,27 +3408,44 @@ function BattleState:enemyMonFainted()
|
||||
end
|
||||
end
|
||||
end
|
||||
-- with EXP.ALL, participants split half the exp and the other half
|
||||
-- is divided among the whole party (engine/battle/experience.asm)
|
||||
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
|
||||
for _, mon in ipairs(alive) do
|
||||
applyShare(mon, participants * (expAll and 2 or 1), true)
|
||||
end
|
||||
if expAll then
|
||||
-- the second GainExperience pass sets the gain flags for the WHOLE
|
||||
-- party, so DivideExpDataByNumMonsGainingExp divides the already
|
||||
-- halved-and-participant-divided exp again by the party count, and
|
||||
-- .partyMonLoop still skips fainted mons (core.asm:818-858 +
|
||||
-- experience.asm:9-13); each mon gets its own GainedText with the
|
||||
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
|
||||
-- summary line
|
||||
for _, mon in ipairs(self.game.save.party) do
|
||||
if mon.hp > 0 then
|
||||
applyShare(mon, math.max(1, participants) * #self.game.save.party * 2, "expAll")
|
||||
-- battle.exp_award: the participant/EXP.ALL split, factored out so a
|
||||
-- mod can replace it wholesale (e.g. a flat undivided share to every
|
||||
-- non-fainted party mon) without re-deriving participants/alive.
|
||||
-- ctx.applyShare(mon, split, announce) is the same helper vanilla uses.
|
||||
local function vanillaExpAward(ctx)
|
||||
-- with EXP.ALL, participants split half the exp and the other half
|
||||
-- is divided among the whole party (engine/battle/experience.asm)
|
||||
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
|
||||
for _, mon in ipairs(ctx.alive) do
|
||||
ctx.applyShare(mon, ctx.participants * (expAll and 2 or 1), true)
|
||||
end
|
||||
if expAll then
|
||||
-- the second GainExperience pass sets the gain flags for the WHOLE
|
||||
-- party, so DivideExpDataByNumMonsGainingExp divides the already
|
||||
-- halved-and-participant-divided exp again by the party count, and
|
||||
-- .partyMonLoop still skips fainted mons (core.asm:818-858 +
|
||||
-- experience.asm:9-13); each mon gets its own GainedText with the
|
||||
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
|
||||
-- summary line
|
||||
for _, mon in ipairs(self.game.save.party) do
|
||||
if mon.hp > 0 then
|
||||
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local awardCtx = { battle = self, participants = participants, alive = alive,
|
||||
applyShare = applyShare }
|
||||
if Runtime.wantsHook("battle.exp_award") then
|
||||
Runtime.call("battle.exp_award", vanillaExpAward, awardCtx)
|
||||
else
|
||||
vanillaExpAward(awardCtx)
|
||||
end
|
||||
self.participants = {}
|
||||
end
|
||||
|
||||
function BattleState:enemyMonFainted()
|
||||
self:awardExp()
|
||||
|
||||
if self.kind == "trainer" then
|
||||
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
|
||||
@@ -3353,10 +3484,12 @@ function BattleState:enemyMonFainted()
|
||||
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
|
||||
local shiftSwitchMon = nil
|
||||
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
|
||||
-- _TrainerAboutToUseText: "X is" / "about to use" then cont nick,
|
||||
-- then para "Will PLAYER" / "change POKéMON?" with YES/NO.
|
||||
self:say(Strings("%s is\nabout to use", self.trainer.name))
|
||||
self:say(Strings("%s!", nextName))
|
||||
-- _TrainerAboutToUseText (data/text/text_2.asm): "X is" / "about to
|
||||
-- use" then a CONT to the nick, not a fresh box -- the box scrolls
|
||||
-- "X is" off so "about to use" stays above the name, instead of the
|
||||
-- page ending on a bare nick (#565). Then para "Will PLAYER" /
|
||||
-- "change POKéMON?" with YES/NO.
|
||||
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
||||
self:sayChoice(
|
||||
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
||||
function(yes)
|
||||
@@ -3474,8 +3607,18 @@ function BattleState:enemyMonFainted()
|
||||
-- PrintEndBattleText prints one text box; a `para` (\f) inside it
|
||||
-- starts a fresh page, which is a message row of its own here (five
|
||||
-- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText)
|
||||
-- TrainerEndBattleText (home/trainers.asm) prints _TrainerNameText
|
||||
-- (wNameBuffer then ": ", data/text/text_1.asm) and only then the
|
||||
-- saved pointer, so the loss line opens with the trainer class tag on
|
||||
-- its first line (RIVAL1/2/3 tag with the rival's name:
|
||||
-- TrainerNamePointers aims those entries at wTrainerName). The tag
|
||||
-- prints once, so a `para` page carries no second copy (#566).
|
||||
local tag = self.trainer and self.trainer.name
|
||||
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
|
||||
if page ~= "" then self:sayNext(page) end
|
||||
if page ~= "" then
|
||||
self:sayNext(tag and (tag .. ": " .. page) or page)
|
||||
tag = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
|
||||
@@ -3875,6 +4018,12 @@ function BattleState:storeCaughtMon()
|
||||
-- (item_effects.asm:472-501), regenerating its move list from the
|
||||
-- base data -- a Mimic'd slot never leaves the battle with it
|
||||
self:restoreMimicked(self.enemy)
|
||||
-- battle.catch_exp: vanilla catches never grant exp; a mod can flip
|
||||
-- this to true to pay out the same award a faint would have.
|
||||
if Runtime.wantsHook("battle.catch_exp")
|
||||
and Runtime.call("battle.catch_exp", function() return false end, { battle = self }) then
|
||||
self:awardExp()
|
||||
end
|
||||
local game = self.game
|
||||
local dex = game.save.pokedex
|
||||
local species = self.enemy.mon.species
|
||||
@@ -4304,6 +4453,18 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
|
||||
end
|
||||
if battler.fainted then return end
|
||||
|
||||
-- MarowakAnim's rOBP1 fades (updateFx): alpha over the paper is what those
|
||||
-- palette shifts look like, and the test has to sit ahead of the plain
|
||||
-- fast path below (#492)
|
||||
local fadePf = self.picFx and self.picFx[battler]
|
||||
if fadePf and fadePf.fade then
|
||||
local cr, cg, cb, ca = love.graphics.getColor()
|
||||
love.graphics.setColor(cr, cg, cb, ca * fadePf.fade)
|
||||
love.graphics.draw(img, x, y, 0, scale, scale)
|
||||
love.graphics.setColor(cr, cg, cb, ca)
|
||||
return
|
||||
end
|
||||
|
||||
local pf = self.picFx and self.picFx[battler]
|
||||
if not pf or (not pf.kind and not pf.hidden and not pf.minimized
|
||||
and (pf.ox or 0) == 0 and (pf.oy or 0) == 0) then
|
||||
@@ -5069,7 +5230,7 @@ function BattleState:drawClassic()
|
||||
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
|
||||
sx = self.frame % 4 < 2 and 2 or -2
|
||||
end
|
||||
local slide = (self.introSlide or 0) * 4 -- intro slide-in offset
|
||||
local slide = (self.introSlide or 0) * 2 -- intro slide-in offset (2px/frame)
|
||||
|
||||
if self:colorMode() then
|
||||
-- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the
|
||||
|
||||
@@ -93,6 +93,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
|
||||
-- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a
|
||||
-- failed accuracy roll (core.asm:5260), and the miss handler still
|
||||
-- runs the explode effect ("even if Explosion or Selfdestruct
|
||||
-- missed, its effect still needs to be activated", core.asm:3223),
|
||||
-- so the user faints against a mid-Fly/Dig target too (#528)
|
||||
if record and record.onMiss then record.onMiss(ctx, "invulnerable") end
|
||||
return
|
||||
end
|
||||
|
||||
|
||||
@@ -183,6 +183,21 @@ local function drawCommandMenu(battle)
|
||||
Font.drawBox(0, 13, 20, 5)
|
||||
Font.drawBox(20, 13, 18, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
-- The old-man / PROF.OAK catch demo has no party, so makeOldManDemo parks
|
||||
-- the WILD mon in battle.player as a placeholder (BattleState:1209). The
|
||||
-- classic layout's demo branch never names anyone (DisplayBattleMenu,
|
||||
-- core.asm:2038-2049, just draws the menu), so naming it here printed
|
||||
-- "What will PIKACHU do?" over Oak's scripted throw (#557). Leave the
|
||||
-- prompt side blank and run the same scripted hand the classic does.
|
||||
if battle.demo then
|
||||
Font.draw(Strings("FIGHT"), 176, 112)
|
||||
Font.drawCode(0xE1, 240, 112); Font.drawCode(0xE2, 248, 112)
|
||||
Font.draw(Strings("ITEM"), 176, 128)
|
||||
Font.draw(Strings("RUN"), 240, 128)
|
||||
-- next to FIGHT for the first 80 frames, then ITEM
|
||||
Font.drawCode(0xED, 168, (battle.demoTimer or 0) <= 80 and 112 or 128)
|
||||
return
|
||||
end
|
||||
Font.draw(Strings("What will"), 8, 112)
|
||||
local who = battle.player and battle.player.name or ""
|
||||
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
|
||||
@@ -305,22 +320,40 @@ function WideBattle.draw(battle)
|
||||
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
|
||||
sx = battle.frame % 4 < 2 and 2 or -2
|
||||
end
|
||||
local slide = (battle.introSlide or 0) * 4
|
||||
local slide = (battle.introSlide or 0) * 2
|
||||
|
||||
-- Each side keeps its original sprite pixels and placement math: the two
|
||||
-- 160x144 OAM regions are translated apart and clipped into the wider
|
||||
-- battlefield rather than either monster being scaled. wideRegion tells
|
||||
-- drawBattlerPic its own side window is already the clip.
|
||||
-- drawBattlerPic its own side window is already the clip. A shake moves
|
||||
-- each region's clip with its contents, so a pic pushed toward a region
|
||||
-- edge is not sheared off it (a vertical shake used to clip the player's
|
||||
-- feet at FIELD_BOTTOM).
|
||||
battle.wideRegion = true
|
||||
inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
|
||||
inRegion(sx, 32 + sy, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
|
||||
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
|
||||
inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
|
||||
inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
|
||||
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
|
||||
battle.wideRegion = nil
|
||||
|
||||
drawHUDs(battle, slide)
|
||||
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the
|
||||
-- shakes move IS the whole screen: PredefShakeScreenHorizontally,
|
||||
-- PredefShakeScreenVertically and AnimationShakeScreenHorizontallySlow
|
||||
-- displace the HUDs and the message window along with the pics, exactly as
|
||||
-- drawClassic offsets its whole BG canvas. Shaking only the two pic
|
||||
-- regions left the foe gliding sideways across a nailed-down screen on
|
||||
-- every applying-attack shake -- TAIL WHIP and every other status move
|
||||
-- (#562). The OAM anim layer stays put, as it does in drawClassic.
|
||||
local function shaken(fn)
|
||||
if sx == 0 and sy == 0 then return fn() end
|
||||
g.push()
|
||||
g.translate(sx, sy)
|
||||
fn()
|
||||
g.pop()
|
||||
end
|
||||
shaken(function() drawHUDs(battle, slide) end)
|
||||
drawAnimationLayer(battle)
|
||||
drawTextArea(battle)
|
||||
shaken(function() drawTextArea(battle) end)
|
||||
|
||||
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
|
||||
g.setColor(1, 1, 1, 0.85)
|
||||
|
||||
+5
-5
@@ -14,12 +14,12 @@ local MODULES = {
|
||||
-- Optional for compatibility with developer and stale caches.
|
||||
local OPTIONAL = { "audio", "palettes", "icons" }
|
||||
|
||||
-- The rules the engine still carries as literals. The constants registry
|
||||
-- deep-merges over these, so a value has to exist before a mod can patch
|
||||
-- it; each one is the number the engine hard-codes today, so seeding them
|
||||
-- changes nothing on a mod-free boot.
|
||||
-- Vanilla defaults for rules exposed through the constants registry. A
|
||||
-- value has to exist before a mod can patch it; each one matches the
|
||||
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
|
||||
-- boot.
|
||||
local CONSTANT_DEFAULTS = {
|
||||
bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua)
|
||||
bagSize = 20, -- BAG_ITEM_CAPACITY (Bag.capacity fallback)
|
||||
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
|
||||
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
|
||||
moveMax = 4,
|
||||
|
||||
@@ -178,6 +178,22 @@ function Game:step(dt)
|
||||
-- this logic tick, not one tick later. With no wrapper this is a no-op.
|
||||
ModRuntime.call("input.step", function() end, self, dt)
|
||||
self.input:step()
|
||||
-- A+B+SELECT+START held for 16 steps: SoftReset (home/init.asm) stops the
|
||||
-- audio, whites the palettes out and falls through into Init, i.e. the
|
||||
-- power-on boot, so everything unsaved is gone and the title sequence
|
||||
-- comes back -- exactly what returnToTitle does for the START menu's QUIT.
|
||||
-- The check lives here, above stack:update, so it fires from any state:
|
||||
-- overworld, battle, a menu or a cutscene, the way _Joypad's does (#563).
|
||||
-- Input:reset clears the four still-physically-held buttons so the title
|
||||
-- screen does not read A as a menu choice on its first frame.
|
||||
-- a tool mod (or a harness) may hand Game a stand-in input with no
|
||||
-- chord bookkeeping; those simply never soft reset
|
||||
if self.input.softResetStep and self.input:softResetStep() then
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
self:returnToTitle()
|
||||
return
|
||||
end
|
||||
-- serviced unconditionally: a link battle's ENet transport must not
|
||||
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
||||
-- on top of BattleState (see LinkBattle.new)
|
||||
@@ -475,8 +491,16 @@ function Game:restartWithMods()
|
||||
require("src.core.HostShell").restart()
|
||||
end
|
||||
|
||||
-- Releases reach Input even while a top state captures raw input: a
|
||||
-- swallowed key-up would strand a held-state flag for a key Input saw go
|
||||
-- down before the capture armed (the stuck-flag hazard Input:reset
|
||||
-- exists for). The top state only OBSERVES the release afterwards,
|
||||
-- unlike onKeyPressed above which owns the press, so BindingsMenu can
|
||||
-- commit a capture on the key-up (#589).
|
||||
function Game:keyreleased(key)
|
||||
Input:keyreleased(key)
|
||||
local top = self.stack and self.stack:top()
|
||||
if top and top.onKeyReleased then top:onKeyReleased(key) end
|
||||
end
|
||||
|
||||
function Game:gamepadpressed(joystick, button)
|
||||
@@ -493,7 +517,10 @@ function Game:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
function Game:gamepadreleased(joystick, button)
|
||||
-- same observe-after-Input contract as Game:keyreleased (#589)
|
||||
Input:gamepadreleased(joystick, button)
|
||||
local top = self.stack and self.stack:top()
|
||||
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
|
||||
end
|
||||
|
||||
function Game:gamepadaxis(joystick, axis, value)
|
||||
@@ -634,6 +661,23 @@ function Game:applyOptions(opts)
|
||||
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||
-- fpsCap key pace at the standard rate (issue #88)
|
||||
require("src.core.FrameCap").applyOptions(opts)
|
||||
-- Scale the optional presentation extras to the device's performance
|
||||
-- tier. Every heavy feature was just applied from the stored options
|
||||
-- above; here we clamp the *live* state down for a weaker device without
|
||||
-- rewriting what the player saved, so raising the tier later restores
|
||||
-- their exact TILT / GBC FX / ZOOM / MAX FPS choices. A HIGH tier (the
|
||||
-- default on a normal desktop, and every options.lua predating this
|
||||
-- option) clamps nothing, so it is a no-op for the common case.
|
||||
local caps = require("src.core.Performance").applyOptions(opts)
|
||||
if not caps.tilt then require("src.render.Tilt").setLevel(0) end
|
||||
if not caps.gbcfx then require("src.render.GBCFX").setLevel(0) end
|
||||
local Zoom = require("src.render.Zoom")
|
||||
Zoom.allowSurvey = caps.survey
|
||||
if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end
|
||||
if caps.fpsMax then
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
if FrameCap.current > caps.fpsMax then FrameCap.apply(caps.fpsMax) end
|
||||
end
|
||||
Input:applyBindings(opts.bindings)
|
||||
TouchControls:applyOptions(opts)
|
||||
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
||||
|
||||
@@ -26,9 +26,30 @@ end
|
||||
-- ("Failed to initialize filesystem: already initialized") and the relaunch
|
||||
-- crashes. So on an AppImage we relaunch the executable; the fresh process's
|
||||
-- Boot step mounts any downloaded update exactly as a manual relaunch would.
|
||||
-- Android hits the same wall (#575): the vendored love.cpp loops runlove()
|
||||
-- in-process on "restart", and PHYSFS_deinit in the old Filesystem module's
|
||||
-- destructor fails ("files still open") whenever any physfs handle survives
|
||||
-- lua_close, so the second PHYSFS_init throws the same "already initialized"
|
||||
-- and the app dies. There we relaunch through the GameActivity.restartApp
|
||||
-- JNI bridge (love.system.restartApp), which schedules our launch intent
|
||||
-- and kills the process so no native state can leak into the fresh run.
|
||||
-- On every other platform the in-process restart works, so keep it.
|
||||
function HostShell.restart()
|
||||
if not (love and love.event and love.event.quit) then return end
|
||||
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
if osName == "Android" then
|
||||
-- restartApp kills the process on success, so a true return is never
|
||||
-- observed; false means the bridge could not schedule the relaunch.
|
||||
-- An older APK whose liblove predates the bridge (love.system.restartApp
|
||||
-- is nil) has no crash-free in-process restart, so quit to the OS
|
||||
-- cleanly and let the player relaunch by hand -- worse than restarting,
|
||||
-- but better than the guaranteed crash of quit("restart") (#575).
|
||||
if love.system.restartApp and love.system.restartApp() then return end
|
||||
love.event.quit()
|
||||
return
|
||||
end
|
||||
|
||||
local appimage = os.getenv("APPIMAGE")
|
||||
if not appimage then
|
||||
love.event.quit("restart")
|
||||
|
||||
@@ -263,8 +263,56 @@ function Input:isDown(btn)
|
||||
return self.state[btn] or false
|
||||
end
|
||||
|
||||
-- True when the on-screen overlay is one of the live sources holding this
|
||||
-- button (see overlayPressed above). Player:turnWindow widens the
|
||||
-- turn-in-place tap window on touch, where a press and release can never be
|
||||
-- as short as a physical d-pad's (#415).
|
||||
function Input:isTouchDown(btn)
|
||||
local sources = self.sources[btn]
|
||||
return (sources and sources["touch:" .. btn]) and true or false
|
||||
end
|
||||
|
||||
function Input:wasPressed(btn)
|
||||
return self.pressed[btn] or false
|
||||
end
|
||||
|
||||
-- Soft reset (#563). _Joypad (engine/joypad.asm) tests the RAW joypad read
|
||||
-- with `cp PAD_BUTTONS` -- an equality, not a mask -- so the combo counts
|
||||
-- only while A, B, SELECT and START are the only buttons down; any d-pad
|
||||
-- direction in the mix cancels it. That test sits ahead of the wJoyIgnore
|
||||
-- and BIT_DISABLE_JOYPAD masking below it, which is why the reset still
|
||||
-- works mid-battle and mid-cutscene where ordinary input is thrown away.
|
||||
-- TrySoftReset then burns one DelayFrame per pass and decrements hSoftReset,
|
||||
-- seeded with 16 by Init (home/init.asm), so the combo has to survive 16
|
||||
-- consecutive polls. That hold is also what keeps the on-screen overlay
|
||||
-- safe: it already takes four separate fingers on four separate controls,
|
||||
-- and they all have to stay put for better than a quarter of a second.
|
||||
local SOFT_RESET_FRAMES = 16
|
||||
|
||||
function Input:softResetHeld()
|
||||
if not (self.state.a and self.state.b
|
||||
and self.state.start and self.state.select) then
|
||||
return false
|
||||
end
|
||||
return not (self.state.up or self.state.down
|
||||
or self.state.left or self.state.right)
|
||||
end
|
||||
|
||||
-- Ticked once per fixed step by Game:step; true on the step the countdown
|
||||
-- runs out. hSoftReset is never re-seeded on release in the original, so
|
||||
-- its count leaks across a whole session; a port that copied that would
|
||||
-- eventually reset on a stray four-button press, so the counter re-arms as
|
||||
-- soon as the combo drops.
|
||||
function Input:softResetStep()
|
||||
if not self:softResetHeld() then
|
||||
self.softResetFrames = nil
|
||||
return false
|
||||
end
|
||||
local left = (self.softResetFrames or SOFT_RESET_FRAMES) - 1
|
||||
self.softResetFrames = left
|
||||
if left > 0 then return false end
|
||||
self.softResetFrames = nil
|
||||
return true
|
||||
end
|
||||
|
||||
return Input
|
||||
|
||||
+8
-1
@@ -23,6 +23,13 @@ local volumeScale = 1
|
||||
local FILTER_HIGHGAIN = { 0.4, 0.16, 0.064 }
|
||||
local filterLevel = 0
|
||||
|
||||
-- Forward-declared here so applyVolume (below) closes over the real playback
|
||||
-- state rather than a nil global: the table literal is assigned further down,
|
||||
-- but a `local state = {}` there would leave every reference above it bound
|
||||
-- to the global `state`. Before this, registering the `music.volume` mod
|
||||
-- hook crashed applyVolume on `state.current` (a nil index).
|
||||
local state
|
||||
|
||||
local function applyVolume(src)
|
||||
if not src then return end
|
||||
local vol = VOLUME * volumeScale
|
||||
@@ -63,7 +70,7 @@ local function applyFilter(src)
|
||||
end
|
||||
end
|
||||
|
||||
local state = {
|
||||
state = {
|
||||
current = nil, -- song label
|
||||
chip = false, -- the playing song is a synthesized channel program
|
||||
source = nil, -- currently playing source
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
-- Graphics performance tier: one knob that scales the port's optional,
|
||||
-- non-faithful presentation extras down for weaker hardware.
|
||||
--
|
||||
-- The heavy extras are all things the Game Boy never had and this port
|
||||
-- adds on top: the whole-screen 3D TILT (transforms the entire map as a
|
||||
-- ground plane), the GBC FX post-process shader (a fullscreen pass), and
|
||||
-- survey ZOOM (zooming out renders the connected neighbor maps -- a lot of
|
||||
-- extra overdraw). A hard FPS ceiling caps present cost on top of that.
|
||||
-- None of this touches game logic, which is fixed-step off dt
|
||||
-- (src/core/FixedStep.lua), so every tier plays identically; they differ
|
||||
-- only in how much optional eye-candy the renderer is allowed to do.
|
||||
--
|
||||
-- The tier is persisted as save.options.performance:
|
||||
-- auto pick a default from the device (see detect())
|
||||
-- high everything on -- the historical behavior
|
||||
-- balanced no TILT, no GBC FX (kept: survey zoom, colors, uncapped FPS)
|
||||
-- low no TILT, no GBC FX, no survey zoom, FPS capped
|
||||
--
|
||||
-- AUTO only chooses the *default* for the current device; every tier is
|
||||
-- selectable in OPTIONS, so a heuristic that guesses wrong is one row away
|
||||
-- from being overridden. The clamps are applied live in Game:applyOptions
|
||||
-- against the stored options without rewriting them, so raising the tier
|
||||
-- restores exactly the TILT / GBC FX / ZOOM / FPS the player had.
|
||||
--
|
||||
-- Zero requires: loads during love.conf and under plain Lua for tools and
|
||||
-- tests, the same way src/core/GameVersion.lua does.
|
||||
|
||||
local Performance = {}
|
||||
|
||||
-- Option-row order (auto first, then most to least capable).
|
||||
Performance.TIERS = { "auto", "high", "balanced", "low" }
|
||||
|
||||
Performance.LABELS = {
|
||||
auto = "AUTO",
|
||||
high = "HIGH",
|
||||
balanced = "BALANCED",
|
||||
low = "LOW",
|
||||
}
|
||||
|
||||
-- What each concrete tier permits. `auto` is resolved to one of these
|
||||
-- before caps are read, so it has no row here. fpsMax = false means no
|
||||
-- extra ceiling (the player's own MAX FPS still applies).
|
||||
Performance.CAPS = {
|
||||
high = { tilt = true, gbcfx = true, survey = true, fpsMax = false },
|
||||
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
|
||||
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
|
||||
}
|
||||
|
||||
-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
|
||||
-- renderer / options row read it back. Defaults to the unclamped tier so
|
||||
-- a pre-boot launcher behaves exactly as it always did.
|
||||
Performance.tier = "high"
|
||||
|
||||
local function loveOS()
|
||||
if love and love.system and love.system.getOS then
|
||||
return love.system.getOS()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function processorCount()
|
||||
if love and love.system and love.system.getProcessorCount then
|
||||
return love.system.getProcessorCount()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- CPU architecture via LuaJIT's jit.arch when present: "arm"/"arm64" on
|
||||
-- phones and PortMaster handhelds, "x86"/"x64" on desktops. nil under a
|
||||
-- plain-Lua tool or test with no jit table, which resolves to HIGH (no
|
||||
-- clamping) so tooling is never surprised.
|
||||
local function cpuArch()
|
||||
return (jit and jit.arch) or nil
|
||||
end
|
||||
|
||||
-- Default tier for the current device. Deliberately conservative: only
|
||||
-- the platforms that are reliably weak drop below HIGH, and AUTO is always
|
||||
-- overridable, so a wrong guess costs one OPTIONS row. A normal
|
||||
-- multi-core desktop -- the common case, and every existing install whose
|
||||
-- options.lua predates this option -- resolves to HIGH and behaves exactly
|
||||
-- as before.
|
||||
function Performance.detect()
|
||||
local os = loveOS()
|
||||
local arch = cpuArch()
|
||||
local isArm = arch == "arm" or arch == "arm64"
|
||||
local cores = processorCount()
|
||||
|
||||
-- PortMaster-style ARM Linux handhelds (e.g. the RG34XXSP the project
|
||||
-- already ships a build for): the weakest target here.
|
||||
if isArm and os ~= "Android" and os ~= "iOS" then
|
||||
return "low"
|
||||
end
|
||||
-- Phones and tablets: GBC FX is already force-disabled here (issue #136);
|
||||
-- balanced additionally drops the 3D tilt, the heaviest remaining extra.
|
||||
if os == "Android" or os == "iOS" then
|
||||
return "balanced"
|
||||
end
|
||||
-- Desktop: a dual-core (or single-core) box is the low-end line.
|
||||
if cores and cores <= 2 then
|
||||
return "balanced"
|
||||
end
|
||||
return "high"
|
||||
end
|
||||
|
||||
-- Fold a stored option value to a concrete tier, resolving "auto" (and any
|
||||
-- hand-edited garbage) through detect().
|
||||
function Performance.resolve(value)
|
||||
if value == "high" or value == "balanced" or value == "low" then
|
||||
return value
|
||||
end
|
||||
return Performance.detect()
|
||||
end
|
||||
|
||||
-- Normalize a stored value to a valid option id (keeps "auto"); a bad value
|
||||
-- degrades to auto so a corrupt options.lua still lands on something sane.
|
||||
function Performance.normalize(value)
|
||||
for _, id in ipairs(Performance.TIERS) do
|
||||
if value == id then return id end
|
||||
end
|
||||
return "auto"
|
||||
end
|
||||
|
||||
-- Row label for a stored value: the tier's own name (AUTO / HIGH / ...).
|
||||
function Performance.label(value)
|
||||
return Performance.LABELS[Performance.normalize(value)]
|
||||
end
|
||||
|
||||
-- Concrete caps for a stored option value (resolving auto).
|
||||
function Performance.caps(value)
|
||||
return Performance.CAPS[Performance.resolve(value)]
|
||||
end
|
||||
|
||||
-- Resolve the tier for these options, record it live, and return its caps.
|
||||
-- Called from Game:applyOptions, which clamps the live presentation modules
|
||||
-- against the returned caps.
|
||||
function Performance.applyOptions(opts)
|
||||
local tier = Performance.resolve(opts and opts.performance)
|
||||
Performance.tier = tier
|
||||
return Performance.CAPS[tier]
|
||||
end
|
||||
|
||||
-- Cycle the OPTIONS row: auto -> high -> balanced -> low -> auto (dir -1
|
||||
-- reverses). Lua's `%` is non-negative for a positive modulus, so a
|
||||
-- negative dir wraps correctly.
|
||||
function Performance.cycle(value, dir)
|
||||
value = Performance.normalize(value)
|
||||
local i = 1
|
||||
for idx, id in ipairs(Performance.TIERS) do
|
||||
if id == value then i = idx break end
|
||||
end
|
||||
local n = #Performance.TIERS
|
||||
local nextIdx = (i - 1 + (dir or 1)) % n + 1
|
||||
return Performance.TIERS[nextIdx]
|
||||
end
|
||||
|
||||
return Performance
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Usable window rect for mobile chrome (notch / Dynamic Island / home
|
||||
-- indicator / Android display cutouts). Wraps love.window.getSafeArea when
|
||||
-- the engine provides it; otherwise the full graphics window.
|
||||
--
|
||||
-- Desktop and headless stubs return the full window, so callers can always
|
||||
-- layout against this rect without platform branches. Interactive chrome
|
||||
-- (touch overlay, launcher) should prefer this over getDimensions; the game
|
||||
-- canvas may still letterbox into the full framebuffer for immersion.
|
||||
|
||||
local SafeArea = {}
|
||||
|
||||
function SafeArea.rect()
|
||||
local ww, wh = 0, 0
|
||||
if love and love.graphics and love.graphics.getDimensions then
|
||||
ww, wh = love.graphics.getDimensions()
|
||||
end
|
||||
if ww <= 0 then ww = 1 end
|
||||
if wh <= 0 then wh = 1 end
|
||||
|
||||
if not (love and love.window and love.window.getSafeArea) then
|
||||
return 0, 0, ww, wh
|
||||
end
|
||||
|
||||
local x, y, w, h = love.window.getSafeArea()
|
||||
if type(x) ~= "number" or type(y) ~= "number"
|
||||
or type(w) ~= "number" or type(h) ~= "number"
|
||||
or w <= 0 or h <= 0 then
|
||||
return 0, 0, ww, wh
|
||||
end
|
||||
|
||||
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
|
||||
-- push layout outside the surface.
|
||||
x = math.max(0, math.min(x, ww))
|
||||
y = math.max(0, math.min(y, wh))
|
||||
w = math.max(1, math.min(w, ww - x))
|
||||
h = math.max(1, math.min(h, wh - y))
|
||||
return x, y, w, h
|
||||
end
|
||||
|
||||
return SafeArea
|
||||
@@ -241,6 +241,11 @@ function SaveData.defaultOptions()
|
||||
videoMode = "windowed",
|
||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||
fpsCap = 60,
|
||||
-- graphics performance tier: auto | high | balanced | low. "auto"
|
||||
-- picks a default from the device (ARM handhelds/phones drop the heavy
|
||||
-- extras); scales TILT / GBC FX / survey ZOOM / FPS but never game
|
||||
-- logic. See src/core/Performance.lua.
|
||||
performance = "auto",
|
||||
-- Per-pipeline display levels, keyed by render_pipelines id (see
|
||||
-- src/render/Pipelines.lua). A level for a mod that is not installed
|
||||
-- is kept rather than pruned, so re-enabling the mod restores the mode
|
||||
@@ -1053,7 +1058,7 @@ local function reclaim(save, data, report)
|
||||
if type(entry) == "table" and known(data.items, entry.id) then
|
||||
table.remove(orphaned.items, i)
|
||||
if entry.from == "pcItems" or type(save.inventory) ~= "table"
|
||||
or not Bag.add(save, entry.id, entry.count or 1) then
|
||||
or not Bag.add(save, entry.id, entry.count or 1, data) then
|
||||
save.pcItems = save.pcItems or {}
|
||||
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
|
||||
end
|
||||
|
||||
+42
-26
@@ -23,6 +23,7 @@
|
||||
-- and a player rebind can never detach the overlay.
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local TouchControls = {}
|
||||
|
||||
@@ -92,20 +93,23 @@ function TouchControls.normalizeConfig(tc)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Pure default layout in LOVE units for a given window size. Shared by
|
||||
-- layout() and the editor's Reset path so defaults stay in one place.
|
||||
function TouchControls.defaultLayout(ww, wh)
|
||||
-- Pure default layout in LOVE units for a usable rect of size ww x wh at
|
||||
-- origin (ox, oy). Shared by layout() and the editor's Reset path so
|
||||
-- defaults stay in one place. ox/oy default to 0 for the headless tests
|
||||
-- and for callers that already pass a full-window size.
|
||||
function TouchControls.defaultLayout(ww, wh, ox, oy)
|
||||
ox, oy = ox or 0, oy or 0
|
||||
local short = math.min(ww, wh)
|
||||
local dpadW = math.min(180, short * 0.34)
|
||||
local abW = dpadW * 0.46
|
||||
local ssW = dpadW * 0.30
|
||||
local margin = dpadW * 0.12
|
||||
return {
|
||||
dpad = { cx = 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 },
|
||||
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
|
||||
b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW },
|
||||
start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
|
||||
select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
|
||||
}
|
||||
end
|
||||
|
||||
@@ -155,6 +159,7 @@ function TouchControls:applyOptions(opts)
|
||||
self.enabled = cfg.enabled
|
||||
self.positions = cfg.positions
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
if not self.enabled then
|
||||
self.controllerHidden = false
|
||||
self:reset()
|
||||
@@ -185,30 +190,37 @@ function TouchControls:visible()
|
||||
and not self.controllerHidden
|
||||
end
|
||||
|
||||
local function clampZone(zone, ww, wh)
|
||||
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
|
||||
local function clampZone(zone, x0, y0, x1, y1)
|
||||
local half = zone.w * 0.5
|
||||
zone.cx = math.max(half, math.min(ww - half, zone.cx))
|
||||
zone.cy = math.max(half, math.min(wh - half, zone.cy))
|
||||
zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx))
|
||||
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
|
||||
end
|
||||
|
||||
-- Layout in LOVE units (density-independent on mobile), recomputed when
|
||||
-- the window size changes (rotation, resize). Default: d-pad bottom-left,
|
||||
-- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT
|
||||
-- flanking the bottom center. Custom positions (normalized 0..1) override
|
||||
-- centers while sizes stay derived from the short edge.
|
||||
-- the window or safe area changes (rotation, resize, notch insets).
|
||||
-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
|
||||
-- diagonal), START/SELECT flanking the bottom center -- all inside the
|
||||
-- device safe area so thumbs clear the home indicator / cutouts.
|
||||
-- Custom positions (normalized 0..1 within the safe rect) override centers
|
||||
-- while sizes stay derived from the short edge.
|
||||
function TouchControls:layout()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end
|
||||
self.layoutW, self.layoutH = ww, wh
|
||||
self.L = TouchControls.defaultLayout(ww, wh)
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
if self.layoutW == sw and self.layoutH == sh
|
||||
and self.layoutOx == ox and self.layoutOy == oy and self.L then
|
||||
return self.L
|
||||
end
|
||||
self.layoutW, self.layoutH = sw, sh
|
||||
self.layoutOx, self.layoutOy = ox, oy
|
||||
self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
|
||||
if self.positions then
|
||||
for _, name in ipairs(CONTROLS) do
|
||||
local p = self.positions[name]
|
||||
local zone = self.L[name]
|
||||
if p and zone then
|
||||
zone.cx = p.x * ww
|
||||
zone.cy = p.y * wh
|
||||
clampZone(zone, ww, wh)
|
||||
zone.cx = ox + p.x * sw
|
||||
zone.cy = oy + p.y * sh
|
||||
clampZone(zone, ox, oy, ox + sw, oy + sh)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -222,21 +234,25 @@ function TouchControls:layout()
|
||||
end
|
||||
|
||||
-- Move one control to a screen-space point and persist its normalized
|
||||
-- position. Used by the layout editor while dragging.
|
||||
-- position within the safe rect. Used by the layout editor while dragging.
|
||||
function TouchControls:setControlCenter(name, cx, cy)
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
local L = self:layout()
|
||||
local zone = L[name]
|
||||
if not zone then return end
|
||||
zone.cx, zone.cy = cx, cy
|
||||
clampZone(zone, ww, wh)
|
||||
clampZone(zone, ox, oy, ox + sw, oy + sh)
|
||||
self.positions = self.positions or {}
|
||||
self.positions[name] = { x = zone.cx / ww, y = zone.cy / wh }
|
||||
self.positions[name] = {
|
||||
x = sw > 0 and (zone.cx - ox) / sw or 0,
|
||||
y = sh > 0 and (zone.cy - oy) / sh or 0,
|
||||
}
|
||||
end
|
||||
|
||||
function TouchControls:clearPositions()
|
||||
self.positions = nil
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
end
|
||||
|
||||
local function inCircle(zone, x, y, slop)
|
||||
|
||||
+1
-1
@@ -214,7 +214,7 @@ function VERBS.give(self, rest)
|
||||
end
|
||||
elseif game.data.items and game.data.items[id] then
|
||||
local n = tonumber(count) or 1
|
||||
if require("src.inventory.Bag").add(save, id, n) then
|
||||
if require("src.inventory.Bag").add(save, id, n, game.data) then
|
||||
self:print(("%s x%d added"):format(id, n))
|
||||
else
|
||||
self:print("bag full")
|
||||
|
||||
@@ -1179,6 +1179,13 @@ function RomExtractor:extractPokemon()
|
||||
completed = completed + 1
|
||||
self:tick("Pokemon", completed, #self.manifest.dexOrder + 10)
|
||||
end
|
||||
-- Yellow-only third back pic: LoadPlayerBackPic (engine/battle/core.asm)
|
||||
-- picks OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for
|
||||
-- BATTLE_TYPE_PIKACHU, the Pallet Town catch scene (#557). Outside the
|
||||
-- ticked loop so the progress denominator stays as it was.
|
||||
if self.symbols["ProfOakPicBack"] then
|
||||
self:writeCompressedPic("ProfOakPicBack", "battle/profoakb.png")
|
||||
end
|
||||
local balls = self:symbol("PokeballTileGraphics")
|
||||
self:write2bpp(self.rom:bytes(balls.bank, balls.address, 64),
|
||||
32, 8, "battle/balls.png", true)
|
||||
@@ -1826,6 +1833,35 @@ function RomExtractor:extractField()
|
||||
end
|
||||
end
|
||||
|
||||
-- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per
|
||||
-- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in
|
||||
-- data/pikachu/pikachu_pic_animation.asm, the pic PikaPicAnimBGFrames_4
|
||||
-- (PikaAnimTilemap_1, column order) paints. Scripts 18/22/23/24 have no
|
||||
-- compressed base and take PikaPicAnimBGFrames_5 -> PikaAnimTilemap_9,
|
||||
-- which is ROW order over a raw 25-tile sheet, hence no columns flag.
|
||||
-- Script 26 shares script 11's base. The pikaframe overlays each script
|
||||
-- draws ON TOP of the base are a second full pose out of the same blob and
|
||||
-- stay unripped: PikachuFollower.picLift stands in for their motion
|
||||
-- (#561, still on #407's stand-in).
|
||||
local PIKAPIC_BASE = {
|
||||
"Pic_e4000", "Pic_e411c", "Pic_e4272", "Pic_e4383", "Pic_e458b",
|
||||
"Pic_e467b", "Pic_e476e", "Pic_e49d1", "Pic_e4b39", "Pic_e4c3e",
|
||||
"Pic_e5000", "Pic_e523f", "Pic_e548e", "Pic_e56d1", "Pic_e5924",
|
||||
"Pic_e5b7d", "Pic_e5ddd", "GFX_e6020", "Pic_e6340", "Pic_e6587",
|
||||
"Pic_e67d6", "GFX_e6e6f", "GFX_e718f", "GFX_e74af", "Pic_e77cf",
|
||||
"Pic_e5000", "Pic_f0abf", "Pic_f0cf4",
|
||||
}
|
||||
if self.symbols[PIKAPIC_BASE[1]] then
|
||||
for script, label in ipairs(PIKAPIC_BASE) do
|
||||
local path = "pikachu/pikapic_" .. script .. ".png"
|
||||
if label:sub(1, 4) == "GFX_" then
|
||||
self:raw2bpp(label, 40, 40, path, { matte = true })
|
||||
else
|
||||
self:writeCompressedPic(label, path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
self:raw1bpp("LedgeHoppingShadow", 8, 8,
|
||||
"fx/shadow.png", true); tick()
|
||||
for _, spec in ipairs({
|
||||
|
||||
+255
-66
@@ -1,12 +1,34 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Strings = require("src.core.Strings")
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local RomImporter = {}
|
||||
RomImporter.__index = RomImporter
|
||||
|
||||
-- love.system.pickFile is a NATIVE BRIDGE, not part of LÖVE: it exists only on
|
||||
-- builds that compiled one (Android, and iOS builds patched by
|
||||
-- mobile/ios/patch_love_src.py). A build without it must fall back to the
|
||||
-- copy-it-into-the-save-folder flow that every caller below already has --
|
||||
-- calling the nil field instead took the whole app down the moment the player
|
||||
-- pressed Import ROM:
|
||||
--
|
||||
-- src/import/RomImporter.lua: attempt to call field 'pickFile' (a nil value)
|
||||
--
|
||||
-- love.system.createFile was already guarded this way at its one call site;
|
||||
-- these three were not. Every caller here treats `false` as "no picker
|
||||
-- available" and shows its own notice, so a missing bridge now degrades to
|
||||
-- exactly the path a picker-less Android device has always taken.
|
||||
local function pickFile(...)
|
||||
local fn = love.system.pickFile
|
||||
if not fn then return false end
|
||||
return fn(...) and true or false
|
||||
end
|
||||
|
||||
-- Cache generation tag; bump to force every imported version to re-extract.
|
||||
local CACHE_FORMAT = "rom-cache-v8:"
|
||||
-- v9: Yellow audio re-anchored on pokeyellow.sym (#522) -- stale caches
|
||||
-- carry Red's bank $1f header, wave-table, and CryData offsets.
|
||||
local CACHE_FORMAT = "rom-cache-v9:"
|
||||
-- The completion marker is written under each version's cache prefix
|
||||
-- (rom-cache.complete for Red, blue/rom-cache.complete for Blue).
|
||||
local MARKER_PATH = "rom-cache.complete"
|
||||
@@ -39,11 +61,22 @@ local REQUIRED_FILES = {
|
||||
-- them re-imports on its own, without dragging the other versions through a
|
||||
-- CACHE_FORMAT bump.
|
||||
local VERSION_REQUIRED_FILES = {
|
||||
yellow = { "assets/generated/battle/trainers/jessie_james.png" }, -- #439
|
||||
yellow = {
|
||||
"assets/generated/battle/trainers/jessie_james.png", -- #439
|
||||
-- Oak's own back pic and the pikapic base frames only exist in caches
|
||||
-- built after their manifest symbols landed, so an older Yellow cache
|
||||
-- has to re-import to stop falling back to the old man's back pic and
|
||||
-- to the battle front pic (#557, #561). Both are gated on manifest
|
||||
-- symbols in RomExtractor, so these markers must only ever list files
|
||||
-- tools/rom_manifest_yellow.json can actually produce -- otherwise the
|
||||
-- cache reads as incomplete and re-importing cannot clear it.
|
||||
"assets/generated/battle/profoakb.png",
|
||||
"assets/generated/pikachu/pikapic_1.png",
|
||||
},
|
||||
}
|
||||
|
||||
-- "Split-screen ROM selector" first-run palette (matches FirstRun.dc.html from
|
||||
-- the Claude Design project): a dark neon arcade panel, one column per game.
|
||||
-- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
|
||||
-- a dark neon arcade panel, one column per game.
|
||||
-- Red, Blue, and Yellow share the same importer flow once listed in
|
||||
-- GameVersion.VERSIONS. Values are 0-255 RGB; alpha is applied per draw.
|
||||
local PAL = {
|
||||
@@ -553,10 +586,16 @@ function RomImporter.new(onComplete, opts)
|
||||
onEditTouchControls = opts.onEditTouchControls,
|
||||
android = android,
|
||||
ios = mobileOS == "iOS",
|
||||
-- One startup poll pass: files dropped through the Files app are swept
|
||||
-- into the save dir before Lua boots (GRBootstrap), but no love.focus
|
||||
-- event necessarily follows, so consume them via the first poll tick.
|
||||
pickPending = mobileOS == "iOS" or nil,
|
||||
-- One startup poll pass on both mobiles. iOS: files dropped through the
|
||||
-- Files app are swept into the save dir before Lua boots (GRBootstrap) with
|
||||
-- no love.focus event necessarily following. Android: the SAF picker is a
|
||||
-- separate activity, and Android is free to destroy GameActivity while it
|
||||
-- is up (memory pressure, or "Don't keep activities"), so the app RESTARTS
|
||||
-- instead of resuming and the love.focus(true) that would have consumed the
|
||||
-- pick never arrives. The file is sitting in the save dir either way, so
|
||||
-- boot armed and let the first poll tick consume it, rather than making the
|
||||
-- player tap Import a second time to trigger the scan by hand (#553).
|
||||
pickPending = android or nil,
|
||||
-- Android drag: the launcher is handed no move events at all (main.lua
|
||||
-- forwards neither touchmoved nor mousemoved while it is up), and its mouse
|
||||
-- emulation is what "no reliable pointer polling" below refers to.
|
||||
@@ -609,6 +648,8 @@ function RomImporter.new(onComplete, opts)
|
||||
-- Android SAF create-document: which game's SAVE FILES card should show
|
||||
-- "Save exported." when export_done.flag appears on focus.
|
||||
androidPendingExportVersion = nil,
|
||||
iosPendingKind = nil,
|
||||
iosPendingVersion = nil,
|
||||
-- Virtual pointer for handhelds / gamepads (Anbernic stock OS has no
|
||||
-- mouse). D-pad + left stick move it; A clicks; shoulders cycle tabs;
|
||||
-- right stick scrolls the save-slot / mods lists.
|
||||
@@ -790,7 +831,11 @@ local function resetPointerCursor(self)
|
||||
if not (love.mouse.isCursorSupported and love.mouse.isCursorSupported()) then
|
||||
return
|
||||
end
|
||||
self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow")
|
||||
if not self.arrowCursor then
|
||||
local ok, cursor = pcall(love.mouse.getSystemCursor, "arrow")
|
||||
if not ok then return end
|
||||
self.arrowCursor = cursor
|
||||
end
|
||||
love.mouse.setCursor(self.arrowCursor)
|
||||
end
|
||||
|
||||
@@ -976,6 +1021,14 @@ end
|
||||
-- which focus/Choose consumes on return.
|
||||
function RomImporter:chooseMod()
|
||||
if self.workState == "working" then return end
|
||||
if self.ios and love.system.getPickedFile then
|
||||
self.iosPendingKind = "mod"
|
||||
if not pickFile("mod") then
|
||||
self.iosPendingKind = nil
|
||||
self.modNotice = { ok = false, text = "Could not open the file picker." }
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.android then
|
||||
local name = findPendingMod(true, self.pickSkip)
|
||||
if name then
|
||||
@@ -984,7 +1037,7 @@ function RomImporter:chooseMod()
|
||||
self.modNotice and self.modNotice.ok)
|
||||
return
|
||||
end
|
||||
if not love.system.pickFile("mod") then
|
||||
if not pickFile("mod") then
|
||||
self.modNotice = { ok = false,
|
||||
text = "Could not open the file picker. Copy a mod .zip via USB." }
|
||||
else
|
||||
@@ -1037,6 +1090,16 @@ end
|
||||
-- Android mirrors ROM / mod import via love.system.pickFile("sav").
|
||||
function RomImporter:chooseSaveImport(version)
|
||||
if self.workState == "working" then return end
|
||||
if self.ios and love.system.getPickedFile then
|
||||
self.iosPendingKind = "sav"
|
||||
self.iosPendingVersion = version
|
||||
if not pickFile("sav") then
|
||||
self.iosPendingKind = nil
|
||||
self.iosPendingVersion = nil
|
||||
self.saveNotice[version] = { ok = false, text = "Could not open the file picker." }
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.android then
|
||||
local name = findPendingSav(true, self.pickSkip)
|
||||
if name then
|
||||
@@ -1047,7 +1110,7 @@ function RomImporter:chooseSaveImport(version)
|
||||
return
|
||||
end
|
||||
self.androidPendingVersion = version
|
||||
if not love.system.pickFile("sav") then
|
||||
if not pickFile("sav") then
|
||||
self.androidPendingVersion = nil
|
||||
self.saveNotice[version] = { ok = false,
|
||||
text = "Could not open the file picker. Copy a .sav via USB." }
|
||||
@@ -1089,7 +1152,7 @@ function RomImporter:exportSave(version)
|
||||
return
|
||||
end
|
||||
self.androidPendingExportVersion = version
|
||||
if love.system.createFile and love.system.createFile(suggested) then
|
||||
if love.system.createFile and love.system.createFile(suggested, love.filesystem.getSaveDirectory()) then
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.saveNotice[version] = { ok = true,
|
||||
@@ -1126,6 +1189,14 @@ end
|
||||
function RomImporter:choose(version)
|
||||
if self.workState == "working" then return end
|
||||
self.chooseVersion = version or "red"
|
||||
if self.ios and love.system.getPickedFile then
|
||||
self.iosPendingKind = "rom"
|
||||
if not pickFile("rom") then
|
||||
self.iosPendingKind = nil
|
||||
self:setError("Could not open the file picker.")
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.android then
|
||||
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
|
||||
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that
|
||||
@@ -1135,7 +1206,7 @@ function RomImporter:choose(version)
|
||||
self:startData(data, name)
|
||||
elseif consumePickedRomError(self) then
|
||||
return -- a rejected pick explains itself instead of silently reopening
|
||||
elseif not love.system.pickFile() then
|
||||
elseif not pickFile() then
|
||||
-- Picker unavailable (API < 19, or no document-picker app installed):
|
||||
-- fall back to the USB folder-drop path as a friendly notice, not an
|
||||
-- error (which would read as a rejected file).
|
||||
@@ -1181,13 +1252,30 @@ function RomImporter:choose(version)
|
||||
end
|
||||
end
|
||||
|
||||
-- iOS: the document picker is an in-process modal sheet, so unlike Android's
|
||||
-- separate SAF activity there is no love.focus(true) when it dismisses.
|
||||
-- While a pick is outstanding, poll the save dir for the bridge's delivered
|
||||
-- file (picked_rom.gb / picked_mod.zip / picked_save.sav / export_done.flag)
|
||||
-- and run the same refocus import path Android uses.
|
||||
-- Poll the save dir for a delivered pick (picked_rom.gb / picked_mod.zip /
|
||||
-- picked_save.sav / export_done.flag) and run the same import path a refocus
|
||||
-- runs. Both mobiles need this, for different reasons:
|
||||
--
|
||||
-- iOS the document picker is an in-process modal sheet, so there is no
|
||||
-- love.focus(true) when it dismisses -- nothing else would consume it.
|
||||
-- Android the SAF picker IS a separate activity and normally does refocus,
|
||||
-- but Android may destroy GameActivity while it is up, in which case
|
||||
-- the app restarts and that focus event never comes. Polling makes
|
||||
-- the outcome the same either way instead of leaving the pick on disk
|
||||
-- for the next tap to find, which is what made users import twice and
|
||||
-- what made it look random: it depends on memory pressure (#553).
|
||||
--
|
||||
-- Deliberately NO timeout. A version of this disarmed the poll after 120s so a
|
||||
-- cancelled picker would stop scanning, which was wrong on iOS: the picker there
|
||||
-- is an in-process modal sheet, so update() keeps running while it is open and
|
||||
-- the window burned down while the player was still browsing Files. The pick
|
||||
-- then landed with nothing armed to consume it, and because every path here is
|
||||
-- silent on success the import just did not happen, with no error shown. A
|
||||
-- half-second directory listing on a menu screen is far cheaper than an import
|
||||
-- that vanishes, so the poll stays armed until something is actually consumed.
|
||||
|
||||
function RomImporter:_pollPickedFiles(dt)
|
||||
if not (self.ios and self.pickPending) then return end
|
||||
if not self.pickPending then return end
|
||||
if self.workState == "working" then return end
|
||||
self.pickTimer = (self.pickTimer or 0) + dt
|
||||
if self.pickTimer < 0.5 then return end
|
||||
@@ -1223,6 +1311,37 @@ end
|
||||
function RomImporter:update(dt)
|
||||
self.pulse = self.pulse + dt
|
||||
self:_updatePadCursor(dt)
|
||||
if self.ios and love.system.getPickedFile and self.workState ~= "working" then
|
||||
local path = love.system.getPickedFile()
|
||||
if path then
|
||||
local kind = self.iosPendingKind or "rom"
|
||||
local version = self.iosPendingVersion
|
||||
self.iosPendingKind = nil
|
||||
self.iosPendingVersion = nil
|
||||
if kind == "mod" then
|
||||
self:_installMod(path)
|
||||
elseif kind == "sav" then
|
||||
self:_importSave(version or self:_savedropTarget(), path)
|
||||
else
|
||||
self:startPath(path)
|
||||
end
|
||||
elseif love.system.getPickError then
|
||||
local errorText = love.system.getPickError()
|
||||
if errorText then
|
||||
local kind = self.iosPendingKind or "rom"
|
||||
local version = self.iosPendingVersion or self:_savedropTarget()
|
||||
self.iosPendingKind = nil
|
||||
self.iosPendingVersion = nil
|
||||
if kind == "mod" then
|
||||
self.modNotice = { ok = false, text = errorText }
|
||||
elseif kind == "sav" then
|
||||
self.saveNotice[version] = { ok = false, text = errorText }
|
||||
else
|
||||
self:setError(errorText)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
self:_pollPickedFiles(dt)
|
||||
if self.workState ~= "working" or not self.worker then return end
|
||||
local started = love.timer.getTime()
|
||||
@@ -1247,10 +1366,10 @@ local PAD_DPAD_SPEED = 420
|
||||
|
||||
function RomImporter:_activatePadCursor()
|
||||
if self._padCursorActive then return end
|
||||
local w, h = love.graphics.getDimensions()
|
||||
local ox, oy, w, h = SafeArea.rect()
|
||||
if not self._padInited then
|
||||
self._padCursor.x = w * 0.5
|
||||
self._padCursor.y = h * 0.45
|
||||
self._padCursor.x = ox + w * 0.5
|
||||
self._padCursor.y = oy + h * 0.45
|
||||
self._padInited = true
|
||||
end
|
||||
self._padCursorActive = true
|
||||
@@ -1267,6 +1386,7 @@ function RomImporter:_cycleTab(delta)
|
||||
self._slotPress = nil
|
||||
self._modPress = nil
|
||||
self._findSearchFocus = false
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_updatePadCursor(dt)
|
||||
@@ -1296,11 +1416,11 @@ function RomImporter:_updatePadCursor(dt)
|
||||
if mag > 1 then dx, dy = dx / mag, dy / mag end
|
||||
local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD)
|
||||
and PAD_SPEED or PAD_DPAD_SPEED
|
||||
local w, h = love.graphics.getDimensions()
|
||||
local ox, oy, w, h = SafeArea.rect()
|
||||
local nx = self._padCursor.x + dx * speed * dt
|
||||
local ny = self._padCursor.y + dy * speed * dt
|
||||
self._padCursor.x = math.max(0, math.min(w, nx))
|
||||
self._padCursor.y = math.max(0, math.min(h, ny))
|
||||
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
|
||||
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
|
||||
end
|
||||
|
||||
-- Right stick scrolls the active list (save slots or mods), or the whole page
|
||||
@@ -1678,7 +1798,10 @@ function RomImporter:_resetFrameRects()
|
||||
end
|
||||
|
||||
function RomImporter:draw()
|
||||
local width, height = love.graphics.getDimensions()
|
||||
-- Full window for immersive backdrop; safe rect for interactive chrome so
|
||||
-- notch / Dynamic Island / home indicator / Android cutouts are respected.
|
||||
local fullW, fullH = love.graphics.getDimensions()
|
||||
local ox, oy, width, height = SafeArea.rect()
|
||||
local s = clamp(height / 768, 0.7, 1.6)
|
||||
local pulse = self.pulse
|
||||
self._s = s
|
||||
@@ -1697,8 +1820,9 @@ function RomImporter:draw()
|
||||
self._anyHover = false
|
||||
self:_resetFrameRects()
|
||||
|
||||
-- Fonts + size-dependent scenery, rebuilt only when the window size changes.
|
||||
local fontKey = ("%dx%d"):format(width, height)
|
||||
-- Fonts + size-dependent scenery, rebuilt only when the window / safe
|
||||
-- area changes (rotation, resize, inset changes).
|
||||
local fontKey = ("%dx%d@%d,%d"):format(fullW, fullH, ox, oy)
|
||||
if self.fontKey ~= fontKey then
|
||||
self.fontKey = fontKey
|
||||
local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end
|
||||
@@ -1722,10 +1846,10 @@ function RomImporter:draw()
|
||||
-- Background: a radial gradient (bright navy at top-centre -> near black).
|
||||
-- A triangle fan from the top-centre gives the radial falloff; the screen
|
||||
-- is cleared to the outer colour first so the corners it does not reach
|
||||
-- match seamlessly.
|
||||
-- match seamlessly. Sized to the full window so unsafe edges stay filled.
|
||||
do
|
||||
local cx, cy = width / 2, 0
|
||||
local rx, ry = width * 1.3, height * 1.08
|
||||
local cx, cy = fullW / 2, 0
|
||||
local rx, ry = fullW * 1.3, fullH * 1.08
|
||||
local n = 72
|
||||
local verts = { { cx, cy, 0, 0,
|
||||
PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } }
|
||||
@@ -1739,8 +1863,8 @@ function RomImporter:draw()
|
||||
|
||||
-- CRT vignette: a gentle edge darkening, centred slightly above the middle.
|
||||
do
|
||||
local cx, cy = width / 2, height * 0.45
|
||||
local rx, ry = width * 0.78, height * 0.78
|
||||
local cx, cy = fullW / 2, fullH * 0.45
|
||||
local rx, ry = fullW * 0.78, fullH * 0.78
|
||||
local n = 72
|
||||
local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } }
|
||||
for i = 0, n do
|
||||
@@ -1762,7 +1886,7 @@ function RomImporter:draw()
|
||||
self.scanlineImage:setWrap("repeat", "repeat")
|
||||
self.scanlineImage:setFilter("nearest", "nearest")
|
||||
end
|
||||
self.scanlineQuad = love.graphics.newQuad(0, 0, width, height, 1, 3)
|
||||
self.scanlineQuad = love.graphics.newQuad(0, 0, fullW, fullH, 1, 3)
|
||||
end
|
||||
|
||||
-- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it
|
||||
@@ -1788,21 +1912,23 @@ function RomImporter:draw()
|
||||
}
|
||||
]])
|
||||
|
||||
-- background
|
||||
-- background (full window — unsafe edges stay painted)
|
||||
col(PAL.bgBot)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.bgMesh)
|
||||
|
||||
-- Centered content container (max ~1440 scaled units on very wide windows)
|
||||
-- with a responsive side gutter; every column below derives from these.
|
||||
-- Origin is the safe-area top-left so chrome clears device insets.
|
||||
local appW = math.min(width, 1440 * s)
|
||||
local appX = (width - appW) / 2
|
||||
local appX = ox + (width - appW) / 2
|
||||
local padH = clamp(appW * 0.03, 12 * s, 26 * s)
|
||||
local third = appW / 3
|
||||
|
||||
-- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom
|
||||
local stripH = math.max(4, 6 * s)
|
||||
local stripY = oy
|
||||
local segs = {
|
||||
{ PAL.red, appX, third },
|
||||
{ PAL.blue, appX + third, third },
|
||||
@@ -1810,11 +1936,11 @@ function RomImporter:draw()
|
||||
}
|
||||
love.graphics.setBlendMode("add")
|
||||
for _, seg in ipairs(segs) do
|
||||
fillGrad(seg[2], stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
|
||||
fillGrad(seg[2], stripY + stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
|
||||
end
|
||||
love.graphics.setBlendMode("alpha")
|
||||
for _, seg in ipairs(segs) do
|
||||
col(seg[1]); love.graphics.rectangle("fill", seg[2], 0, seg[3], stripH)
|
||||
col(seg[1]); love.graphics.rectangle("fill", seg[2], stripY, seg[3], stripH)
|
||||
end
|
||||
|
||||
-- Footer (Boi's Club Games logo + trust warning), measured first so the
|
||||
@@ -1836,7 +1962,7 @@ function RomImporter:draw()
|
||||
math.min(330 * s, appW - 32 * s))
|
||||
local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH)
|
||||
local logoDW, logoDH = logoW * logoScale, logoH * logoScale
|
||||
local logoY = stripH + 14 * s
|
||||
local logoY = stripY + stripH + 14 * s
|
||||
|
||||
-- Tab bar: R/B/Y/divider/MODS chips (label + underline on the active one),
|
||||
-- with "N of 3 ready" right-aligned.
|
||||
@@ -1866,7 +1992,7 @@ function RomImporter:draw()
|
||||
local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s
|
||||
local cX = appX + padH
|
||||
local cW = appW - 2 * padH
|
||||
local contentBottom = height - footerH - bannerBand
|
||||
local contentBottom = oy + height - footerH - bannerBand
|
||||
local cH = math.max(0, contentBottom - contentTop)
|
||||
|
||||
-- Page scroll. Everything under the tab bar -- panel, updater banner and
|
||||
@@ -1877,14 +2003,14 @@ function RomImporter:draw()
|
||||
-- the previous frame's measurement, the same one-frame settle the slot and
|
||||
-- mod lists already rely on. While the page fits, `paged` is false and every
|
||||
-- measurement below is what it always was.
|
||||
local viewportH = math.max(0, height - contentTop)
|
||||
local viewportH = math.max(0, oy + height - contentTop)
|
||||
self._panelNaturalH = self._panelNaturalH or {}
|
||||
local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH
|
||||
local paged, pageScroll, maxPage =
|
||||
RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll)
|
||||
self.pageScroll, self._pageMax = pageScroll, maxPage
|
||||
-- read by the hit tests; a scrolled control is live only inside the viewport
|
||||
pageBand = paged and { contentTop, height } or nil
|
||||
pageBand = paged and { contentTop, oy + height } or nil
|
||||
|
||||
-- tab bar (rebuilds self.tabRects). Pinned: it is the launcher's navigation,
|
||||
-- and it sits above the scrolling viewport.
|
||||
@@ -2055,10 +2181,10 @@ function RomImporter:draw()
|
||||
|
||||
-- logo, over the split, with a gentle bob + gold glow + sweeping shine
|
||||
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s
|
||||
local lx, ly = (width - logoDW) / 2, logoY + bob
|
||||
local lx, ly = ox + (width - logoDW) / 2, logoY + bob
|
||||
love.graphics.setBlendMode("add")
|
||||
love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6)))
|
||||
love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
|
||||
love.graphics.draw(self.logo, ox + (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
|
||||
logoScale * 1.05, logoScale * 1.05)
|
||||
love.graphics.setBlendMode("alpha")
|
||||
local shineW = 0.16
|
||||
@@ -2091,11 +2217,11 @@ function RomImporter:draw()
|
||||
-- save-slot rename modal (#205), drawn over everything
|
||||
if self._rename then
|
||||
col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
local dw = math.min(appW - 32 * s, 420 * s)
|
||||
local dh = 128 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4)
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85)
|
||||
@@ -2137,11 +2263,11 @@ function RomImporter:draw()
|
||||
-- it is typed.
|
||||
if self._indexPrompt then
|
||||
col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
local dw = math.min(appW - 32 * s, 520 * s)
|
||||
local dh = 168 * s
|
||||
local dh = 176 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4)
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.9, 0.9)
|
||||
@@ -2183,6 +2309,14 @@ function RomImporter:draw()
|
||||
fy + 7 * s, math.max(1, 1.5 * s), fh - 14 * s)
|
||||
end
|
||||
|
||||
-- PASTE under the field: a touch screen has no ctrl+V, and an index URL
|
||||
-- is not something anyone retypes on a soft keyboard (#578). This rect
|
||||
-- is the one click mousepressed honors while the prompt is up; pinned so
|
||||
-- page-scroll banding never eats the tap.
|
||||
self._indexPasteRect = self:_chipButton(fx + fw - 84 * s, fy + fh + 8 * s,
|
||||
Strings("Paste"), { w = 84 * s, h = 28 * s, kind = "accent" })
|
||||
self._indexPasteRect.pinned = true
|
||||
|
||||
love.graphics.setFont(self.hintFont)
|
||||
col(PAL.warning)
|
||||
printfB(Strings("Enter to add - Esc to cancel"),
|
||||
@@ -2193,7 +2327,7 @@ function RomImporter:draw()
|
||||
if self._modConfirm or self._modVersions or self._modReleaseNotes
|
||||
or self._findDetails then
|
||||
col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
end
|
||||
if self._modConfirm then
|
||||
local c = self._modConfirm
|
||||
@@ -2201,7 +2335,7 @@ function RomImporter:draw()
|
||||
local lineH = self.hintFont:getHeight() + 4 * s
|
||||
local dh = 36 * s + (#c.lines) * lineH + 56 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2243,7 +2377,7 @@ function RomImporter:draw()
|
||||
local dw = math.min(appW - 32 * s, 480 * s)
|
||||
local dh = math.min(height - 48 * s, 360 * s)
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2288,7 +2422,7 @@ function RomImporter:draw()
|
||||
local dw = math.min(appW - 32 * s, 520 * s)
|
||||
local dh = math.min(height - 48 * s, 420 * s)
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2345,7 +2479,7 @@ function RomImporter:draw()
|
||||
listH = listN * rowH
|
||||
dh = headerH + listH + footerH
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2354,7 +2488,7 @@ function RomImporter:draw()
|
||||
|
||||
love.graphics.setFont(self.slotNameFont)
|
||||
col(PAL.white)
|
||||
love.graphics.printf("Other versions — " .. tostring(v.name),
|
||||
love.graphics.printf("Other versions: " .. tostring(v.name),
|
||||
dx + pad, dy + 10 * s, dw - pad * 2, "left")
|
||||
love.graphics.setFont(self.hintFont)
|
||||
local info = self:_modUpdateInfo(v.id)
|
||||
@@ -2461,8 +2595,11 @@ function RomImporter:draw()
|
||||
if self._hoverEnabled and not self._padCursorActive
|
||||
and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then
|
||||
if self._anyHover then
|
||||
self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand")
|
||||
love.mouse.setCursor(self.handCursor)
|
||||
if not self.handCursor then
|
||||
local ok, cursor = pcall(love.mouse.getSystemCursor, "hand")
|
||||
if ok then self.handCursor = cursor end
|
||||
end
|
||||
if self.handCursor then love.mouse.setCursor(self.handCursor) end
|
||||
else
|
||||
resetPointerCursor(self)
|
||||
end
|
||||
@@ -2521,7 +2658,14 @@ end
|
||||
|
||||
function RomImporter:mousepressed(x, y, button)
|
||||
if self._rename then return end -- the rename modal swallows all clicks
|
||||
if self._indexPrompt then return end -- and so does the add-index prompt
|
||||
-- The add-index prompt swallows clicks too, except its PASTE button: a
|
||||
-- touch screen has no ctrl+V, so the button is the only paste path (#578).
|
||||
if self._indexPrompt then
|
||||
if button == 1 and inside(self._indexPasteRect, x, y) then
|
||||
self:_pasteIndexUrl()
|
||||
end
|
||||
return
|
||||
end
|
||||
-- Mod confirm / versions / release-notes modals swallow clicks too.
|
||||
if self._modConfirm then
|
||||
if button ~= 1 then return end
|
||||
@@ -2628,6 +2772,7 @@ function RomImporter:mousepressed(x, y, button)
|
||||
self._modPress = nil -- and any half-started mod toggle press
|
||||
self._pagePress = nil -- and any half-started page pan
|
||||
self._findSearchFocus = false -- and the search caret, now off screen
|
||||
self:_disarmTextInput()
|
||||
-- Each tab is its own column of a different length; carrying one tab's
|
||||
-- offset into another lands somewhere arbitrary.
|
||||
self.pageScroll = 0
|
||||
@@ -2749,11 +2894,14 @@ function RomImporter:mousepressed(x, y, button)
|
||||
end
|
||||
if inside(self.findRefreshRect, x, y) then
|
||||
self._findSearchFocus = false
|
||||
self:_disarmTextInput()
|
||||
self:_refreshFind(true)
|
||||
return
|
||||
end
|
||||
if inside(self.findSearchRect, x, y) then
|
||||
self._findSearchFocus = true; return
|
||||
self._findSearchFocus = true
|
||||
self:_armTextInput()
|
||||
return
|
||||
end
|
||||
for _, r in ipairs(self.findSourceRemoveRects or {}) do
|
||||
if inside(r, x, y) then self:_removeIndex(r.id); return end
|
||||
@@ -2781,7 +2929,10 @@ function RomImporter:mousepressed(x, y, button)
|
||||
end
|
||||
-- A press anywhere else on the tab drops the search caret, so the field does
|
||||
-- not silently keep eating keystrokes once the player has moved on.
|
||||
if self.tab == "find" then self._findSearchFocus = false end
|
||||
if self.tab == "find" and self._findSearchFocus then
|
||||
self._findSearchFocus = false
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
-- Nothing was hit. On a scrolling page that is a press on empty background,
|
||||
-- which is the natural place to grab and pan from.
|
||||
if armDrag and (self._pageMax or 0) > 0 then
|
||||
@@ -2797,6 +2948,7 @@ function RomImporter:keypressed(key)
|
||||
self:_commitRename()
|
||||
elseif key == "escape" then
|
||||
self._rename = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -2807,13 +2959,11 @@ function RomImporter:keypressed(key)
|
||||
self:_commitAddIndex()
|
||||
elseif key == "escape" then
|
||||
self._indexPrompt = nil
|
||||
self:_disarmTextInput()
|
||||
elseif key == "v" and (love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui")) then
|
||||
-- an index URL is long and comes from a browser: typing it out by hand
|
||||
-- is the difference between adding one and giving up
|
||||
local ok, text = pcall(love.system.getClipboardText)
|
||||
if ok and type(text) == "string" then
|
||||
self._indexPrompt.text = self._indexPrompt.text .. text:gsub("%s", "")
|
||||
end
|
||||
self:_pasteIndexUrl()
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -2837,6 +2987,7 @@ function RomImporter:keypressed(key)
|
||||
self.findScroll = 0
|
||||
elseif key == "escape" or key == "return" or key == "kpenter" then
|
||||
self._findSearchFocus = false
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -3390,6 +3541,27 @@ local MAX_SLOT_LABEL = 24
|
||||
local MAX_INDEX_URL = 200
|
||||
local MAX_FIND_QUERY = 48
|
||||
|
||||
-- Mobile LOVE only delivers love.textinput while setTextInput(true) is armed,
|
||||
-- and arming it is also what raises the soft keyboard, so a cabled USB
|
||||
-- keyboard is just as dead without it (#578). Every site that opens one of
|
||||
-- the launcher's three text fields (_rename, _indexPrompt, _findSearchFocus)
|
||||
-- arms through here, and every site that closes one disarms. Desktop has
|
||||
-- text input on by default and the save editor hosted from this launcher
|
||||
-- depends on it staying on (tools/save-editor/Kit.lua, #529), so disarm only
|
||||
-- lowers on mobile -- setTextInput is global SDL state, not per-widget.
|
||||
function RomImporter:_armTextInput()
|
||||
if love.keyboard and love.keyboard.setTextInput then
|
||||
pcall(love.keyboard.setTextInput, true)
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_disarmTextInput()
|
||||
if not self.android then return end
|
||||
if love.keyboard and love.keyboard.setTextInput then
|
||||
pcall(love.keyboard.setTextInput, false)
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_beginRename(version, id)
|
||||
local label
|
||||
for _, slot in ipairs(self.slots[version] or {}) do
|
||||
@@ -3397,12 +3569,14 @@ function RomImporter:_beginRename(version, id)
|
||||
end
|
||||
self._rename = { version = version, id = id, text = label or "" }
|
||||
self._slotPress = nil -- cancel any armed click/drag on the list
|
||||
self:_armTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_commitRename()
|
||||
local r = self._rename
|
||||
if not r then return end
|
||||
self._rename = nil
|
||||
self:_disarmTextInput()
|
||||
require("src.core.SaveData").renameSlot(r.version, r.id, r.text)
|
||||
self:_refreshSlots(r.version)
|
||||
end
|
||||
@@ -3424,6 +3598,19 @@ function RomImporter:textinput(text)
|
||||
self._rename.text = utf8Cap(self._rename.text .. text, MAX_SLOT_LABEL)
|
||||
end
|
||||
|
||||
-- Clipboard into the index prompt, shared by ctrl/cmd+V and the prompt's
|
||||
-- on-screen PASTE button (#578). Same rule as typed input: URLs never
|
||||
-- contain a literal space, and a pasted one usually arrives with a stray
|
||||
-- newline attached.
|
||||
function RomImporter:_pasteIndexUrl()
|
||||
if not self._indexPrompt then return end
|
||||
local ok, text = pcall(love.system.getClipboardText)
|
||||
if ok and type(text) == "string" then
|
||||
self._indexPrompt.text =
|
||||
utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL)
|
||||
end
|
||||
end
|
||||
|
||||
-- "+ New save slot": register an empty slot, make it active, relist, and pin the
|
||||
-- scroll to the bottom (clamped next draw) so the new row is on screen.
|
||||
function RomImporter:_newSlot(version)
|
||||
@@ -3575,7 +3762,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged)
|
||||
local metaH = self.labelFont:getHeight()
|
||||
local rowPadV = 10 * s
|
||||
local chipBtnH = self.hintFont:getHeight() + 10 * s
|
||||
-- LOADED sits top-right; Edit/Delete sit bottom-right — row must fit both
|
||||
-- LOADED sits top-right; Edit/Delete sit bottom-right -- row must fit both
|
||||
-- without stacking on the same y (chip buttons are taller than the old text).
|
||||
local loadedH = self.warningFont:getHeight() + 6 * s
|
||||
local rowH = math.max(rowPadV * 2 + nameH + 4 * s + metaH,
|
||||
@@ -4467,11 +4654,13 @@ end
|
||||
-- would make the launcher's choice look like an endorsement.
|
||||
function RomImporter:_promptAddIndex()
|
||||
self._indexPrompt = { text = "" }
|
||||
self:_armTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_commitAddIndex()
|
||||
local prompt = self._indexPrompt
|
||||
self._indexPrompt = nil
|
||||
self:_disarmTextInput()
|
||||
if not prompt then return end
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
local row, err = ModIndex.addSource(prompt.text or "")
|
||||
|
||||
+23
-7
@@ -1,11 +1,26 @@
|
||||
-- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm):
|
||||
-- a distinct item id occupies one slot regardless of quantity; badges
|
||||
-- live in the inventory table but are not bag items. save.bagOrder
|
||||
-- keeps acquisition order like wBagItems (SELECT can reorder it).
|
||||
-- The bag defaults to 20 slots (BAG_ITEM_CAPACITY,
|
||||
-- constants/menu_constants.asm), but mods may replace that limit through
|
||||
-- Data.constants.bagSize. A distinct item id occupies one slot regardless
|
||||
-- of quantity; badges live in the inventory table but are not bag items.
|
||||
-- save.bagOrder keeps acquisition order like wBagItems (SELECT can reorder
|
||||
-- it).
|
||||
|
||||
local Bag = {}
|
||||
|
||||
Bag.CAPACITY = 20
|
||||
local DEFAULT_CAPACITY = 20
|
||||
|
||||
-- `data` is injectable for the save editor and headless mod tests. Normal
|
||||
-- gameplay may omit it because the loader merges mods into the Data
|
||||
-- singleton before any item can be added. The fallback keeps old/stale
|
||||
-- generated caches and isolated callers at the vanilla limit.
|
||||
function Bag.capacity(data)
|
||||
data = data or require("src.core.Data")
|
||||
local configured = data and data.constants and data.constants.bagSize
|
||||
if type(configured) == "number" and configured >= 1 then
|
||||
return math.floor(configured)
|
||||
end
|
||||
return DEFAULT_CAPACITY
|
||||
end
|
||||
|
||||
local function isBadge(id)
|
||||
return id:find("BADGE", 1, true) ~= nil
|
||||
@@ -55,9 +70,10 @@ end
|
||||
-- Add qty of an item; returns false (and adds nothing) when a new slot
|
||||
-- is needed and the bag is full, or when the stack would pass 99
|
||||
-- (AddItemToInventory's per-slot quantity cap).
|
||||
function Bag.add(save, id, qty)
|
||||
function Bag.add(save, id, qty, data)
|
||||
local inv = save.inventory
|
||||
if not inv[id] and not isBadge(id) and Bag.slots(save) >= Bag.CAPACITY then
|
||||
if not inv[id] and not isBadge(id)
|
||||
and Bag.slots(save) >= Bag.capacity(data) then
|
||||
return false
|
||||
end
|
||||
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
-- "ball" caller must throw it (battle only)
|
||||
-- "learn", moveId caller must run the learn-move flow
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Flags = require("src.script.Flags")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
@@ -433,6 +432,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if battle then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
-- FishingInit (engine/items/item_effects.asm): cp wWalkBikeSurfState, 2
|
||||
-- (surfing) sets carry, and every ItemUseXRod does jp c, ItemUseNotTime
|
||||
-- on that carry -- surfing refuses the rod with the same OAK text as
|
||||
-- the mid-battle case above, no rod-specific message (#533)
|
||||
if ow and ow.player and ow.player.surfing then
|
||||
return "failed", { Strings("OAK: %s!\nThis isn't the\ntime to use that!", save.player.name) }
|
||||
end
|
||||
return "fish", itemId
|
||||
end
|
||||
|
||||
|
||||
@@ -128,7 +128,14 @@ end
|
||||
|
||||
-- ------- the link surface
|
||||
|
||||
local SPECIES_FIELDS = { "baseStats", "types", "catchRate", "baseExp",
|
||||
-- catchRate stays out (#511): no link mode ever reads it -- a ball thrown
|
||||
-- in a link battle is a trainer-battle throw and always refused (pokered
|
||||
-- engine/items/item_effects.asm ItemUseBall), and the trade rebuild never
|
||||
-- touches it. Hashing it split Red/Blue from Yellow, whose only link
|
||||
-- surface delta is the Dragonair/Dragonite catch-rate bytes
|
||||
-- (data/pokemon/base_stats/dragonair.asm db 45 vs 27, dragonite.asm 45
|
||||
-- vs 9), when the real cable links R/B/Y freely.
|
||||
local SPECIES_FIELDS = { "baseStats", "types", "baseExp",
|
||||
"growthRate", "evolutions" }
|
||||
local MOVE_FIELDS = { "power", "type", "accuracy", "pp", "effect", "category",
|
||||
"priority", "highCrit", "fixedDamage", "multiHit",
|
||||
|
||||
+9
-12
@@ -178,19 +178,16 @@ end
|
||||
|
||||
-- ------- subset negotiation
|
||||
|
||||
-- the species and moves this party actually references, so the exchange
|
||||
-- stays small (six mons) instead of shipping the whole catalog
|
||||
-- every record hash, not just this party's slice: the receiver filters
|
||||
-- its OWN party against these (eligibleParty), so a message limited to the
|
||||
-- sender's party read "not on the other game" for every species the two
|
||||
-- parties did not happen to share, and a subset trade between different
|
||||
-- games showed neither side (#511). The full catalog is ~300 short hash
|
||||
-- strings -- still one message.
|
||||
function Protocol.recordsMessage(data, party)
|
||||
local species = Fingerprint.records(data, "pokemon")
|
||||
local moves = Fingerprint.records(data, "moves")
|
||||
local outSpecies, outMoves = {}, {}
|
||||
for _, mon in ipairs(party or {}) do
|
||||
if species[mon.species] then outSpecies[mon.species] = species[mon.species] end
|
||||
for _, mv in ipairs(mon.moves or {}) do
|
||||
if moves[mv.id] then outMoves[mv.id] = moves[mv.id] end
|
||||
end
|
||||
end
|
||||
return { type = "records", pokemon = outSpecies, moves = outMoves }
|
||||
return { type = "records",
|
||||
pokemon = Fingerprint.records(data, "pokemon"),
|
||||
moves = Fingerprint.records(data, "moves") }
|
||||
end
|
||||
|
||||
-- a mon may cross the wire only if both peers rebuild it identically: the
|
||||
|
||||
+10
-3
@@ -384,9 +384,16 @@ function Tournament:update(dt)
|
||||
else
|
||||
self.pendingBattleOpts.seed = msg.seed
|
||||
end
|
||||
local battle, why = self.isHost
|
||||
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
||||
-- Split rather than `cond and newHost() or newGuest()`: the and/or
|
||||
-- idiom truncates a call to its first result, so the second return
|
||||
-- (the specific reason) was always dropped and every failure showed
|
||||
-- the generic fallback instead of "same mods on both games" etc.
|
||||
local battle, why
|
||||
if self.isHost then
|
||||
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||
else
|
||||
battle, why = LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
||||
end
|
||||
if not battle then
|
||||
self:exitWith(why or Strings("Link battle\ncan't start."))
|
||||
return
|
||||
|
||||
@@ -223,7 +223,9 @@ local function discover()
|
||||
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
||||
local path = "mods/" .. name
|
||||
local info = fs.getInfo(path)
|
||||
if info and info.type == "directory" then
|
||||
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
|
||||
-- setSymlinksEnabled(true); see the matching note in Loader:_discover.
|
||||
if info and (info.type == "directory" or info.type == "symlink") then
|
||||
local raw = fs.read(path .. "/manifest.json")
|
||||
if raw then
|
||||
local manifest = decodeManifest(raw, path)
|
||||
|
||||
+5
-1
@@ -206,7 +206,11 @@ function Loader:_discover()
|
||||
for _, name in ipairs(self.fs.getDirectoryItems(root)) do
|
||||
local path = root .. "/" .. name
|
||||
local info = self.fs.getInfo(path)
|
||||
if info and info.type == "directory" then
|
||||
-- a dev-linked mod dir (ln -s) reports type "symlink" even with
|
||||
-- setSymlinksEnabled(true) -- PhysFS never resolves the symlink's
|
||||
-- own getInfo, only traversal into it. readManifest below still
|
||||
-- correctly no-ops on a symlink that isn't a directory.
|
||||
if info and (info.type == "directory" or info.type == "symlink") then
|
||||
local manifest, err = readManifest(self.fs, path)
|
||||
if manifest then
|
||||
if self.mods[manifest.id] then
|
||||
|
||||
@@ -1186,11 +1186,13 @@ R.field = {
|
||||
nest = f.opt(f.any) },
|
||||
flyOrder = f.list(f.str),
|
||||
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
|
||||
-- back pic, the catch tutorial's old man, and the front pic the intro,
|
||||
-- trainer card and Hall of Fame share. Every key is optional so a
|
||||
-- conversion can replace one pic and inherit the rest.
|
||||
-- back pic, the catch tutorial's old man, Yellow's PROF.OAK variant of
|
||||
-- it (#557), and the front pic the intro, trainer card and Hall of Fame
|
||||
-- share. Every key is optional so a conversion can replace one pic and
|
||||
-- inherit the rest.
|
||||
playerPics = f.rec{
|
||||
back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) },
|
||||
back = f.opt(f.str), demoBack = f.opt(f.str),
|
||||
oakBack = f.opt(f.str), front = f.opt(f.str) },
|
||||
-- the new-game and boot config a total conversion replaces
|
||||
boot = f.rec{
|
||||
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
|
||||
|
||||
+13
-1
@@ -46,20 +46,32 @@ end
|
||||
-- opts.kind: "battle" | "intro" | "trainer_card" | "hof"
|
||||
-- opts.demo: the catch tutorial, where the old man fights in the player's
|
||||
-- place and stands in for the back pic
|
||||
-- opts.oakDemo: the Yellow variant of that demo (BATTLE_TYPE_PIKACHU), where
|
||||
-- PROF.OAK fights in the player's place behind his own back pic
|
||||
-- opts.battle: the live battle, for kind == "battle"
|
||||
-- Returns path, trueColor.
|
||||
function Sprites.playerPath(data, side, opts)
|
||||
opts = opts or {}
|
||||
side = side == "back" and "back" or "front"
|
||||
-- one key per pic, so a conversion can replace the back and inherit the
|
||||
-- rest; fieldValue folds data.field over FieldDefaults per key
|
||||
-- rest; fieldValue folds data.field over FieldDefaults per key. The two
|
||||
-- demo keys mirror LoadPlayerBackPic's wBattleType branch (#557).
|
||||
local key = side == "front" and "front"
|
||||
or (opts.oakDemo and "oakBack")
|
||||
or (opts.demo and "demoBack" or "back")
|
||||
local path = FieldDefaults.fieldValue(data, "playerPics", key)
|
||||
-- ProfOakPicBack is a Yellow-only rip, so a cache imported before it
|
||||
-- existed has no file there; fall back to the old man rather than hand a
|
||||
-- missing path to getImage (#557)
|
||||
if key == "oakBack" and path
|
||||
and not require("src.render.Assets").exists(path) then
|
||||
path = FieldDefaults.fieldValue(data, "playerPics", "demoBack")
|
||||
end
|
||||
local ctx = {
|
||||
side = side,
|
||||
kind = opts.kind or "battle",
|
||||
demo = opts.demo and true or false,
|
||||
oakDemo = opts.oakDemo and true or false,
|
||||
battle = opts.battle,
|
||||
trueColor = false,
|
||||
data = data,
|
||||
|
||||
@@ -120,7 +120,7 @@ end
|
||||
-- shape (love.graphics.newCanvas), so accept either and nothing else.
|
||||
local function isCanvas(v)
|
||||
if type(v) == "userdata" then
|
||||
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
|
||||
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
||||
end
|
||||
if type(v) == "table" then
|
||||
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
||||
|
||||
+75
-32
@@ -32,7 +32,7 @@ Renderer.MAX_UI_HEIGHT = 576
|
||||
-- would otherwise take the frame down with it.
|
||||
local function isCanvas(v)
|
||||
if type(v) == "userdata" then
|
||||
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
|
||||
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
||||
end
|
||||
if type(v) == "table" then
|
||||
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
||||
@@ -474,6 +474,41 @@ local function withTrueColor(zoneList, pass)
|
||||
return merged
|
||||
end
|
||||
|
||||
-- Palette-correct blit of `canvas` at (sx, sy) LOVE-unit scales into origin
|
||||
-- (bx, by), scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/
|
||||
-- zoneSy convert zone coords (canvas-space) into screen units. Public so a
|
||||
-- render.compose mod can composite the world/UI canvases into its own layout.
|
||||
function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
||||
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
|
||||
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
||||
if not shader then
|
||||
love.graphics.setScissor(boxX, boxY, boxW, boxH)
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
love.graphics.setScissor()
|
||||
return
|
||||
end
|
||||
love.graphics.setShader(shader)
|
||||
-- a colors == false zone is the trueColor opt-out: its rect draws with
|
||||
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
|
||||
-- list never toggles and issues exactly the calls it always did.
|
||||
local bare = false
|
||||
for _, z in ipairs(zoneList) do
|
||||
local plain = z.colors == false
|
||||
if plain ~= bare then
|
||||
bare = plain
|
||||
love.graphics.setShader(not plain and shader or nil)
|
||||
end
|
||||
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
||||
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
|
||||
z.w * zoneSx, z.h * zoneSy,
|
||||
boxX, boxY, boxW, boxH, dpiX, dpiY) then
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
end
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
love.graphics.setShader()
|
||||
end
|
||||
|
||||
-- zones: optional list of SGB palette regions (see PaletteFX) in
|
||||
-- 160x144 UI space, applied to the UI pass. worldZones: optional
|
||||
-- regions in world-canvas pixels (overworld survey zoom colors each
|
||||
@@ -506,6 +541,36 @@ function Renderer:endFrame(zones, worldZones)
|
||||
zones = withTrueColor(zones, "ui")
|
||||
worldZones = withTrueColor(worldZones, "world")
|
||||
|
||||
-- render.compose: hand a mod the finished world + UI canvases (and their
|
||||
-- SGB zones), the frame metrics, Renderer:blitCanvas and the SecondScreen
|
||||
-- bridge, letting it lay the two passes out however it likes -- e.g. as two
|
||||
-- stacked Game Boy screens, or driving one onto a second physical display.
|
||||
-- The mod returns true to take over the whole window; anything else (or no
|
||||
-- mod wrapping the hook) falls through to the normal composite below.
|
||||
if Runtime.wantsHook("render.compose") then
|
||||
local ctx = {
|
||||
renderer = self,
|
||||
worldCanvas = self.worldCanvas, uiCanvas = self.canvas,
|
||||
worldOverride = self.worldOverride,
|
||||
worldActive = self.worldActive and true or false,
|
||||
zones = zones, worldZones = worldZones,
|
||||
ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy,
|
||||
vpw = vpw, vph = vph, uiw = uiw, uih = uih,
|
||||
scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY,
|
||||
secondScreen = require("src.render.SecondScreen"),
|
||||
}
|
||||
if Runtime.call("render.compose", function() return false end, self, ctx) == true then
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
self.worldOverride = nil
|
||||
PaletteFX.setPass(nil)
|
||||
return {
|
||||
width = ww, height = wh, gameX = ox, gameY = oy,
|
||||
gameWidth = vpw, gameHeight = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- A post-process pipeline needs the whole composite in a canvas for the
|
||||
-- same reason GBC FX does, so either one alone is enough to take the
|
||||
-- present path; with neither, the frame draws straight to the screen
|
||||
@@ -560,38 +625,12 @@ function Renderer:endFrame(zones, worldZones)
|
||||
})
|
||||
end
|
||||
|
||||
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
|
||||
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
|
||||
-- convert zone coords (canvas-space) into screen units.
|
||||
-- see Renderer:blitCanvas; bound here to the frame's dpi so the composite
|
||||
-- call sites below stay unchanged.
|
||||
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
||||
bx, by, boxX, boxY, boxW, boxH)
|
||||
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
||||
if not shader then
|
||||
love.graphics.setScissor(boxX, boxY, boxW, boxH)
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
love.graphics.setScissor()
|
||||
return
|
||||
end
|
||||
love.graphics.setShader(shader)
|
||||
-- a colors == false zone is the trueColor opt-out: its rect draws with
|
||||
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
|
||||
-- list never toggles and issues exactly the calls it always did.
|
||||
local bare = false
|
||||
for _, z in ipairs(zoneList) do
|
||||
local plain = z.colors == false
|
||||
if plain ~= bare then
|
||||
bare = plain
|
||||
love.graphics.setShader(not plain and shader or nil)
|
||||
end
|
||||
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
||||
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
|
||||
z.w * zoneSx, z.h * zoneSy,
|
||||
boxX, boxY, boxW, boxH, dpiX, dpiY) then
|
||||
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
||||
end
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
love.graphics.setShader()
|
||||
return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
||||
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
|
||||
end
|
||||
|
||||
if self.worldOverride then
|
||||
@@ -602,7 +641,11 @@ 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 / dpiX, 1 / dpiY)
|
||||
if love.system and love.system.getOS and love.system.getOS() == "iOS" then
|
||||
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
|
||||
else
|
||||
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
-- the screen-space overlays the flat path draws over its composite
|
||||
local fade = self.worldFadeAlpha
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Bridge to native secondary-display output (Android Presentation). The C
|
||||
-- functions live in mobile/android/love/src/jni/love/src/common/android.cpp.
|
||||
-- Everything is guarded: off Android, or if the symbols cannot be resolved,
|
||||
-- this stays inert and the renderer keeps the in-window stacked layout.
|
||||
|
||||
local SecondScreen = {}
|
||||
local C = nil
|
||||
|
||||
local function log(msg)
|
||||
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
|
||||
end
|
||||
|
||||
do
|
||||
local ok, ffi = pcall(require, "ffi")
|
||||
if not (ok and ffi) then
|
||||
log("ffi unavailable (not LuaJIT); second display disabled")
|
||||
else
|
||||
pcall(ffi.cdef, [[
|
||||
int love_android_secondary_ready();
|
||||
void love_android_push_secondary(const void *rgba, int w, int h);
|
||||
void love_android_secondary_enable(int on);
|
||||
]])
|
||||
local okLib, lib = pcall(ffi.load, "love")
|
||||
if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then
|
||||
C = lib
|
||||
log("bridge linked via ffi.load('love')")
|
||||
elseif pcall(function() return ffi.C.love_android_secondary_ready end) then
|
||||
C = ffi.C
|
||||
log("bridge linked via default namespace")
|
||||
else
|
||||
log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
|
||||
:format(tostring(okLib)))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function SecondScreen.usable()
|
||||
return C ~= nil
|
||||
end
|
||||
|
||||
function SecondScreen.available()
|
||||
if not C then return false end
|
||||
local ok, r = pcall(C.love_android_secondary_ready)
|
||||
return ok and r ~= 0
|
||||
end
|
||||
|
||||
function SecondScreen.push(imageData, w, h)
|
||||
if not C or not imageData then return false end
|
||||
return pcall(function()
|
||||
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
|
||||
end)
|
||||
end
|
||||
|
||||
function SecondScreen.setEnabled(on)
|
||||
if not C then return end
|
||||
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
|
||||
end
|
||||
|
||||
return SecondScreen
|
||||
@@ -10,6 +10,13 @@ local Zoom = {}
|
||||
|
||||
Zoom.offset = 0
|
||||
|
||||
-- Survey zoom (zooming out past FIT, which renders connected neighbor maps)
|
||||
-- is the port's most expensive optional extra. The performance tier sets
|
||||
-- this false on LOW hardware (Game:applyOptions); offsetRange then floors
|
||||
-- the range at FIT so the option row, hotkey, and mouse wheel all stop at
|
||||
-- close-up. Nil/true keeps the historical full range.
|
||||
Zoom.allowSurvey = true
|
||||
|
||||
-- legal offset range for a given fit scale (vanilla: survey at 1 px/world
|
||||
-- through 2× fit). zoom.range may widen or shrink the window.
|
||||
function Zoom.offsetRange(S)
|
||||
@@ -21,6 +28,9 @@ function Zoom.offsetRange(S)
|
||||
hi = math.floor(tonumber(hi) or S)
|
||||
if lo > hi then lo, hi = hi, lo end
|
||||
end
|
||||
-- LOW performance tier: no survey (negative offsets), even if a mod's
|
||||
-- zoom.range widened it. == false so nil/true stays permissive.
|
||||
if Zoom.allowSurvey == false and lo < 0 then lo = 0 end
|
||||
return lo, hi
|
||||
end
|
||||
|
||||
|
||||
@@ -210,11 +210,12 @@ end
|
||||
-- text (label or literal; {RAM:wStringBuffer} becomes the item name);
|
||||
-- pass false when the script shows its own received-text row.
|
||||
function Commands.give_item(ctx, itemId, count, gotText)
|
||||
-- the 20-slot bag can refuse (BAG_ITEM_CAPACITY): say so and halt
|
||||
-- the bag can refuse at its configured capacity (20 in vanilla): halt
|
||||
-- the script, so later set_flag rows don't burn the gift -- make
|
||||
-- room and talk again, like the original (pokered's `jr nc, .bag_full`
|
||||
-- skips the received text entirely when AddItemToInventory refuses)
|
||||
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
|
||||
if not require("src.inventory.Bag").add(
|
||||
ctx.save, itemId, count or 1, ctx.game.data) then
|
||||
Commands.show_text(ctx, ctx.game.data.text
|
||||
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
|
||||
return math.huge
|
||||
|
||||
+151
-27
@@ -6,6 +6,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local Input = require("src.core.Input")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
@@ -13,16 +14,18 @@ local BindingsMenu = setmetatable({}, { __index = ListMenu })
|
||||
BindingsMenu.__index = BindingsMenu
|
||||
|
||||
-- Input.lua's map, primary key first where several keys share a button.
|
||||
-- `pad` is the default SDL gamecontroller button (see Input.lua); shown
|
||||
-- on the SELECT row so controller Back/View is discoverable (#73).
|
||||
-- `pad` mirrors DEFAULT_GAMEPAD_BINDINGS in src/core/Input.lua row for
|
||||
-- row; keep the two in sync. Every row shows its key and pad so the
|
||||
-- controller side is discoverable (#73, #589), and the swap in
|
||||
-- storeBinding leans on each row holding a value in both slots.
|
||||
local BUTTONS = {
|
||||
{ id = "up", label = "UP", key = "up" },
|
||||
{ id = "down", label = "DOWN", key = "down" },
|
||||
{ id = "left", label = "LEFT", key = "left" },
|
||||
{ id = "right", label = "RIGHT", key = "right" },
|
||||
{ id = "a", label = "A", key = "z" },
|
||||
{ id = "b", label = "B", key = "x" },
|
||||
{ id = "start", label = "START", key = "escape" },
|
||||
{ id = "up", label = "UP", key = "up", pad = "dpup" },
|
||||
{ id = "down", label = "DOWN", key = "down", pad = "dpdown" },
|
||||
{ id = "left", label = "LEFT", key = "left", pad = "dpleft" },
|
||||
{ id = "right", label = "RIGHT", key = "right", pad = "dpright" },
|
||||
{ id = "a", label = "A", key = "z", pad = "a" },
|
||||
{ id = "b", label = "B", key = "x", pad = "b" },
|
||||
{ id = "start", label = "START", key = "escape", pad = "start" },
|
||||
{ id = "select", label = "SELECT", key = "tab", pad = "back" },
|
||||
}
|
||||
|
||||
@@ -41,14 +44,33 @@ local function boundPad(overlay, def)
|
||||
return def.pad
|
||||
end
|
||||
|
||||
-- Key column for every row. SELECT also appends "/PAD" (default BACK)
|
||||
-- so controller Select/View is visible without opening a second legend.
|
||||
-- The right column is KEY/PAD (e.g. "Z/A"). The row is 20 tiles and the
|
||||
-- widest label ("SELECT") ends at x=64, so each half is clamped to 5
|
||||
-- glyphs: 5+1+5 right-aligned at x=152 starts no further left than x=64.
|
||||
-- SDL names longer than 5 get a fixed short form before the clamp.
|
||||
local KEY_SHORT = {
|
||||
escape = "ESC", backspace = "BKSP", ["return"] = "ENTER",
|
||||
kpenter = "ENTER", space = "SPACE",
|
||||
}
|
||||
local PAD_SHORT = {
|
||||
dpup = "D-UP", dpdown = "D-DN", dpleft = "D-LT", dpright = "D-RT",
|
||||
leftshoulder = "LB", rightshoulder = "RB",
|
||||
leftstick = "LS", rightstick = "RS", guide = "GUIDE",
|
||||
}
|
||||
local function shortName(name, shorts)
|
||||
local s = shorts[name]
|
||||
if s then return s end
|
||||
s = name:upper()
|
||||
return #s > 5 and s:sub(1, 5) or s
|
||||
end
|
||||
|
||||
-- Right column for every row: effective key and pad together, so a
|
||||
-- controller player can read the whole map without a second legend (#589).
|
||||
local function boundRight(overlay, def)
|
||||
local key = boundKey(overlay, def)
|
||||
if def.id ~= "select" then return key:upper() end
|
||||
local key = shortName(boundKey(overlay, def), KEY_SHORT)
|
||||
local pad = boundPad(overlay, def)
|
||||
if pad then return (key .. "/" .. pad):upper() end
|
||||
return key:upper()
|
||||
if pad then return key .. "/" .. shortName(pad, PAD_SHORT) end
|
||||
return key
|
||||
end
|
||||
|
||||
function BindingsMenu.new(game)
|
||||
@@ -61,9 +83,17 @@ function BindingsMenu.new(game)
|
||||
items[i] = { label = Strings(def.label),
|
||||
right = boundRight(overlay, def), button = def }
|
||||
end
|
||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
||||
BindingsMenu)
|
||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {
|
||||
-- 6 rows leaves the bottom two lines free for the hint; a clear or
|
||||
-- reset nobody can see on screen may as well not exist (#589)
|
||||
rows = 6,
|
||||
footer = Strings("SELECT:CLEAR ROW\nSTART:RESET ALL"),
|
||||
}), BindingsMenu)
|
||||
self.onChoose = function(item) self:beginCapture(item) end
|
||||
-- SELECT deletes one row's rebind: dropping the overlay entry is enough
|
||||
-- because Input:applyBindings rebuilds the whole map from the defaults
|
||||
-- on every call (#589)
|
||||
self.onSelectKey = function(item) self:clearBinding(item) end
|
||||
-- A rebind reaches Input only when this screen closes (#510). The menu
|
||||
-- steers by the live map, so applying "B = Z" the instant it was captured
|
||||
-- turned the player's next confirm press into a cancel and shut the
|
||||
@@ -82,12 +112,28 @@ function BindingsMenu:commitBindings()
|
||||
if opts then Input:applyBindings(opts.bindings) end
|
||||
end
|
||||
|
||||
-- the capture handlers are per-instance slots, so Game's raw-input
|
||||
-- routing only ever sees this screen while a capture is armed
|
||||
-- The capture handlers are per-instance slots, so Game's raw-input
|
||||
-- routing only ever sees this screen while a capture is armed. A capture
|
||||
-- no longer commits on the press: it commits when that press is RELEASED,
|
||||
-- and a second key or pad button going down while the first is still held
|
||||
-- cancels instead. That gives a bare controller a way to back out of an
|
||||
-- armed row, where Escape cannot help (#589).
|
||||
function BindingsMenu:beginCapture(item)
|
||||
self.capture = item
|
||||
self.pending = nil
|
||||
self.onKeyPressed = BindingsMenu.captureKey
|
||||
self.onGamepadPressed = BindingsMenu.capturePad
|
||||
self.onKeyReleased = BindingsMenu.captureKeyRelease
|
||||
self.onGamepadReleased = BindingsMenu.capturePadRelease
|
||||
end
|
||||
|
||||
function BindingsMenu:endCapture()
|
||||
self.capture = nil
|
||||
self.pending = nil
|
||||
self.onKeyPressed = nil
|
||||
self.onGamepadPressed = nil
|
||||
self.onKeyReleased = nil
|
||||
self.onGamepadReleased = nil
|
||||
end
|
||||
|
||||
-- Escape is the capture's way out, so it is never captured: every other
|
||||
@@ -95,23 +141,64 @@ end
|
||||
-- to bind something (#510). Escape stays START in Input's default map,
|
||||
-- which no rebind removes, so reserving it costs the player nothing.
|
||||
function BindingsMenu:captureKey(key)
|
||||
if key == "escape" then return self:storeBinding("key", nil) end
|
||||
self:storeBinding("key", key)
|
||||
if key == "escape" or self.pending then return self:endCapture() end
|
||||
self.pending = { slot = "key", value = key }
|
||||
end
|
||||
|
||||
function BindingsMenu:capturePad(button)
|
||||
self:storeBinding("pad", button)
|
||||
if self.pending then return self:endCapture() end
|
||||
self.pending = { slot = "pad", value = button }
|
||||
end
|
||||
|
||||
-- Game forwards every release to Input BEFORE these hooks (see
|
||||
-- Game:keyreleased): the capture observes releases, it never owns them,
|
||||
-- so Input's held-state stays honest for keys it saw go down before the
|
||||
-- capture armed. A release that does not match the pending input (the
|
||||
-- press that armed the row, a cancelled capture's stragglers) is noise.
|
||||
function BindingsMenu:captureKeyRelease(key)
|
||||
local p = self.pending
|
||||
if p and p.slot == "key" and p.value == key then
|
||||
self:storeBinding("key", key)
|
||||
end
|
||||
end
|
||||
|
||||
function BindingsMenu:capturePadRelease(button)
|
||||
local p = self.pending
|
||||
if p and p.slot == "pad" and p.value == button then
|
||||
self:storeBinding("pad", button)
|
||||
end
|
||||
end
|
||||
|
||||
function BindingsMenu:storeBinding(slot, value)
|
||||
local item = self.capture
|
||||
self.capture = nil
|
||||
self.onKeyPressed = nil
|
||||
self.onGamepadPressed = nil
|
||||
self:endCapture()
|
||||
local game = self.game
|
||||
if not (item and value and game.save and game.save.options) then return end
|
||||
local opts = game.save.options
|
||||
opts.bindings = opts.bindings or {}
|
||||
-- Swap, never steal (#589): when the captured input is another row's
|
||||
-- effective binding in this slot, that row inherits this row's previous
|
||||
-- binding. Every BUTTONS row has a default in both slots, so `prev`
|
||||
-- always exists: no row goes empty and no input serves two rows.
|
||||
-- Default key ALIASES (W beside Up, Space beside Z; DEFAULT_BINDINGS in
|
||||
-- Input.lua) are not effective bindings, so capturing one costs the
|
||||
-- other row a spare alias, never its shown key.
|
||||
local effective = (slot == "key") and boundKey or boundPad
|
||||
local prev = effective(opts.bindings, item.button)
|
||||
if value ~= prev then
|
||||
for _, other in ipairs(self.items) do
|
||||
if other ~= item and effective(opts.bindings, other.button) == value then
|
||||
local ob = opts.bindings[other.button.id]
|
||||
if type(ob) ~= "table" then
|
||||
ob = { key = type(ob) == "string" and ob or nil }
|
||||
end
|
||||
ob[slot] = prev
|
||||
opts.bindings[other.button.id] = ob
|
||||
other.right = boundRight(opts.bindings, other.button)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
local b = opts.bindings[item.button.id]
|
||||
if type(b) ~= "table" then
|
||||
-- keep a direct-edited plain key string when only the pad changes
|
||||
@@ -123,18 +210,55 @@ function BindingsMenu:storeBinding(slot, value)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end
|
||||
|
||||
-- SELECT: forget one row's rebind and fall back to the defaults. #510's
|
||||
-- deferral still holds: only options change here, the live map catches up
|
||||
-- in commitBindings on close.
|
||||
function BindingsMenu:clearBinding(item)
|
||||
local game = self.game
|
||||
local opts = game and game.save and game.save.options
|
||||
if not (opts and opts.bindings and opts.bindings[item.button.id]) then
|
||||
return
|
||||
end
|
||||
opts.bindings[item.button.id] = nil
|
||||
item.right = boundRight(opts.bindings, item.button)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end
|
||||
|
||||
-- START: confirm, then drop the whole overlay (#589). The footer doubles
|
||||
-- as the prompt while the YES/NO box is up, the same bottom-line pattern
|
||||
-- the mart and PC screens use.
|
||||
function BindingsMenu:confirmReset()
|
||||
local game = self.game
|
||||
local hint = self.footer
|
||||
self.footer = Strings("RESET ALL BINDINGS?")
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
self.footer = hint
|
||||
if not yes then return end
|
||||
local opts = game.save and game.save.options
|
||||
if opts then opts.bindings = nil end
|
||||
for _, it in ipairs(self.items) do
|
||||
it.right = boundRight(nil, it.button)
|
||||
end
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end, { defaultNo = true }))
|
||||
end
|
||||
|
||||
function BindingsMenu:update(dt)
|
||||
if self.capture then return end -- the raw capture owns the input
|
||||
if self.game.input:wasPressed("start") then
|
||||
return self:confirmReset()
|
||||
end
|
||||
ListMenu.update(self, dt)
|
||||
end
|
||||
|
||||
function BindingsMenu:draw()
|
||||
ListMenu.draw(self)
|
||||
if self.capture then
|
||||
Font.drawBox(1, 6, 18, 5)
|
||||
Font.drawBox(1, 6, 18, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
|
||||
Font.draw(Strings("ESC TO CANCEL"), 24, 72)
|
||||
Font.draw(Strings("RELEASE TO SET"), 24, 72)
|
||||
Font.draw(Strings("ESC/2ND CANCELS"), 24, 84)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -66,6 +66,7 @@ local function withdraw(game)
|
||||
end
|
||||
game.stack:push(ListMenu.new(game,
|
||||
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
local mon = box[item.value]
|
||||
if not mon then return end
|
||||
@@ -112,6 +113,7 @@ local function deposit(game)
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
local mon = game.save.party[item.value]
|
||||
if not mon then return end
|
||||
@@ -157,6 +159,7 @@ local function release(game)
|
||||
end
|
||||
game.stack:push(ListMenu.new(game,
|
||||
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(_, list)
|
||||
local mon = box[list.index]
|
||||
if not mon then return end
|
||||
@@ -189,6 +192,7 @@ local function changeBox(game)
|
||||
})
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
-- the original asks BEFORE switching ("When you change a #MON
|
||||
-- BOX, data will be saved. OK?"); declining aborts the change
|
||||
|
||||
@@ -78,6 +78,10 @@ function ListMenu.new(game, title, items, opts)
|
||||
-- bottom text box -- same row budget as the mart, without the money box.
|
||||
self.messageBox = opts.messageBox
|
||||
self.money = opts.money -- () -> current money for the box
|
||||
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): both PC sessions hold the flag
|
||||
-- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm),
|
||||
-- so their lists opt out of the A/B beep the same way Menu's noSound does
|
||||
self.noSound = opts.noSound or false
|
||||
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
|
||||
return self
|
||||
end
|
||||
@@ -101,6 +105,17 @@ local function syncScroll(self)
|
||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||
end
|
||||
|
||||
-- HandleMenuInput_ (home/window.asm) replays SFX_PRESS_AB for any watched
|
||||
-- A or B press; DisplayListMenuID's mask is PAD_A | PAD_B | PAD_SELECT
|
||||
-- (home/list_menu.asm), so the SELECT swap key stays silent (#570)
|
||||
local function beep(self)
|
||||
-- game.data is nil under the UI harnesses that drive a list with a stub
|
||||
-- game (tests/engine/rebind_capture_bug510.lua), where there is no audio
|
||||
-- cache to play out of
|
||||
if self.noSound or not (self.game and self.game.data) then return end
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
|
||||
-- edge press or key-repeat tick for a held direction
|
||||
local function navPressed(self, dir)
|
||||
if dir == "up" then
|
||||
@@ -126,6 +141,7 @@ function ListMenu:update(dt)
|
||||
local input = self.game.input
|
||||
if #self.items == 0 then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
beep(self)
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
@@ -148,10 +164,12 @@ function ListMenu:update(dt)
|
||||
elseif self.onSelectKey and input:wasPressed("select") then
|
||||
self.onSelectKey(self.items[self.index], self)
|
||||
elseif input:wasPressed("b") then
|
||||
beep(self)
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
return
|
||||
elseif input:wasPressed("a") then
|
||||
beep(self)
|
||||
local item = self.items[self.index]
|
||||
if self.onChoose then
|
||||
self.onChoose(item, self)
|
||||
|
||||
+18
-4
@@ -108,20 +108,34 @@ function Menu:draw()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
|
||||
or #self.items
|
||||
-- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior
|
||||
-- row and let any slack fall as a blank row under the top edge --
|
||||
-- draw_start_menu.asm (TextBoxBorder 10,0, then hlcoord 12,2 /
|
||||
-- wTopMenuItemY 2), players_pc.asm and bills_pc.asm all do the same. The
|
||||
-- bottom border is ty + th - 1, so the last choice sits on ty + th - 2 and
|
||||
-- row r counts back up from there. Anchoring from the top instead only
|
||||
-- agrees when th is exactly visible * rowStep + 2, which is Menu.new's
|
||||
-- default but NOT what a caller sizing its own box passes: BagMenu's
|
||||
-- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's
|
||||
-- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto
|
||||
-- the bottom border (#564, #572).
|
||||
for row = 1, visible do
|
||||
local item = self.items[self.scroll + row]
|
||||
if not item then break end
|
||||
Font.draw(item.label, (self.tx + 2) * 8,
|
||||
(self.ty + row * self.rowStep - (self.rowStep - 1)) * 8)
|
||||
(self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8)
|
||||
end
|
||||
local cursorRow = self.index - self.scroll
|
||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
|
||||
(self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8)
|
||||
(self.ty + self.th - 2 - (visible - cursorRow) * self.rowStep) * 8)
|
||||
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
|
||||
-- use, sat on the bottom border like TextBox's page-advance cursor
|
||||
-- use, sat on the bottom border like TextBox's page-advance cursor. It
|
||||
-- has to be the border row, not ty + th - 2: that is the last interior
|
||||
-- row, which the last choice now occupies, and Menu.new widens the box to
|
||||
-- widest + 3 so tx + tw - 2 is exactly that label's final glyph (#564).
|
||||
if self.maxVisible and self.scroll + self.maxVisible < #self.items then
|
||||
Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8,
|
||||
(self.ty + self.th - 2) * 8)
|
||||
(self.ty + self.th - 1) * 8)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -20,6 +20,7 @@ local GameSpeed = require("src.core.GameSpeed")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local VideoMode = require("src.core.VideoMode")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local Performance = require("src.core.Performance")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
@@ -202,6 +203,21 @@ local function buildRows(game)
|
||||
require("src.core.Music").setFilterLevel(o.musicFilter)
|
||||
return true
|
||||
end },
|
||||
-- Heads the port's display group: one tier that scales the heavy extras
|
||||
-- (TILT / GBC FX / survey ZOOM) and the FPS ceiling for weaker devices.
|
||||
-- AUTO picks a default from the hardware; every tier is overridable.
|
||||
-- Re-applies live so the extras clamp (or, on a higher tier, restore to
|
||||
-- the player's stored TILT / GBC FX / ZOOM) the moment the row changes.
|
||||
{ id = "performance", label = Strings("PERFORMANCE"),
|
||||
value = function(g)
|
||||
return Strings(Performance.label(g.save.options.performance))
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
o.performance = Performance.cycle(o.performance, dir)
|
||||
g:applyOptions(o)
|
||||
return true
|
||||
end },
|
||||
{ id = "colors", label = Strings("COLORS"),
|
||||
value = function(g)
|
||||
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
|
||||
@@ -432,10 +448,21 @@ function OptionsMenu:update(dt)
|
||||
elseif row and row.step then
|
||||
changed = row.step(self.game, dir) and true or false
|
||||
elseif input:wasPressed("a") then -- CANCEL
|
||||
-- DisplayOptionMenu .exitMenu (engine/menus/main_menu.asm) is the
|
||||
-- only spot in this menu that plays SFX_PRESS_AB: A on a setting row
|
||||
-- and the Left/Right toggles stay silent (#570). game.data is nil
|
||||
-- under the stub games the UI harnesses drive.
|
||||
if self.game.data then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
elseif input:wasPressed("b") or input:wasPressed("start") then
|
||||
-- .exitMenu again: B and START leave the same way, sound and all
|
||||
if self.game.data then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
|
||||
@@ -310,6 +310,14 @@ function PartyMenu:update(dt)
|
||||
local input = self.game.input
|
||||
local party = self.party or self.game.save.party
|
||||
|
||||
-- HandlePartyMenuInput (home/pokemon.asm) and the field-move submenu
|
||||
-- (engine/menus/start_sub_menus.asm .chosePokemon) both run through
|
||||
-- HandleMenuInput_, which beeps SFX_PRESS_AB on any A or B press (#570).
|
||||
-- game.data is nil under the stub games the UI harnesses drive.
|
||||
if self.game.data and (input:wasPressed("a") or input:wasPressed("b")) then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
|
||||
if self.submenu then
|
||||
local n = #self.subItems
|
||||
if input:wasPressed("up") then
|
||||
|
||||
+4
-1
@@ -73,10 +73,11 @@ local function withdraw(game)
|
||||
local pc = game.save.pcItems
|
||||
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
|
||||
messageBox = true,
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
if not Bag.add(game.save, item.value, qty) then
|
||||
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||
list.footer = Strings("You can't carry\nany more items.")
|
||||
return
|
||||
end
|
||||
@@ -110,6 +111,7 @@ local function deposit(game)
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
|
||||
messageBox = true,
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
|
||||
if pcFull(game, pc, item.value) then
|
||||
@@ -130,6 +132,7 @@ local function toss(game)
|
||||
local pc = game.save.pcItems
|
||||
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
|
||||
messageBox = true,
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
local def = game.data.items[item.value]
|
||||
if (def and def.keyItem) or item.value:find("^HM_") then
|
||||
|
||||
+11
-3
@@ -50,11 +50,13 @@ function PokedexMenu.new(game, opts)
|
||||
footer = Strings("SEEN %d OWNED %d", seen, owned),
|
||||
pageJump = true, -- Left/Right page jumps like the original
|
||||
onCancel = opts.onCancel, -- B returns to the start menu when opened from it
|
||||
onChoose = function(item)
|
||||
onChoose = function(item, dexList)
|
||||
if not item.value then return end
|
||||
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
|
||||
-- PokedexMenuItemsText); CRY keeps the side menu open like the
|
||||
-- original, QUIT returns to the list
|
||||
-- original. B is what returns to the list: HandlePokedexSideMenu
|
||||
-- hands back b=2 for B and b=1 for QUIT, and ShowPokedexMenu sends
|
||||
-- b=1 to .exitPokedex, so QUIT closes the whole Pokédex (#571)
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local entries = {
|
||||
@@ -91,7 +93,13 @@ function PokedexMenu.new(game, opts)
|
||||
or Strings("Printer error!\n%s", tostring(err))))
|
||||
end }
|
||||
end
|
||||
entries[#entries + 1] = { label = Strings("QUIT") }
|
||||
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
|
||||
-- .exitPokedex: drop the dex list too and hand back to whoever
|
||||
-- opened it (the start menu, whose saved cursor is still on
|
||||
-- POKéDEX -- wBattleAndStartSavedMenuItem)
|
||||
dexList:close()
|
||||
if opts.onCancel then opts.onCancel() end
|
||||
end }
|
||||
game.stack:push(Menu.new(game, entries,
|
||||
{ tx = 12, ty = 8, tw = 8,
|
||||
th = #entries * 2 + 2 }))
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ local function buy(game, stock)
|
||||
list.footer = notEnough
|
||||
return
|
||||
end
|
||||
if not Bag.add(game.save, item.value, qty) then
|
||||
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||
list.footer = txt(game, "_PokemartItemBagFullText",
|
||||
Strings("You can't carry\nany more items."))
|
||||
return
|
||||
|
||||
@@ -109,34 +109,36 @@ function Editor.update(_dt)
|
||||
end
|
||||
|
||||
function Editor.draw()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
local fullW, fullH = love.graphics.getDimensions()
|
||||
local ox, oy, ww, wh = SafeArea.rect()
|
||||
local s = math.max(0.75, math.min(1.4, wh / 768))
|
||||
Editor.rects = {}
|
||||
|
||||
-- radial-ish navy field (two stacked fills; matches launcher atmosphere)
|
||||
col(PAL.bgBot)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
col(PAL.bgTop, 0.85)
|
||||
love.graphics.circle("fill", ww * 0.5, wh * 0.15, math.max(ww, wh) * 0.55)
|
||||
love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
|
||||
|
||||
local pad = 18 * s
|
||||
local barH = 56 * s
|
||||
local btnH = 40 * s
|
||||
local btnW = 100 * s
|
||||
|
||||
-- top bar
|
||||
-- top bar (inside the safe area so it clears the notch / status bar)
|
||||
col(PAL.card, 0.92)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, barH + pad)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, oy + barH + pad)
|
||||
col(PAL.stroke, 0.35)
|
||||
love.graphics.setLineWidth(1)
|
||||
love.graphics.line(0, barH + pad, ww, barH + pad)
|
||||
love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
|
||||
|
||||
love.graphics.setFont(Editor.fonts.title)
|
||||
col(PAL.white)
|
||||
love.graphics.print("Touch Controls", pad, pad + 4 * s)
|
||||
love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
|
||||
|
||||
-- Done / Reset
|
||||
local done = { x = ww - pad - btnW, y = pad + (barH - btnH) / 2,
|
||||
local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
|
||||
w = btnW, h = btnH }
|
||||
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
|
||||
Editor.rects.done, Editor.rects.reset = done, reset
|
||||
@@ -156,9 +158,9 @@ function Editor.draw()
|
||||
chromeBtn(done, "Done", PAL.green)
|
||||
|
||||
-- enable toggle card
|
||||
local cardY = barH + pad + 14 * s
|
||||
local cardY = oy + barH + pad + 14 * s
|
||||
local cardH = 64 * s
|
||||
local cardX, cardW = pad, ww - 2 * pad
|
||||
local cardX, cardW = ox + pad, ww - 2 * pad
|
||||
col(PAL.card, 0.88)
|
||||
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
|
||||
col(PAL.stroke, 0.4)
|
||||
@@ -190,7 +192,7 @@ function Editor.draw()
|
||||
local hint = on
|
||||
and "Drag each button to reposition. Layout is saved when you tap Done."
|
||||
or "Controls are hidden in-game. Enable them to show and edit the layout."
|
||||
love.graphics.printf(hint, pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
|
||||
love.graphics.printf(hint, ox + pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
|
||||
|
||||
-- the overlay itself (preview mode; dimmed when disabled)
|
||||
TouchControls:draw()
|
||||
|
||||
+99
-12
@@ -148,6 +148,37 @@ local SPEED_BARS = {
|
||||
{ 0xE0, 0x80, 4 }, { 0xF0, 0x90, 2 },
|
||||
}
|
||||
|
||||
-- PlayIntroScene's .loop (pokeyellow engine/movie/intro_yellow.asm) ends
|
||||
-- every iteration with its own `call DelayFrame`, so one scene handler costs
|
||||
-- one frame plus whatever DelayFrame calls it makes itself. The port runs a
|
||||
-- handler inside a single update() tick, so every one of those internal
|
||||
-- DelayFrame calls has to be spent explicitly or the movie runs short of
|
||||
-- hardware. Three places owe frames:
|
||||
--
|
||||
-- SETUP_SCENE_DELAY scenes 2/4/6/8/10/12 open with
|
||||
-- YellowIntro_BlankPalsDelay2AndDisableLCD: rBGP/rOBP0/
|
||||
-- rOBP1 to $00 (color 0 in every shade -- the lightest,
|
||||
-- white on DMG), two DelayFrame calls, then DisableLCD.
|
||||
-- Two internal plus the loop's own is a 3-frame
|
||||
-- iteration, after which the next wait scene takes its
|
||||
-- first timer decrement. Func_f9e9a restores rBGP $e4
|
||||
-- at the end of the same handler.
|
||||
-- HEAD_FRAMES PlayIntroScene spends a DelayFrame between
|
||||
-- InitYellowIntroGFXAndMusic (which ends in PlayMusic)
|
||||
-- and the first .loop pass, and YellowIntroScene0 then
|
||||
-- owns a whole iteration of its own.
|
||||
-- EXIT_FRAMES .go_to_title_screen blanks the palettes and spends
|
||||
-- four DelayFrame calls clearing the tilemap and OAM
|
||||
-- before the title screen is built.
|
||||
--
|
||||
-- These restore the movie's real elapsed length; they do NOT lengthen the
|
||||
-- intro song. title.asm's StopAllMusic before MUSIC_TITLE_SCREEN is
|
||||
-- unconditional (only PikachuCry1 is waited on), so hardware cuts
|
||||
-- Music_YellowIntro off mid-phrase too (#523).
|
||||
local SETUP_SCENE_DELAY = 3
|
||||
local HEAD_FRAMES = 2
|
||||
local EXIT_FRAMES = 4
|
||||
|
||||
-- scene-6 sine (YellowIntro_Copy8BitSineWave.SineWave), signed SCY deltas
|
||||
local WAVE = { 0, 0, 1, 2, 2, 3, 3, 3, 4, 3, 3, 3, 2, 2, 1, 0,
|
||||
0, 0, -1, -2, -2, -3, -3, -3, -4, -3, -3, -3, -2, -2, -1, 0 }
|
||||
@@ -208,6 +239,8 @@ function YellowIntro.new(game, onDone)
|
||||
self.palName = "MEWMON" -- PalPacket_Generic
|
||||
self.objects = {}
|
||||
self.cloudFrame = 0
|
||||
self.pendingThen = nil -- what enterDelay runs once pendingDelay hits 0 (#523)
|
||||
self.pendingDelay = 0
|
||||
|
||||
self.atlas1 = tryImage("assets/generated/intro/yellow_intro_1.png")
|
||||
self.atlas2 = tryImage("assets/generated/intro/yellow_intro_2.png")
|
||||
@@ -559,6 +592,24 @@ function YellowIntro:startScene(scene)
|
||||
end
|
||||
end
|
||||
|
||||
-- Spend `frames` real frames, then run fn: the port's stand-in for the
|
||||
-- DelayFrame calls a pokeyellow scene handler makes inside its own
|
||||
-- PlayIntroScene .loop iteration (#523). fn may be nil to just burn time.
|
||||
function YellowIntro:enterDelay(frames, fn)
|
||||
self.pendingDelay = frames
|
||||
self.pendingThen = fn
|
||||
end
|
||||
|
||||
-- YellowIntro_BlankPalsDelay2AndDisableLCD blanks the palettes for the
|
||||
-- delay; Func_f9e9a puts rBGP back to $e4 at the tail of the same handler.
|
||||
function YellowIntro:enterSetupScene(scene)
|
||||
self.bgp = 0x00
|
||||
self:enterDelay(SETUP_SCENE_DELAY, function()
|
||||
self.bgp = 0xE4
|
||||
self:startScene(scene)
|
||||
end)
|
||||
end
|
||||
|
||||
function YellowIntro:enter()
|
||||
if self.pre then return end -- pre-roll first; beginScenes takes over
|
||||
self:beginScenes()
|
||||
@@ -571,7 +622,11 @@ function YellowIntro:beginScenes()
|
||||
local song = songs and (songs.Music_YellowIntro and "Music_YellowIntro"
|
||||
or songs.Music_IntroBattle and "Music_IntroBattle")
|
||||
if song then pcall(Music.play, data, song, false) end
|
||||
self:startScene(0)
|
||||
-- HEAD_FRAMES: the DelayFrame between PlayMusic and the first .loop pass
|
||||
-- plus YellowIntroScene0's own iteration. new() already laid down the
|
||||
-- Func_f9e5f letterbox that InitYellowIntroGFXAndMusic writes, so these
|
||||
-- frames show what hardware shows: the letterbox with no pika yet.
|
||||
self:enterDelay(HEAD_FRAMES, function() self:startScene(0) end)
|
||||
end
|
||||
|
||||
-- The movie's exit never stops the song (intro_yellow.asm PlayIntroScene
|
||||
@@ -586,16 +641,44 @@ function YellowIntro:finish()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
-- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land
|
||||
-- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then
|
||||
-- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM
|
||||
-- buffers before the title screen is built (#523). The pre-roll's own skip
|
||||
-- is IntroMovie's path, not this one, and still finishes immediately.
|
||||
function YellowIntro:exitToTitle()
|
||||
if self.finished or self.exiting then return end
|
||||
self.exiting = true
|
||||
self.bgp = 0x00
|
||||
self:clearObjects()
|
||||
self:enterDelay(EXIT_FRAMES, function() self:finish() end)
|
||||
end
|
||||
|
||||
function YellowIntro:update(dt)
|
||||
if self.finished then return end
|
||||
if self.pre then
|
||||
self.pre:update(dt)
|
||||
return
|
||||
end
|
||||
-- Ahead of the skip poll on purpose: JoypadLowSensitivity runs once at the
|
||||
-- top of each .loop iteration, never inside a handler, so hardware is deaf
|
||||
-- for the DelayFrame calls these ticks stand in for (#523).
|
||||
if self.pendingDelay > 0 then
|
||||
self.pendingDelay = self.pendingDelay - 1
|
||||
if self.pendingDelay == 0 then
|
||||
local fn = self.pendingThen
|
||||
self.pendingThen = nil
|
||||
if fn then fn() end
|
||||
end
|
||||
self:updateObjects()
|
||||
if self.bgDirty then self:rebuildBgCanvas() end
|
||||
return
|
||||
end
|
||||
|
||||
local input = self.game.input
|
||||
if input:wasPressed("a") or input:wasPressed("b")
|
||||
or input:wasPressed("start") then
|
||||
self:finish()
|
||||
self:exitToTitle()
|
||||
return
|
||||
end
|
||||
|
||||
@@ -605,7 +688,7 @@ function YellowIntro:update(dt)
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(scene + 1)
|
||||
self:enterSetupScene(scene + 1) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 3 then
|
||||
if self.timer > 0 then
|
||||
@@ -613,7 +696,7 @@ function YellowIntro:update(dt)
|
||||
if self.scx ~= 0x68 then self.scx = self.scx + 4 end
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(4)
|
||||
self:enterSetupScene(4) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 7 then
|
||||
if self.timer > 0 then
|
||||
@@ -625,7 +708,7 @@ function YellowIntro:update(dt)
|
||||
self.wave[255] = first
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(8)
|
||||
self:enterSetupScene(8) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 11 then
|
||||
if self.timer > 0 then
|
||||
@@ -640,7 +723,7 @@ function YellowIntro:update(dt)
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self:clearObjects()
|
||||
self:startScene(12)
|
||||
self:enterSetupScene(12) -- BlankPalsDelay2AndDisableLCD (#523)
|
||||
end
|
||||
elseif scene == 13 then
|
||||
if self.timer > 0 then
|
||||
@@ -656,13 +739,17 @@ function YellowIntro:update(dt)
|
||||
if v then
|
||||
self.bgp = v
|
||||
else
|
||||
-- .expired: everything despawns, letterbox returns, logo/face
|
||||
-- object $7 appears for the strobe scene
|
||||
-- .expired: everything despawns and the letterbox returns, then the
|
||||
-- handler spends three DelayFrame calls with hAutoBGTransferEnabled
|
||||
-- pushing that rebuilt tilemap to VRAM before it restores rBGP $e4
|
||||
-- and spawns the logo/face object $7 for the strobe scene (#523).
|
||||
self:clearObjects()
|
||||
self:bgLetterbox()
|
||||
self.bgp = 0xE4
|
||||
self:spawn(7, 0x58, 0x58)
|
||||
self:startScene(15)
|
||||
self:enterDelay(3, function()
|
||||
self.bgp = 0xE4
|
||||
self:spawn(7, 0x58, 0x58)
|
||||
self:startScene(15)
|
||||
end)
|
||||
end
|
||||
elseif scene == 15 then
|
||||
if self.timer > 0 then
|
||||
@@ -687,7 +774,7 @@ function YellowIntro:update(dt)
|
||||
if self.timer > 0 then
|
||||
self.timer = self.timer - 1
|
||||
else
|
||||
self:finish()
|
||||
self:exitToTitle()
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
@@ -89,6 +89,11 @@ local PLAYER_SPRITES = {
|
||||
local PLAYER_PICS = {
|
||||
back = "assets/generated/battle/redb.png",
|
||||
demoBack = "assets/generated/battle/oldmanb.png",
|
||||
-- LoadPlayerBackPic keys the demo pic off wBattleType, not off "is a
|
||||
-- demo": BATTLE_TYPE_PIKACHU (Yellow's Pallet catch scene) puts PROF.OAK
|
||||
-- in the player's place with his own back pic, not the old man's (#557).
|
||||
-- Red/Blue never reach it -- only the Yellow script names that thrower.
|
||||
oakBack = "assets/generated/battle/profoakb.png",
|
||||
front = "assets/generated/trainer_card/red.png",
|
||||
}
|
||||
|
||||
@@ -185,7 +190,7 @@ FieldDefaults.CONSTANTS = {
|
||||
neighborHops = 2, -- connection hops drawn around the current map
|
||||
stepFrames = 16, -- 1px per frame, 16 frames per tile
|
||||
bikeStepFrames = 8, -- the bicycle doubles walking speed
|
||||
turnFrames = 2, -- the extra OverworldLoop pass after a turn
|
||||
turnFrames = 4, -- tap window before a turn commits to a step
|
||||
},
|
||||
-- cumulative slot thresholds out of 256 (engine/battle/wild_encounters.asm)
|
||||
encounterBuckets = { 51, 102, 141, 166, 191, 216, 229, 242, 253, 256 },
|
||||
|
||||
@@ -360,6 +360,36 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
else
|
||||
self.player = Player.new(Game.data, x, y, facing)
|
||||
end
|
||||
-- boot only: the original persists the surf state. wWalkBikeSurfState
|
||||
-- (ram/wram.asm) lives inside wMainDataStart..wMainDataEnd, which
|
||||
-- engine/menus/save.asm block-copies into sMainData on save and back out
|
||||
-- on load (sram.asm declares sMainData as `ds wMainDataEnd -
|
||||
-- wMainDataStart`), and Continue never clears it -- the only `xor a /
|
||||
-- ld [wWalkBikeSurfState], a` on that path is the cable club's. Restore
|
||||
-- it here, before Music.playMap reads it below and before
|
||||
-- PikachuFollower.onMapEntered, matching LoadMapData calling
|
||||
-- LoadPlayerSpriteGraphics ahead of PlayDefaultMusic (home/overworld.asm,
|
||||
-- home/audio.asm). Without this the player resumed on foot on a water
|
||||
-- cell, which softlocks here: Collision.canMove (src/world/Collision.lua)
|
||||
-- picks land tile-pairs whenever mover.surfing is falsy, and land
|
||||
-- tile-pairs never permit stepping off a water cell (#536). Same
|
||||
-- boot-only shape as the refreshStandingOnWarp door-mat restore (#378).
|
||||
if opts and opts.via == "boot" then
|
||||
local ps = Game.save and Game.save.player
|
||||
if ps and ps.surfing ~= nil then
|
||||
self.player.surfing = ps.surfing and true or false
|
||||
else
|
||||
-- saves written before #536 carry no flag. Map:isWaterCell alone is
|
||||
-- not self-sufficient (see src/world/Map.lua: water and shore share
|
||||
-- one lookup, and no tileset stamps waterTiles, so tile $14 -- a
|
||||
-- walkable floor in HOUSE/GATE/LOBBY/MANSION/MUSEUM -- reads as
|
||||
-- water), so gate it the way facingIsShoreOrWater does and require a
|
||||
-- cell you could not be standing on upright.
|
||||
self.player.surfing = self:tilesetHasWater()
|
||||
and not self.map:isWalkableCell(x, y)
|
||||
and self.map:isWaterCell(x, y)
|
||||
end
|
||||
end
|
||||
-- crossConnection re-arms this after setMap; clear so a warp/reload
|
||||
-- cannot leave a stale deferred PlayMapMusic pending
|
||||
self.pendingSeamMusic = nil
|
||||
@@ -1058,19 +1088,38 @@ function OverworldState:handleInput()
|
||||
-- OverworldLoop (home/overworld.asm) gates ALL of JoypadOverworld on
|
||||
-- wWalkCounter == 0 ("if the player sprite has not yet completed the
|
||||
-- walking animation" it jumps straight to .moveAhead): A, START and
|
||||
-- direction initiation are only ever looked at while the player stands
|
||||
-- on a tile, and a button pressed mid-step is simply never seen.
|
||||
-- Without this gate a mid-step A/START pushed its TextBox/StartMenu
|
||||
-- right there and froze Red between tiles, mid-animation (#286). Held
|
||||
-- directions need no buffering -- isDown below picks them up on the
|
||||
-- landing frame.
|
||||
if self.player.moving then return end
|
||||
-- direction initiation are only ever ACTED ON while the player stands on
|
||||
-- a tile. Without this gate a mid-step A/START pushed its TextBox/
|
||||
-- StartMenu right there and froze Red between tiles, mid-animation
|
||||
-- (#286). Held directions need no buffering -- isDown below picks them
|
||||
-- up on the landing frame.
|
||||
--
|
||||
-- The original defers the poll rather than discarding it, though. Joypad
|
||||
-- (engine/joypad.asm _Joypad) computes hJoyPressed against hJoyLast and
|
||||
-- advances hJoyLast only when something calls it; the mid-step path never
|
||||
-- does, and vblank's per-frame ReadJoypad refreshes hJoyInput alone.
|
||||
-- hJoyLast is frozen for the whole animation, so a button pressed
|
||||
-- mid-step and STILL HELD when the step lands reads as a fresh press at
|
||||
-- the next poll -- one released before then is genuinely lost. Dropping
|
||||
-- the edge outright made START a coin flip on the Cycling Road roll,
|
||||
-- where the pull below re-arms a step on the single idle frame in
|
||||
-- bikeStepFrames (#525).
|
||||
if self.player.moving then
|
||||
local held = self.joyLatch
|
||||
if not held then held = {}; self.joyLatch = held end
|
||||
if input:wasPressed("a") then held.a = true end
|
||||
if input:wasPressed("start") then held.start = true end
|
||||
return
|
||||
end
|
||||
local latch = self.joyLatch
|
||||
self.joyLatch = nil
|
||||
|
||||
if input:wasPressed("a") then
|
||||
if input:wasPressed("a") or (latch and latch.a and input:isDown("a")) then
|
||||
self:interact()
|
||||
return
|
||||
end
|
||||
if input:wasPressed("start") then
|
||||
if input:wasPressed("start")
|
||||
or (latch and latch.start and input:isDown("start")) then
|
||||
require("src.core.Sound").play(Game.data, "Start_Menu")
|
||||
Screens.push(Game, "StartMenu")
|
||||
return
|
||||
@@ -1106,6 +1155,13 @@ function OverworldState:handleInput()
|
||||
end
|
||||
end
|
||||
|
||||
-- .noDirectionButtonsPressed (home/overworld.asm) is the only place that
|
||||
-- sets wCheckFor180DegreeTurn, so reaching this line -- a poll that found
|
||||
-- no direction held -- is what re-arms the next turn in place. The early
|
||||
-- returns above (mid-step, A, START) skip it exactly as the original's
|
||||
-- jumps to .moveAhead and .displayDialogue do (#415).
|
||||
self.player.turnArmed = true
|
||||
|
||||
-- Cycling Road's downhill pull: with no d-pad held the bike rolls
|
||||
-- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN).
|
||||
-- The mask there is PAD_CTRL_PAD | PAD_B | PAD_A, so HOLDING A or B
|
||||
@@ -1754,7 +1810,7 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
if h.x == fx and h.y == fy then
|
||||
save.hiddenTaken = save.hiddenTaken or {}
|
||||
if save.hiddenTaken[key] then return false end
|
||||
if not require("src.inventory.Bag").add(save, h.item, 1) then
|
||||
if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then
|
||||
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
||||
return true
|
||||
end
|
||||
@@ -1842,9 +1898,20 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
end
|
||||
end
|
||||
|
||||
-- bench guys (data/events/bench_guys.asm)
|
||||
-- Bench guys (data/events/bench_guys.asm). A hidden_event's fourth byte
|
||||
-- is wHiddenEventFunctionArgument, not a facing gate -- pokered's own
|
||||
-- macro comment says the SPRITE_FACING_* values parked there "do not
|
||||
-- actually prevent the player from interacting with them in any
|
||||
-- direction" (data/events/hidden_events.asm). The facing that does decide
|
||||
-- a bench guy is PrintBenchGuyText's own test against BenchGuyTextPointers,
|
||||
-- SPRITE_FACING_LEFT for all twelve seats, which the manifest carries as
|
||||
-- `textFacing`. Gating on the hidden_event byte instead silenced the four
|
||||
-- seats that store SPRITE_FACING_UP (Vermilion, Saffron, Fuchsia,
|
||||
-- Cinnabar): (0,4) is the bench wall cell and can only ever be faced from
|
||||
-- the right (#488).
|
||||
for _, h in ipairs(extras.benchGuys[self.map.id] or {}) do
|
||||
if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then
|
||||
local want = h.textFacing or h.facing
|
||||
if h.x == fx and h.y == fy and (not want or want == facing) then
|
||||
local text = OverworldState.benchGuyText(Game.data, save, h.text)
|
||||
if text then
|
||||
Game.stack:push(TextBox.new(Game, text))
|
||||
@@ -2374,7 +2441,7 @@ function OverworldState:talkTo(npc)
|
||||
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
|
||||
-- the string "0" as truthy, so screen it out and fall through to text.
|
||||
if d.item and d.item ~= "0" and d.item ~= 0 then
|
||||
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
|
||||
if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then
|
||||
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
||||
return
|
||||
end
|
||||
@@ -2509,8 +2576,7 @@ function OverworldState:openPC(onDone)
|
||||
table.insert(items, {
|
||||
label = Strings("PROF.OAK's PC"),
|
||||
onSelect = function()
|
||||
self:dexRating()
|
||||
done()
|
||||
self:openOaksPC(done)
|
||||
end,
|
||||
})
|
||||
end
|
||||
@@ -2537,11 +2603,41 @@ function OverworldState:openPC(onDone)
|
||||
noSound = true }))
|
||||
end
|
||||
|
||||
-- The PROF. OAK's PC session (engine/menus/oaks_pc.asm OpenOaksPC): the
|
||||
-- access text, "Want to get your #DEX rated?" with a YES/NO, then the
|
||||
-- rating, and "Closed link to PROF.OAK's PC." before control returns -- the
|
||||
-- intro and closing links the launcher skipped, jingle ordering aside (#576).
|
||||
function OverworldState:openOaksPC(onDone)
|
||||
local done = onDone or function() end
|
||||
local text = Game.data.text or {}
|
||||
local accessed = text._AccessedOaksPCText
|
||||
or Strings("Accessed PROF.\nOAK's PC.\fAccessed POKéDEX\nRating System.")
|
||||
local rated = text._GetDexRatedText
|
||||
or Strings("Want to get your\nPOKéDEX rated?")
|
||||
local closed = text._ClosedOaksPCText
|
||||
or Strings("Closed link to\nPROF.OAK's PC.")
|
||||
local function close()
|
||||
Game.stack:push(TextBox.new(Game, closed, done))
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, accessed, function()
|
||||
-- _GetDexRatedText ends with `done`, so the YES/NO pops as soon as the
|
||||
-- text has typed out, with no button wait in between (YesNoChoice)
|
||||
Game.stack:push(TextBox.new(Game, rated, nil, {
|
||||
choice = function(yes)
|
||||
if not yes then
|
||||
close()
|
||||
return
|
||||
end
|
||||
self:dexRating(close)
|
||||
end,
|
||||
}))
|
||||
end))
|
||||
end
|
||||
|
||||
-- Prof. Oak's dex rating service (engine/events/pokedex_rating.asm):
|
||||
-- the completion line with seen AND owned counts, then the per-decade
|
||||
-- rating text.
|
||||
function OverworldState:dexRating(onDone)
|
||||
require("src.core.Sound").play(Game.data, "Pokedex_Rating")
|
||||
local seen, owned = 0, 0
|
||||
for _ in pairs(Game.save.pokedex.seen or {}) do seen = seen + 1 end
|
||||
for _ in pairs(Game.save.pokedex.owned or {}) do owned = owned + 1 end
|
||||
@@ -2558,7 +2654,15 @@ function OverworldState:dexRating(onDone)
|
||||
completion = completion
|
||||
:gsub("{NUM:hDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
||||
:gsub("{NUM:hDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
||||
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating, onDone))
|
||||
-- DisplayDexRating prints the completion line, then the tier text, and
|
||||
-- only then plays the rating jingle and waits for a button -- the fanfare
|
||||
-- must not pre-empt the evaluation it celebrates (#576). auto.wait hands
|
||||
-- the box to the plain A/B path once the jingle has sounded.
|
||||
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating, onDone, {
|
||||
auto = { wait = true, sound = function()
|
||||
return require("src.core.Sound").play(Game.data, "Pokedex_Rating")
|
||||
end },
|
||||
}))
|
||||
end
|
||||
|
||||
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): balls
|
||||
@@ -3780,7 +3884,15 @@ end
|
||||
-- dev-mode hot reload). The neighbors go too: their strips render the
|
||||
-- same tileset. When the active map is the one that changed, the player
|
||||
-- is clamped back in bounds, the NPC pool is reused so runtime handles
|
||||
-- survive, and the tile-pair table is re-read.
|
||||
-- survive, and the tile-pair table is re-read. keepMusic: a reload is not a
|
||||
-- map entry. Its counterpart ReloadMapData (home/reload_tiles.asm) only
|
||||
-- re-reads the map view and the tileset tile patterns after the Pokedex /
|
||||
-- start menu / PC clobbered VRAM; map music starts from LoadMapData alone
|
||||
-- (home/overworld.asm, gated on BIT_NO_MAP_MUSIC). Whatever is playing
|
||||
-- belongs to the state on top, so a COLORS cycle during a battle
|
||||
-- (PaletteFX.setMode reloads the live map to rebuild its baked atlas) must
|
||||
-- not drop the route theme over the battle song (#484). The out-of-bounds
|
||||
-- fallback below is a real map change and keeps its map music.
|
||||
function OverworldState:reloadMap(mapId, reason)
|
||||
MapLoader.invalidate(mapId)
|
||||
for _, nb in ipairs(self.neighbors or {}) do MapLoader.invalidate(nb.map.id) end
|
||||
@@ -3788,7 +3900,8 @@ function OverworldState:reloadMap(mapId, reason)
|
||||
local p = self.player
|
||||
local x, y, facing = p.cellX, p.cellY, p.facing
|
||||
Collision.load(Game.data)
|
||||
self:setMap(mapId, x, y, facing, { seamless = true, via = "reload" })
|
||||
self:setMap(mapId, x, y, facing,
|
||||
{ seamless = true, via = "reload", keepMusic = true })
|
||||
if not self.map:inBounds(x, y) then
|
||||
local heal = self:healPoint()
|
||||
Logger.warn("map %s reloaded out from under the player; sending to %s",
|
||||
@@ -4519,9 +4632,9 @@ function OverworldState:drawUI()
|
||||
-- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm
|
||||
-- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a
|
||||
-- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The
|
||||
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so the
|
||||
-- front pic stands in for every frame of the script; PikachuFollower
|
||||
-- .picLift lifts it on the runs that draw the alternate pose, and the
|
||||
-- script's base frame is ripped as pikachu/pikapic_N.png (#561) but the
|
||||
-- pikaframe overlays on top of it are not, so PikachuFollower
|
||||
-- .picLift lifts the base on the runs that draw the alternate pose, and the
|
||||
-- script's own duration times the beat (#407, #424). Palette zone
|
||||
-- PAL_PIKACHU_PORTRAIT covers (7,6)-(11,10) via sgbPalettes above.
|
||||
if self.emote and self.emote.pikaPic then
|
||||
@@ -4561,6 +4674,11 @@ function OverworldState:captureSave(save)
|
||||
save.player.x = self.player.cellX
|
||||
save.player.y = self.player.cellY
|
||||
save.player.facing = self.player.facing
|
||||
-- wWalkBikeSurfState (ram/wram.asm) sits inside the wMainDataStart..
|
||||
-- wMainDataEnd range engine/menus/save.asm block-copies into sMainData,
|
||||
-- so the original saves and restores the surf state; setMap's boot path
|
||||
-- reads this back (#536).
|
||||
save.player.surfing = self.player.surfing and true or false
|
||||
end
|
||||
|
||||
return OverworldState
|
||||
|
||||
@@ -518,9 +518,12 @@ end
|
||||
-- pikachu_emotions.asm): pick a scripted emotion, then play its bubble
|
||||
-- and voiced PCM clip, and raise the framed Pikachu picture the original
|
||||
-- puts over the map (pikaemotion_pikapic -> pikachu_pic_animation.asm
|
||||
-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. The
|
||||
-- per-emotion animation frames (gfx/pikachu/unknown_*) are not extracted,
|
||||
-- so the front pic stands in for all twenty of them (#407).
|
||||
-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. Each
|
||||
-- script's BASE 5x5 frame is ripped as pikachu/pikapic_N.png (#561); the
|
||||
-- pikaframe overlays it alternates with are a second full-body pose out of
|
||||
-- the same blob and are still unported, so picLift below stands in for
|
||||
-- their motion and the battle front pic covers caches built before the
|
||||
-- rip (#407).
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- PikachuEmotionTable, reduced to each entry's bubble + pikaemotion_pcm
|
||||
@@ -715,9 +718,17 @@ function PikachuFollower.talk(game, ow, npc, done)
|
||||
-- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame;
|
||||
-- Sprites.path keeps a mod's replacement skin in play.
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local pic = Sprites.path(game.data, "PIKACHU", "front",
|
||||
{ kind = "overworld" })
|
||||
local anim = PIKAPIC[PIKAPIC_SCRIPT[emotion] or emotion] or PIKAPIC[1]
|
||||
-- The chosen script's own base frame (its first pikapic_loadgfx, ripped as
|
||||
-- pikachu/pikapic_N.png). Red/Blue have no such art and Yellow caches
|
||||
-- built before the rip do not carry it, so both fall back to the battle
|
||||
-- front pic that stood in for every script before (#561).
|
||||
local script = PIKAPIC_SCRIPT[emotion] or emotion
|
||||
local pic = "assets/generated/pikachu/pikapic_" .. script .. ".png"
|
||||
if not require("src.render.Assets").exists(pic) then
|
||||
pic = Sprites.path(game.data, "PIKACHU", "front",
|
||||
{ kind = "overworld" })
|
||||
end
|
||||
local anim = PIKAPIC[script] or PIKAPIC[1]
|
||||
local hold = anim.dur * PIKAPIC_TICK
|
||||
ow.emote = {
|
||||
npc = npc, frames = hold, bubble = bi or false, pikaPic = pic,
|
||||
@@ -790,10 +801,22 @@ function PikachuFollower.onBillEnteredMachine(game, ow)
|
||||
if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end
|
||||
local npc = findFollower(ow)
|
||||
if not npc then return end
|
||||
-- BillsHouseScript3 (pokeyellow scripts/BillsHouse.asm:100-115). The two
|
||||
-- movement tables are named the wrong way round in the cartridge source:
|
||||
-- hl is seeded with ..._EnterCellSeparatorDown, then
|
||||
-- and a ; cp SPRITE_FACING_DOWN (SPRITE_FACING_DOWN is 0)
|
||||
-- jr nz, .applyPikachuMovement
|
||||
-- keeps that seeded table when the player is NOT facing down, and only the
|
||||
-- fallthrough -- facing down -- swaps in ..._EnterCellSeparatorNotDown.
|
||||
-- So facing down takes the long way round the cell separator and every
|
||||
-- other facing walks straight up. Following the label names instead of the
|
||||
-- branch inverts the scene (#455).
|
||||
local steps = ow.player.facing == "down"
|
||||
and { { "up", 3 } }
|
||||
or { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } }
|
||||
and { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } }
|
||||
or { { "up", 3 } }
|
||||
movePikachu(ow, npc, steps, function()
|
||||
-- PIKAMOVEMENT_LOOK_UP closes the detour table before the bubble
|
||||
npc.facing = "up"
|
||||
billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE")
|
||||
end)
|
||||
end
|
||||
|
||||
+46
-6
@@ -12,10 +12,24 @@ local Player = {}
|
||||
Player.__index = Player
|
||||
|
||||
local STEP_FRAMES = 16
|
||||
-- a turn in place holds for the ~2 frames the original spends on the
|
||||
-- extra OverworldLoop pass (home/overworld.asm .handleDirectionButtonPress
|
||||
-- returns to the loop without moving after a direction change)
|
||||
local TURN_FRAMES = 2
|
||||
-- a turn in place blocks movement for the one extra OverworldLoop pass the
|
||||
-- original spends after a direction change: .handleDirectionButtonPress ends
|
||||
-- `jp OverworldLoop` (home/overworld.asm), and OverworldLoop burns two
|
||||
-- DelayFrame calls before the next JoypadOverworld, so the step can only
|
||||
-- commit at the following poll -- the same 2-frames-per-iteration cadence
|
||||
-- that makes STEP_FRAMES 16 above (wWalkCounter = 8, 2px per
|
||||
-- AdvancePlayerSprite).
|
||||
-- Those polls sit on a 2-frame grid, so the hardware samples a press 0 or 1
|
||||
-- frames after the d-pad physically goes down and the release deadline lands
|
||||
-- 2 or 3 frames after that. We sample on the frame the button goes down
|
||||
-- with none of that poll latency, so a flat 2 handed every tap the tightest
|
||||
-- case the original could produce; 4 covers the grid instead of
|
||||
-- undercutting it (#415).
|
||||
local TURN_FRAMES = 4
|
||||
-- The on-screen d-pad cannot produce a 60ms tap: a finger press and release
|
||||
-- run well past it even before the OS batches the touch events, so the
|
||||
-- overlay gets a longer window than a physical pad (#415).
|
||||
local TOUCH_TURN_FRAMES = 8
|
||||
|
||||
function Player.new(data, cx, cy, facing)
|
||||
local self = setmetatable({}, Player)
|
||||
@@ -63,6 +77,11 @@ function Player.new(data, cx, cy, facing)
|
||||
self.progress = 0
|
||||
self.stepFlip = false
|
||||
self.turnTimer = 0
|
||||
-- wCheckFor180DegreeTurn (home/overworld.asm): the original only lets a
|
||||
-- turn in place happen on a poll whose previous pass found no direction
|
||||
-- held. It starts armed, tryMove spends it, and OverworldState:handleInput
|
||||
-- re-arms it from a standstill.
|
||||
self.turnArmed = true
|
||||
self.inputLocked = false
|
||||
return self
|
||||
end
|
||||
@@ -71,14 +90,35 @@ function Player:position()
|
||||
return self.cellX, self.cellY
|
||||
end
|
||||
|
||||
-- How long a fresh turn holds the step off for; see TURN_FRAMES. The
|
||||
-- overlay is detected per source rather than by whether the touch controls
|
||||
-- are on screen, so a phone with a controller attached still gets the
|
||||
-- physical pad's window (Input:isTouchDown, src/core/Input.lua).
|
||||
function Player:turnWindow()
|
||||
local frames = self.turnFrames or TURN_FRAMES
|
||||
local input = require("src.core.Game").input
|
||||
if input and input.isTouchDown and input:isTouchDown(self.facing) then
|
||||
return math.max(frames, TOUCH_TURN_FRAMES)
|
||||
end
|
||||
return frames
|
||||
end
|
||||
|
||||
-- Attempt to start a step; returns "moved"|"turned"|"blocked"|nil.
|
||||
function Player:tryMove(dir, map, entities)
|
||||
if self.moving or self.inputLocked then return nil end
|
||||
if self.facing ~= dir then
|
||||
self.facing = dir
|
||||
self.turnTimer = self.turnFrames or TURN_FRAMES
|
||||
self.bumpFrames = nil -- turning to a new facing ends any wall-bonk cycle
|
||||
return "turned"
|
||||
-- .handleDirectionButtonPress only reaches the turn while
|
||||
-- wCheckFor180DegreeTurn is still set, and .noDirectionButtonsPressed is
|
||||
-- the one place that sets it (home/overworld.asm), so a facing change
|
||||
-- made without the d-pad ever coming up steps straight away rather than
|
||||
-- paying the turn delay at every corner (#415)
|
||||
if self.turnArmed then
|
||||
self.turnArmed = false
|
||||
self.turnTimer = self:turnWindow()
|
||||
return "turned"
|
||||
end
|
||||
end
|
||||
if self.turnTimer > 0 then return nil end
|
||||
local ok, why = Collision.canMove(map, entities, self, dir)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
-- The Cerulean badge man's list needs a CANCEL row (#569).
|
||||
-- CeruleanBadgeHouseMiddleAgedManText loops DisplayListMenuID over
|
||||
-- .BadgeItemList and takes `jr c, .done` out (pokered
|
||||
-- scripts/CeruleanBadgeHouse.asm); PrintListMenuEntries prints
|
||||
-- ListMenuCancelText at the list's $FF terminator, so CANCEL is a real row
|
||||
-- and choosing it sets carry the same way B does.
|
||||
-- POKEPORT_DRIVER=tests/drivers/badge_cancel_bug569_test.lua POKEPORT_IDENTITY=bug569 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- pokered data/maps/objects/CeruleanBadgeHouse.asm: the middle-aged man is
|
||||
-- object_event 5, 3, STAY, so he never wanders off the tile; the floor to
|
||||
-- his west is the natural approach.
|
||||
local MAP = "CERULEAN_BADGE_HOUSE"
|
||||
local NPC = "CERULEANBADGEHOUSE_MIDDLE_AGED_MAN"
|
||||
local STAND = { x = 4, y = 3, facing = "right" }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- a renamed or unextracted text label reads on screen exactly like the
|
||||
-- missing row did: nothing happens
|
||||
local t = game.data.text
|
||||
for _, key in ipairs({
|
||||
"_CeruleanBadgeHouseMiddleAgedManText",
|
||||
"_CeruleanBadgeHouseMiddleAgedManWhichBadgeText",
|
||||
"_CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText",
|
||||
"_CeruleanBadgeHouseBoulderBadgeText",
|
||||
}) do
|
||||
check(key .. " resolves to a string", type(t[key]) == "string")
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
|
||||
local function manIn(ow)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == NPC then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function facingTheMan()
|
||||
local ow = game.overworld
|
||||
local man = ow and manIn(ow)
|
||||
if not man then return false end
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == man
|
||||
end
|
||||
|
||||
local ow = game.overworld
|
||||
local man = ow and manIn(ow)
|
||||
check("the badge man is on " .. MAP, man ~= nil)
|
||||
|
||||
if man and not facingTheMan() then
|
||||
-- a map edit moved him: take any free walkable neighbour instead.
|
||||
-- {dx, dy, facing} is the offset from him to the stand cell plus the
|
||||
-- direction that looks back, so +1 on x means facing left.
|
||||
local sides = {
|
||||
{ -1, 0, "right" }, { 1, 0, "left" }, { 0, 1, "up" }, { 0, -1, "down" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = man.cellX + s[1], man.cellY + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("approach cell is blocked, standing on", cx, cy, "facing", s[3])
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("player is standing in front of the badge man", facingTheMan())
|
||||
|
||||
-- talk, then mash through the greeting and the "Which badge?" prompt
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
-- the greeting runs three pages before the "Which badge?" prompt, so give
|
||||
-- the mash room; an A landing mid-type only speeds that page up
|
||||
local list
|
||||
for _ = 1, 60 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == ListMenu then list = top break end
|
||||
if getmetatable(top) == TextBox then U.tap(game, "a") end
|
||||
U.wait(10)
|
||||
end
|
||||
check("talking to him opened the badge list", list ~= nil)
|
||||
|
||||
if list then
|
||||
local labels = {}
|
||||
for i, item in ipairs(list.items) do labels[i] = item.label end
|
||||
U.log("badge list rows:", table.concat(labels, " "))
|
||||
check("the list has the eight badges plus one more row", #list.items == 9)
|
||||
local last = list.items[#list.items]
|
||||
check("the last row is CANCEL", last.label == "CANCEL")
|
||||
check("the CANCEL row carries no badge to describe", last.value == nil)
|
||||
|
||||
-- scroll it into view so the screenshot shows the row, not just the data
|
||||
for _ = 1, #list.items do
|
||||
if list.index == #list.items then break end
|
||||
U.tap(game, "down")
|
||||
U.wait(3)
|
||||
end
|
||||
check("cursor reached CANCEL", list.index == #list.items)
|
||||
check("badge list screenshot",
|
||||
U.shot(game, SHOT_DIR .. "/bug569_badge_list.png"))
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(25)
|
||||
local top = game.stack:top()
|
||||
check("CANCEL closed the list", getmetatable(top) ~= ListMenu)
|
||||
check("CANCEL printed the goodbye line, like B does",
|
||||
getmetatable(top) == TextBox)
|
||||
check("goodbye screenshot",
|
||||
U.shot(game, SHOT_DIR .. "/bug569_after_cancel.png"))
|
||||
U.tap(game, "a")
|
||||
U.wait(25)
|
||||
end
|
||||
|
||||
U.log("The goodbye line has been dismissed; press A on the man to run the")
|
||||
U.log("list again. CANCEL should be the ninth row under EARTHBADGE and")
|
||||
U.log("should close the whole conversation with \"Visit any time\" -- the")
|
||||
U.log("near miss is a CANCEL that only closes the list and drops you back")
|
||||
U.log("on the \"Which badge?\" prompt. The badge rows above it are the")
|
||||
U.log("control: each prints its description and returns to the list.")
|
||||
U.log("Screenshots are under " .. SHOT_DIR)
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,196 @@
|
||||
-- Ear check: cycling COLORS with 2 during a battle keeps the battle song (#484).
|
||||
-- The COLORS change rebuilds the live map's atlas through reloadMap, which used
|
||||
-- to re-enter the map and fire PlayMapMusic; ReloadMapData (home/reload_tiles.asm)
|
||||
-- only re-reads the view and tile patterns, map music comes from LoadMapData
|
||||
-- alone (home/overworld.asm). The invariants are in tests/engine/battle_colors_bug484.lua.
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_colors_bug484_test.lua POKEPORT_IDENTITY=bug484 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
-- No POKEPORT_SPEED: it scales the logic clock only while audio runs on its own
|
||||
-- real-time accumulator in Game:update, so it desyncs the ordering being judged.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Music = require("src.core.Music")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
-- pokered data/maps/objects/Route1.asm puts both youngsters at (5,24) and
|
||||
-- (15,13) and the sign at (9,27), so the top of the road is empty; the
|
||||
-- battle is pushed straight in, the cell is only somewhere to stand.
|
||||
local MAP = "ROUTE_1"
|
||||
local STAND = { x = 5, y = 6, facing = "down" }
|
||||
local PARTY = { { "BULBASAUR", 12 }, { "PIDGEOTTO", 18 } }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- what the ear cannot check -----------------------------------------
|
||||
local opts = game.save.options or {}
|
||||
local musicVol = opts.musicVol or 7
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if musicVol == 0 then
|
||||
U.log("FAIL MUSIC volume is 0. Everything below is about which song is")
|
||||
U.log(" playing, and at 0 there is no song. Set MUSIC to 7 in OPTION")
|
||||
U.log(" and run this again, or the whole check is worthless.")
|
||||
end
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL SFX volume is 0, so the battle menu's own click is gone too")
|
||||
U.log(" and there is no way to tell a dead audio path from a fixed bug.")
|
||||
U.log(" Set SFX to 7 in OPTION.")
|
||||
end
|
||||
check(("MUSIC volume %d, SFX volume %d"):format(musicVol, sfxVol),
|
||||
musicVol > 0 and sfxVol > 0)
|
||||
|
||||
local battleSongs = (game.data.audio or {}).battle or {}
|
||||
local mapSongs = (game.data.audio or {}).mapSongs or {}
|
||||
check(MAP .. " has a map theme to wrongly cut in (" ..
|
||||
tostring(mapSongs[MAP]) .. ")", mapSongs[MAP] ~= nil)
|
||||
check("the wild battle theme exists (" .. tostring(battleSongs.wild) .. ")",
|
||||
battleSongs.wild ~= nil)
|
||||
check("they are different songs, so the swap would be audible",
|
||||
battleSongs.wild ~= nil and battleSongs.wild ~= mapSongs[MAP])
|
||||
U.log(" COLORS ladder:", table.concat(PaletteFX.MODES, ", "))
|
||||
|
||||
-- Every music cue in the run, forwarded so playback is untouched. A map
|
||||
-- cue raised while a battle owns the screen is the bug, and it is the one
|
||||
-- thing an ear can miss if the two songs happen to open alike.
|
||||
local cues = {}
|
||||
local realPlay, realPlayMap = Music.play, Music.playMap
|
||||
Music.play = function(data, song, loop, ctx)
|
||||
cues[#cues + 1] = { song = song, reason = ctx and ctx.reason or "direct" }
|
||||
return realPlay(data, song, loop, ctx)
|
||||
end
|
||||
Music.playMap = function(data, mapId, onBike, surfing)
|
||||
cues[#cues + 1] = { song = "(playMap)", reason = "map", mapId = mapId }
|
||||
return realPlayMap(data, mapId, onBike, surfing)
|
||||
end
|
||||
local function mapCuesSince(mark)
|
||||
local hits = {}
|
||||
for i = mark + 1, #cues do
|
||||
if cues[i].reason == "map" then hits[#hits + 1] = cues[i].song end
|
||||
end
|
||||
return hits
|
||||
end
|
||||
local function lastSong()
|
||||
for i = #cues, 1, -1 do
|
||||
if cues[i].song ~= "(playMap)" then return cues[i].song end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---- get into a battle --------------------------------------------------
|
||||
game.save.party = {}
|
||||
for _, slot in ipairs(PARTY) do
|
||||
table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2]))
|
||||
end
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit moved the road: any free cell on Route 1 will do, the
|
||||
-- battle is pushed rather than walked into
|
||||
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
|
||||
local cx, cy = STAND.x + d[1], STAND.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP)
|
||||
U.wait(60) -- let the route theme actually get going first
|
||||
U.log(" route theme:", tostring(lastSong()))
|
||||
|
||||
local battle = BattleState.newWild(game, "RATTATA", 5)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the battle reached the screen", game.stack:top() == battle)
|
||||
for _ = 1, 120 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
end
|
||||
check("the battle reached its FIGHT/PKMN/ITEM/RUN menu", battle.phase == "menu")
|
||||
|
||||
local songInBattle = lastSong()
|
||||
check("the battle theme is what is playing (" .. tostring(songInBattle) .. ")",
|
||||
songInBattle == battleSongs.wild)
|
||||
U.shot(game, DIR .. "/bug484_1_" .. tostring(PaletteFX.mode) .. ".png")
|
||||
|
||||
-- ---- press 2, for real --------------------------------------------------
|
||||
local before = PaletteFX.mode
|
||||
local mark = #cues
|
||||
game:keypressed("2")
|
||||
U.wait(30)
|
||||
check("pressing 2 in a battle still changed COLORS ("
|
||||
.. PaletteFX.modeLabel(before) .. " -> "
|
||||
.. PaletteFX.modeLabel(PaletteFX.mode) .. ")",
|
||||
PaletteFX.mode ~= before)
|
||||
local strays = mapCuesSince(mark)
|
||||
check("no map music cue came out of it (#484)", #strays == 0)
|
||||
check("the battle theme is still the current song",
|
||||
lastSong() == songInBattle)
|
||||
U.shot(game, DIR .. "/bug484_2_" .. tostring(PaletteFX.mode) .. ".png")
|
||||
|
||||
-- the whole ladder, because a partial fix can leak on one mode only
|
||||
local seen = { [before] = true, [PaletteFX.mode] = true }
|
||||
local leaked = {}
|
||||
for _ = 2, #PaletteFX.MODES do
|
||||
local m = #cues
|
||||
game:keypressed("2")
|
||||
U.wait(20)
|
||||
seen[PaletteFX.mode] = true
|
||||
for _, s in ipairs(mapCuesSince(m)) do
|
||||
leaked[#leaked + 1] = PaletteFX.modeLabel(PaletteFX.mode) .. " (" .. s .. ")"
|
||||
end
|
||||
end
|
||||
local modes = 0
|
||||
for _ in pairs(seen) do modes = modes + 1 end
|
||||
check(("all %d COLORS modes were reachable from inside the battle"):format(modes),
|
||||
modes == #PaletteFX.MODES)
|
||||
check("no mode on the ladder started the route theme", #leaked == 0)
|
||||
if #leaked > 0 then U.log(" leaked on:", table.concat(leaked, ", ")) end
|
||||
check("still the battle theme after the full ladder",
|
||||
lastSong() == songInBattle)
|
||||
U.shot(game, DIR .. "/bug484_3_" .. tostring(PaletteFX.mode) .. ".png")
|
||||
|
||||
-- ---- over to you --------------------------------------------------------
|
||||
U.log("You are at the battle menu with the wild theme running. Press 2 a few")
|
||||
U.log("times: the wash behind the two pics, RATTATA's shading and the HP bars")
|
||||
U.log("move mode to mode (shots 1 and 2 are one press apart), and the music")
|
||||
U.log("underneath keeps going without a seam. #484 dropped Route 1's theme")
|
||||
U.log("over it on the first press. The near miss to listen for is the battle")
|
||||
U.log("theme restarting from its opening bar rather than being replaced, and")
|
||||
U.log("the near miss to look for is nothing moving at all -- a hotkey switched")
|
||||
U.log("off in battle is not a fix. A on the menu clicks, so that click is your")
|
||||
U.log("proof the audio path is up at all.")
|
||||
U.log("Shots: " .. DIR .. "/bug484_*.png")
|
||||
|
||||
-- keeps reporting after the hand-off: a map cue with a battle on top is the
|
||||
-- bug whether or not the two songs are easy to tell apart by ear
|
||||
local reported = #cues -- only what happens from the hand-off on
|
||||
while true do
|
||||
local top = game.stack:top()
|
||||
local inBattle = top == battle or (top and top.battle == battle)
|
||||
if inBattle and #cues > reported then
|
||||
for i = reported + 1, #cues do
|
||||
if cues[i].reason == "map" then
|
||||
U.log("FAIL map music cue during the battle:", tostring(cues[i].song),
|
||||
tostring(cues[i].mapId))
|
||||
end
|
||||
end
|
||||
end
|
||||
reported = #cues
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,186 @@
|
||||
-- Look at the two boxes a test cannot judge: the bike shop's BICYCLE/CANCEL
|
||||
-- price window (#568) and a Pokemon Center bench guy answering A (#488).
|
||||
-- Oracle: scripts/BikeShop.asm BikeShopClerkText (TextBoxBorder 0,0 b=4 c=15
|
||||
-- over BikeShopMenuText/BikeShopMenuPrice) and data/events/bench_guys.asm.
|
||||
-- The logic halves are asserted in tests/parity_bike_shop_clerk_bug568.lua
|
||||
-- and tests/parity_bench_guy_facing_bug488.lua.
|
||||
-- POKEPORT_DRIVER=tests/drivers/bike_shop_bug568_test.lua POKEPORT_IDENTITY=bug568 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local failures = 0
|
||||
local function check(label, ok)
|
||||
if not ok then failures = failures + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function boxOnScreen()
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= TextBox then return nil end
|
||||
local lines = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
for _, line in ipairs(page) do lines[#lines + 1] = line end
|
||||
end
|
||||
return top, table.concat(lines, " / ")
|
||||
end
|
||||
|
||||
-- A missing string or a renamed label goes quiet in exactly the way both
|
||||
-- bugs did, so resolve everything first and say so before anyone looks.
|
||||
local t = game.data.text
|
||||
for _, label in ipairs({ "_BikeShopClerkWelcomeText",
|
||||
"_BikeShopClerkDoYouLikeItText", "_BikeShopCantAffordText",
|
||||
"_BikeShopComeAgainText", "_VermilionPokecenterGuyText" }) do
|
||||
check(label .. " resolves", type(t[label]) == "string" and t[label] ~= "")
|
||||
end
|
||||
check("BICYCLE is an item the window can name",
|
||||
game.data.items.BICYCLE ~= nil)
|
||||
|
||||
local seat = (game.data.field.hiddenExtras.benchGuys.VERMILION_POKECENTER
|
||||
or {})[1]
|
||||
check("the Vermilion bench guy is in the generated data", seat ~= nil)
|
||||
check("his seat is faced from the left (BenchGuyTextPointers)",
|
||||
seat and seat.textFacing == "left")
|
||||
|
||||
if failures > 0 then
|
||||
U.log(failures, "check(s) failed above; the screens below will not tell")
|
||||
U.log("you anything the log has not already said.")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- #488
|
||||
-- data/events/hidden_events.asm parks the seat at (0,4); it is the bench
|
||||
-- wall itself, so the player stands on the floor tile east of it. The
|
||||
-- cell north of the seat is walkable too and is the control: in the
|
||||
-- original, PrintBenchGuyText only answers a LEFT facing, so an A press
|
||||
-- from up there does nothing, and that is correct.
|
||||
local BENCH = { map = "VERMILION_POKECENTER", x = seat and seat.x or 0,
|
||||
y = seat and seat.y or 4 }
|
||||
|
||||
U.teleport(game, BENCH.map, BENCH.x, BENCH.y - 1, "down")
|
||||
U.wait(10)
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
check("facing the seat from the north stays silent, like the original",
|
||||
boxOnScreen() == nil)
|
||||
|
||||
-- the seat's own approach, with a fallback in case a map edit walls it in
|
||||
local stand = { x = BENCH.x + 1, y = BENCH.y, facing = "left" }
|
||||
if not game.overworld.map:isWalkableCell(stand.x, stand.y) then
|
||||
for _, side in ipairs({ { -1, 0, "right" }, { 0, 1, "up" }, { 0, -1, "down" } }) do
|
||||
local cx, cy = BENCH.x + side[1], BENCH.y + side[2]
|
||||
if game.overworld.map:isWalkableCell(cx, cy) then
|
||||
U.log("the floor east of the seat is gone; standing at", cx, cy)
|
||||
stand = { x = cx, y = cy, facing = side[3] }
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
U.teleport(game, BENCH.map, stand.x, stand.y, stand.facing)
|
||||
U.wait(10)
|
||||
U.tap(game, "a")
|
||||
U.wait(90) -- let the first page finish typing before the shot
|
||||
local box, text = boxOnScreen()
|
||||
check("A from the east opens the bench guy's box (#488)", box ~= nil)
|
||||
if box then
|
||||
U.log("bench guy reads:", text)
|
||||
check("it is his line about no universally strong POKeMON",
|
||||
text:find("universally", 1, true) ~= nil)
|
||||
check("screenshot on disk",
|
||||
U.shot(game, SHOT_DIR .. "/bug488_bench_guy.png"))
|
||||
U.log("captured", SHOT_DIR .. "/bug488_bench_guy.png")
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- #568
|
||||
-- pokered data/maps/objects/BikeShop.asm: the clerk stands at (6,2)
|
||||
-- behind the counter row, so the player talks across it from (6,4).
|
||||
-- Clear the bike and the voucher so the talk takes the sales pitch.
|
||||
game.save.inventory.BICYCLE = nil
|
||||
game.save.inventory.BIKE_VOUCHER = nil
|
||||
game.save.flags.EVENT_GOT_BICYCLE = nil
|
||||
|
||||
U.teleport(game, "BIKE_SHOP", 6, 4, "up")
|
||||
U.wait(10)
|
||||
|
||||
local ow = game.overworld
|
||||
local clerk
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == "BIKESHOP_CLERK" then clerk = n end
|
||||
-- the middle-aged woman paces; park her so she cannot wander into shot
|
||||
if n.def and n.def.name == "BIKESHOP_MIDDLE_AGED_WOMAN" then
|
||||
n.frozen = true
|
||||
end
|
||||
end
|
||||
check("the clerk is on the map", clerk ~= nil)
|
||||
|
||||
-- talking across a counter reads one cell further, so the stand cell is
|
||||
-- two south of him; fall back to any free neighbour if the shop is redrawn
|
||||
local function facingTheClerk()
|
||||
local w = game.overworld
|
||||
local fx, fy = w.player:facingCell()
|
||||
if w:npcAtCell(fx, fy) == clerk then return true end
|
||||
if not w.map:isCounterCell(fx, fy) then return false end
|
||||
local Collision = require("src.world.Collision")
|
||||
local bx, by = Collision.target(fx, fy, w.player.facing)
|
||||
return w:npcAtCell(bx, by) == clerk
|
||||
end
|
||||
if clerk and not facingTheClerk() then
|
||||
for _, side in ipairs({ { 0, 2, "up" }, { 0, 1, "up" }, { 0, -1, "down" },
|
||||
{ 1, 0, "left" }, { -1, 0, "right" } }) do
|
||||
local cx, cy = clerk.cellX + side[1], clerk.cellY + side[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("(6,4) no longer reaches the counter; standing at", cx, cy)
|
||||
U.teleport(game, "BIKE_SHOP", cx, cy, side[3])
|
||||
U.wait(10)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("the player is at the counter", facingTheClerk())
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
local _, welcome = boxOnScreen()
|
||||
check("A opens the welcome line", welcome ~= nil)
|
||||
if welcome then U.log("clerk reads:", welcome) end
|
||||
|
||||
-- mash through the welcome box; the pitch box then types itself out and
|
||||
-- pops on its own, handing the screen to the price window
|
||||
local window
|
||||
for _ = 1, 200 do
|
||||
local top = game.stack:top()
|
||||
if top ~= game.overworld and getmetatable(top) ~= TextBox then
|
||||
window = top
|
||||
break
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
check("the price window is up and holding input (#568)", window ~= nil)
|
||||
if window then
|
||||
check("the cursor starts on BICYCLE", window.index == 1)
|
||||
check("it kept the pitch line for the bottom box",
|
||||
type(window.footer) == "table" and #window.footer > 0)
|
||||
check("screenshot on disk",
|
||||
U.shot(game, SHOT_DIR .. "/bug568_price_window.png"))
|
||||
U.log("captured", SHOT_DIR .. "/bug568_price_window.png")
|
||||
end
|
||||
|
||||
U.log("The window on screen is the shop menu: BICYCLE over CANCEL at the")
|
||||
U.log("top left, \194\1651000000 beside the BICYCLE row, cursor on BICYCLE, and")
|
||||
U.log("\"It's a cool BIKE! Do you want it?\" still in the bottom box under it.")
|
||||
U.log("Input is live. Up and down move between the two rows and clamp -- the")
|
||||
U.log("cursor must not wrap. A buys, B or CANCEL backs out; either way the")
|
||||
U.log("window has to stay on screen behind the closing lines and only")
|
||||
U.log("disappear with \"Come back again some time!\". A window that vanishes")
|
||||
U.log("the moment you answer, or a bottom box that blanks while the menu is")
|
||||
U.log("up, is the near miss to watch for. Talk to him again to replay it.")
|
||||
U.log("The boy by the door is the control: he has no menu, just a line.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Driver: opening the START menu while the bike force-rolls down Cycling
|
||||
-- Road (#525). src/world/OverworldController.lua handleInput() used to
|
||||
-- drop A/START outright while self.player.moving, so a press that landed
|
||||
-- mid-step and was still held when the step completed vanished -- on
|
||||
-- Cycling Road's forced roll, where a step re-arms on the single idle
|
||||
-- frame between steps, that made START a coin flip. The fix latches a
|
||||
-- still-held press and acts on it on the landing frame instead
|
||||
-- (see tests/parity_midstep_buttons.lua for the mechanical half).
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/cycling_road_menu_bug525_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug525 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
|
||||
-- SHOT_DIR=/tmp/shots love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Same clear stretch bug255 uses: x=2 is open road from y=8 past y=34.
|
||||
local MAP, START_X, START_Y = "ROUTE_17", 2, 20
|
||||
|
||||
game.save.onBike = true
|
||||
U.teleport(game, MAP, START_X, START_Y, "down")
|
||||
U.wait(15)
|
||||
local ow = game.overworld
|
||||
check("standing on Cycling Road, on the bike",
|
||||
ow.map.id == MAP and game.save.onBike)
|
||||
|
||||
-- hands off the pad: the slope pulls the bike south on its own
|
||||
-- (field.forcedMovement.slopeMaps, same mechanism bug255 proved)
|
||||
U.wait(20)
|
||||
check("the roll is already under way", ow.player.moving == true
|
||||
or ow.player.cellY > START_Y)
|
||||
U.shot(game, DIR .. "/bug525_1_rolling.png")
|
||||
|
||||
-- press START mid-step and keep it held through the landing frame, the
|
||||
-- exact shape a real thumb makes reaching for the menu while rolling
|
||||
local pressedMidStep = false
|
||||
local guard = 0
|
||||
while guard < 60 do
|
||||
guard = guard + 1
|
||||
if ow.player.moving and ow.player.progress
|
||||
and ow.player.progress > 2 and not pressedMidStep then
|
||||
table.insert(game.input.pressQueue, "start")
|
||||
game.input.state.start = true
|
||||
pressedMidStep = true
|
||||
U.log("pressed START mid-step at progress", ow.player.progress)
|
||||
end
|
||||
coroutine.yield()
|
||||
if getmetatable(game.stack:top()) ~= nil
|
||||
and game.stack:top() ~= ow and pressedMidStep then
|
||||
break
|
||||
end
|
||||
end
|
||||
U.wait(2)
|
||||
game.input.state.start = false
|
||||
|
||||
local top = game.stack:top()
|
||||
U.shot(game, DIR .. "/bug525_2_after_start.png")
|
||||
check("START held through the landing opened the start menu",
|
||||
top ~= ow and top ~= nil)
|
||||
check("the player is not frozen mid-tile (px/py land on a 16px cell)",
|
||||
ow.player.px % 16 == 0 and ow.player.py % 16 == 0)
|
||||
|
||||
-- close it and let the roll continue, proving the pull did not get
|
||||
-- eaten along with the buffered button
|
||||
if top ~= ow then
|
||||
while game.stack:top() ~= ow do
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
end
|
||||
end
|
||||
local yBeforeResume = ow.player.cellY
|
||||
U.wait(48)
|
||||
check("the roll resumes south after closing the menu",
|
||||
ow.player.cellY > yBeforeResume)
|
||||
U.shot(game, DIR .. "/bug525_3_resumed.png")
|
||||
|
||||
-- park with road left to roll, on the bike, before handing over
|
||||
U.teleport(game, MAP, START_X, START_Y, "down")
|
||||
U.wait(10)
|
||||
game.save.onBike = true
|
||||
|
||||
U.log("On the bike on Cycling Road at (" .. START_X .. "," .. START_Y ..
|
||||
"), rolling south hands-off.")
|
||||
U.log("Press START right as you see a step land, and again mid-step")
|
||||
U.log("while releasing before it lands. Right: a START you keep pressed")
|
||||
U.log("into the landing opens the menu every time, one you let go of")
|
||||
U.log("mid-step does nothing (same as standing still). #525 was the")
|
||||
U.log("held case sometimes doing nothing at all, a coin flip on this hill.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,216 @@
|
||||
-- Manual check that the rival shoves Red off the EEVEE ball on the right
|
||||
-- beat (#559). pokeyellow scripts/OaksLab.asm: OaksLabChoseStarterScript
|
||||
-- queues DOWN + RIGHT x3, and .asm_1c564 polls until wNPCNumScriptedSteps
|
||||
-- reads 1 before it simulates the PAD_RIGHT x2 -- so the push starts as the
|
||||
-- rival steps onto the player's own tile, not when he leaves the table.
|
||||
-- No POKEPORT_SPEED: the whole point is which frame the player starts on.
|
||||
-- No POKEPORT_IDENTITY either -- a sandbox save dir has no Yellow cache and
|
||||
-- boots stale Red data underneath a Yellow-looking run.
|
||||
-- POKEPORT_DRIVER=tests/drivers/eevee_shove_bug559_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
local Commands = require("src.script.Commands")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function idle()
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
-- Red and Blue load data/scripts/oaks_lab.lua instead (three starter
|
||||
-- balls, no shove at all), so every line below would fail for the wrong
|
||||
-- reason on the Red cache.
|
||||
if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
|
||||
GameVersion.isYellow()) then
|
||||
U.log("Yellow is the only version with an EEVEE ball. Re-run with")
|
||||
U.log("POKEPORT_VERSION=yellow.")
|
||||
idle()
|
||||
end
|
||||
|
||||
-- pokeyellow data/maps/objects/OaksLab.asm: OAKSLAB_RIVAL (4,3),
|
||||
-- OAKSLAB_EEVEE_POKE_BALL (7,3), OAKSLAB_OAK1 (5,2). The player talks to
|
||||
-- the ball from (7,4), and that y == 4 is what selects the shove branch
|
||||
-- in both the original (.asm_1c564 tests wYCoord) and the port.
|
||||
local MAP = "OAKS_LAB"
|
||||
local BALL = "OAKSLAB_EEVEE_POKE_BALL"
|
||||
local RIVAL_INDEX = 1
|
||||
local RIVAL_HOME = { x = 4, y = 3 }
|
||||
local STAND = { x = 7, y = 4, facing = "up" }
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
|
||||
-- data/scripts/init.lua picks oaks_lab_yellow over oaks_lab on the Yellow
|
||||
-- cache; if the Red module won, the ball is one of three starters and
|
||||
-- nothing below means anything. Asked after the teleport because the
|
||||
-- registry only fills when OverworldState first requires it.
|
||||
local labModule = require("data.scripts.oaks_lab_yellow")
|
||||
local bound = MapScripts.talkScript(MAP, "TEXT_OAKSLAB_EEVEE_POKE_BALL")
|
||||
U.log("the map dispatches a", type(bound), "for the ball")
|
||||
check("the Yellow ball handler is the one bound to the map",
|
||||
bound ~= nil and bound == labModule.talk.TEXT_OAKSLAB_EEVEE_POKE_BALL)
|
||||
|
||||
local ow = game.overworld
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
|
||||
local function npcNamed(name)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == name then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The scene the shove belongs to: Oak has asked the player to choose and
|
||||
-- the rival is still standing at the table. Oak himself starts hidden on
|
||||
-- this map, so put him back the way OaksLabScript does before its speech.
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_OAK_ASKED_TO_CHOOSE_MON = true
|
||||
game.save.flags.EVENT_GOT_STARTER = nil
|
||||
game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
|
||||
Commands.show_object(ctx, MAP, "OAKSLAB_RIVAL")
|
||||
Commands.show_object(ctx, MAP, "OAKSLAB_OAK1")
|
||||
U.wait(2)
|
||||
|
||||
local rival = ow:npcByIndex(RIVAL_INDEX)
|
||||
if rival and (rival.cellX ~= RIVAL_HOME.x or rival.cellY ~= RIVAL_HOME.y) then
|
||||
-- he wanders nowhere on this map (STAY), but a converted save can drop
|
||||
-- him mid-walk; pin him back on the table corner he starts from
|
||||
U.log("rival was at", rival.cellX, rival.cellY, "- pinning him to",
|
||||
RIVAL_HOME.x, RIVAL_HOME.y)
|
||||
Commands.place_npc(ctx, RIVAL_INDEX, RIVAL_HOME.x, RIVAL_HOME.y, "down")
|
||||
end
|
||||
if not check("the rival is on the map at (4,3)",
|
||||
rival ~= nil and rival.cellX == RIVAL_HOME.x
|
||||
and rival.cellY == RIVAL_HOME.y) then
|
||||
idle()
|
||||
end
|
||||
|
||||
local ball = npcNamed(BALL)
|
||||
if not check("the EEVEE ball object is on the map", ball ~= nil) then
|
||||
U.log("Nothing to talk to. A save past EVENT_GOT_STARTER has already")
|
||||
U.log("hidden it, and this scene cannot be replayed on that save.")
|
||||
idle()
|
||||
end
|
||||
|
||||
-- derive the talking cell from the ball rather than trusting (7,4): a
|
||||
-- later map edit degrades to another free neighbour instead of parking
|
||||
-- the player inside the table
|
||||
local DIRS = { { "up", 0, 1 }, { "down", 0, -1 },
|
||||
{ "left", 1, 0 }, { "right", -1, 0 } }
|
||||
local function facingBall()
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == ball
|
||||
end
|
||||
if not facingBall() then
|
||||
for _, d in ipairs(DIRS) do
|
||||
local cx, cy = ball.cellX + d[2], ball.cellY + d[3]
|
||||
if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy)
|
||||
and not ow:npcAtCell(cx, cy) then
|
||||
U.log("standing at", cx, cy, "facing", d[1], "instead")
|
||||
U.teleport(game, MAP, cx, cy, d[1])
|
||||
U.wait(6)
|
||||
ow = game.overworld
|
||||
ctx.overworld = ow
|
||||
ball = npcNamed(BALL)
|
||||
rival = ow:npcByIndex(RIVAL_INDEX)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("player is facing the ball", facingBall())
|
||||
local p = ow.player
|
||||
U.log("player at", p.cellX, p.cellY, "facing", p.facing,
|
||||
"| rival at", rival.cellX, rival.cellY,
|
||||
"| ball at", ball.cellX, ball.cellY)
|
||||
-- y == 4 is the branch under test; from anywhere else both the original
|
||||
-- and the port just walk the rival over with no shove at all
|
||||
check("the player is on the row that triggers the shove (y == 4)",
|
||||
p.cellY == 4)
|
||||
|
||||
if U.shot(game, SHOT_DIR .. "/bug559_before.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug559_before.png")
|
||||
end
|
||||
|
||||
-- ---- talk to the ball and watch the two walks frame by frame ----
|
||||
U.tap(game, "a")
|
||||
|
||||
local shoveFrame, rivalAtShove
|
||||
local trace, lastCell = {}, nil
|
||||
for f = 1, 900 do
|
||||
local cell = rival.cellX .. "," .. rival.cellY
|
||||
if cell ~= lastCell then
|
||||
lastCell = cell
|
||||
trace[#trace + 1] = "f" .. f .. " rival " .. cell
|
||||
end
|
||||
if not shoveFrame and ow.player.moving then
|
||||
shoveFrame = f
|
||||
rivalAtShove = { x = rival.cellX, y = rival.cellY }
|
||||
end
|
||||
if shoveFrame and not ow.player.moving and ow.player.cellX >= STAND.x + 2 then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
U.log("rival trace:", table.concat(trace, " | "))
|
||||
if not check("the player was pushed at all", shoveFrame ~= nil) then
|
||||
U.log("The rival's walk ran but nothing moved the player, so the scene")
|
||||
U.log("never reached move_player. Check the runner did not stall on the")
|
||||
U.log("emote row.")
|
||||
idle()
|
||||
end
|
||||
U.log("the push started on driver frame", shoveFrame, "with the rival at",
|
||||
rivalAtShove.x, rivalAtShove.y)
|
||||
|
||||
-- The whole of #559: at the frame the player starts moving the rival must
|
||||
-- already be alongside him on row 4, one step from the tile he is being
|
||||
-- pushed off. Before the fix both walks started together and the rival
|
||||
-- was still up at the table (4,3)/(4,4).
|
||||
check("the rival had reached row 4 before the push", rivalAtShove.y == 4)
|
||||
check("he was one step from the player's tile, not still at the table",
|
||||
rivalAtShove.x >= ball.cellX - 1)
|
||||
|
||||
if U.shot(game, SHOT_DIR .. "/bug559_shove.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug559_shove.png")
|
||||
end
|
||||
|
||||
-- let the two walks settle, then the beat the script waits before it turns
|
||||
-- the rival up to face the player (the row after move_player)
|
||||
for _ = 1, 240 do
|
||||
if not ow.player.moving and not rival.moving and rival.facing == "up" then
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("after the push: player at", ow.player.cellX, ow.player.cellY,
|
||||
"| rival at", rival.cellX, rival.cellY, "facing", rival.facing)
|
||||
check("the player ended two tiles right of where he stood",
|
||||
ow.player.cellX == STAND.x + 2 and ow.player.cellY == STAND.y)
|
||||
check("the rival ended on the ball's row-4 tile facing up",
|
||||
rival.cellX == ball.cellX and rival.cellY == 4
|
||||
and rival.facing == "up")
|
||||
|
||||
if U.shot(game, SHOT_DIR .. "/bug559_after.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug559_after.png")
|
||||
end
|
||||
|
||||
U.log("The shove has already happened; the box on screen is the rival")
|
||||
U.log("gloating. Press A through his five lines to see the rest. Watch the")
|
||||
U.log("shot pair: the rival walks down and across the table first, and Red")
|
||||
U.log("only slides right as the rival arrives on top of him, both moving")
|
||||
U.log("together for that last tile. The near miss to look for is Red")
|
||||
U.log("stepping away early, with the rival still up at (4,3) or halfway")
|
||||
U.log("along row 4 behind him -- that is #559 unchanged.")
|
||||
U.log("Control case: on a save where Oak has not asked you to choose yet,")
|
||||
U.log("the same A press only prints \"That's a POKe BALL\" and nobody")
|
||||
U.log("moves, which is the handler working correctly.")
|
||||
|
||||
idle()
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
-- Driver: Fighting Dojo Karate Master does not leave his post (#502),
|
||||
-- the opposite-direction report against the same script #495 fixed
|
||||
-- (data/scripts/story4.lua dojoMasterGate / M.FIGHTING_DOJO.onStep).
|
||||
--
|
||||
-- Before the fix his trainer header used range=4/DOWN sight aggro
|
||||
-- (src/core/Data.lua seedFightingDojoKarateMaster), so walking up his
|
||||
-- column from below put him in CheckFightingMapTrainers' generic path:
|
||||
-- the NPC walks toward the player before the pre-battle text, straight
|
||||
-- through FIGHTINGDOJO_BLACKBELT3/4's tiles at (5,5)/(5,7) -- the
|
||||
-- "merges with a pupil" glitch, and it fired regardless of whether the
|
||||
-- pupils in front of him had been beaten yet ("forces the fight early").
|
||||
-- The fix sets range=0 and gates him on the single tile at his left
|
||||
-- (4,3) instead (onStep = dojoMasterGate): he only ever turns to face
|
||||
-- the player there, never steps.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/fighting_dojo_master_bug502_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug502 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function masterIn(ow)
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == "FIGHTINGDOJO_KARATE_MASTER" then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- No pupil beaten, master not beaten: the exact state the report says
|
||||
-- triggered the early merge (walking up before clearing the pupils).
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_BEAT_KARATE_MASTER = nil
|
||||
game.save.defeatedTrainers = {}
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- Direction 1 (#502): stand in the master's own sight column, below
|
||||
-- him, with his southern pupils (BLACKBELT3 at (5,5), BLACKBELT4 at
|
||||
-- (5,7)) still in the way -- exactly the "walk towards Karate Master"
|
||||
-- report. The old range=4/DOWN header put any of these cells in his
|
||||
-- sight; his first step toward the player collided with BLACKBELT3's
|
||||
-- tile. Collision blocks a real walk onto an NPC's cell, so probe by
|
||||
-- teleport (a scripted approach cannot walk through them either) and
|
||||
-- watch several fixed steps for the master moving or engaging at all.
|
||||
------------------------------------------------------------------
|
||||
local ow, master
|
||||
local function probe(px, py, label)
|
||||
U.teleport(game, "FIGHTING_DOJO", px, py, "up")
|
||||
U.wait(10)
|
||||
ow = game.stack:top()
|
||||
master = masterIn(ow)
|
||||
U.shot(game, DIR .. "/bug502_1_" .. label .. "_before.png")
|
||||
local moved, engaged = false, false
|
||||
for _ = 1, 40 do
|
||||
U.wait(1)
|
||||
if master.cellX ~= 5 or master.cellY ~= 3 then moved = true end
|
||||
if getmetatable(game.stack:top()) == TextBox then engaged = true; break end
|
||||
end
|
||||
U.shot(game, DIR .. "/bug502_1_" .. label .. "_after.png")
|
||||
check("Fighting Dojo is loaded (" .. label .. ")", ow.map.id == "FIGHTING_DOJO")
|
||||
check("Karate Master stayed at (5,3), did not walk toward (" .. label .. ")",
|
||||
not moved)
|
||||
check("standing at " .. label .. " started no battle on its own",
|
||||
not engaged)
|
||||
return moved or engaged
|
||||
end
|
||||
|
||||
-- (5,4): directly below the master, one tile from BLACKBELT3 (5,5).
|
||||
-- (5,6): the gap between BLACKBELT3 and BLACKBELT4, still in the old
|
||||
-- sight range and still boxed in by pupils on both sides.
|
||||
local bad1 = probe(5, 4, "below_master")
|
||||
local bad2 = probe(5, 6, "between_pupils")
|
||||
|
||||
------------------------------------------------------------------
|
||||
-- Direction 2 (#495, same script): the ONLY tile that should start his
|
||||
-- fight is one step left of him. Confirm the gate still works so the
|
||||
-- fix didn't just make him inert in both directions.
|
||||
------------------------------------------------------------------
|
||||
U.teleport(game, "FIGHTING_DOJO", 4, 2, "down")
|
||||
U.wait(10)
|
||||
ow = game.stack:top()
|
||||
master = masterIn(ow)
|
||||
U.shot(game, DIR .. "/bug502_3_gate_before.png")
|
||||
U.tap(game, "down")
|
||||
U.wait(30)
|
||||
local gateOpened = getmetatable(game.stack:top()) == TextBox
|
||||
U.shot(game, DIR .. "/bug502_4_gate_after.png")
|
||||
check("stepping onto (4,3) still starts the Master's challenge", gateOpened)
|
||||
check("he turned to face the player instead of walking to them",
|
||||
master.cellX == 5 and master.cellY == 3 and master.facing == "left")
|
||||
|
||||
U.log("Compare bug502_1/2: the Master's sprite should sit in the same")
|
||||
U.log("spot in both, never sliding down onto BLACKBELT3 or BLACKBELT4.")
|
||||
U.log("bug502_4 should show his pre-battle text box, with him still on")
|
||||
U.log("(5,3), just turned to face left -- that is the correct trigger,")
|
||||
U.log("distinct from the column walk that used to glitch him south.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,175 @@
|
||||
-- Manual check of the Silph Scope unveil in the POKEMON_TOWER_6F battle (#492).
|
||||
-- PrintBeginningBattleText .isMarowak (engine/battle/common_text.asm:49-60)
|
||||
-- enters disguised even with the scope and plays UnveiledGhostText + MarowakAnim
|
||||
-- over the GHOST before "Wild MAROWAK appeared!"; the port used to open the
|
||||
-- battle with MAROWAK already loaded. Sequencing half:
|
||||
-- tests/engine/ghost_unveil_bug492.lua. Never under POKEPORT_SPEED -- the
|
||||
-- unveil is the thing being timed.
|
||||
-- POKEPORT_DRIVER=tests/drivers/ghost_unveil_bug492_test.lua POKEPORT_IDENTITY=bug492 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots 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 BattleState = require("src.battle.BattleState")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- machine checks ----------------------------------------------------
|
||||
-- A missing text key, a renamed asset and an unarmed branch all look like
|
||||
-- the bug from outside: the battle just opens with MAROWAK on screen.
|
||||
U.log("#492 Silph Scope unveil: machine checks")
|
||||
check("SILPH_SCOPE is a real item", game.data.items.SILPH_SCOPE ~= nil)
|
||||
check("MAROWAK is a real species", game.data.pokemon.MAROWAK ~= nil)
|
||||
check("BattleState:makeUnveiledGhost exists",
|
||||
type(BattleState.makeUnveiledGhost) == "function")
|
||||
local unveil = game.data.text._UnveiledGhostText
|
||||
check("_UnveiledGhostText extracted", type(unveil) == "string" and unveil ~= "")
|
||||
if type(unveil) == "string" then
|
||||
U.log("the unveil line reads:", (unveil:gsub("[\n\011\012]", " / ")))
|
||||
end
|
||||
check("_PokemonTower6FBeGoneText extracted",
|
||||
type(game.data.text._PokemonTower6FBeGoneText) == "string")
|
||||
-- the disguise pic resolves through the same override chain the battle uses
|
||||
local Assets = require("src.render.Assets")
|
||||
local GHOST_PIC = "assets/generated/battle/front/ghost.png"
|
||||
check("the ghost front pic is in the cache",
|
||||
Assets.exists(Assets.resolve(GHOST_PIC)))
|
||||
|
||||
local opts = game.save.options or {}
|
||||
U.log("sfxVol", tostring(opts.sfxVol), "musicVol", tostring(opts.musicVol))
|
||||
if opts.sfxVol == 0 or opts.musicVol == 0 then
|
||||
U.log("a volume is at 0: raise it in OPTION before judging what you hear")
|
||||
end
|
||||
U.log("no intro cry here is CORRECT -- .isMarowak never reaches PlayCry.")
|
||||
U.log("The CHANNELER at (12, 10) is the control: her GASTLY cries normally.")
|
||||
|
||||
-- ---- reach the trigger -------------------------------------------------
|
||||
-- pokered scripts/PokemonTower6F.asm PokemonTower6FMarowakCoords:
|
||||
-- dbmapcoord 10, 16. Row 17 is solid wall and (9, 16) is the stairwell
|
||||
-- warp down to 5F, so the approach is from (10, 15) facing down.
|
||||
local MAP = "POKEMON_TOWER_6F"
|
||||
local TRIGGER = { x = 10, y = 16 }
|
||||
local STAND = { x = 10, y = 15, facing = "down", step = "down" }
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.save.player.name = "RED"
|
||||
game.save.inventory.SILPH_SCOPE = 1
|
||||
game.save.flags.EVENT_BEAT_GHOST_MAROWAK = nil
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("the overworld is up on " .. MAP, ow ~= nil and ow.map ~= nil)
|
||||
|
||||
if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit moved the approach: take any walkable neighbour of the
|
||||
-- trigger that is not the stairwell warp, and step back toward it.
|
||||
-- {dx, dy, facing} is the offset from the trigger to the stand cell plus
|
||||
-- the direction that walks back onto it.
|
||||
local sides = { { 0, -1, "down" }, { 1, 0, "left" },
|
||||
{ -1, 0, "right" }, { 0, 1, "up" } }
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow.map:warpAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, approaching from"):format(STAND.x, STAND.y),
|
||||
cx, cy, "stepping", s[3])
|
||||
STAND = { x = cx, y = cy, facing = s[3], step = s[3] }
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- walk onto the trigger; MapScripts onStep fires on the completed step
|
||||
U.hold(game, STAND.step, 20)
|
||||
U.wait(20)
|
||||
check("the Be-gone text opened", game.stack:top() ~= game.overworld)
|
||||
|
||||
-- ---- into the battle ---------------------------------------------------
|
||||
local battle
|
||||
for _ = 1, 300 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == BattleState then battle = top break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
check("the MAROWAK battle opened", battle ~= nil)
|
||||
if not battle then
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
battle.onFinish = battle.onFinish or function() end
|
||||
check("the scope armed the unveil, not a plain wild battle",
|
||||
battle.scopeReveal == true)
|
||||
check("IsGhostBattle stays false with the scope in the bag", not battle.ghost)
|
||||
check("the battle OPENED as the GHOST, not as MAROWAK (#492)",
|
||||
battle.enemy and battle.enemy.name == "GHOST")
|
||||
check("the real nick is held back for the unveil",
|
||||
battle.ghostReal ~= nil and battle.ghostReal.name == "MAROWAK")
|
||||
|
||||
-- ---- watch the unveil --------------------------------------------------
|
||||
-- A turns the "GHOST appeared!" and unveil boxes; stop pressing the moment
|
||||
-- MarowakAnim arms so nothing mashes past "Wild MAROWAK appeared!".
|
||||
for _ = 1, 600 do
|
||||
if battle.ghostReveal then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
check("MarowakAnim started", battle.ghostReveal ~= nil)
|
||||
|
||||
local FLASH = BattleState.GHOST_FLASH_FRAMES
|
||||
local FADE_OUT = BattleState.GHOST_FADE_OUT_FRAMES
|
||||
local shotFlash, swapT, nameDuringFlash = false, nil, true
|
||||
for _ = 1, 400 do
|
||||
local gr = battle.ghostReveal
|
||||
local t = gr and gr.t or nil
|
||||
if t and t <= FLASH + FADE_OUT and battle.enemy.name ~= "GHOST" then
|
||||
nameDuringFlash = false
|
||||
end
|
||||
if t and not swapT and battle.enemy.name ~= "GHOST" then swapT = t end
|
||||
if t and t >= 25 and not shotFlash then
|
||||
shotFlash = U.shot(game, DIR .. "/bug492_ghost_flashing.png")
|
||||
end
|
||||
if not gr and swapT then break end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
check("the GHOST was still on screen through the flash and fade-out (#492)",
|
||||
nameDuringFlash)
|
||||
check(("the swap landed after the fade-out (frame %s of %d)")
|
||||
:format(tostring(swapT), FLASH + FADE_OUT),
|
||||
swapT ~= nil and swapT > FLASH + FADE_OUT)
|
||||
check("the foe is MAROWAK once the animation finishes",
|
||||
battle.enemy.name == "MAROWAK")
|
||||
check("captured the flashing ghost", shotFlash)
|
||||
|
||||
-- the "Wild MAROWAK appeared!" box that closes .isMarowak
|
||||
U.wait(30)
|
||||
check("captured the unveiled MAROWAK",
|
||||
U.shot(game, DIR .. "/bug492_marowak_revealed.png"))
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
U.log("The pad is yours in the battle the unveil just finished. What you")
|
||||
U.log("should have seen: the box says the GHOST appeared, the SILPH SCOPE")
|
||||
U.log("line follows, and only THEN does the ghost flash, dissolve and come")
|
||||
U.log("back as MAROWAK before \"Wild MAROWAK appeared!\". The near miss to")
|
||||
U.log("watch for is the sprite swapping early -- MAROWAK already standing")
|
||||
U.log("there while the unveil text is still typing, which is #492 itself.")
|
||||
U.log("To see it again: run from the battle (that steps you one cell right),")
|
||||
U.log("walk back to (10, 15) and step down onto (10, 16). Beating it sets")
|
||||
U.log("EVENT_BEAT_GHOST_MAROWAK and the trigger is spent for this save.")
|
||||
U.log("Screenshots: " .. DIR .. "/bug492_*.png")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,234 @@
|
||||
-- Ear check: the menu click on backing out of POKéDEX / POKéMON / ITEM /
|
||||
-- OPTION, on the first A on an item in ITEM, and on A over CANCEL in OPTION
|
||||
-- (#570). HandleMenuInput_ (home/window.asm) replays SFX_PRESS_AB for the
|
||||
-- PAD_A | PAD_B half of a menu's watched keys; DisplayOptionMenu beeps only
|
||||
-- at .exitMenu (engine/menus/main_menu.asm). Silence where the original is
|
||||
-- silent counts too, so this walks the mute cases as well. Invariants are in
|
||||
-- tests/engine/menu_click_bug570.lua.
|
||||
-- POKEPORT_DRIVER=tests/drivers/menu_click_bug570_test.lua POKEPORT_IDENTITY=bug570 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
-- No POKEPORT_SPEED: it scales the logic clock only while audio runs on its own
|
||||
-- real-time accumulator in Game:update, so it desyncs the exact ordering of
|
||||
-- press and click that this run exists to judge.
|
||||
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 ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local OptionsMenu = require("src.ui.OptionsMenu")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
-- pokered data/maps/objects/PalletTown.asm: OAK at (8,5), the girl at (3,8),
|
||||
-- the fisher at (11,14), warps at (5,5), (13,5) and (12,11). (10,7) is clear
|
||||
-- of all of them and off every mat; the menus are what is being judged, the
|
||||
-- cell only has to be somewhere the start menu opens.
|
||||
local MAP = "PALLET_TOWN"
|
||||
local STAND = { x = 10, y = 7, facing = "down" }
|
||||
local PARTY = { { "BULBASAUR", 12 }, { "PIDGEY", 9 } }
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ---- what the ear cannot check -----------------------------------------
|
||||
local opts = game.save.options or {}
|
||||
local musicVol = opts.musicVol or 7
|
||||
local sfxVol = opts.sfxVol or 7
|
||||
if sfxVol == 0 then
|
||||
U.log("FAIL SFX volume is 0. Every claim below is about a click that is or")
|
||||
U.log(" is not there, and at 0 none of them are. Set SFX to 7 in OPTION")
|
||||
U.log(" and run this again, or the whole check is worthless.")
|
||||
end
|
||||
if musicVol == 0 then
|
||||
U.log("FAIL MUSIC volume is 0, so Pallet Town's theme is gone and there is")
|
||||
U.log(" nothing to tell a dead audio device from a missing click.")
|
||||
U.log(" Set MUSIC to 7 in OPTION.")
|
||||
end
|
||||
check(("SFX volume %d, MUSIC volume %d"):format(sfxVol, musicVol),
|
||||
sfxVol > 0 and musicVol > 0)
|
||||
|
||||
-- an unresolved sfx key is silent in exactly the way the bug was
|
||||
local sfx = (game.data.audio or {}).sfx or {}
|
||||
for _, key in ipairs({ "Press_AB", "Start_Menu", "Swap" }) do
|
||||
check("sfx " .. key .. " is in the cache", sfx[key] ~= nil)
|
||||
end
|
||||
|
||||
-- every cue the run makes, forwarded so playback is untouched
|
||||
local cues = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name)
|
||||
cues[#cues + 1] = name
|
||||
return realPlay(data, name)
|
||||
end
|
||||
local function since(mark)
|
||||
local clicks, others = 0, {}
|
||||
for i = mark + 1, #cues do
|
||||
if cues[i] == "Press_AB" then clicks = clicks + 1
|
||||
else others[#others + 1] = cues[i] end
|
||||
end
|
||||
return clicks, others
|
||||
end
|
||||
|
||||
-- one press, then long enough for the click to be its own sound
|
||||
local function press(btn, want, label)
|
||||
local mark = #cues
|
||||
U.tap(game, btn)
|
||||
U.wait(28)
|
||||
local clicks, others = since(mark)
|
||||
local note = ""
|
||||
if #others > 0 then note = " (also: " .. table.concat(others, ", ") .. ")" end
|
||||
check(("%s: %s gave %d click%s, expected %d%s")
|
||||
:format(label, btn:upper(), clicks, clicks == 1 and "" or "s",
|
||||
want, note),
|
||||
clicks == want)
|
||||
return clicks
|
||||
end
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function isA(class) return getmetatable(top()) == class end
|
||||
|
||||
-- ---- set the save up so every start-menu row is live --------------------
|
||||
game.save.party = {}
|
||||
for _, slot in ipairs(PARTY) do
|
||||
table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2]))
|
||||
end
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_POKEDEX = true -- StartMenu hides POKéDEX without it
|
||||
game.save.pokedex = game.save.pokedex or { seen = {}, owned = {} }
|
||||
for _, id in ipairs({ "BULBASAUR", "PIDGEY", "RATTATA" }) do
|
||||
game.save.pokedex.seen[id] = true
|
||||
game.save.pokedex.owned[id] = true
|
||||
end
|
||||
Bag.add(game.save, "POTION", 3)
|
||||
Bag.add(game.save, "ANTIDOTE", 2)
|
||||
game.save.startMenuIndex = 1
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- a map edit blocked the cell: any free walkable neighbour will do
|
||||
for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do
|
||||
local cx, cy = STAND.x + d[1], STAND.y + d[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y),
|
||||
cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, STAND.facing)
|
||||
U.wait(20)
|
||||
ow = game.overworld
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("standing on " .. MAP, ow ~= nil and ow.map.id == MAP)
|
||||
-- the girl and the fisher both WALK: pin them so the map under the menus
|
||||
-- looks the same on every run and in every screenshot
|
||||
for _, n in ipairs((ow or {}).npcs or {}) do n.frozen = true end
|
||||
|
||||
-- ---- walk the menus -----------------------------------------------------
|
||||
press("start", 0, "opening the start menu is silent (its own Start_Menu "
|
||||
.. "cue is not the A/B click)")
|
||||
check("the start menu is up", isA(Menu))
|
||||
|
||||
-- rows come and go with save state and the ui.start_menu.items hook, so
|
||||
-- find each one by label rather than counting cursor steps
|
||||
local function rowIndex(label)
|
||||
for i, item in ipairs(top().items or {}) do
|
||||
if item.label == label then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local function openRow(label)
|
||||
local menu = top()
|
||||
local i = rowIndex(label)
|
||||
if not i then
|
||||
check("the start menu has a " .. label .. " row", false)
|
||||
return false
|
||||
end
|
||||
menu.index = i
|
||||
menu:clampScroll()
|
||||
press("a", 1, "A on " .. label .. " clicks")
|
||||
return true
|
||||
end
|
||||
|
||||
if openRow(Strings("POKéDEX")) then
|
||||
check("the POKéDEX list opened", isA(ListMenu))
|
||||
U.shot(game, DIR .. "/bug570_1_pokedex.png")
|
||||
press("b", 1, "backing out of POKéDEX clicks (#570)")
|
||||
check("and the start menu came back", isA(Menu))
|
||||
end
|
||||
|
||||
if openRow(Strings("POKéMON")) then
|
||||
check("the party menu opened", getmetatable(top()) == PartyMenu)
|
||||
press("b", 1, "backing out of POKéMON clicks (#570)")
|
||||
check("and the start menu came back", isA(Menu))
|
||||
end
|
||||
|
||||
if openRow(Strings("ITEM")) then
|
||||
check("the bag opened", isA(ListMenu))
|
||||
check("the bag has items to click on", #(top().items or {}) > 0)
|
||||
press("a", 1, "the first A on an item in ITEM clicks (#570)")
|
||||
check("the USE / TOSS submenu opened", isA(Menu))
|
||||
U.shot(game, DIR .. "/bug570_2_bag_usetoss.png")
|
||||
press("b", 1, "backing out of USE / TOSS clicks")
|
||||
check("back on the item list", isA(ListMenu))
|
||||
-- SELECT is inside DisplayListMenuID's watched mask but outside its
|
||||
-- PAD_A | PAD_B sound test (home/list_menu.asm), so arming a swap is
|
||||
-- silent and completing it makes the swap chirp, not the menu click
|
||||
press("select", 0, "SELECT arms a swap without clicking")
|
||||
press("select", 0, "and completing it chirps instead of clicking")
|
||||
press("b", 1, "backing out of ITEM clicks (#570)")
|
||||
check("and the start menu came back", isA(Menu))
|
||||
end
|
||||
|
||||
if openRow(Strings("OPTION")) then
|
||||
check("the options screen opened", getmetatable(top()) == OptionsMenu)
|
||||
press("right", 0, "the Left/Right toggles are silent")
|
||||
press("left", 0, "in both directions")
|
||||
-- up from the first row wraps onto CANCEL, which sits one past the rows
|
||||
press("up", 0, "moving the cursor is silent")
|
||||
check("the cursor is on CANCEL",
|
||||
top().index == #top().rows + 1)
|
||||
U.shot(game, DIR .. "/bug570_3_option_cancel.png")
|
||||
press("a", 1, "A on CANCEL in OPTION clicks (#570)")
|
||||
check("and the start menu came back", isA(Menu))
|
||||
end
|
||||
|
||||
if openRow(Strings("OPTION")) then
|
||||
press("b", 1, "B out of OPTION clicks too (#570)")
|
||||
check("and the start menu came back", isA(Menu))
|
||||
end
|
||||
|
||||
press("start", 0, "START closes the start menu silently: draw_start_menu's "
|
||||
.. "mask watches it, but it is not in the PAD_A | PAD_B sound branch")
|
||||
check("back on the map", top() == game.overworld)
|
||||
|
||||
-- ---- over to you --------------------------------------------------------
|
||||
U.log("The run above already pressed all of it; do it again by ear. Open START,")
|
||||
U.log("go into POKéDEX, POKéMON, ITEM and OPTION and back out of each with B.")
|
||||
U.log("Every one of those B presses makes the same short click that A on the")
|
||||
U.log("row made going in, the first A on an item in ITEM makes it, and A on")
|
||||
U.log("CANCEL in OPTION closes it with it. #570 was silence on all of those.")
|
||||
U.log("The near miss is a click at the wrong moment: OPTION's Left/Right")
|
||||
U.log("toggles and its A on a setting row stay silent, START opens and closes")
|
||||
U.log("the start menu with its own longer cue and no click, and SELECT in the")
|
||||
U.log("bag chirps a swap rather than clicking.")
|
||||
U.log("Shots: " .. DIR .. "/bug570_*.png")
|
||||
|
||||
-- keeps printing the cue stream after the hand-off, so a click that lands a
|
||||
-- beat late reads as a late line rather than as the right sound
|
||||
local reported = #cues
|
||||
while true do
|
||||
if #cues > reported then
|
||||
for i = reported + 1, #cues do
|
||||
U.log("cue", cues[i], "frame", U.frame())
|
||||
end
|
||||
reported = #cues
|
||||
end
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,211 @@
|
||||
-- Look at the boxed menus whose rows moved down one tile (#564 start menu,
|
||||
-- #572 the PC and the Pokédex entry window) and watch QUIT close the whole
|
||||
-- Pokédex (#571). Row geometry comes from pokered draw_start_menu.asm
|
||||
-- (hlcoord 12,2 inside a TextBoxBorder at 10,0) and players_pc.asm
|
||||
-- (hlcoord 2,2 inside a 16x10 box); the exits come from pokedex.asm
|
||||
-- HandlePokedexSideMenu / .exitPokedex. Screenshots land in $SHOT_DIR.
|
||||
-- POKEPORT_DRIVER=tests/drivers/menu_windows_bug564_test.lua POKEPORT_IDENTITY=bug564 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- The bedroom PC is hidden_event 0, 1, OpenRedsPC, SPRITE_FACING_UP
|
||||
-- (pokered data/events/hidden_objects.asm), so the only approach is the
|
||||
-- floor tile below it.
|
||||
local MAP = "REDS_HOUSE_2F"
|
||||
local STAND = { x = 0, y = 2, facing = "up" }
|
||||
local DEX_SPECIES = "BULBASAUR" -- dex #1, so it is the list's first row
|
||||
local BAG_ITEM = "POTION"
|
||||
|
||||
-- a save the start menu will draw all seven rows for: dex flag on,
|
||||
-- something owned so the dex list has a real entry, a party for LINK,
|
||||
-- and one bag item so ITEM opens onto a row that has a USE/TOSS box
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_POKEDEX = true
|
||||
game.save.pokedex = game.save.pokedex or { seen = {}, owned = {} }
|
||||
game.save.pokedex.seen[DEX_SPECIES] = true
|
||||
game.save.pokedex.owned[DEX_SPECIES] = true
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, DEX_SPECIES, 12),
|
||||
Pokemon.new(game.data, "PIKACHU", 10),
|
||||
}
|
||||
Bag.add(game.save, BAG_ITEM, 3)
|
||||
|
||||
check("dex entry for " .. DEX_SPECIES .. " exists in the loaded data",
|
||||
game.data.pokemon[DEX_SPECIES] ~= nil)
|
||||
check(BAG_ITEM .. " resolves as an item",
|
||||
game.data.items[BAG_ITEM] ~= nil)
|
||||
|
||||
-- pcTiles is what OverworldController checks before it opens the PC; an
|
||||
-- empty list here and a mis-aimed player look identical from outside
|
||||
local extras = game.data.field and game.data.field.hiddenExtras
|
||||
local pcTile = extras and (extras.pcTiles[MAP] or {})[1]
|
||||
check("the bedroom PC tile is in the field data", pcTile ~= nil)
|
||||
if pcTile then
|
||||
U.log("PC tile at", pcTile.x, pcTile.y, "facing", pcTile.facing)
|
||||
-- a map or field-data edit moves the tile: stand under it wherever it is
|
||||
STAND = { x = pcTile.x, y = pcTile.y + 1, facing = "up" }
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
-- fall back to any free walkable neighbour that still looks at the tile
|
||||
local sides = {
|
||||
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
|
||||
}
|
||||
for _, s in ipairs(sides) do
|
||||
local cx, cy = pcTile.x + s[1], pcTile.y + s[2]
|
||||
if ow.map:isWalkableCell(cx, cy) then
|
||||
U.log("standing on", cx, cy, "facing", s[3], "instead")
|
||||
STAND = { x = cx, y = cy, facing = s[3] }
|
||||
U.teleport(game, MAP, cx, cy, s[3])
|
||||
U.wait(10)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("player is standing at the PC",
|
||||
game.overworld ~= nil
|
||||
and game.overworld.player.cellX == STAND.x
|
||||
and game.overworld.player.cellY == STAND.y)
|
||||
|
||||
-- walk a Menu's cursor onto a row by label, with real Down presses
|
||||
local function moveTo(menu, label)
|
||||
for _ = 1, #menu.items + 1 do
|
||||
if menu.items[menu.index] and menu.items[menu.index].label == label then
|
||||
return true
|
||||
end
|
||||
U.tap(game, "down")
|
||||
U.wait(2)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- #564
|
||||
U.tap(game, "start")
|
||||
U.wait(20)
|
||||
local start = game.stack:top()
|
||||
check("START opened the start menu", start ~= nil and start.items ~= nil)
|
||||
if start and start.items then
|
||||
local labels = {}
|
||||
for i, it in ipairs(start.items) do labels[i] = it.label end
|
||||
U.log("start menu rows:", table.concat(labels, " "))
|
||||
end
|
||||
check("start menu screenshot",
|
||||
U.shot(game, SHOT_DIR .. "/bug564_start_menu.png"))
|
||||
|
||||
-- --------------------------------------------------------------- #571
|
||||
check("POKéDEX is on the start menu", moveTo(start, "POKéDEX"))
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
local dex = game.stack:top()
|
||||
check("the dex list opened", dex ~= nil and dex.screenId == "PokedexMenu")
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
local side = game.stack:top()
|
||||
local isSide = side ~= nil and side ~= dex and side.items ~= nil
|
||||
check("A on " .. DEX_SPECIES .. " opened the DATA/CRY/AREA/QUIT window",
|
||||
isSide)
|
||||
check("Pokédex entry window screenshot",
|
||||
U.shot(game, SHOT_DIR .. "/bug572_pokedex_entry_menu.png"))
|
||||
|
||||
if isSide then
|
||||
check("QUIT is on the entry window", moveTo(side, "QUIT"))
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
local top = game.stack:top()
|
||||
-- the machine-checkable half of #571: the stack has to unwind past the
|
||||
-- dex list, not just pop the side menu
|
||||
check("QUIT left the Pokédex entirely", top ~= dex and top ~= side)
|
||||
check("QUIT landed on the start menu",
|
||||
top ~= nil and top.screenId == "StartMenu")
|
||||
if top and top.items and top.items[top.index] then
|
||||
U.log("start menu cursor is on:", top.items[top.index].label)
|
||||
check("the cursor is still on POKéDEX",
|
||||
top.items[top.index].label == "POKéDEX")
|
||||
end
|
||||
check("start menu after QUIT screenshot",
|
||||
U.shot(game, SHOT_DIR .. "/bug571_after_quit.png"))
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- #572
|
||||
-- the bag's USE/TOSS box, reached from the same start menu
|
||||
local menu = game.stack:top()
|
||||
if menu and menu.items and moveTo(menu, "ITEM") then
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
U.tap(game, "a") -- A on the first bag row opens USE/TOSS
|
||||
U.wait(20)
|
||||
local useToss = game.stack:top()
|
||||
local ok = useToss and useToss.items and #useToss.items == 2
|
||||
and useToss.items[1].label == "USE"
|
||||
check("the bag's USE/TOSS box opened", ok and true or false)
|
||||
check("USE/TOSS screenshot",
|
||||
U.shot(game, SHOT_DIR .. "/bug572_bag_use_toss.png"))
|
||||
if ok then
|
||||
-- pokered data/text_boxes.asm USE_TOSS_MENU_TEMPLATE is (13,10) to
|
||||
-- (19,14) with its text at 15,11: two rows exactly fill 11 and 13,
|
||||
-- with no blank row under the top border. Mirror Menu:draw's own
|
||||
-- bottom anchor rather than re-deriving one here -- an assertion that
|
||||
-- carries its own copy of the formula passes on whatever it assumes
|
||||
-- instead of on what the menu actually draws (#564, #572).
|
||||
local n = #useToss.items
|
||||
local bottom = useToss.ty + useToss.th - 2
|
||||
local firstRow = bottom - (n - 1) * useToss.rowStep
|
||||
local lastRow = bottom
|
||||
check("TOSS is inside the box, not on its bottom border",
|
||||
lastRow <= useToss.ty + useToss.th - 2)
|
||||
check("USE and TOSS land on the pokered rows (11 and 13)",
|
||||
firstRow == 11 and lastRow == 13)
|
||||
U.log("USE/TOSS box ty=" .. useToss.ty .. " th=" .. useToss.th
|
||||
.. ", rows land on " .. firstRow
|
||||
.. " and " .. lastRow
|
||||
.. ", bottom interior row is " .. bottom)
|
||||
end
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
U.tap(game, "b")
|
||||
U.wait(20)
|
||||
end
|
||||
|
||||
-- back out to the overworld and open the bedroom PC
|
||||
for _ = 1, 4 do
|
||||
if game.stack:top() == game.overworld then break end
|
||||
U.tap(game, "b")
|
||||
U.wait(15)
|
||||
end
|
||||
check("back in the overworld", game.stack:top() == game.overworld)
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(25)
|
||||
local pc = game.stack:top()
|
||||
local isPC = pc ~= nil and pc.screenId == "PlayerPC"
|
||||
check("the bedroom PC opened", isPC)
|
||||
check("player's PC screenshot",
|
||||
U.shot(game, SHOT_DIR .. "/bug572_player_pc.png"))
|
||||
|
||||
U.log("The player's PC is open on screen; B closes it and START reopens")
|
||||
U.log("the start menu. In all of these the last choice should sit on the")
|
||||
U.log("row just above the bottom border and the blank row should be under")
|
||||
U.log("the top border, with the ▶ level with its label -- the near miss is")
|
||||
U.log("text that clears the top edge but leaves a gap at the bottom, or a")
|
||||
U.log("cursor that drifts a row off its own label. The text boxes the")
|
||||
U.log("Pokémon and item lists draw are the control: they never moved.")
|
||||
U.log("the bag's USE/TOSS box is the tight one: pokered draws it a row")
|
||||
U.log("shorter than the rest, so USE and TOSS fill 11 and 13 with no blank")
|
||||
U.log("row at all. If TOSS is sitting on the border there, that is the bug.")
|
||||
U.log("Screenshots are under " .. SHOT_DIR)
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,262 @@
|
||||
-- Manual check of Yellow's two extra pic rips: PROF.OAK's own back pic in
|
||||
-- the Pallet catch scene (#557, LoadPlayerBackPic's BATTLE_TYPE_PIKACHU
|
||||
-- branch, pokeyellow engine/battle/core.asm:6384-6391) and the framed
|
||||
-- portrait TalkToPikachu raises (#561, each PikaPicAnimScript's own base
|
||||
-- frame, data/pikachu/pikachu_pic_animation.asm). Both are gated on files
|
||||
-- the importer writes, so the asset check comes before any staging. No
|
||||
-- POKEPORT_IDENTITY: a sandbox save dir carries no Yellow cache at all.
|
||||
-- POKEPORT_DRIVER=tests/drivers/oak_pikapic_bug557_bug561_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local PROF_BACK = "assets/generated/battle/profoakb.png"
|
||||
local OLDMAN_BACK = "assets/generated/battle/oldmanb.png"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function idle()
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
|
||||
GameVersion.isYellow()) then
|
||||
U.log("Neither pic exists in Red or Blue. Re-run with")
|
||||
U.log("POKEPORT_VERSION=yellow.")
|
||||
idle()
|
||||
end
|
||||
|
||||
-- ---- the two rips, before anything is staged ----
|
||||
-- The importer writes these only when the manifest carries the symbol, and
|
||||
-- both call sites fall back silently when the file is absent, so a missing
|
||||
-- file looks on screen exactly like the bug being unfixed.
|
||||
local oakPicOk = love.filesystem.getInfo(PROF_BACK) ~= nil
|
||||
check("Oak's back pic is in the cache (" .. PROF_BACK .. ")", oakPicOk)
|
||||
if not oakPicOk then
|
||||
U.log("The importer skipped it: tools/rom_manifest_yellow.json has no")
|
||||
U.log("ProfOakPicBack entry, so RomExtractor's gate never fires and")
|
||||
U.log("Sprites.playerPath falls back to " .. OLDMAN_BACK .. ".")
|
||||
U.log("Re-importing will not help until the symbol is in the manifest.")
|
||||
end
|
||||
|
||||
local pikapicFound = {}
|
||||
for script = 1, 28 do
|
||||
local path = "assets/generated/pikachu/pikapic_" .. script .. ".png"
|
||||
if love.filesystem.getInfo(path) then
|
||||
pikapicFound[#pikapicFound + 1] = script
|
||||
end
|
||||
end
|
||||
local pikapicOk = #pikapicFound > 0
|
||||
check("the pikapic base frames are in the cache (assets/generated/pikachu/)",
|
||||
pikapicOk)
|
||||
if pikapicOk then
|
||||
U.log("found base frames for scripts:", table.concat(pikapicFound, " "))
|
||||
else
|
||||
U.log("The importer skipped all 28: the Pic_e4000..Pic_f0cf4 labels are")
|
||||
U.log("absent from tools/rom_manifest_yellow.json, so PikachuFollower")
|
||||
U.log("keeps standing the battle front pic in the frame, which is #561")
|
||||
U.log("unchanged. Not staging the talk: it cannot look right yet.")
|
||||
end
|
||||
U.log("tests/engine/yellow_oak_back_pikapic.lua asserts both symbol lists.")
|
||||
|
||||
-- ---- #557: the Pallet catch scene ----
|
||||
-- Same two calls data/scripts/story2.lua:255 makes once Oak has said
|
||||
-- "That was close!" Standing in Pallet Town first so the battle draws
|
||||
-- over a real map, as it does in play.
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "up")
|
||||
U.wait(10)
|
||||
game.save.player.name = "bryan"
|
||||
|
||||
local backPath = Sprites.playerPath(game.data, "back",
|
||||
{ kind = "battle", demo = true, oakDemo = true })
|
||||
U.log("the demo resolves its back pic to:", tostring(backPath))
|
||||
check("it is Oak's pic, not the old man's", backPath == PROF_BACK)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
||||
battle:makeOldManDemo("PROF.OAK")
|
||||
check("the thrower is named PROF.OAK", battle.demoName == "PROF.OAK")
|
||||
check("the battle asks for the BATTLE_TYPE_PIKACHU back pic",
|
||||
battle.oakDemo == true)
|
||||
|
||||
-- record what the scripted throw actually prints, so the name half can be
|
||||
-- judged without reading glyph codes off the canvas
|
||||
local said = {}
|
||||
local realSay = battle.say
|
||||
battle.say = function(self, text)
|
||||
said[#said + 1] = tostring(text)
|
||||
return realSay(self, text)
|
||||
end
|
||||
|
||||
game.stack:push(battle)
|
||||
U.wait(10)
|
||||
|
||||
-- clear the "Wild PIKACHU appeared!" box; the scripted cursor script only
|
||||
-- starts once the intro chrome is gone
|
||||
for _ = 1, 90 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
if not check("the demo battle reached its scripted menu",
|
||||
battle.phase == "menu") then
|
||||
U.log("phase is", tostring(battle.phase), "- the intro never cleared.")
|
||||
idle()
|
||||
end
|
||||
U.wait(20)
|
||||
if U.shot(game, SHOT_DIR .. "/bug557_back_pic.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug557_back_pic.png")
|
||||
end
|
||||
|
||||
-- The other half of the issue title, "referred to as Pikachu".
|
||||
-- DisplayBattleMenu's simulated branch puts BATTLE_MENU_TEMPLATE on screen
|
||||
-- and nothing else (data/text_boxes.asm:31 -- FIGHT/PKMN/ITEM/RUN from
|
||||
-- column 8), so no name is printed at all: there is no player mon in this
|
||||
-- battle to name. The classic layout ports that branch; WideBattle's menu
|
||||
-- has no demo case and prints "What will <battle.player.name> do?" over the
|
||||
-- hidden placeholder battler, which is a level 5 PIKACHU.
|
||||
local options = game.save.options or {}
|
||||
U.log("battle layout option:", tostring(options.battleLayout or "classic"))
|
||||
local wide = battle:isWideBattleLayout()
|
||||
check("the layout on screen leaves the scripted menu unnamed", not wide)
|
||||
if wide then
|
||||
U.log("The wide layout is on, so the menu reads \"What will PIKACHU do?\"")
|
||||
U.log("beside Oak's throw. Switch OPTIONS to the classic battle layout")
|
||||
U.log("and re-run to see the box the original draws.")
|
||||
end
|
||||
|
||||
-- the rest runs itself: 130 frames of cursor, the forced ITEM list, then
|
||||
-- the throw. Never tap through it -- the timing is the port of
|
||||
-- DisplayBattleMenu's simulated keystrokes.
|
||||
local function thrownLine()
|
||||
for _, text in ipairs(said) do
|
||||
if text:find("POK", 1, true) and text:find("PROF.OAK", 1, true) then
|
||||
return text
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
for _ = 1, 600 do
|
||||
if thrownLine() then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local line = thrownLine()
|
||||
check("the throw is announced under PROF.OAK's name", line ~= nil)
|
||||
if line then U.log("the box reads:", (line:gsub("\n", " / "))) end
|
||||
U.wait(45)
|
||||
if U.shot(game, SHOT_DIR .. "/bug557_throw.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug557_throw.png")
|
||||
end
|
||||
|
||||
if oakPicOk then
|
||||
U.log("Both #557 shots are taken. The back pic in the lower left is the")
|
||||
U.log("man throwing the ball: Oak in his lab coat, and the throw line")
|
||||
U.log("names PROF.OAK. The near miss is a back pic that reads as a")
|
||||
U.log("person and looks plausible while still being oldmanb.png --")
|
||||
U.log("compare against the catch tutorial north of Viridian, which")
|
||||
U.log("SHOULD be the old man and proves the demo path works at all.")
|
||||
else
|
||||
U.log("The shots are evidence of the FAIL, not of a fix: the back pic in")
|
||||
U.log("the lower left is the bald OLD MAN from the Route 5 tutorial,")
|
||||
U.log("exactly as #557 reported him. The throw line above it does name")
|
||||
U.log("PROF.OAK, so the thrower half is right and only the pic is stuck.")
|
||||
end
|
||||
|
||||
-- ---- #561: the framed portrait ----
|
||||
if not pikapicOk then
|
||||
U.log("Skipping the Pikachu portrait: see the FAIL above.")
|
||||
idle()
|
||||
end
|
||||
|
||||
for _ = 1, 300 do
|
||||
if game.stack:top() ~= battle then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(20)
|
||||
|
||||
-- ShouldPikachuSpawn (pokeyellow engine/pikachu/pikachu_follow.asm): the
|
||||
-- lab gift happened and a healthy Pikachu leads the party. Pallet's
|
||||
-- objects sit at (10,4), (3,8) and (11,14), so (10,8) and the road cells
|
||||
-- around it are clear.
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) }
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(12)
|
||||
|
||||
local ow = game.overworld
|
||||
local function follower()
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.pikachuFollower then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local npc = follower()
|
||||
if not check("the follower spawned", npc ~= nil) then idle() end
|
||||
|
||||
-- one step then a turn back, so the follower is on the cell just vacated
|
||||
local DIRS = { { "down", 0, 1 }, { "up", 0, -1 },
|
||||
{ "left", -1, 0 }, { "right", 1, 0 } }
|
||||
local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local stepDir
|
||||
for _, d in ipairs(DIRS) do
|
||||
local cx, cy = ow.player.cellX + d[2], ow.player.cellY + d[3]
|
||||
if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy)
|
||||
and not ow:npcAtCell(cx, cy) then
|
||||
stepDir = d[1]
|
||||
break
|
||||
end
|
||||
end
|
||||
if not check("a walkable neighbour to step into exists", stepDir ~= nil) then
|
||||
idle()
|
||||
end
|
||||
U.hold(game, stepDir, 24)
|
||||
U.wait(6)
|
||||
U.tap(game, OPPOSITE[stepDir])
|
||||
U.wait(6)
|
||||
|
||||
local function facingFollower()
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == npc
|
||||
end
|
||||
if not facingFollower() then
|
||||
for _, d in ipairs(DIRS) do
|
||||
if npc.cellX == ow.player.cellX + d[2]
|
||||
and npc.cellY == ow.player.cellY + d[3] then
|
||||
U.tap(game, d[1])
|
||||
U.wait(6)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("player is facing the follower", facingFollower())
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
local emote = ow.emote
|
||||
if not check("the A press raised a portrait", emote ~= nil and emote.pikaPic) then
|
||||
U.log("Nothing answered the press, which is #407 territory, not #561.")
|
||||
idle()
|
||||
end
|
||||
U.log("the frame is drawing:", tostring(emote.pikaPic))
|
||||
check("it is this script's own base frame, not the battle front pic",
|
||||
emote.pikaPic:find("pikachu/pikapic_", 1, true) ~= nil)
|
||||
if U.shot(game, SHOT_DIR .. "/bug561_portrait.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug561_portrait.png")
|
||||
end
|
||||
|
||||
U.log("The portrait box is up over the map now. Inside the 5x5 frame is a")
|
||||
U.log("posed Pikachu drawn for that emotion, not the battle front pic")
|
||||
U.log("standing to attention -- that stand-in is the screenshot on #561.")
|
||||
U.log("Face it and press A again for another emotion; the pose should")
|
||||
U.log("change with it. A frame that never changes between emotions is the")
|
||||
U.log("near miss: the path resolved but every script picked one base.")
|
||||
|
||||
idle()
|
||||
end
|
||||
@@ -2414,14 +2414,15 @@ local function sellItem(id)
|
||||
return sold
|
||||
end
|
||||
|
||||
-- Free up bag slots so `needed` NEW item kinds fit (Bag.CAPACITY is 20
|
||||
-- slots; a stack of an item already held costs nothing). This is why
|
||||
-- Free up bag slots so `needed` NEW item kinds fit (20 slots without a
|
||||
-- capacity mod; a stack of an item already held costs nothing). This is why
|
||||
-- every restock had been reporting "HYPER_POTION x0": the buy list
|
||||
-- opened, the quantity was set, the engine said "no room" -- and the run
|
||||
-- walked into the Mansion with FULL_HEALs but not one HP restore.
|
||||
local function freeBagSlots(needed, where)
|
||||
local used = #(G.save.bagOrder or {})
|
||||
local free = 20 - used
|
||||
local capacity = require("src.inventory.Bag").capacity(G.data)
|
||||
local free = capacity - used
|
||||
for _, id in ipairs(SELLABLE_JUNK) do
|
||||
if free >= needed then break end
|
||||
if ((G.save.inventory or {})[id] or 0) > 0 then
|
||||
@@ -2632,7 +2633,8 @@ function ops.shop(s, where)
|
||||
end
|
||||
end
|
||||
local used = #(G.save.bagOrder or {})
|
||||
if newKinds > 0 and 20 - used < newKinds then
|
||||
local capacity = require("src.inventory.Bag").capacity(G.data)
|
||||
if newKinds > 0 and capacity - used < newKinds then
|
||||
freeBagSlots(newKinds, where)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
-- Soft reset from inside a battle (#563): A+B+SELECT+START held together
|
||||
-- drops back to the title, the way _Joypad's `cp PAD_BUTTONS` (pokered
|
||||
-- engine/joypad.asm:6) reads the raw pad ahead of the wJoyIgnore masking
|
||||
-- that a battle turn leans on. Counting and touch-overlay halves are
|
||||
-- asserted in tests/engine/soft_reset_bug563.lua; this is the ear and eye
|
||||
-- half -- music cutting to the title theme, unsaved progress gone.
|
||||
-- POKEPORT_DRIVER=tests/drivers/soft_reset_bug563_test.lua POKEPORT_IDENTITY=bug563 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots 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 BattleState = require("src.battle.BattleState")
|
||||
local Input = require("src.core.Input")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local CHORD = { "a", "b", "start", "select" }
|
||||
|
||||
-- The driver presses GB buttons by name, so it proves the chord is read
|
||||
-- but says nothing about which keys reach it. Print the live map for the
|
||||
-- hand-off instead of hard-coding key names into the notes.
|
||||
local function keysFor(btn)
|
||||
local keys = {}
|
||||
for key, action in pairs(Input.keyBindings or {}) do
|
||||
if action == btn then keys[#keys + 1] = key end
|
||||
end
|
||||
table.sort(keys)
|
||||
return table.concat(keys, " / ")
|
||||
end
|
||||
|
||||
U.log("#563 soft reset: machine checks")
|
||||
check("Input:softResetHeld exists", type(Input.softResetHeld) == "function")
|
||||
check("Input:softResetStep exists", type(Input.softResetStep) == "function")
|
||||
for _, btn in ipairs(CHORD) do
|
||||
local keys = keysFor(btn)
|
||||
check(btn:upper() .. " has a key bound (" .. keys .. ")", keys ~= "")
|
||||
end
|
||||
|
||||
local musicVol = game.save.options and game.save.options.musicVol
|
||||
if musicVol == 0 then
|
||||
U.log("musicVol is 0: the battle music cutting to the title theme is the")
|
||||
U.log("main thing to listen for, so raise it in OPTION before judging")
|
||||
else
|
||||
U.log("musicVol", tostring(musicVol),
|
||||
"-- the battle theme should stop dead and the title theme start")
|
||||
end
|
||||
|
||||
-- ---- a battle to reset out of -----------------------------------------
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "SQUIRTLE", 30),
|
||||
Pokemon.new(game.data, "PIDGEOTTO", 28),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- data/generated/maps.lua ROUTE_1: (5, 5) is open walkable ground. The
|
||||
-- battle is pushed straight in rather than encountered, so the cell only
|
||||
-- has to be somewhere the player can legally stand.
|
||||
local START = { x = 5, y = 5 }
|
||||
U.teleport(game, "ROUTE_1", START.x, START.y, "down")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(START.x, START.y) then
|
||||
-- a map edit moved the path: take the nearest walkable cell instead
|
||||
local sx, sy
|
||||
for r = 1, 6 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
if not sx and ow.map:isWalkableCell(START.x + dx, START.y + dy) then
|
||||
sx, sy = START.x + dx, START.y + dy
|
||||
end
|
||||
end
|
||||
end
|
||||
if sx then break end
|
||||
end
|
||||
if sx then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(START.x, START.y), sx, sy)
|
||||
U.teleport(game, "ROUTE_1", sx, sy, "down")
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
check("the overworld is up on ROUTE_1", ow ~= nil)
|
||||
|
||||
-- something to reset away from: a fight in progress and 2 unsaved mon
|
||||
local function enterBattle()
|
||||
local battle = BattleState.newWild(game, "RATTATA", 12)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
return battle
|
||||
end
|
||||
|
||||
local battle = enterBattle()
|
||||
check("the battle reached the screen", game.stack:top() == battle)
|
||||
check("battle screenshot reached disk",
|
||||
U.shot(game, DIR .. "/bug563_battle.png"))
|
||||
|
||||
-- ---- hold the chord ----------------------------------------------------
|
||||
|
||||
-- A real hold is one press edge and then sustained state, so queue the
|
||||
-- edge once and only keep `state` alive after that. Re-queueing every
|
||||
-- frame would feed the battle 40 A presses and run the fight away before
|
||||
-- the countdown ever finished. Stops the moment the title appears: the
|
||||
-- driver has no key-up, so a hold left running past the reset would press
|
||||
-- A on the title's first frame and start a new game by itself.
|
||||
local function holdChord(frames, extra)
|
||||
local buttons = { unpack(CHORD) }
|
||||
if extra then buttons[#buttons + 1] = extra end
|
||||
for frame = 1, frames do
|
||||
for _, btn in ipairs(buttons) do
|
||||
if frame == 1 then table.insert(game.input.pressQueue, btn) end
|
||||
game.input.state[btn] = true
|
||||
end
|
||||
coroutine.yield()
|
||||
local top = game.stack:top()
|
||||
if top and top.screenId == "TitleState" then break end
|
||||
end
|
||||
for _, btn in ipairs(buttons) do game.input.state[btn] = false end
|
||||
end
|
||||
|
||||
-- Control case first: `cp PAD_BUTTONS` is an equality, so the same four
|
||||
-- buttons plus a direction is not the combo however long it is held. If
|
||||
-- this one resets, the port is masking the read instead of comparing it.
|
||||
holdChord(40, "up")
|
||||
U.wait(5)
|
||||
check("four buttons plus UP for 40 frames does NOT reset",
|
||||
game.stack:top() == battle)
|
||||
|
||||
-- 16 polls is the countdown Init seeds hSoftReset with (home/init.asm:81);
|
||||
-- 60 frames leaves room for it without hiding an off-by-one, which the
|
||||
-- engine suite pins exactly.
|
||||
holdChord(60)
|
||||
U.wait(20)
|
||||
|
||||
local top = game.stack:top()
|
||||
check("the chord dropped the battle for the title",
|
||||
top ~= nil and top.screenId == "TitleState")
|
||||
check("the battle state is gone from the stack", top ~= battle)
|
||||
check("no button is left held into the title", not game.input:isDown("a"))
|
||||
check("title screenshot reached disk", U.shot(game, DIR .. "/bug563_title.png"))
|
||||
|
||||
-- the title must not act on the still-held buttons on its own first frames
|
||||
U.wait(60)
|
||||
check("the title is still up a second later, not a menu it auto-picked",
|
||||
game.stack:top() == top)
|
||||
|
||||
-- ---- hand off inside a fresh battle ------------------------------------
|
||||
|
||||
-- Handing the pad over needs a party again. A player who did this for
|
||||
-- real would be picking CONTINUE here and getting whatever was last saved,
|
||||
-- which is the point of the reset -- this is only the driver restocking.
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "SQUIRTLE", 30),
|
||||
Pokemon.new(game.data, "PIDGEOTTO", 28),
|
||||
}
|
||||
U.teleport(game, "ROUTE_1", START.x, START.y, "down")
|
||||
U.wait(10)
|
||||
local again = enterBattle()
|
||||
check("a second battle is up for the manual attempt", game.stack:top() == again)
|
||||
|
||||
U.log("The driver already did this once: /tmp/shots/bug563_battle.png is the")
|
||||
U.log("fight it was in, bug563_title.png is where it landed. There is a")
|
||||
U.log("fresh RATTATA battle on screen now for you to repeat it by hand.")
|
||||
U.log("Hold " .. keysFor("a") .. " (A) + " .. keysFor("b") .. " (B) + "
|
||||
.. keysFor("start") .. " (START) + " .. keysFor("select") .. " (SELECT)")
|
||||
U.log("together for about a third of a second. The battle theme should cut")
|
||||
U.log("out and the title come back with its own music; CONTINUE from there")
|
||||
U.log("gives you the last save, so the fight and anything after it is gone.")
|
||||
U.log("Add any arrow key to the four and nothing should happen at all. A")
|
||||
U.log("quick tap of all four in passing must not reset either, and letting")
|
||||
U.log("one go a beat early must start the count over rather than resume it.")
|
||||
U.log("Control case if it does nothing: QUIT in the START menu runs the same")
|
||||
U.log("return-to-title, so if QUIT works the pad read is what to look at.")
|
||||
U.log("For the on-screen overlay, re-run with POKEPORT_TOUCH=1: the mouse is")
|
||||
U.log("one finger there, so the combo cannot be reached at all on desktop.")
|
||||
U.log(("checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,223 @@
|
||||
-- Manual check of the two trainer-battle text boxes, #565 and #566.
|
||||
-- #565 _TrainerAboutToUseText (data/text/text_2.asm:911-921): the SHIFT
|
||||
-- offer is one box with a `cont`, so the nick scrolls in UNDER "about to
|
||||
-- use" instead of landing alone on a fresh page.
|
||||
-- #566 TrainerEndBattleText (home/trainers.asm:381-386) prints
|
||||
-- _TrainerNameText -- the name then ": " -- ahead of the loss line.
|
||||
-- The string half is asserted in tests/engine/trainer_shift_prompt_bug565.lua
|
||||
-- and tests/engine/trainer_loss_line_bug566.lua; this is the look of it.
|
||||
-- POKEPORT_DRIVER=tests/drivers/trainer_battle_text_bug565_bug566_test.lua POKEPORT_IDENTITY=bug565 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Font = require("src.render.Font")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
|
||||
-- pokered data/maps/objects/Route3.asm: ROUTE3_YOUNGSTER2 is object 3, at
|
||||
-- (14, 4), STAY, facing DOWN, OPP_YOUNGSTER party 1 (RATTATA + EKANS). Its
|
||||
-- TrainerHeader gives sight range 3, so the cone is (14,5)..(14,7); y = 7 is
|
||||
-- the ledge row and is solid, so the walk-in is east-to-west along y = 6.
|
||||
local MAP, MAP_LABEL = "ROUTE_3", "Route3"
|
||||
local NPC_NAME = "ROUTE3_YOUNGSTER2"
|
||||
local OBJ_INDEX = 3
|
||||
local START = { x = 16, y = 6, facing = "left" }
|
||||
|
||||
local fails = 0
|
||||
local function check(label, ok)
|
||||
if not ok then fails = fails + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Everything the two boxes are assembled from. A renamed text label, an
|
||||
-- opponent whose party shrank to one mon or a species constant that no
|
||||
-- longer resolves all show up on screen as exactly the silence the bugs
|
||||
-- did, so name them here before anyone squints at a screenshot.
|
||||
local header = game.data:trainerHeader(MAP_LABEL, OBJ_INDEX)
|
||||
check("Route3 object " .. OBJ_INDEX .. " has a trainer header", header ~= nil)
|
||||
local lossText = header and header.won and game.data.text[header.won]
|
||||
check("its EndBattleText resolves to a string",
|
||||
type(lossText) == "string" and lossText ~= "")
|
||||
|
||||
local trainer = game.data.trainers.OPP_YOUNGSTER
|
||||
check("OPP_YOUNGSTER is a known trainer class", trainer ~= nil)
|
||||
local party = trainer and trainer.parties[1]
|
||||
check("its party 1 has a reserve to send out (SHIFT needs one)",
|
||||
party ~= nil and #party >= 2)
|
||||
local secondName
|
||||
if party and party[2] then
|
||||
local species = game.data.pokemon[party[2].species]
|
||||
secondName = species and species.name
|
||||
end
|
||||
check("the reserve's species resolves to a name", secondName ~= nil)
|
||||
|
||||
local TAG = trainer and trainer.name
|
||||
local firstLine = TAG and lossText
|
||||
and (TAG .. ": " .. lossText:match("^[^\n\f\v]*"))
|
||||
if firstLine then
|
||||
-- the tag is prose the box has to hold: 18 columns, TextBox MAX_COLS
|
||||
local spans = Font.split(firstLine)
|
||||
check("the tagged first line fits the 18-column box (" .. #spans .. " cols)",
|
||||
Font.spansFitting(spans, 18 * 8) >= #spans)
|
||||
U.log("loss box should read:",
|
||||
((TAG .. ": " .. lossText):gsub("[\n\v]", " / "):gsub("\f", " || ")))
|
||||
end
|
||||
|
||||
-- SHIFT only offers the switch when the player holds more than one party
|
||||
-- slot and the active mon is alive (core.asm:1366-1443)
|
||||
game.save.player.name = "RED"
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 40),
|
||||
Pokemon.new(game.data, "BLASTOISE", 40),
|
||||
}
|
||||
game.save.party[1].moves[1] = { id = "SLASH", pp = 20 }
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.battleStyle = "shift"
|
||||
check("two party slots and SHIFT style are set",
|
||||
#game.save.party > 1 and game.save.options.battleStyle == "shift")
|
||||
check("the window is open and drawing",
|
||||
love.window and love.window.isOpen and love.window.isOpen())
|
||||
|
||||
if fails > 0 then
|
||||
U.log(fails .. " preflight check(s) failed; the boxes below cannot be trusted.")
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, START.x, START.y, START.facing)
|
||||
U.wait(15)
|
||||
|
||||
local function findNpc()
|
||||
for _, n in ipairs((game.overworld and game.overworld.npcs) or {}) do
|
||||
if n.def and n.def.name == NPC_NAME then return n end
|
||||
end
|
||||
end
|
||||
local npc = findNpc()
|
||||
check("the YOUNGSTER is on the map", npc ~= nil)
|
||||
|
||||
-- A map edit that moves him invalidates the hand-derived walk-in, so fall
|
||||
-- back to standing next to wherever he ended up and talking: engageTrainer
|
||||
-- is the same call the line-of-sight approach makes.
|
||||
local talkedTo = false
|
||||
if npc and (npc.cellX ~= 14 or npc.cellY ~= 4) then
|
||||
local ow = game.overworld
|
||||
-- {dx, dy, facing to look back at him}
|
||||
for _, side in ipairs({ { 0, 1, "up" }, { 0, -1, "down" },
|
||||
{ 1, 0, "left" }, { -1, 0, "right" } }) do
|
||||
local cx, cy = npc.cellX + side[1], npc.cellY + side[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("he has moved to", npc.cellX, npc.cellY, "- standing at", cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, side[3])
|
||||
U.wait(10)
|
||||
talkedTo = true
|
||||
U.tap(game, "a")
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function battleNow()
|
||||
local top = game.stack:top()
|
||||
if type(top) == "table" and top.kind == "trainer" then return top end
|
||||
end
|
||||
|
||||
-- walk west into the sight cone; two cells, plus slack for the "!" bubble
|
||||
if not talkedTo then
|
||||
for _ = 1, 6 do
|
||||
if game.overworld and game.overworld.engaging then break end
|
||||
U.hold(game, "left", 18)
|
||||
U.wait(4)
|
||||
end
|
||||
end
|
||||
check("the trainer engaged",
|
||||
(game.overworld and game.overworld.engaging) or battleNow() ~= nil
|
||||
or talkedTo)
|
||||
|
||||
-- Play it out. Nothing here is fast-forwarded: the taps are A presses at
|
||||
-- the same cadence a player makes them, and the loop stops on the row
|
||||
-- under test before it can press the box away.
|
||||
local function currentRow()
|
||||
local b = battleNow()
|
||||
local row = b and b.current
|
||||
return b, row and row.text
|
||||
end
|
||||
local function pump(pred, maxFrames)
|
||||
local cool = 0
|
||||
for _ = 1, maxFrames or 4000 do
|
||||
if pred() then return true end
|
||||
if cool > 0 then
|
||||
cool = cool - 1
|
||||
U.wait(1)
|
||||
else
|
||||
U.tap(game, "a")
|
||||
local b = battleNow()
|
||||
cool = (b and (b.phase == "menu" or b.phase == "moveSelect")) and 8 or 5
|
||||
end
|
||||
end
|
||||
return pred()
|
||||
end
|
||||
|
||||
local ok = pump(function()
|
||||
local _, text = currentRow()
|
||||
return text ~= nil and text:find("about to use", 1, true) ~= nil
|
||||
end)
|
||||
check("the SHIFT offer box came up after the first KO", ok)
|
||||
|
||||
-- Hold at the `cont`: the box has typed "X is" / "about to use" and is
|
||||
-- blinking the arrow, waiting for A before it scrolls (ContText).
|
||||
pump(function()
|
||||
local b = currentRow()
|
||||
return b and b.msgWaiting
|
||||
end, 400)
|
||||
U.shot(game, SHOT_DIR .. "/bug565_1_cont_wait.png")
|
||||
local b = battleNow()
|
||||
if b and b.current then
|
||||
U.log("offer box holds:", (b.current.text:gsub("\n", " / "):gsub("\v", " CONT ")))
|
||||
end
|
||||
U.tap(game, "a")
|
||||
|
||||
-- and after the scroll: "about to use" on top, the nick beneath it
|
||||
pump(function()
|
||||
local bb = battleNow()
|
||||
return bb and (bb.charIndex or 0) >= (bb.total or 1) and not bb.msgWaiting
|
||||
end, 400)
|
||||
U.shot(game, SHOT_DIR .. "/bug565_2_scrolled.png")
|
||||
|
||||
-- decline the switch (B is NO, ChoiceBox) and finish the fight
|
||||
pump(function() return getmetatable(game.stack:top()) == ChoiceBox end, 600)
|
||||
U.wait(10)
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
|
||||
local tagged = TAG and (TAG .. ": ") or "YOUNGSTER: "
|
||||
ok = pump(function()
|
||||
local _, text = currentRow()
|
||||
return text ~= nil and text:sub(1, #tagged) == tagged
|
||||
end)
|
||||
check("the loss line came up tagged with the trainer class", ok)
|
||||
pump(function()
|
||||
local bb = battleNow()
|
||||
return bb and bb.msgPrompt
|
||||
end, 400)
|
||||
U.shot(game, SHOT_DIR .. "/bug566_loss_line.png")
|
||||
local lb = battleNow()
|
||||
if lb and lb.current then
|
||||
U.log("loss box holds:", (lb.current.text:gsub("\n", " / ")))
|
||||
end
|
||||
|
||||
U.log("Shots are in " .. SHOT_DIR .. ".")
|
||||
U.log("bug565_1 should read \"YOUNGSTER is\" / \"about to use\" with the")
|
||||
U.log("blinking arrow; bug565_2 the same box after the scroll, \"about to")
|
||||
U.log("use\" over \"EKANS!\". The near miss is a box whose two lines are")
|
||||
U.log("\"about to use\" and nothing, or a page holding only \"EKANS!\".")
|
||||
U.log("bug566 should read \"YOUNGSTER: I don't\" / \"believe it!\" -- the")
|
||||
U.log("colon and the class, not a bare \"I don't believe it!\".")
|
||||
U.log("The prize line follows it; the tag never repeats on that one.")
|
||||
U.log("A is yours now: press through the money line back to the route.")
|
||||
U.log("ROUTE3_YOUNGSTER1 at (10,6) has three mons, so the SHIFT box comes")
|
||||
U.log("up twice. ROUTE3_YOUNGSTER4 at (22,9) is the control: one mon, so")
|
||||
U.log("he must show the tagged loss line and no SHIFT offer at all.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,257 @@
|
||||
-- A tap on the d-pad turns the player in place; only a held direction steps
|
||||
-- (#415). home/overworld.asm .handleDirectionButtonPress only reaches the
|
||||
-- turn while wCheckFor180DegreeTurn is set, and .noDirectionButtonsPressed
|
||||
-- (the poll that finds nothing held) is the one place that sets it, so the
|
||||
-- delay is paid once per press and never at a corner. This scripts the
|
||||
-- window it can measure and then hands the pad over, because no scripted
|
||||
-- press has a human thumb's length.
|
||||
-- POKEPORT_DRIVER=tests/drivers/turn_in_place_bug415_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug415 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- pokered data/maps/objects/PalletTown.asm: PALLETTOWN_GIRL is the one
|
||||
-- WALK/ANY_DIR object on the town's open west side, at cell (3, 8) with
|
||||
-- floor on three sides -- the ordinary "someone standing next to you"
|
||||
-- shape the report is about. object_event coords are already walk-grid
|
||||
-- cells (src/world/NPC.lua), so they carry over unchanged.
|
||||
local MAP = "PALLET_TOWN"
|
||||
local MAP_LABEL = "PalletTown"
|
||||
local NPC_NAME = "PALLETTOWN_GIRL"
|
||||
local NPC_TEXT = "TEXT_PALLETTOWN_GIRL"
|
||||
local GIRL = { x = 3, y = 8 }
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
-- Held-direction primitives that never lift the d-pad. U.hold releases at
|
||||
-- the end of every call, which is exactly what the corner case below must
|
||||
-- not do: a release re-arms the turn and hides the thing being measured.
|
||||
local function press(btn)
|
||||
table.insert(game.input.pressQueue, btn)
|
||||
game.input.state[btn] = true
|
||||
end
|
||||
local function release(btn)
|
||||
game.input.state[btn] = false
|
||||
end
|
||||
|
||||
-- an unresolved text label, a renamed object and a press that never landed
|
||||
-- all look like the same nothing-happened the bug looked like
|
||||
local girlText = game.data:resolveText(MAP_LABEL, NPC_TEXT)
|
||||
check(NPC_TEXT .. " resolves to a string",
|
||||
type(girlText) == "string" and girlText ~= "")
|
||||
|
||||
U.teleport(game, MAP, GIRL.x, GIRL.y - 1, "up")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
check("Pallet Town is loaded", ow ~= nil and ow.map.id == MAP)
|
||||
|
||||
local function girlIn(state)
|
||||
for _, n in ipairs(state.npcs or {}) do
|
||||
if n.def and n.def.name == NPC_NAME then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- She is a WALK object, so pin her before deriving anything from her cell:
|
||||
-- NPC:update returns early on frozen, and snapping the pixels back undoes
|
||||
-- any step already in flight when the driver got here.
|
||||
local function pin(state)
|
||||
local npc = girlIn(state)
|
||||
if npc then
|
||||
npc.frozen = true
|
||||
npc.moving = false
|
||||
npc.cellX, npc.cellY = GIRL.x, GIRL.y
|
||||
npc.px, npc.py = npc.cellX * 16, npc.cellY * 16
|
||||
npc.facing = "down"
|
||||
end
|
||||
return npc
|
||||
end
|
||||
|
||||
local girl = pin(ow)
|
||||
check("the girl object loaded and is pinned at (3, 8)",
|
||||
girl ~= nil and girl.cellX == GIRL.x and girl.cellY == GIRL.y)
|
||||
|
||||
-- Stand on her north side facing away, so the first tap has to turn. If a
|
||||
-- map edit takes that cell, fall back to any free walkable neighbour and
|
||||
-- face away from her there; {dx, dy, away, toward} is the offset from her
|
||||
-- to the stand cell plus the two directions that matter from it.
|
||||
local stand = { x = GIRL.x, y = GIRL.y - 1, away = "up", toward = "down" }
|
||||
local function free(cx, cy)
|
||||
return ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy)
|
||||
end
|
||||
if girl and not free(stand.x, stand.y) then
|
||||
for _, s in ipairs({ { 0, 1, "down", "up" }, { -1, 0, "left", "right" },
|
||||
{ 1, 0, "right", "left" } }) do
|
||||
local cx, cy = girl.cellX + s[1], girl.cellY + s[2]
|
||||
if free(cx, cy) then
|
||||
U.log(("(%d, %d) is not free; standing at"):format(stand.x, stand.y),
|
||||
cx, cy, "facing", s[3])
|
||||
stand = { x = cx, y = cy, away = s[3], toward = s[4] }
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
U.teleport(game, MAP, stand.x, stand.y, stand.away)
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
pin(ow)
|
||||
local p = ow.player
|
||||
check("standing beside the girl, facing away from her",
|
||||
p.cellX == stand.x and p.cellY == stand.y and p.facing == stand.away)
|
||||
|
||||
-- What the fix moved, read off the running game rather than repeated from
|
||||
-- a comment: turnFrames is the dataset constant a mod can restamp
|
||||
-- (src/world/FieldDefaults.lua) and the touch figure comes back out of
|
||||
-- Player:turnWindow with a live overlay source. turnWindow is the fix's
|
||||
-- own entry point, so a build without it falls back to the one window it
|
||||
-- had for every source and the two checks below say so.
|
||||
local function window()
|
||||
return p.turnWindow and p:turnWindow() or p.turnFrames
|
||||
end
|
||||
local padWindow = window()
|
||||
game.input:overlayPressed(p.facing)
|
||||
game.input:step()
|
||||
local touchWindow = window()
|
||||
game.input:overlayReleased(p.facing)
|
||||
game.input:step()
|
||||
check("the pad turn window is 4 fixed steps, up from 2", padWindow == 4)
|
||||
check("the on-screen d-pad gets 8", touchWindow == 8)
|
||||
U.log(("turn window: %d fixed steps on keyboard and pad (%d ms), %d on the ")
|
||||
:format(padWindow, math.floor(padWindow * 1000 / 60), touchWindow)
|
||||
.. ("on-screen d-pad (%d ms); hold past it and the step commits.")
|
||||
:format(math.floor(touchWindow * 1000 / 60)))
|
||||
|
||||
-- A press no longer than the window turns and stops there.
|
||||
for _ = 1, padWindow do
|
||||
press(stand.toward)
|
||||
coroutine.yield()
|
||||
end
|
||||
release(stand.toward)
|
||||
U.wait(6)
|
||||
check(("a %d-step press turned toward the girl"):format(padWindow),
|
||||
p.facing == stand.toward)
|
||||
check("and left the feet on the same tile",
|
||||
p.cellX == stand.x and p.cellY == stand.y)
|
||||
|
||||
-- The same press held well past the window does step. Sideways, because
|
||||
-- the girl blocks the step toward her and would mask it.
|
||||
local openDir, openBack
|
||||
for _, s in ipairs({ { "left", "right", -1, 0 }, { "right", "left", 1, 0 },
|
||||
{ "up", "down", 0, -1 }, { "down", "up", 0, 1 } }) do
|
||||
if s[1] ~= stand.toward and free(stand.x + s[3], stand.y + s[4]) then
|
||||
openDir, openBack = s[1], s[2]
|
||||
break
|
||||
end
|
||||
end
|
||||
if openDir then
|
||||
U.hold(game, openDir, 40)
|
||||
U.wait(10)
|
||||
check(("a 40-step hold of %s walks off the tile"):format(openDir),
|
||||
p.cellX ~= stand.x or p.cellY ~= stand.y)
|
||||
|
||||
-- The corner: swap direction without ever lifting the d-pad. Walk back
|
||||
-- on openBack first and break on the landing poll, so the pad is still
|
||||
-- down and the turn is still spent when the swap arrives.
|
||||
local walked = false
|
||||
for _ = 1, 240 do
|
||||
press(openBack)
|
||||
coroutine.yield()
|
||||
if p.moving then
|
||||
walked = true
|
||||
elseif walked then
|
||||
break
|
||||
end
|
||||
end
|
||||
check(("the return hold of %s is walking"):format(openBack), walked)
|
||||
|
||||
-- Nothing has re-armed wCheckFor180DegreeTurn, so the new facing steps
|
||||
-- on the poll it arrives on with no turn delay. Pre-fix, every corner
|
||||
-- paid the window again. Perpendicular and walkable from wherever the
|
||||
-- return hold landed, so a blocked cell can't read as a stall.
|
||||
local DELTA = { up = { 0, -1 }, down = { 0, 1 },
|
||||
left = { -1, 0 }, right = { 1, 0 } }
|
||||
local swap
|
||||
for _, d in ipairs({ "up", "down", "left", "right" }) do
|
||||
local dd = DELTA[d]
|
||||
if d ~= openDir and d ~= openBack
|
||||
and free(p.cellX + dd[1], p.cellY + dd[2]) then
|
||||
swap = d
|
||||
break
|
||||
end
|
||||
end
|
||||
if swap then
|
||||
local wasFacing = p.facing
|
||||
release(openBack)
|
||||
press(swap) -- same poll, so the d-pad never reads empty
|
||||
coroutine.yield()
|
||||
U.log(("corner: swapped %s for %s without a release, facing went %s to %s")
|
||||
:format(openBack, swap, wasFacing, p.facing))
|
||||
check("the swapped direction steps on the poll it arrives on",
|
||||
p.facing == swap and p.moving and p.turnTimer == 0)
|
||||
release(swap)
|
||||
else
|
||||
U.log("no perpendicular open cell here; skipped the corner check")
|
||||
release(openBack)
|
||||
end
|
||||
for _ = 1, 200 do
|
||||
if not p.moving then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
else
|
||||
U.log("no open cell beside the stand tile; skipped the hold and corner")
|
||||
end
|
||||
|
||||
-- Back beside her, facing her, and talk: a silent A here would look exactly
|
||||
-- like a facing that missed.
|
||||
U.teleport(game, MAP, stand.x, stand.y, stand.toward)
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
p = ow.player
|
||||
girl = pin(ow)
|
||||
local fx, fy = p:facingCell()
|
||||
check("the facing cell is the girl's",
|
||||
girl ~= nil and ow:npcAtCell(fx, fy) == girl)
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
local top = game.stack:top()
|
||||
local isBox = getmetatable(top) == TextBox
|
||||
check("A opened her text box", isBox)
|
||||
if isBox then
|
||||
local shown = {}
|
||||
for _, page in ipairs(top.pages or {}) do
|
||||
for _, line in ipairs(page) do shown[#shown + 1] = line end
|
||||
end
|
||||
U.log("box reads:", table.concat(shown, " / "))
|
||||
end
|
||||
if U.shot(game, SHOT_DIR .. "/bug415_turn_in_place.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug415_turn_in_place.png",
|
||||
"so the window did render; a blank screen later is not the driver")
|
||||
end
|
||||
U.tap(game, "b")
|
||||
U.wait(20)
|
||||
|
||||
U.log("You are on the girl's north side in Pallet Town, facing her, with")
|
||||
U.log("open ground north, east and west. Tap a direction: Red should")
|
||||
U.log("pivot on the spot and stay on his tile. Hold the same direction")
|
||||
U.log("and he walks, with no extra pause before the first step. Then walk")
|
||||
U.log("a lap and change direction without letting go; corners should stay")
|
||||
U.log("smooth, and a stall there is the half of #415 that is not the tap.")
|
||||
U.log("The near miss to watch for is a tap that turns AND slides one tile,")
|
||||
U.log("which still reads as a turn unless you watch Red's feet against the")
|
||||
U.log("fence. The girl is the control: she blocks the step, so tapping")
|
||||
U.log("toward her looked fine even while this was broken. That is the")
|
||||
U.log("Yellow complaint in the report -- the follower Pikachu does not")
|
||||
U.log("block (src/world/PikachuFollower.lua), so a tap meant to turn and")
|
||||
U.log("talk to it walked onto its cell and straight past it instead.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,390 @@
|
||||
-- Manual check that BATTLE LAYOUT = WIDE draws move animations intact (#562).
|
||||
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the shake
|
||||
-- predefs displace is the whole screen; the wide composition moved only the
|
||||
-- two pic regions, which slid the monsters across a nailed-down HUD and text
|
||||
-- box. TAIL WHIP is the reported case (wAnimationType 6,
|
||||
-- AnimationShakeScreenHorizontallySlow b=3, animations.asm:526). Composition
|
||||
-- half: tests/engine/wide_battle_shake_bug562.lua. Never under POKEPORT_SPEED.
|
||||
-- POKEPORT_DRIVER=tests/drivers/wide_move_anim_bug562_test.lua POKEPORT_IDENTITY=bug562 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots 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 BattleState = require("src.battle.BattleState")
|
||||
local WideBattle = require("src.battle.WideBattle")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- One move per shake family the wide composition has to carry. `hitX` is
|
||||
-- the applying-attack amplitude PlayApplyingAttackAnimation plays after the
|
||||
-- move (AnimationTypePointerTable, animations.asm:475); `animX` is a shake
|
||||
-- the move's own subanimation runs. wAnimationType 4, 5 and 6 are the
|
||||
-- player's turn -- the vertical 1 and the wide 2 / 3 are the foe's, and the
|
||||
-- foe's rows are reported below rather than driven.
|
||||
-- TAIL WHIP type 6, AnimationShakeScreenHorizontallySlow b=3 (silent;
|
||||
-- every player status move takes this path)
|
||||
-- BUBBLEBEAM type 5, fast horizontal b=2, with the damage thud
|
||||
-- EARTHQUAKE SE_SHAKE_SCREEN inside its own animation (fastShakeProg 8),
|
||||
-- shared with FISSURE and SEISMIC TOSS
|
||||
-- WITHDRAW the control: a stat-UP effect leaves wAnimationType at 0
|
||||
-- (effects.asm, PlayCurrentMoveAnimation), so nothing moves.
|
||||
-- It also fills the fourth grid slot, which the 2x2 needs.
|
||||
local MOVES = {
|
||||
{ slot = 1, id = "TAIL_WHIP", type = 6, hitX = 3 },
|
||||
{ slot = 2, id = "BUBBLEBEAM", type = 5, hitX = 2 },
|
||||
{ slot = 3, id = "EARTHQUAKE", type = 4, animX = 8 },
|
||||
{ slot = 4, id = "WITHDRAW", type = 0, hitX = 0, hitY = 0 },
|
||||
}
|
||||
|
||||
-- ---- machine checks ----------------------------------------------------
|
||||
-- A move missing from the table, a layout that never switched and a battle
|
||||
-- that never reached WIDE all look like "the animation is fine" from a chair.
|
||||
U.log("#562 wide-layout move animations: machine checks")
|
||||
for _, m in ipairs(MOVES) do
|
||||
check(m.id .. " is in the move table", game.data.moves[m.id] ~= nil)
|
||||
end
|
||||
local anims = game.data.battle_anims and game.data.battle_anims.moveAnims
|
||||
check("battle_anims carries TAIL WHIP's own animation",
|
||||
anims ~= nil and anims.TAIL_WHIP ~= nil)
|
||||
check("...and EARTHQUAKE's, which carries SE_SHAKE_SCREEN itself",
|
||||
anims ~= nil and anims.EARTHQUAKE ~= nil)
|
||||
check("animations are on in OPTIONS (the shakes are gated on them)",
|
||||
game.save.options.animations ~= false)
|
||||
|
||||
game.save.options.battleLayout = "wide"
|
||||
check("BATTLE LAYOUT is WIDE", game.save.options.battleLayout == "wide")
|
||||
|
||||
local vol = game.save.options and game.save.options.sfxVol
|
||||
U.log("sfxVol", tostring(vol),
|
||||
vol == 0 and "-- SILENT, raise it in OPTION before judging the sound"
|
||||
or "-- BUBBLEBEAM and EARTHQUAKE carry the damage thud; TAIL WHIP is silent")
|
||||
|
||||
-- ---- fixture -----------------------------------------------------------
|
||||
local squirtle = Pokemon.new(game.data, "SQUIRTLE", 40)
|
||||
squirtle.moves = {}
|
||||
for _, m in ipairs(MOVES) do
|
||||
squirtle.moves[m.slot] = { id = m.id, pp = game.data.moves[m.id].pp }
|
||||
end
|
||||
game.save.party = { squirtle }
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- data/generated/maps.lua ROUTE_1: (5, 5) is open walkable ground; the
|
||||
-- battle is pushed straight in rather than encountered
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if ow and ow.map and not ow.map:isWalkableCell(5, 5) then
|
||||
-- a map edit moved the path: take the nearest walkable cell instead
|
||||
local sx, sy
|
||||
for r = 1, 6 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
if not sx and ow.map:isWalkableCell(5 + dx, 5 + dy) then
|
||||
sx, sy = 5 + dx, 5 + dy
|
||||
end
|
||||
end
|
||||
end
|
||||
if sx then break end
|
||||
end
|
||||
if sx then
|
||||
U.log("(5, 5) is blocked, standing on", sx, sy)
|
||||
U.teleport(game, "ROUTE_1", sx, sy, "down")
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
check("the overworld is up on ROUTE_1", ow ~= nil)
|
||||
|
||||
-- a Normal-type foe: PIDGEY is Flying and would sit out EARTHQUAKE entirely
|
||||
local battle = BattleState.newWild(game, "RATTATA", 20)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the battle reached the screen", game.stack:top() == battle)
|
||||
check("the battle is drawing the wide surface", battle:wideLayout() == true)
|
||||
local uw = battle:uiSize()
|
||||
check(("the surface is %dpx wide (got %s)"):format(WideBattle.WIDTH, tostring(uw)),
|
||||
uw == WideBattle.WIDTH)
|
||||
|
||||
-- a foe that survives the whole run: every family needs its own turn, and
|
||||
-- EARTHQUAKE off a level 40 SQUIRTLE takes a large bite
|
||||
battle.enemy.mon.stats.hp = 900
|
||||
battle.enemy.mon.hp = 900
|
||||
battle.enemy.shownHP = 900
|
||||
-- WHIRLWIND ends a wild battle outright ("run"), taking the untested
|
||||
-- families with it; curMoves aliases mon.moves, so one pass covers both
|
||||
for i = #battle.enemy.mon.moves, 1, -1 do
|
||||
local id = battle.enemy.mon.moves[i].id
|
||||
if id == "WHIRLWIND" or id == "ROAR" or id == "TELEPORT" then
|
||||
table.remove(battle.enemy.mon.moves, i)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- sample the offsets the composition is fed -------------------------
|
||||
-- One row per performMove, EITHER side: a row that stayed open across the
|
||||
-- foe's reply would credit the player's move with the foe's shake. The
|
||||
-- split at the first applyHitFx separates a shake the move's own animation
|
||||
-- ran (SE_SHAKE_SCREEN) from the applying-attack one that follows it.
|
||||
local rows, cur = {}, nil
|
||||
local realPerform = battle.performMove
|
||||
battle.performMove = function(self, user, target, moveInst, ...)
|
||||
cur = { move = moveInst and moveInst.id, isPlayer = user.isPlayer,
|
||||
animX = 0, animY = 0, hitX = 0, hitY = 0, hitFrames = 0 }
|
||||
rows[#rows + 1] = cur
|
||||
return realPerform(self, user, target, moveInst, ...)
|
||||
end
|
||||
local realFx = battle.applyHitFx
|
||||
battle.applyHitFx = function(self, hit)
|
||||
if cur and cur.animType == nil then cur.animType = hit.animType or 0 end
|
||||
return realFx(self, hit)
|
||||
end
|
||||
|
||||
local function sample()
|
||||
local fx = battle.fx
|
||||
if not (cur and fx) then return end
|
||||
local dx, dy = math.abs(fx.shakeX or 0), math.abs(fx.shakeY or 0)
|
||||
if cur.animType == nil then
|
||||
cur.animX, cur.animY = math.max(cur.animX, dx), math.max(cur.animY, dy)
|
||||
return
|
||||
end
|
||||
if dx > 0 or dy > 0 then cur.hitFrames = cur.hitFrames + 1 end
|
||||
cur.hitX, cur.hitY = math.max(cur.hitX, dx), math.max(cur.hitY, dy)
|
||||
end
|
||||
|
||||
-- has this row finished its applying-attack effect? Type 4 runs a pic
|
||||
-- blink and no screen program at all, so it is done as soon as it fires.
|
||||
local function settled(row)
|
||||
return row ~= nil and row.animType ~= nil
|
||||
and (battle.fx == nil or battle.fx.shakeProg == nil)
|
||||
end
|
||||
|
||||
-- step n frames sampling every one, pressing A every `mash` frames but only
|
||||
-- while a box is up: an A on the FIGHT menu starts a turn behind our back
|
||||
local function pump(n, mash, stop)
|
||||
for i = 1, n do
|
||||
if mash and i % mash == 0 and battle.phase == "messages" then
|
||||
table.insert(game.input.pressQueue, "a")
|
||||
end
|
||||
U.wait(1)
|
||||
game.input.state.a = false
|
||||
sample()
|
||||
if stop and stop() then return end
|
||||
end
|
||||
end
|
||||
|
||||
local function toMenu()
|
||||
pump(1500, 6, function() return battle.phase == "menu" and #battle.queue == 0 end)
|
||||
return battle.phase == "menu"
|
||||
end
|
||||
|
||||
-- The wide move list is a 2x2 grid, and a press that would land on an
|
||||
-- absent fourth move holds the cursor still (WideBattle.moveGridIndex), so
|
||||
-- the route is searched through the engine's own navigation rather than
|
||||
-- assumed: walking the column never reaches slots 2 and 4, and walking the
|
||||
-- row can dead-end on a three-move party.
|
||||
local DIRS = { "up", "down", "left", "right" }
|
||||
local function routeTo(from, slot, count)
|
||||
local seen, queue = { [from] = true }, { { at = from, path = {} } }
|
||||
while #queue > 0 do
|
||||
local node = table.remove(queue, 1)
|
||||
if node.at == slot then return node.path end
|
||||
for _, dir in ipairs(DIRS) do
|
||||
local nxt = WideBattle.moveGridIndex(node.at, count, dir)
|
||||
if nxt and not seen[nxt] then
|
||||
seen[nxt] = true
|
||||
local path = { unpack(node.path) }
|
||||
path[#path + 1] = dir
|
||||
queue[#queue + 1] = { at = nxt, path = path }
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- FIGHT is slot 1 of the wide command grid; A opens moveSelect. A press on
|
||||
-- a frame the battle is not reading input is simply lost, so retry.
|
||||
local function useMove(slot)
|
||||
for _ = 1, 80 do
|
||||
if battle.phase == "moveSelect" then break end
|
||||
if battle.phase == "menu" then
|
||||
if battle.menuIndex ~= 1 then
|
||||
U.tap(game, battle.menuIndex > 2 and "up" or "left")
|
||||
else
|
||||
U.tap(game, "a")
|
||||
end
|
||||
else
|
||||
U.tap(game, "a")
|
||||
end
|
||||
U.wait(4)
|
||||
end
|
||||
if battle.phase ~= "moveSelect" then return false end
|
||||
-- recompute the route every press: a press that lands on a frame the
|
||||
-- battle is not reading is lost, and the cursor simply did not move
|
||||
local count = #(battle.player.curMoves or {})
|
||||
for _ = 1, 30 do
|
||||
if battle.moveIndex == slot then break end
|
||||
local path = routeTo(battle.moveIndex, slot, count)
|
||||
if not path or #path == 0 then break end
|
||||
U.tap(game, path[1])
|
||||
U.wait(3)
|
||||
end
|
||||
if battle.moveIndex ~= slot then return false end
|
||||
for _ = 1, 20 do
|
||||
if battle.phase ~= "moveSelect" then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function lastPlayerRow(id)
|
||||
for i = #rows, 1, -1 do
|
||||
if rows[i].move == id and rows[i].isPlayer then return rows[i] end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local SHOTS = { TAIL_WHIP = "bug562_wide_tailwhip.png",
|
||||
BUBBLEBEAM = "bug562_wide_bubblebeam.png",
|
||||
EARTHQUAKE = "bug562_wide_earthquake.png" }
|
||||
-- the foe's vertical drop (type 1) plays on ITS turn, so it is taken
|
||||
-- whenever one goes past and chased down after the table below if not
|
||||
local vertShot = false
|
||||
|
||||
check("the battle reached its FIGHT menu", toMenu())
|
||||
|
||||
for _, m in ipairs(MOVES) do
|
||||
local shotAt, sent, r
|
||||
-- BUBBLEBEAM whiffs 1 in 6 and the foe's TAIL WHIP stacks accuracy drops,
|
||||
-- so give each move a few turns rather than reading a miss (which plays no
|
||||
-- applying animation at all) as a regression; hand the accuracy back first
|
||||
for _ = 1, 8 do
|
||||
battle.player.stages.accuracy = 0
|
||||
sent = useMove(m.slot)
|
||||
if not sent then
|
||||
U.log((" could not reach slot %d for %s (phase %s, move index %s, result %s)")
|
||||
:format(m.slot, m.id, tostring(battle.phase),
|
||||
tostring(battle.moveIndex), tostring(battle.result)))
|
||||
break
|
||||
end
|
||||
pump(1500, 8, function()
|
||||
local fx = battle.fx
|
||||
local row = lastPlayerRow(m.id)
|
||||
if not shotAt and row == cur and SHOTS[m.id] and fx
|
||||
and (fx.shakeX or 0) ~= 0 then
|
||||
shotAt = U.shot(game, DIR .. "/" .. SHOTS[m.id])
|
||||
end
|
||||
if not vertShot and fx and (fx.shakeY or 0) ~= 0
|
||||
and cur and not cur.isPlayer then
|
||||
vertShot = U.shot(game, DIR .. "/bug562_wide_foe_vertical.png")
|
||||
end
|
||||
if row and battle.phase == "menu" and #battle.queue == 0 then return true end
|
||||
return settled(row)
|
||||
end)
|
||||
toMenu()
|
||||
r = lastPlayerRow(m.id)
|
||||
-- a type-0 move never queues an applying-attack row, so one clean turn
|
||||
-- is all it owes
|
||||
if r and (r.animType ~= nil or m.type == 0) then break end
|
||||
U.log(" " .. m.id .. " did not connect that turn; using it again")
|
||||
end
|
||||
check("chose " .. m.id .. " from the wide move grid", sent)
|
||||
if sent and check(m.id .. " was performed", r ~= nil) then
|
||||
U.log((" %s: type %s, own animation %dpx across / %dpx down, applying "
|
||||
.. "%dpx across / %dpx down over %d frames")
|
||||
:format(m.id, tostring(r.animType), r.animX, r.animY,
|
||||
r.hitX, r.hitY, r.hitFrames))
|
||||
check(("%s is wAnimationType %d (got %d)")
|
||||
:format(m.id, m.type, r.animType or 0),
|
||||
(r.animType or 0) == m.type)
|
||||
if m.hitX then
|
||||
check(("%s shakes the screen %dpx sideways (got %d)")
|
||||
:format(m.id, m.hitX, r.hitX), r.hitX == m.hitX)
|
||||
end
|
||||
if m.hitY then
|
||||
check(("%s leaves the vertical alone (got %d)"):format(m.id, r.hitY),
|
||||
r.hitY == m.hitY)
|
||||
end
|
||||
if m.animX then
|
||||
check(("%s's own animation shakes %dpx sideways (got %d)")
|
||||
:format(m.id, m.animX, r.animX), r.animX == m.animX)
|
||||
end
|
||||
if SHOTS[m.id] then
|
||||
check("captured " .. m.id .. " mid-shake", shotAt == true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- The vertical drop (type 1) only plays on the foe's turn, so it is taken by
|
||||
-- spending harmless WITHDRAW turns until the RATTATA picks a plain damaging
|
||||
-- move. Which move it picks is an AI roll, so a run where it never does is
|
||||
-- a thin run, not a regression.
|
||||
local function foeTypeOne()
|
||||
for _, row in ipairs(rows) do
|
||||
if not row.isPlayer and row.animType == 1 then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
for _ = 1, 6 do
|
||||
if vertShot then break end
|
||||
if not useMove(4) then break end
|
||||
pump(1200, 8, function()
|
||||
local fx = battle.fx
|
||||
if not vertShot and fx and (fx.shakeY or 0) ~= 0
|
||||
and cur and not cur.isPlayer then
|
||||
vertShot = U.shot(game, DIR .. "/bug562_wide_foe_vertical.png")
|
||||
end
|
||||
return battle.phase == "menu" and #battle.queue == 0
|
||||
end)
|
||||
toMenu()
|
||||
end
|
||||
|
||||
local foe = {}
|
||||
for _, row in ipairs(rows) do
|
||||
if not row.isPlayer and row.animType then
|
||||
foe[#foe + 1] = ("%s type %s (%dpx across, %dpx down)")
|
||||
:format(tostring(row.move), tostring(row.animType),
|
||||
math.max(row.animX, row.hitX),
|
||||
math.max(row.animY, row.hitY))
|
||||
end
|
||||
end
|
||||
U.log("the RATTATA's rows: " .. (#foe > 0 and table.concat(foe, "; ") or "none"))
|
||||
if vertShot then
|
||||
U.log("captured the foe's 8px vertical drop as well")
|
||||
elseif foeTypeOne() then
|
||||
check("the foe's vertical drop was captured", false)
|
||||
else
|
||||
U.log("the RATTATA never used a plain damaging move in these turns, so")
|
||||
U.log("there was no vertical drop to capture; watch for one by hand")
|
||||
end
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
U.log("The pad is yours at the wide FIGHT menu, all four panels on screen.")
|
||||
U.log("Slot 1 TAIL WHIP creeps the WHOLE screen 1px at a time out to 3px and")
|
||||
U.log("back, twice, in silence: foe, both status panels and the message box")
|
||||
U.log("move as one sheet. Slot 2 BUBBLEBEAM snaps it 2px across with the")
|
||||
U.log("damage thud, slot 3 EARTHQUAKE shakes it 8px from inside its own")
|
||||
U.log("animation. Let the RATTATA hit back for the vertical one: a plain")
|
||||
U.log("TACKLE or QUICK ATTACK drops the same sheet 8px. The near miss to")
|
||||
U.log("watch for is the one #562 reported: the monsters sliding while the HUD")
|
||||
U.log("panels and the text box stay nailed down, or a pic sheared off at the")
|
||||
U.log("seam between the two side windows on the way.")
|
||||
U.log("Slot 4 WITHDRAW is the control: it moves nothing at all, on purpose.")
|
||||
U.log("So are the animations' own sprites (the bubbles, the EARTHQUAKE rocks)")
|
||||
U.log("-- those are OAM and correctly do NOT ride the shake, same as in OG.")
|
||||
U.log("Flip OPTION -> BATTLE LAYOUT to OG and repeat: it should look the same.")
|
||||
U.log("Screenshots: " .. DIR .. "/bug562_*.png")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
-- Manual check that Yellow's bank $1f music headers, wave table and cry
|
||||
-- data read from the right ROM addresses (#522). Before the fix every
|
||||
-- header past Music_YellowIntro (a 3-channel header where Red's
|
||||
-- Music_IntroBattle was 4) was read 3 bytes off, so Viridian Forest's
|
||||
-- Music_Dungeon2 lost 3 of its 4 channels and its tempo command; every
|
||||
-- song's channel 3 sampled engine code instead of a wave table.
|
||||
-- POKEPORT_DRIVER=tests/drivers/yellow_audio_bug522_test.lua POKEPORT_IDENTITY=bug522 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local ChipSynth = require("src.core.ChipSynth")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local audio = game.data.audio or {}
|
||||
local header = audio.musicHeaders and audio.musicHeaders.Music_Dungeon2
|
||||
|
||||
-- pokeyellow.sym: Music_Dungeon2 = 1f:42a9 (bank 31, address 17065)
|
||||
check("Music_Dungeon2 header reads pokeyellow.sym's address",
|
||||
header and header.address == 17065 and header.bank == 31)
|
||||
|
||||
-- pokeyellow.sym: CryData = 0e:5462 (bank 14, address 21602)
|
||||
local cry = audio.cryData
|
||||
check("cryData reads pokeyellow.sym's CryData",
|
||||
cry and cry.address == 21602 and cry.bank == 14)
|
||||
|
||||
-- pokeyellow only has one wave table, Audio1_WavePointers.wave0 = 02:5a26
|
||||
local waves = audio.waveBanks or {}
|
||||
local waveOk = true
|
||||
for _, engine in ipairs({ "1", "2", "3" }) do
|
||||
local w = waves[engine]
|
||||
if not (w and w.address == 23078 and w.bank == 2) then waveOk = false end
|
||||
end
|
||||
check("all three waveBanks point at Audio1_WavePointers.wave0", waveOk)
|
||||
|
||||
-- Rebuild the engine exactly as ChipAudio does and count the channels a
|
||||
-- misread header would drop to 1 (see headerChannels, ChipSynth.lua).
|
||||
local engine, engErr
|
||||
if header then
|
||||
local ok
|
||||
ok, engine = pcall(ChipSynth.newEngine, game.data, header)
|
||||
if not ok then engErr = engine; engine = nil end
|
||||
end
|
||||
check("Music_Dungeon2 engine builds" .. (engErr and (": " .. tostring(engErr)) or ""),
|
||||
engine ~= nil)
|
||||
check("Music_Dungeon2 has all 4 channels, not the misread header's 1",
|
||||
engine and #engine.channels == 4)
|
||||
|
||||
-- Ch1 opens with the tempo command (dungeon2.asm "Music_Dungeon2_Ch1::
|
||||
-- tempo 144"); a header pointed at the wrong row drops it and the engine
|
||||
-- is left at the 0x100 default, ~1.78x slower.
|
||||
if engine and engine.channels[1] then
|
||||
engine.channels[1]:nextEvent()
|
||||
end
|
||||
check("Ch1's tempo command set engine.tempo to 144, not the 0x100 default",
|
||||
engine and engine.tempo == 144)
|
||||
|
||||
-- pokered data/maps/objects/ViridianForest.asm: the south gate warps land
|
||||
-- around (16-18, 47); one step north of that is inside the forest proper.
|
||||
local MAP = "VIRIDIAN_FOREST"
|
||||
local STAND = { x = 16, y = 46, facing = "up" }
|
||||
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
|
||||
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then
|
||||
local function nearestWalkable()
|
||||
for dy = -3, 3 do
|
||||
for dx = -3, 3 do
|
||||
local cx, cy = STAND.x + dx, STAND.y + dy
|
||||
if ow.map:isWalkableCell(cx, cy) then return cx, cy end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local cx, cy = nearestWalkable()
|
||||
if cx then
|
||||
U.log("start cell blocked, standing at", cx, cy, "instead")
|
||||
U.teleport(game, MAP, cx, cy, "up")
|
||||
end
|
||||
end
|
||||
|
||||
if game.save and game.save.options and game.save.options.musicVol == 0 then
|
||||
U.log("FAIL musicVol is 0 -- turn it up, this run is silent by design")
|
||||
end
|
||||
|
||||
U.log("Standing in Viridian Forest; Music_Dungeon2 is playing now.")
|
||||
U.log("Right: four voices at a brisk clip -- lead melody, a counter line")
|
||||
U.log("underneath it, a soft rounded bass, and a hi-hat tick on top.")
|
||||
U.log("Wrong (the bug): one thin lonely voice at a noticeably slower,")
|
||||
U.log("draggy tempo, with no bass or hi-hat at all.")
|
||||
U.log("Also listen to that bass note's timbre -- it should be a soft")
|
||||
U.log("rounded triangle-ish tone, not a harsh buzzy rasp (waveBanks).")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Manual check that the Yellow intro now runs long enough for
|
||||
-- Music_YellowIntro's descending run and long final note to be audible
|
||||
-- before title.asm's unconditional StopAllMusic swaps in the title theme
|
||||
-- (#523). Never taps a button: Game:load() already pushed YellowIntro,
|
||||
-- so this driver only watches it play out at real, unaccelerated speed.
|
||||
-- POKEPORT_SPEED must stay unset -- Music runs on its own real-time 60Hz
|
||||
-- accumulator (Game:update), decoupled from the fast-forwardable logic
|
||||
-- clock, so speeding up the driver would desync the exact ordering under
|
||||
-- test here instead of just playing it back faster.
|
||||
-- POKEPORT_DRIVER=tests/drivers/yellow_intro_bug523_test.lua POKEPORT_IDENTITY=bug523 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Music = require("src.core.Music")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
check("booted into a state (Yellow intro, per bootScreens)",
|
||||
game.stack:top() ~= nil)
|
||||
|
||||
if game.save and game.save.options and game.save.options.musicVol == 0 then
|
||||
U.log("FAIL musicVol is 0 -- turn it up, this run is silent by design")
|
||||
end
|
||||
|
||||
local sawIntroSong = false
|
||||
local sawTitleSong = false
|
||||
|
||||
-- Music.lua keeps the current song in a module-local `state` table with
|
||||
-- no public getter, so watch the one seam that is public: wrap Music.play
|
||||
-- itself for the life of this driver and log what it's handed. Both the
|
||||
-- YellowIntro:beginScenes() pcall and title.asm's StopAllMusic-then-
|
||||
-- PlaySound land here.
|
||||
local frame = 0
|
||||
local originalPlay = Music.play
|
||||
Music.play = function(data, song, loop, ctx)
|
||||
if song and song:find("Intro") and not sawIntroSong then
|
||||
sawIntroSong = true
|
||||
U.log("Music_YellowIntro started")
|
||||
end
|
||||
if song == "Music_TitleScreen" and not sawTitleSong then
|
||||
sawTitleSong = true
|
||||
U.log("Music_TitleScreen started")
|
||||
end
|
||||
return originalPlay(data, song, loop, ctx)
|
||||
end
|
||||
|
||||
-- 40s ceiling counted from driver start, not from the intro song: roughly 500 frames of
|
||||
-- boot and bootScreens run before Music_YellowIntro begins, and the title swap lands
|
||||
-- around frame 1680 (28s), so a 20s ceiling expired before the moment under test (#523).
|
||||
while frame < 40 * 60 and not sawTitleSong do
|
||||
frame = frame + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
|
||||
check("Music_YellowIntro started", sawIntroSong)
|
||||
check("the movie reached the title screen and Music_TitleScreen took over",
|
||||
sawTitleSong)
|
||||
|
||||
U.log("Listen for Music_YellowIntro's ending: a short descending run of")
|
||||
U.log("notes (F#, F, F#...F#, E, D#, C#) followed by one long low note,")
|
||||
U.log("right before the title theme cuts in. Before #523 the swap landed")
|
||||
U.log("early and those descending notes never played -- the track just")
|
||||
U.log("stopped mid-phrase. The swap itself is still an abrupt cut by")
|
||||
U.log("design (pokeyellow's title.asm stops the intro song unconditionally,")
|
||||
U.log("it never waits for it to finish); what changed is how much of the")
|
||||
U.log("track plays before that cut lands.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,158 @@
|
||||
-- Cycling COLORS during a battle must keep the battle song (#484). The
|
||||
-- pack change rebuilds the live map's baked atlas through
|
||||
-- OverworldState:reloadMap, and that used to re-enter the map: setMap's
|
||||
-- PlayMapMusic put the route theme over the battle theme. ReloadMapData
|
||||
-- (home/reload_tiles.asm) only re-reads the map view and the tileset
|
||||
-- patterns; map music comes from LoadMapData alone (home/overworld.asm,
|
||||
-- gated on BIT_NO_MAP_MUSIC), so a reload is not a map entry.
|
||||
-- The other half matters as much: the hotkey must still work in a battle,
|
||||
-- so a "fix" that gates key 2 off while BattleState is on top fails here.
|
||||
-- luajit tests/engine/battle_colors_bug484.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
|
||||
-- OverworldController takes its collaborators as file-locals, and Game is
|
||||
-- only bound when a real state enters; swapping the upvalues is how
|
||||
-- tests/mod_world_tests.lua drives one of these methods with no dataset.
|
||||
local function setUpvalue(fn, name, value)
|
||||
local i = 1
|
||||
while true do
|
||||
local n = debug.getupvalue(fn, i)
|
||||
if not n then return false end
|
||||
if n == name then
|
||||
debug.setupvalue(fn, i, value)
|
||||
return true
|
||||
end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
|
||||
local emitted = {}
|
||||
for _, up in ipairs({
|
||||
{ "MapLoader", { invalidate = function() end,
|
||||
invalidateAll = function() end } },
|
||||
{ "Collision", { load = function() end } },
|
||||
{ "Game", { data = {} } },
|
||||
{ "Logger", { warn = function() end, error = function() end } },
|
||||
{ "Runtime", { emit = function(name, payload)
|
||||
emitted[#emitted + 1] = { name = name, payload = payload }
|
||||
end } },
|
||||
}) do
|
||||
check(setUpvalue(OverworldState.reloadMap, up[1], up[2]),
|
||||
"reloadMap still closes over " .. up[1])
|
||||
end
|
||||
|
||||
-- A state stub with just what reloadMap reads: the live map, the player's
|
||||
-- cell, and a setMap that records the opts instead of loading anything.
|
||||
-- `shrank` makes the reloaded map report the player's cell out of bounds
|
||||
-- once, which is the only way past the first setMap.
|
||||
local function newState(mapId, shrank)
|
||||
local calls, entered = {}, 0
|
||||
return {
|
||||
map = { id = mapId, inBounds = function() return true end },
|
||||
player = { cellX = 5, cellY = 5, facing = "down" },
|
||||
neighbors = {},
|
||||
calls = calls,
|
||||
setMap = function(self, id, x, y, facing, opts)
|
||||
calls[#calls + 1] = { id = id, x = x, y = y, facing = facing, opts = opts }
|
||||
entered = entered + 1
|
||||
local ok = not (shrank and entered == 1)
|
||||
self.map = { id = id, inBounds = function() return ok end }
|
||||
end,
|
||||
healPoint = function() return { map = "PALLET_TOWN", x = 3, y = 6 } end,
|
||||
}
|
||||
end
|
||||
|
||||
local live = newState("ROUTE_1")
|
||||
OverworldState.reloadMap(live, "ROUTE_1", "colors")
|
||||
eq(#live.calls, 1, "reloading the live map re-enters it exactly once")
|
||||
local opts = live.calls[1].opts or {}
|
||||
check(opts.keepMusic == true,
|
||||
"the reload keeps whatever song is playing (#484): a COLORS cycle "
|
||||
.. "during a battle must not start the route theme")
|
||||
eq(opts.via, "reload", "and it is still tagged as a reload, not a warp")
|
||||
check(opts.seamless == true, "with no transition wipe")
|
||||
eq(live.calls[1].x, 5, "the player is put back on the cell they were on")
|
||||
eq(live.calls[1].y, 5, "on both axes")
|
||||
|
||||
-- the escape hatch under it is a genuine map change, and PlayMapMusic
|
||||
-- belongs there: the player is being sent to a heal point
|
||||
local shrunk = newState("ROUTE_1", true)
|
||||
OverworldState.reloadMap(shrunk, "ROUTE_1", "colors")
|
||||
eq(#shrunk.calls, 2, "a map that shrank under the player sends them away")
|
||||
eq(shrunk.calls[2].id, "PALLET_TOWN", "to their heal point")
|
||||
check(not (shrunk.calls[2].opts or {}).keepMusic,
|
||||
"and that one starts the destination's own music")
|
||||
|
||||
local other = newState("ROUTE_1")
|
||||
OverworldState.reloadMap(other, "VIRIDIAN_CITY", "colors")
|
||||
eq(#other.calls, 0, "reloading a map that is not live touches nothing")
|
||||
check(emitted[#emitted] and emitted[#emitted].name == "map.reloaded",
|
||||
"map.reloaded still fires either way")
|
||||
|
||||
-- The hotkey half, end to end through the real Game and PaletteFX: with a
|
||||
-- battle on top the COLORS key is not gated off, and every press moves one
|
||||
-- rung down the ladder.
|
||||
local Game = require("src.core.Game")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local reloads = {}
|
||||
local ow = {
|
||||
map = { id = "ROUTE_1" },
|
||||
reloadMap = function(_, id, reason)
|
||||
reloads[#reloads + 1] = { id = id, reason = reason }
|
||||
end,
|
||||
}
|
||||
Game.overworld = ow -- PaletteFX.setMode reaches the live overworld this way
|
||||
|
||||
local battle = { isOpaque = true } -- stands in for BattleState on the stack
|
||||
local session = {
|
||||
overworld = ow,
|
||||
save = { options = {} },
|
||||
stack = { states = { ow, battle },
|
||||
top = function(self) return self.states[#self.states] end },
|
||||
writeOptions = function() end,
|
||||
}
|
||||
|
||||
local seen, start = {}, PaletteFX.mode
|
||||
for _ = 1, #PaletteFX.MODES do
|
||||
Game.keypressed(session, "2")
|
||||
seen[#seen + 1] = PaletteFX.mode
|
||||
end
|
||||
eq(#reloads, #PaletteFX.MODES,
|
||||
"every COLORS press in a battle still rebuilds the map's atlas")
|
||||
eq(reloads[1].reason, "colors", "tagged as the COLORS cycle")
|
||||
eq(reloads[1].id, "ROUTE_1", "on the map the player is standing on")
|
||||
|
||||
local distinct = {}
|
||||
for _, mode in ipairs(seen) do distinct[mode] = true end
|
||||
local count = 0
|
||||
for _ in pairs(distinct) do count = count + 1 end
|
||||
eq(count, #PaletteFX.MODES,
|
||||
"the palette visibly cycles: one press per mode walks the whole ladder")
|
||||
eq(PaletteFX.mode, start, "and lands back where it started")
|
||||
eq(session.save.options.colors, PaletteFX.mode,
|
||||
"the choice is written to options like it is in the overworld")
|
||||
|
||||
-- the pre-existing gate is unchanged: a script driving the overworld still
|
||||
-- holds the key off, because reloadMap rebuilds the live NPC array
|
||||
local busy = {
|
||||
overworld = ow,
|
||||
save = { options = {} },
|
||||
stack = { states = { ow }, top = function(self) return self.states[1] end },
|
||||
writeOptions = function() end,
|
||||
}
|
||||
ow.runner = { isRunning = function() return true end }
|
||||
local before = PaletteFX.mode
|
||||
local reloadsBefore = #reloads
|
||||
Game.keypressed(busy, "2")
|
||||
eq(PaletteFX.mode, before, "a running map script still holds COLORS off")
|
||||
eq(#reloads, reloadsBefore, "and nothing reloads under it")
|
||||
|
||||
T.finish("battle_colors_bug484")
|
||||
@@ -0,0 +1,128 @@
|
||||
-- The Silph Scope unveil in the POKEMON_TOWER_6F battle (#492).
|
||||
--
|
||||
-- PrintBeginningBattleText .isMarowak (engine/battle/common_text.asm:49-60):
|
||||
-- with the scope in the bag the battle still ENTERS disguised -- InitWildBattle
|
||||
-- takes its .isGhost branch on wCurOpponent == RESTLESS_SOUL either way -- and
|
||||
-- the scope only buys the unveil that is played over the disguise:
|
||||
-- EnemyAppearedText, UnveiledGhostText, LoadEnemyMonData, MarowakAnim, then
|
||||
-- WildMonAppearedText. The port used to hand the scope-carrying player a
|
||||
-- battle that opened with MAROWAK already on screen and no unveil at all.
|
||||
--
|
||||
-- The story3 dispatch half (which branch the trigger picks, and that the ball
|
||||
-- dodge survives the unveil) is tests/parity_marowak.lua and
|
||||
-- tests/parity_marowak_ball.lua.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
T.check(type(BattleState.makeUnveiledGhost) == "function",
|
||||
"BattleState:makeUnveiledGhost exists")
|
||||
T.check(BattleState.makeUnveiledGhost ~= BattleState.makeGhost,
|
||||
"the unveiled battle is not just the scope-less ghost battle")
|
||||
|
||||
local REAL_SPRITE = { marker = "the MAROWAK front pic" }
|
||||
|
||||
local function newBattle()
|
||||
return setmetatable({
|
||||
kind = "wild",
|
||||
queue = {},
|
||||
frame = 0,
|
||||
data = { text = {}, pokemon = {} },
|
||||
enemy = { name = "MAROWAK", sprite = REAL_SPRITE,
|
||||
mon = { species = "MAROWAK", level = 30 } },
|
||||
}, BattleState)
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------- entering disguised
|
||||
local b = newBattle()
|
||||
b:makeUnveiledGhost()
|
||||
T.eq(b.enemy.name, "GHOST",
|
||||
"with the scope the battle still OPENS as the GHOST (#492)")
|
||||
T.check(b.enemy.sprite ~= REAL_SPRITE,
|
||||
"and wears the ghost pic, not the MAROWAK one")
|
||||
T.check(b.scopeReveal == true, "the unveil is armed")
|
||||
T.check(not b.ghost,
|
||||
"but IsGhostBattle stays false: the mon can be attacked and flees normally")
|
||||
T.eq(b.ghostReal.name, "MAROWAK", "the real nick is kept for LoadEnemyMonData")
|
||||
T.eq(b.ghostReal.sprite, REAL_SPRITE, "...and the real pic with it")
|
||||
|
||||
local ghostSprite = b.enemy.sprite
|
||||
|
||||
-- ------------------------------------------------------------- the row order
|
||||
-- .isMarowak prints over the "GHOST appeared!" box enter() already queued: the
|
||||
-- unveil line, MarowakAnim (a wait row parked over the fx state machine), then
|
||||
-- "Wild MAROWAK appeared!" under the restored name.
|
||||
b:queueScopeReveal()
|
||||
T.eq(#b.queue, 4, "the unveil is four queue rows")
|
||||
T.check(type(b.queue[1].text) == "string"
|
||||
and b.queue[1].text:find("SILPH SCOPE", 1, true) ~= nil,
|
||||
"row 1 is UnveiledGhostText")
|
||||
T.check(type(b.queue[2].fn) == "function", "row 2 starts MarowakAnim")
|
||||
T.eq(b.queue[3].wait, BattleState.GHOST_REVEAL_FRAMES,
|
||||
"row 3 holds the queue for the length of the animation")
|
||||
T.check(type(b.queue[4].text) == "string"
|
||||
and b.queue[4].text:find("MAROWAK", 1, true) ~= nil,
|
||||
"row 4 is WildMonAppearedText under the real name")
|
||||
T.check(b.queue[4].text:find("GHOST", 1, true) == nil,
|
||||
"...and not under the disguise")
|
||||
|
||||
-- ------------------------------------------------------------ the animation
|
||||
-- MarowakAnim (engine/battle/ghost_marowak_anim.asm): FlashSprite8Times, the
|
||||
-- rOBP1 fade-out, the pic swap, the fade-in. The whole point of #492 is that
|
||||
-- the swap comes AFTER the ghost has faded off the paper, so every frame up to
|
||||
-- there still shows the GHOST.
|
||||
b.queue[2].fn()
|
||||
T.check(b.ghostReveal ~= nil, "the fn arms the reveal state")
|
||||
|
||||
local FLASH = BattleState.GHOST_FLASH_FRAMES
|
||||
local FADE_OUT = BattleState.GHOST_FADE_OUT_FRAMES
|
||||
local swapFrame, fades, stillGhost = nil, {}, true
|
||||
for frame = 1, BattleState.GHOST_REVEAL_FRAMES do
|
||||
b:updateFx()
|
||||
local pf = b.picFx and b.picFx[b.enemy]
|
||||
fades[frame] = pf and pf.fade
|
||||
if not swapFrame and b.enemy.name ~= "GHOST" then swapFrame = frame end
|
||||
if frame <= FLASH + FADE_OUT and b.enemy.name ~= "GHOST" then
|
||||
stillGhost = false
|
||||
end
|
||||
end
|
||||
|
||||
T.check(stillGhost,
|
||||
"the GHOST is on screen for the whole flash and fade-out (#492)")
|
||||
T.eq(swapFrame, FLASH + FADE_OUT + 1,
|
||||
"the pic and nick swap the frame after the ghost has faded out")
|
||||
T.eq(b.enemy.name, "MAROWAK", "the real nick is back by the end")
|
||||
T.eq(b.enemy.sprite, REAL_SPRITE, "and the real pic with it")
|
||||
T.check(b.enemy.sprite ~= ghostSprite, "the ghost pic is gone")
|
||||
|
||||
-- FlashSprite8Times xors rOBP1 with $80 every 10 frames, so the body alternates
|
||||
-- between two shades rather than sitting opaque
|
||||
T.eq(fades[5], 1, "the flash opens on the ghost at full strength")
|
||||
T.eq(fades[15], 0.5, "...drops a shade 10 frames in")
|
||||
T.eq(fades[25], 1, "...and comes back")
|
||||
T.check(fades[FLASH + FADE_OUT] == 0,
|
||||
"the ghost is fully faded out when the swap lands")
|
||||
T.check(fades[FLASH + FADE_OUT + 1] > 0
|
||||
and fades[FLASH + FADE_OUT + 1] < 1,
|
||||
"the MAROWAK fades IN rather than popping on")
|
||||
T.eq(fades[BattleState.GHOST_REVEAL_FRAMES - 1], 1,
|
||||
"and reaches full strength before the box turns")
|
||||
|
||||
T.check(b.ghostReveal == nil, "the reveal state clears itself")
|
||||
T.check(b.scopeReveal == nil, "so does the flag that gated the intro cry")
|
||||
local pf = b.picFx and b.picFx[b.enemy]
|
||||
T.check(pf == nil or pf.fade == nil,
|
||||
"no leftover alpha on the pic for the rest of the battle")
|
||||
|
||||
-- ------------------------------------------------ the scope-less ghost still
|
||||
-- Nothing above may change the battle you get WITHOUT the scope: still a real
|
||||
-- ghost battle, still no unveil queued.
|
||||
local noScope = newBattle()
|
||||
noScope:makeGhost()
|
||||
T.check(noScope.ghost == true, "without the scope IsGhostBattle is true")
|
||||
T.eq(noScope.enemy.name, "GHOST", "and the disguise is the same one")
|
||||
T.check(noScope.scopeReveal == nil, "with no unveil armed")
|
||||
T.eq(#noScope.queue, 0, "and no unveil rows queued")
|
||||
|
||||
T.finish("ghost unveil")
|
||||
@@ -0,0 +1,59 @@
|
||||
-- #575: HostShell.restart on Android must never reach love.event.quit
|
||||
-- ("restart") -- the vendored love.cpp loops runlove() in-process on
|
||||
-- "restart" and the second PHYSFS_init crashes ("already initialized").
|
||||
-- The fix prefers the love.system.restartApp JNI bridge (which kills the
|
||||
-- process, so a true return is never observed live) and, on an old APK
|
||||
-- whose liblove lacks the bridge, falls back to a CLEAN quit with no
|
||||
-- argument. Desktop keeps the in-process quit("restart").
|
||||
-- luajit tests/engine/host_restart_android_bug575.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local HostShell = require("src.core.HostShell")
|
||||
|
||||
local quits = {}
|
||||
love.event = {
|
||||
-- record the argument distinctly from "called with none": quit() and
|
||||
-- quit("restart") are the whole difference this test pins
|
||||
quit = function(...)
|
||||
quits[#quits + 1] = { n = select("#", ...), arg = (...) }
|
||||
end,
|
||||
}
|
||||
|
||||
local osName = "Android"
|
||||
local restartCalls = 0
|
||||
love.system = love.system or {}
|
||||
love.system.getOS = function() return osName end
|
||||
|
||||
-- bridge present and schedulable: restart goes through it, quit untouched
|
||||
love.system.restartApp = function() restartCalls = restartCalls + 1 return true end
|
||||
HostShell.restart()
|
||||
eq(restartCalls, 1, "Android restart prefers the restartApp bridge (#575)")
|
||||
eq(#quits, 0, "a scheduled relaunch never touches love.event.quit")
|
||||
|
||||
-- bridge present but could not schedule: clean quit, never quit("restart")
|
||||
love.system.restartApp = function() restartCalls = restartCalls + 1 return false end
|
||||
HostShell.restart()
|
||||
eq(restartCalls, 2, "the bridge is still tried first")
|
||||
eq(#quits, 1, "a failed schedule falls back to one quit")
|
||||
eq(quits[1].n, 0, "and it is a bare quit(), not quit(\"restart\")")
|
||||
|
||||
-- old APK, no bridge compiled in: same clean quit fallback
|
||||
love.system.restartApp = nil
|
||||
HostShell.restart()
|
||||
eq(#quits, 2, "a bridge-less APK quits cleanly instead of crashing")
|
||||
eq(quits[2].n, 0, "again with no restart argument")
|
||||
|
||||
-- desktop (no AppImage in a test environment) keeps the in-process restart
|
||||
if not os.getenv("APPIMAGE") then
|
||||
osName = "OS X"
|
||||
HostShell.restart()
|
||||
eq(quits[3] and quits[3].arg, "restart",
|
||||
"non-Android still restarts in-process")
|
||||
end
|
||||
|
||||
T.finish("host_restart_android_bug575")
|
||||
@@ -0,0 +1,148 @@
|
||||
-- #578: the launcher's "Add an index" prompt (and the rename / find-search
|
||||
-- fields) accepted no typing on Android, because nothing ever called
|
||||
-- love.keyboard.setTextInput(true) -- mobile LOVE only delivers
|
||||
-- love.textinput while it is armed. Every site that opens a text field must
|
||||
-- arm, every site that closes one must disarm, and disarm must be a no-op on
|
||||
-- desktop where the hosted save editor depends on text input staying on
|
||||
-- (tools/save-editor/Kit.lua, #529). A touch screen also has no ctrl+V, so
|
||||
-- the prompt grew a PASTE chip; both paste paths share _pasteIndexUrl and
|
||||
-- both honor the whitespace strip and the MAX_INDEX_URL cap.
|
||||
-- luajit tests/engine/launcher_text_input_bug578.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
-- record every setTextInput transition; the assertions read this log
|
||||
local textInputLog = {}
|
||||
love.keyboard.setTextInput = function(on)
|
||||
textInputLog[#textInputLog + 1] = on
|
||||
end
|
||||
local function lastArm() return textInputLog[#textInputLog] end
|
||||
|
||||
local clipboard = ""
|
||||
love.system = love.system or {}
|
||||
love.system.getClipboardText = function() return clipboard end
|
||||
|
||||
-- _commitAddIndex hands the typed URL to ModIndex.addSource; a canned
|
||||
-- failure keeps the commit path off the network and out of options.lua
|
||||
local addSourceUrl = nil
|
||||
package.loaded["src.mods.ModIndex"] = {
|
||||
addSource = function(url) addSourceUrl = url return nil, "offline" end,
|
||||
}
|
||||
-- _commitRename goes through SaveData.renameSlot; record the call
|
||||
local renamed = nil
|
||||
package.loaded["src.core.SaveData"] = {
|
||||
renameSlot = function(version, id, text) renamed = { version, id, text } end,
|
||||
}
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local ri = setmetatable({
|
||||
android = true, workState = nil, tab = "find",
|
||||
ready = {}, slots = { red = { { id = "s1", label = "OLD" } } },
|
||||
slotScroll = {}, activeSlot = {},
|
||||
}, RomImporter)
|
||||
ri._refreshSlots = function() end -- rename commit relists; nothing to relist
|
||||
|
||||
-- ---- index prompt: arm on open, disarm on escape and on commit ------------
|
||||
|
||||
ri:_promptAddIndex()
|
||||
check(ri._indexPrompt ~= nil, "the add-index prompt opens")
|
||||
eq(lastArm(), true, "opening the prompt arms setTextInput (#578)")
|
||||
|
||||
-- typed input strips whitespace (URLs never contain a literal space)
|
||||
ri:textinput("https://ex ample.com\n/idx")
|
||||
eq(ri._indexPrompt.text, "https://example.com/idx",
|
||||
"typed input lands with whitespace stripped")
|
||||
|
||||
ri:keypressed("escape")
|
||||
check(ri._indexPrompt == nil, "escape closes the prompt")
|
||||
eq(lastArm(), false, "and disarms setTextInput")
|
||||
|
||||
ri:_promptAddIndex()
|
||||
ri._indexPrompt.text = "https://example.com/index.json"
|
||||
ri:keypressed("return")
|
||||
check(ri._indexPrompt == nil, "enter commits and closes the prompt")
|
||||
eq(lastArm(), false, "commit disarms setTextInput too")
|
||||
eq(addSourceUrl, "https://example.com/index.json",
|
||||
"the committed text reaches ModIndex.addSource")
|
||||
check(ri.findNotice and ri.findNotice.ok == false,
|
||||
"a rejected source surfaces as a notice, not a crash")
|
||||
|
||||
-- ---- PASTE chip: same entry point the touch screen uses -------------------
|
||||
|
||||
ri:_promptAddIndex()
|
||||
-- the chip rect is what draw() published last frame (pinned: modal chrome
|
||||
-- ignores the page-scroll band); mousepressed hit-tests it while the
|
||||
-- prompt is up and everywhere else the prompt swallows the press
|
||||
ri._indexPasteRect = { x = 10, y = 10, width = 60, height = 24, pinned = true }
|
||||
clipboard = " https://example.com/mods/index.json\n"
|
||||
ri:mousepressed(200, 200, 1)
|
||||
eq(ri._indexPrompt.text, "", "a press outside the chip pastes nothing")
|
||||
ri:mousepressed(20, 20, 1)
|
||||
eq(ri._indexPrompt.text, "https://example.com/mods/index.json",
|
||||
"the PASTE chip lands the clipboard with whitespace stripped (#578)")
|
||||
|
||||
-- the cap holds through the button path: a 300-char clipboard cannot
|
||||
-- overflow MAX_INDEX_URL (200)
|
||||
ri._indexPrompt.text = ""
|
||||
clipboard = string.rep("a", 300)
|
||||
ri:mousepressed(20, 20, 1)
|
||||
eq(#ri._indexPrompt.text, 200, "the PASTE chip enforces MAX_INDEX_URL")
|
||||
|
||||
-- and through ctrl/cmd+V, which used to skip the cap entirely
|
||||
ri._indexPrompt.text = ""
|
||||
local savedIsDown = love.keyboard.isDown
|
||||
love.keyboard.isDown = function() return true end
|
||||
ri:keypressed("v")
|
||||
love.keyboard.isDown = savedIsDown
|
||||
eq(#ri._indexPrompt.text, 200, "ctrl+V routes through the same cap (#578)")
|
||||
ri:keypressed("escape")
|
||||
|
||||
-- ---- rename field: arm on open, disarm on escape and on commit ------------
|
||||
|
||||
ri:_beginRename("red", "s1")
|
||||
check(ri._rename ~= nil, "the rename modal opens")
|
||||
eq(lastArm(), true, "opening the rename arms setTextInput")
|
||||
ri:keypressed("escape")
|
||||
check(ri._rename == nil, "escape closes the rename")
|
||||
eq(lastArm(), false, "and disarms setTextInput")
|
||||
|
||||
ri:_beginRename("red", "s1")
|
||||
ri:textinput("!")
|
||||
ri:keypressed("return")
|
||||
eq(lastArm(), false, "committing the rename disarms setTextInput")
|
||||
eq(renamed and renamed[3], "OLD!", "the commit reaches SaveData.renameSlot")
|
||||
|
||||
-- ---- find-search field: arm on rect press, disarm on escape ---------------
|
||||
|
||||
ri.findSearchRect = { x = 100, y = 100, width = 80, height = 20 }
|
||||
ri:mousepressed(110, 110, 1)
|
||||
check(ri._findSearchFocus == true, "pressing the search field takes focus")
|
||||
eq(lastArm(), true, "and arms setTextInput")
|
||||
ri:keypressed("escape")
|
||||
check(ri._findSearchFocus == false, "escape drops the search caret")
|
||||
eq(lastArm(), false, "and disarms setTextInput")
|
||||
|
||||
-- a press elsewhere on the find tab also drops the caret and disarms
|
||||
ri:mousepressed(110, 110, 1)
|
||||
eq(lastArm(), true, "refocus for the click-away case")
|
||||
ri:mousepressed(400, 400, 1)
|
||||
check(ri._findSearchFocus == false, "a click away drops the caret")
|
||||
eq(lastArm(), false, "and disarms setTextInput")
|
||||
|
||||
-- ---- desktop contract (#529): disarm never lowers off Android -------------
|
||||
|
||||
ri.android = false
|
||||
ri:_promptAddIndex()
|
||||
eq(lastArm(), true, "desktop still arms (harmless, already on)")
|
||||
local before = #textInputLog
|
||||
ri:keypressed("escape")
|
||||
eq(#textInputLog, before,
|
||||
"desktop disarm is a no-op: the hosted save editor keeps text input on "
|
||||
.. "(#529)")
|
||||
|
||||
T.finish("launcher_text_input_bug578")
|
||||
@@ -0,0 +1,156 @@
|
||||
-- Menu button sounds (#570). HandleMenuInput_ (home/window.asm) replays
|
||||
-- SFX_PRESS_AB whenever the watched keys it just returned on include
|
||||
-- PAD_A | PAD_B, unless BIT_NO_MENU_BUTTON_SOUND is set in wMiscFlags;
|
||||
-- DisplayListMenuID watches PAD_A | PAD_B | PAD_SELECT (home/list_menu.asm),
|
||||
-- so SELECT is answered but never beeps; DisplayOptionMenu plays it only at
|
||||
-- .exitMenu (engine/menus/main_menu.asm), i.e. B, START and A on CANCEL.
|
||||
-- Silence is half the contract: a click the original does not make is as
|
||||
-- wrong as one it makes and the port swallows.
|
||||
-- luajit tests/engine/menu_click_bug570.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
-- The UI modules reach for Sound lazily (require inside the press branch),
|
||||
-- so seeding package.loaded before they load is enough to see every cue.
|
||||
local played = {}
|
||||
package.loaded["src.core.Sound"] = {
|
||||
play = function(_, key) played[#played + 1] = key end,
|
||||
}
|
||||
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local OptionsMenu = require("src.ui.OptionsMenu")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
|
||||
-- A stub game with the three things a menu touches: a stack it can pop
|
||||
-- itself off, an input queue that is one fixed step of edges, and a
|
||||
-- non-nil `data` (the beep helpers skip themselves when data is nil, which
|
||||
-- is how the harness-driven screens stay silent).
|
||||
local function newGame()
|
||||
local game = { data = {}, save = { options = {}, party = {},
|
||||
flags = {}, inventory = {} } }
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
game.input = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function() return false end,
|
||||
}
|
||||
function game:writeOptions() end
|
||||
return game
|
||||
end
|
||||
|
||||
-- one fixed step with `btn` on its edge; returns the cues it produced
|
||||
local function press(state, btn)
|
||||
played = {}
|
||||
state.game.input.queue = { [btn] = true }
|
||||
state:update(1 / 60)
|
||||
state.game.input.queue = {}
|
||||
return played
|
||||
end
|
||||
|
||||
local function beeps(state, btn)
|
||||
local cues = press(state, btn)
|
||||
for _, key in ipairs(cues) do
|
||||
if key ~= "Press_AB" then
|
||||
check(false, "unexpected cue " .. tostring(key) .. " on " .. btn)
|
||||
end
|
||||
end
|
||||
return #cues
|
||||
end
|
||||
|
||||
-- ITEM and POKéDEX are both DisplayListMenuID lists, and cancelling either
|
||||
-- was the silent half of the report.
|
||||
local function newList(opts)
|
||||
local game = newGame()
|
||||
local list = ListMenu.new(game, "BAG",
|
||||
{ { label = "POTION", value = "POTION" },
|
||||
{ label = "ANTIDOTE", value = "ANTIDOTE" } }, opts)
|
||||
game.stack:push(list)
|
||||
return list, game
|
||||
end
|
||||
|
||||
local list = newList({ onChoose = function() end })
|
||||
eq(beeps(list, "a"), 1, "the first A on an item in ITEM clicks once (#570)")
|
||||
eq(beeps(list, "b"), 1, "B out of a list clicks once (ITEM, POKéDEX cancel)")
|
||||
|
||||
local moves = newList({ onChoose = function() end,
|
||||
onSelectKey = function() end })
|
||||
eq(beeps(moves, "select"), 0,
|
||||
"SELECT is watched by DisplayListMenuID but outside its PAD_A | PAD_B "
|
||||
.. "sound test, so the swap key stays silent")
|
||||
eq(beeps(moves, "up"), 0, "moving the cursor is silent")
|
||||
eq(beeps(moves, "down"), 0, "moving the cursor is silent both ways")
|
||||
|
||||
-- Both PC sessions set BIT_NO_MENU_BUTTON_SOUND for their whole run
|
||||
-- (engine/menus/pc.asm, engine/menus/players_pc.asm), so their lists are
|
||||
-- the control case: the same code path, deliberately mute.
|
||||
local pc = newList({ noSound = true, onChoose = function() end })
|
||||
eq(beeps(pc, "a"), 0, "a PC list holds BIT_NO_MENU_BUTTON_SOUND: A is mute")
|
||||
eq(beeps(pc, "b"), 0, "and so is backing out of it")
|
||||
|
||||
-- an emptied bag still answers A and B, and HandleMenuInput_ does not care
|
||||
-- that the list has no rows
|
||||
local emptyGame = newGame()
|
||||
local empty = ListMenu.new(emptyGame, "BAG", {}, {})
|
||||
emptyGame.stack:push(empty)
|
||||
eq(beeps(empty, "a"), 1, "A out of an empty list still clicks")
|
||||
|
||||
-- OPTION: .exitMenu is the only PlaySound in DisplayOptionMenu
|
||||
local function newOptions()
|
||||
local game = newGame()
|
||||
local om = OptionsMenu.new(game)
|
||||
game.stack:push(om)
|
||||
return om
|
||||
end
|
||||
|
||||
local om = newOptions()
|
||||
om.index = 1
|
||||
eq(beeps(om, "a"), 0, "A on a setting row re-loops in the original: silent")
|
||||
eq(beeps(om, "right"), 0, "and the Left/Right toggles are silent too")
|
||||
eq(beeps(om, "left"), 0, "in both directions")
|
||||
om.index = #om.rows + 1 -- CANCEL sits one past the hook-built rows
|
||||
eq(beeps(om, "a"), 1, "A on CANCEL in OPTION clicks once (#570)")
|
||||
eq(beeps(newOptions(), "b"), 1, "B out of OPTION clicks once (#570)")
|
||||
eq(beeps(newOptions(), "start"), 1, "START leaves OPTION the same way")
|
||||
|
||||
-- POKéMON: HandlePartyMenuInput runs through HandleMenuInput_
|
||||
local function newParty()
|
||||
local game = newGame()
|
||||
local pm = PartyMenu.new(game, {})
|
||||
game.stack:push(pm)
|
||||
return pm
|
||||
end
|
||||
|
||||
eq(beeps(newParty(), "b"), 1, "B out of POKéMON clicks once (#570)")
|
||||
eq(beeps(newParty(), "a"), 1, "and A in POKéMON clicks")
|
||||
eq(beeps(newParty(), "down"), 0, "moving between slots is silent")
|
||||
|
||||
-- The start menu itself already beeped before the fix; pinned here because
|
||||
-- an over-eager patch that beeps on every watched key would break it.
|
||||
-- draw_start_menu.asm's mask includes PAD_START, and START is outside the
|
||||
-- PAD_A | PAD_B sound test, so closing the menu with it is silent.
|
||||
local function newStartMenu()
|
||||
local game = newGame()
|
||||
local m = Menu.new(game, { { label = "POKéDEX", onSelect = function() end },
|
||||
{ label = "ITEM", onSelect = function() end } },
|
||||
{ startCloses = true })
|
||||
game.stack:push(m)
|
||||
return m
|
||||
end
|
||||
|
||||
eq(beeps(newStartMenu(), "a"), 1, "A on a start-menu row clicks")
|
||||
eq(beeps(newStartMenu(), "b"), 1, "B closing the start menu clicks")
|
||||
eq(beeps(newStartMenu(), "start"), 0,
|
||||
"START closes the start menu silently: it is watched but not in the "
|
||||
.. "PAD_A | PAD_B branch")
|
||||
|
||||
T.finish("menu_click_bug570")
|
||||
@@ -0,0 +1,141 @@
|
||||
-- Boxed-menu row geometry (#564 start menu, #572 the PC and Pokédex windows).
|
||||
-- pokered's menu boxes hug their choices: the last one lands on the bottom
|
||||
-- interior row, so a double-spaced menu whose box is n*2+2 tall starts at
|
||||
-- ty+2 -- draw_start_menu.asm (TextBoxBorder at 10,0 b=$0e, then hlcoord
|
||||
-- 12,2 with wTopMenuItemY 2), players_pc.asm (0,0 b=8 c=14, hlcoord 2,2).
|
||||
-- The port drew every row one tile high, which parked the text against the
|
||||
-- top border and left the bottom interior row blank.
|
||||
-- ROM-free: builds the real screens over tests/fixture_data.
|
||||
-- luajit tests/engine/menu_row_offset_bug564.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local check, eq, same = T.check, T.eq, T.same
|
||||
local Data = T.fixtures.load()
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local function newGame()
|
||||
local stack = setmetatable({}, { __index = StateStack })
|
||||
stack:init()
|
||||
local save = SaveData.newGame()
|
||||
save.flags = save.flags or {}
|
||||
save.flags.EVENT_GOT_POKEDEX = true
|
||||
save.pokedex = { seen = { FIXMON_A = true }, owned = { FIXMON_A = true } }
|
||||
save.inventory = { FIX_POTION = 3 }
|
||||
return {
|
||||
data = Data, save = save, stack = stack,
|
||||
input = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function() return false end,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- Draw the menu with the font stubbed out and report, in tile rows, where
|
||||
-- the labels, the ▶ cursor and the "more below" arrow actually landed.
|
||||
local function layout(menu)
|
||||
local realDraw, realCode = Font.draw, Font.drawCode
|
||||
local out = { rows = {}, cols = {} }
|
||||
Font.draw = function(_, x, y)
|
||||
out.rows[#out.rows + 1] = y / 8
|
||||
out.cols[#out.cols + 1] = x / 8
|
||||
return 8
|
||||
end
|
||||
Font.drawCode = function(code, x, y)
|
||||
if code == Theme.cursor then
|
||||
out.cursor, out.cursorCol = y / 8, x / 8
|
||||
elseif code == Theme.moreArrow then
|
||||
out.arrow = y / 8
|
||||
end
|
||||
end
|
||||
local ok, err = pcall(menu.draw, menu)
|
||||
Font.draw, Font.drawCode = realDraw, realCode
|
||||
if not ok then error(err, 0) end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Every boxed menu owes the same two things to its border, and the second
|
||||
-- is the one #564/#572 were about: a menu that starts at ty+1 leaves the
|
||||
-- bottom interior row empty and the text crowded under the top edge.
|
||||
local function boxRules(menu, label)
|
||||
local at = layout(menu)
|
||||
local first, last = at.rows[1], at.rows[#at.rows]
|
||||
check(first and first >= menu.ty + 1 and last <= menu.ty + menu.th - 2,
|
||||
label .. ": every choice sits inside the border (rows "
|
||||
.. tostring(first) .. ".." .. tostring(last) .. ", interior "
|
||||
.. (menu.ty + 1) .. ".." .. (menu.ty + menu.th - 2) .. ")")
|
||||
eq(last, menu.ty + menu.th - 2,
|
||||
label .. ": the last choice lands on the bottom interior row")
|
||||
eq(at.cursor, at.rows[menu.index - menu.scroll],
|
||||
label .. ": the cursor shares its row with the highlighted choice")
|
||||
eq(at.cursorCol, menu.tx + 1, label .. ": the cursor sits one tile in")
|
||||
return at
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- #564
|
||||
-- draw_start_menu.asm: box at (10,0) 14 rows tall, first choice at row 2,
|
||||
-- seven double-spaced choices, so the last is on row 14 and the blank row
|
||||
-- is the one under the top border.
|
||||
local game = newGame()
|
||||
local start = Screens.push(game, "StartMenu")
|
||||
eq(#start.items, 7, "vanilla start menu has seven rows with the dex")
|
||||
local at = boxRules(start, "start menu")
|
||||
same(at.rows, { 2, 4, 6, 8, 10, 12, 14 }, "start menu rows match hlcoord 12,2")
|
||||
eq(at.cols[1], start.tx + 2, "start menu labels sit two tiles in")
|
||||
|
||||
start.index = 3
|
||||
eq(layout(start).cursor, 6, "cursor follows the third row down to row 6")
|
||||
|
||||
-- The "more below" glyph rides the bottom border, because the bottom
|
||||
-- interior row now belongs to a choice (#564).
|
||||
local Menu = require("src.ui.Menu")
|
||||
local many = {}
|
||||
for i = 1, 10 do many[i] = { label = "ROW" .. i } end
|
||||
local scrolled = Menu.new(game, many,
|
||||
{ tx = 9, ty = 0, tw = 11, maxVisible = 8 }) -- StartMenu's own opts
|
||||
local sat = layout(scrolled)
|
||||
eq(sat.arrow, scrolled.ty + scrolled.th - 1,
|
||||
"the more-below arrow is on the border row")
|
||||
check(sat.arrow > sat.rows[#sat.rows],
|
||||
"the arrow is clear of the last visible choice")
|
||||
|
||||
-- ---------------------------------------------------------------- #572
|
||||
-- players_pc.asm: TextBoxBorder (0,0) b=8 c=14 -> a 16x10 box whose four
|
||||
-- choices start at hlcoord 2,2.
|
||||
local pc = Screens.push(newGame(), "PlayerPC")
|
||||
eq(pc.th, 10, "player's PC box is 10 tiles tall")
|
||||
same(boxRules(pc, "player's PC").rows, { 2, 4, 6, 8 },
|
||||
"player's PC rows match hlcoord 2,2")
|
||||
|
||||
-- The Pokédex side menu (pokedex.asm PokedexMenuItemsText): DATA / CRY /
|
||||
-- AREA / QUIT, opened by choosing a seen species off the dex list.
|
||||
local dexGame = newGame()
|
||||
local dexList = Screens.push(dexGame, "PokedexMenu", {})
|
||||
local entry = dexList.items[1]
|
||||
eq(entry.value, "FIXMON_A", "the fixture dex list opens on a seen species")
|
||||
dexList.onChoose(entry, dexList)
|
||||
local side = dexGame.stack:top()
|
||||
check(side ~= dexList, "choosing an entry pushes the side menu")
|
||||
same(boxRules(side, "Pokédex side menu").rows, { 10, 12, 14, 16 },
|
||||
"side menu rows fill its box")
|
||||
|
||||
-- The bag's USE / TOSS box is the one menu pokered draws without a blank
|
||||
-- leading row: USE_TOSS_MENU_TEMPLATE (data/text_boxes.asm) is (13,10) to
|
||||
-- (19,14) with the text at 15,11, so two double-spaced choices exactly
|
||||
-- fill rows 11 and 13. #284 sized the port's box to that, which is why
|
||||
-- the shared "first choice at ty+2" rule pushes TOSS onto the border here.
|
||||
local bagGame = newGame()
|
||||
local bag = Screens.push(bagGame, "BagMenu")
|
||||
bag.onChoose(bag.items[1], bag)
|
||||
local useToss = bagGame.stack:top()
|
||||
eq(#useToss.items, 2, "the bag submenu is USE / TOSS")
|
||||
boxRules(useToss, "bag USE/TOSS")
|
||||
|
||||
T.finish("menu_row_offset_bug564")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user