mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 15:51:17 +02:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9954225701 | |||
| 6bb2e078c0 | |||
| 323c59e54f | |||
| d26d63ed38 | |||
| 2009df3dd1 | |||
| f0f3e9634b | |||
| ca1beaefe7 | |||
| 24cf1b3317 | |||
| 88e2ec2042 | |||
| be60c7c1d5 | |||
| 9be21380c7 | |||
| 22fd196045 | |||
| d5293dffb8 | |||
| 9326b07583 | |||
| 90437a5d8d | |||
| c4ec5dc1c5 | |||
| 3b19826f1e | |||
| 95e4715bc3 | |||
| 81edc97d86 | |||
| 92c8bac714 | |||
| 1df04aa18d | |||
| 218b9249a2 | |||
| 50947eec2a | |||
| 2624bb0210 | |||
| 6e9685f302 | |||
| 33899c237e | |||
| 0fad222569 | |||
| ddcce5b95a | |||
| 6fd9741fab | |||
| caa65182dc |
@@ -49,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!",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+28
-1
@@ -315,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
|
||||
@@ -433,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.
|
||||
|
||||
@@ -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,6 +94,17 @@ 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
|
||||
@@ -312,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -426,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) {
|
||||
@@ -448,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());
|
||||
@@ -478,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) {
|
||||
@@ -532,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;
|
||||
@@ -762,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);
|
||||
}
|
||||
@@ -944,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>
|
||||
|
||||
@@ -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 {} \;
|
||||
|
||||
+35
-3
@@ -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,7 @@ 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=""
|
||||
@@ -82,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"
|
||||
@@ -92,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
|
||||
@@ -232,6 +238,30 @@ 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
|
||||
@@ -326,8 +356,9 @@ pack_game_love() {
|
||||
# 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 tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
tools/save-editor/panels/Party.lua $MANIFESTS; do
|
||||
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
|
||||
@@ -640,6 +671,7 @@ 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
|
||||
|
||||
+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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
+155
-17
@@ -61,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 = {
|
||||
@@ -637,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.
|
||||
@@ -818,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
|
||||
|
||||
@@ -1004,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
|
||||
@@ -1065,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
|
||||
@@ -1117,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,
|
||||
@@ -1154,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
|
||||
@@ -1268,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()
|
||||
@@ -1312,6 +1386,7 @@ function RomImporter:_cycleTab(delta)
|
||||
self._slotPress = nil
|
||||
self._modPress = nil
|
||||
self._findSearchFocus = false
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_updatePadCursor(dt)
|
||||
@@ -2190,7 +2265,7 @@ function RomImporter:draw()
|
||||
col(PAL.bgBot, 0.72)
|
||||
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 = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
@@ -2234,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"),
|
||||
@@ -2405,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)
|
||||
@@ -2512,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
|
||||
@@ -2572,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
|
||||
@@ -2679,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
|
||||
@@ -2800,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
|
||||
@@ -2832,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
|
||||
@@ -2848,6 +2948,7 @@ function RomImporter:keypressed(key)
|
||||
self:_commitRename()
|
||||
elseif key == "escape" then
|
||||
self._rename = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -2858,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
|
||||
@@ -2888,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
|
||||
@@ -3441,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
|
||||
@@ -3448,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
|
||||
@@ -3475,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)
|
||||
@@ -3626,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,
|
||||
@@ -4518,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 "")
|
||||
|
||||
@@ -86,8 +86,58 @@ local function mergeConflictLists(conflicts, incompatible)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
|
||||
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
|
||||
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
|
||||
-- panel may draw must be scrubbed here -- the one place every mod manifest
|
||||
-- passes through -- or a single mangled description crashes the whole MODS
|
||||
-- panel instead of misrendering one card.
|
||||
local function scrubUtf8(s)
|
||||
if type(s) ~= "string" then return s end
|
||||
s = s:gsub("^\239\187\191", "")
|
||||
local out, i, n = {}, 1, #s
|
||||
while i <= n do
|
||||
local b = s:byte(i)
|
||||
local len
|
||||
if b < 0x80 then len = 1
|
||||
elseif b >= 0xC2 and b <= 0xDF then len = 2
|
||||
elseif b >= 0xE0 and b <= 0xEF then len = 3
|
||||
elseif b >= 0xF0 and b <= 0xF4 then len = 4
|
||||
end
|
||||
local ok = len ~= nil and i + len - 1 <= n
|
||||
if ok and len > 1 then
|
||||
for j = i + 1, i + len - 1 do
|
||||
local c = s:byte(j)
|
||||
if c < 0x80 or c > 0xBF then ok = false; break end
|
||||
end
|
||||
if ok then
|
||||
-- boundary lead bytes narrow their second byte: no overlongs
|
||||
-- (E0/F0), no surrogates (ED), nothing past U+10FFFF (F4)
|
||||
local b2 = s:byte(i + 1)
|
||||
if (b == 0xE0 and b2 < 0xA0) or (b == 0xED and b2 > 0x9F)
|
||||
or (b == 0xF0 and b2 < 0x90) or (b == 0xF4 and b2 > 0x8F) then
|
||||
ok = false
|
||||
end
|
||||
end
|
||||
end
|
||||
if ok then
|
||||
out[#out + 1] = s:sub(i, i + len - 1)
|
||||
i = i + len
|
||||
else
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
function Manifest.validate(raw, path)
|
||||
assert(type(raw) == "table", "manifest must be an object")
|
||||
-- scrubbed in place so every later reader agrees, including the launcher's
|
||||
-- badge derivation, which reads raw.category rather than the validated copy
|
||||
raw.name = scrubUtf8(raw.name)
|
||||
raw.version = scrubUtf8(raw.version)
|
||||
raw.description = scrubUtf8(raw.description)
|
||||
raw.category = scrubUtf8(raw.category)
|
||||
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
|
||||
"manifest id must contain only letters, numbers, _ or -")
|
||||
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
|
||||
|
||||
@@ -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
|
||||
+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
|
||||
|
||||
@@ -448,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
|
||||
|
||||
@@ -73,6 +73,7 @@ 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")
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -1155,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
|
||||
@@ -1891,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))
|
||||
@@ -2558,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
|
||||
@@ -2586,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
|
||||
@@ -2607,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
|
||||
@@ -3829,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
|
||||
@@ -3837,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",
|
||||
@@ -4568,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
|
||||
|
||||
@@ -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
|
||||
|
||||
+43
-7
@@ -15,11 +15,21 @@ local STEP_FRAMES = 16
|
||||
-- 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 sample that can
|
||||
-- commit to a step lands exactly 2 fixed steps after the turn -- the same
|
||||
-- 2-frames-per-iteration cadence that makes STEP_FRAMES 16 above
|
||||
-- (wWalkCounter = 8, 2px per AdvancePlayerSprite) (#415)
|
||||
local TURN_FRAMES = 2
|
||||
-- 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)
|
||||
@@ -67,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
|
||||
@@ -75,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,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,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
|
||||
@@ -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
|
||||
@@ -1,40 +1,255 @@
|
||||
-- Driver: a quick directional tap should turn the player in place, not
|
||||
-- also take a step (#415). Player:tryMove (src/world/Player.lua) only
|
||||
-- steps once turnTimer has counted down; TURN_FRAMES/turnFrames set how
|
||||
-- many fixed steps a facing change blocks movement for before a held
|
||||
-- direction is allowed to commit to a step. This is an input-timing feel
|
||||
-- bug: no assertion here can tell a quick tap from a held one the way a
|
||||
-- human thumb can, so this hands the pad over rather than scripting taps
|
||||
-- at a fixed, unrealistic frame count.
|
||||
--
|
||||
-- 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
|
||||
|
||||
-- REDS_HOUSE_2F: the player's own bedroom, the default new-game spawn
|
||||
-- (src/core/SaveData.lua bedroom fallback). Small enough that an
|
||||
-- accidental step is obvious -- a stray step toward the stairs at
|
||||
-- (7,1)/(7,2) or into the desk/bed furniture is immediately visible.
|
||||
U.teleport(game, "REDS_HOUSE_2F", 5, 6, "down")
|
||||
U.wait(10)
|
||||
local ow = game.stack:top()
|
||||
check("Red's bedroom is loaded", ow.map.id == "REDS_HOUSE_2F")
|
||||
check("player starts facing down", ow.player.facing == "down")
|
||||
-- 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 }
|
||||
|
||||
U.log("Standing in the middle of the bedroom, facing down.")
|
||||
U.log("Tap Up once, quickly, then tap Right once, quickly.")
|
||||
U.log("Right: each tap only turns you to face that way -- your feet")
|
||||
U.log("stay on the same tile both times. Wrong (#415): a quick tap")
|
||||
U.log("still slides you one tile in the new direction, same as holding.")
|
||||
U.log("Now hold Up for a beat: turning into a real step, then a walk,")
|
||||
U.log("should feel identical to before -- no extra pause was added there.")
|
||||
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()
|
||||
|
||||
@@ -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,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")
|
||||
@@ -297,4 +297,35 @@ do
|
||||
eq(rows[1].name, "bare", "a nameless row falls back to its id")
|
||||
end
|
||||
|
||||
-- ------- manifest strings are scrubbed to valid UTF-8 (MODS panel crash:
|
||||
-- LÖVE's printf raises "Invalid UTF-8" on a mangled name/description, so
|
||||
-- validate must drop bad bytes before any panel draws them)
|
||||
|
||||
do
|
||||
local m = mf({ id = "utf", entry = "m.lua",
|
||||
-- BOM-prefixed name (a real manifest shipped this way), a Latin-1 e-acute
|
||||
-- (\233, invalid as UTF-8) in the description, and a lone continuation
|
||||
-- byte in the version
|
||||
name = "\239\187\191Run Mode",
|
||||
version = "1.0\128.0",
|
||||
description = "caf\233 latt\233",
|
||||
category = "UI\255" })
|
||||
eq(m.name, "Run Mode", "a leading BOM is stripped from the name")
|
||||
eq(m.version, "1.0.0", "invalid bytes are dropped from the version")
|
||||
eq(m.description, "caf latt", "Latin-1 bytes are dropped, not replaced")
|
||||
eq(m.raw.category, "UI", "raw.category is scrubbed in place for the badge")
|
||||
|
||||
local ok2 = mf({ id = "utf2", name = "Vers\195\163oVermelha", version = "1.0.0",
|
||||
entry = "m.lua", description = "Pok\195\169mon \240\159\148\165" })
|
||||
eq(ok2.name, "Vers\195\163oVermelha", "valid two-byte sequences survive")
|
||||
eq(ok2.description, "Pok\195\169mon \240\159\148\165",
|
||||
"valid three- and four-byte sequences survive")
|
||||
|
||||
-- surrogate half (ED A0 80) and overlong slash (C0 AF) are invalid even
|
||||
-- though their lead bytes look plausible
|
||||
local bad = mf({ id = "utf3", name = "a\237\160\128b\192\175c",
|
||||
version = "1.0.0", entry = "m.lua" })
|
||||
eq(bad.name, "abc", "surrogates and overlongs are dropped")
|
||||
end
|
||||
|
||||
T.finish("launcher_mods")
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,163 @@
|
||||
-- Prof. Oak's PC session (#576): engine/menus/oaks_pc.asm OpenOaksPC --
|
||||
-- the access text, "Want to get your #DEX rated?" with a YES/NO, the dex
|
||||
-- rating (completion line + tier text), the rating jingle only once the
|
||||
-- rating text has printed (DisplayDexRating -> PlayPokedexRatingSfx), and
|
||||
-- the "Closed link to PROF.OAK's PC." tail before control returns. The
|
||||
-- old flow played the jingle the moment the entry was picked and skipped
|
||||
-- both the intro and the closing link.
|
||||
-- ROM-free: uses the fixture dataset so CI (no data/generated/) stays green.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.load()
|
||||
|
||||
-- the ROM-extracted strings the fixture text table does not carry; labels
|
||||
-- and wording match pokered text/pokedex_ratings.asm + oaks_pc.asm
|
||||
Data.text._AccessedOaksPCText =
|
||||
"Accessed PROF.\nOAK's PC.\fAccessed #DEX\nRating System."
|
||||
Data.text._GetDexRatedText = "Want to get your\n#DEX rated?"
|
||||
Data.text._ClosedOaksPCText = "Closed link to\nPROF.OAK's PC."
|
||||
Data.text._DexCompletionText =
|
||||
"#DEX comp-\nletion is:\f{NUM:hDexRatingNumMonsSeen} #MON seen\n" ..
|
||||
"{NUM:hDexRatingNumMonsOwned} #MON owned\fPROF.OAK's\nRating:"
|
||||
Data.text._DexRatingText_Own50To59 =
|
||||
"You finally got at\nleast 50 species!"
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
local function setUpvalue(fn, name, val)
|
||||
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, val); return true end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
|
||||
local pushed = {}
|
||||
local plays = {}
|
||||
local stackStub = {
|
||||
push = function(_, item)
|
||||
pushed[#pushed + 1] = item
|
||||
end,
|
||||
}
|
||||
local textBoxStub = {
|
||||
new = function(_, text, onDone, opts)
|
||||
return { kind = "text", text = text, onDone = onDone, opts = opts }
|
||||
end,
|
||||
}
|
||||
local menuStub = {
|
||||
new = function(_, items, opts)
|
||||
return { kind = "menu", items = items, opts = opts or {} }
|
||||
end,
|
||||
}
|
||||
-- Sound / Menu are required lazily at the call sites; stub via package.loaded
|
||||
local realSound = package.loaded["src.core.Sound"]
|
||||
package.loaded["src.core.Sound"] = {
|
||||
play = function(_, name)
|
||||
plays[#plays + 1] = name
|
||||
end,
|
||||
playCry = function() end,
|
||||
}
|
||||
local realMenu = package.loaded["src.ui.Menu"]
|
||||
package.loaded["src.ui.Menu"] = menuStub
|
||||
|
||||
local fakeGame = {
|
||||
data = Data,
|
||||
save = SaveData.newGame(),
|
||||
stack = stackStub,
|
||||
}
|
||||
for _, name in ipairs({ "openOaksPC", "dexRating" }) do
|
||||
T.check(setUpvalue(OW[name], "TextBox", textBoxStub),
|
||||
("TextBox upvalue on %s"):format(name))
|
||||
T.check(setUpvalue(OW[name], "Game", fakeGame),
|
||||
("Game upvalue on %s"):format(name))
|
||||
end
|
||||
T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC")
|
||||
|
||||
local fakeSelf = setmetatable({}, { __index = OW })
|
||||
|
||||
local function lastPush()
|
||||
return pushed[#pushed]
|
||||
end
|
||||
local function reset()
|
||||
fakeGame.save = SaveData.newGame()
|
||||
fakeGame.save.flags.EVENT_GOT_POKEDEX = true
|
||||
local seen, owned = {}, {}
|
||||
for i = 1, 55 do seen[i] = true; owned[i] = true end
|
||||
fakeGame.save.pokedex = { seen = seen, owned = owned }
|
||||
pushed = {}
|
||||
plays = {}
|
||||
end
|
||||
local function runChain()
|
||||
-- A through every box that is up; the choice box answers YES
|
||||
local guard = 0
|
||||
while lastPush() and lastPush().kind == "text" and guard < 10 do
|
||||
guard = guard + 1
|
||||
local box = lastPush()
|
||||
if box.opts and box.opts.choice then
|
||||
box.opts.choice(true)
|
||||
elseif box.onDone then
|
||||
box.onDone()
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- === full session from the launcher menu: intro, YES, rating, jingle, close
|
||||
reset()
|
||||
local done = false
|
||||
fakeSelf:openPC(function() done = true end)
|
||||
local menu = lastPush()
|
||||
T.eq(menu.kind, "menu", "openPC pushes the PC menu")
|
||||
local oak
|
||||
for _, item in ipairs(menu.items) do
|
||||
if item.label == "PROF.OAK's PC" then oak = item end
|
||||
end
|
||||
T.check(oak ~= nil, "PROF.OAK's PC is offered once the Pokédex is had")
|
||||
plays = {} -- drop the menu's Turn_On_PC; the session's jingle is what counts
|
||||
oak.onSelect()
|
||||
T.eq(pushed[2].kind, "text", "selection opens the access text")
|
||||
T.check(tostring(pushed[2].text):find("Accessed", 1, true) ~= nil,
|
||||
"first session box is the access text")
|
||||
runChain()
|
||||
T.check(done, "session completes")
|
||||
T.check(pushed[3].opts ~= nil and pushed[3].opts.choice ~= nil,
|
||||
"the rated question carries the YES/NO choice")
|
||||
T.check(tostring(pushed[3].text):find("rated", 1, true) ~= nil,
|
||||
"second session box asks for the rating")
|
||||
local ratingBox = pushed[4]
|
||||
T.check(ratingBox.opts and ratingBox.opts.auto ~= nil
|
||||
and ratingBox.opts.auto.wait ~= nil,
|
||||
"rating box sounds the jingle then waits for a button")
|
||||
T.check(tostring(ratingBox.text):find("55", 1, true) ~= nil,
|
||||
"completion line carries the seen/owned counts")
|
||||
T.check(tostring(ratingBox.text):find("least 50 species", 1, true) ~= nil,
|
||||
"rating box carries the Own50To59 tier text")
|
||||
T.eq(#plays, 0, "no jingle while the evaluation is printing")
|
||||
ratingBox.opts.auto.sound()
|
||||
T.eq(#plays, 1, "jingle fires once the rating text is printed")
|
||||
T.eq(plays[1], "Pokedex_Rating", "jingle is the Pokedex_Rating fanfare")
|
||||
T.check(tostring(pushed[5].text):find("Closed link", 1, true) ~= nil,
|
||||
"the closing link prints at the end of the session")
|
||||
|
||||
-- === declining the rating skips the evaluation but still closes the link
|
||||
reset()
|
||||
done = false
|
||||
fakeSelf:openOaksPC(function() done = true end)
|
||||
T.eq(pushed[1].kind, "text", "openOaksPC opens with the access text")
|
||||
pushed[1].onDone()
|
||||
T.check(pushed[2].opts and pushed[2].opts.choice ~= nil,
|
||||
"rated question is a YES/NO")
|
||||
pushed[2].opts.choice(false)
|
||||
T.check(tostring(lastPush().text):find("Closed link", 1, true) ~= nil,
|
||||
"NO skips the rating and closes the PC")
|
||||
T.eq(#plays, 0, "declining plays no jingle")
|
||||
|
||||
package.loaded["src.ui.Menu"] = realMenu
|
||||
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end
|
||||
|
||||
T.finish("oaks_pc_flow")
|
||||
@@ -0,0 +1,94 @@
|
||||
-- QUIT on a Pokédex entry closes the whole Pokédex (#571).
|
||||
-- HandlePokedexSideMenu hands ShowPokedexMenu b=1 for QUIT and b=2 for B
|
||||
-- (engine/menus/pokedex.asm); only b=2 loops back to .doPokemonListMenu,
|
||||
-- b=1 falls through to .exitPokedex, which drops the dex and returns to
|
||||
-- whoever opened it with wBattleAndStartSavedMenuItem intact. The port
|
||||
-- popped the side menu and left the list up, so QUIT looked like B.
|
||||
-- ROM-free: real StateStack and real screens over tests/fixture_data.
|
||||
-- luajit tests/engine/pokedex_quit_bug571.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local check, eq = T.check, T.eq
|
||||
local Data = T.fixtures.load()
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
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 } }
|
||||
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
|
||||
|
||||
local function press(game, btn)
|
||||
game.input.queue = { [btn] = true }
|
||||
game.stack:update(1 / 60)
|
||||
game.input.queue = {}
|
||||
end
|
||||
|
||||
-- what the stack is carrying, named the way Screens tags its screens
|
||||
local function stackIds(game)
|
||||
local ids = {}
|
||||
for i, state in ipairs(game.stack.states) do
|
||||
ids[i] = tostring(state.screenId or "sideMenu")
|
||||
end
|
||||
return table.concat(ids, " > ")
|
||||
end
|
||||
|
||||
local function openSideMenu(game)
|
||||
local start = Screens.push(game, "StartMenu")
|
||||
eq(start.items[start.index].label, "POKéDEX",
|
||||
"the start menu opens with the cursor on POKéDEX")
|
||||
press(game, "a")
|
||||
local list = game.stack:top()
|
||||
eq(list.screenId, "PokedexMenu", "POKéDEX opens the dex list")
|
||||
eq(list.items[list.index].value, "FIXMON_A",
|
||||
"the cursor is on a seen species, so A opens the side menu")
|
||||
press(game, "a")
|
||||
local side = game.stack:top()
|
||||
check(side ~= list, "A on the entry pushed the DATA/CRY/AREA/QUIT menu")
|
||||
return list, side
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- QUIT
|
||||
local game = newGame()
|
||||
local list, side = openSideMenu(game)
|
||||
-- the start menu popped itself when POKéDEX was chosen (Menu pops before
|
||||
-- onSelect unless the row is keepOpen), so this is dex list + side menu
|
||||
eq(#game.stack.states, 2, "dex list and side menu are what is on the stack")
|
||||
eq(side.items[#side.items].label, "QUIT", "QUIT is the last side-menu row")
|
||||
side.index = #side.items
|
||||
press(game, "a")
|
||||
|
||||
eq(#game.stack.states, 1,
|
||||
"QUIT unwound the side menu and the dex list (" .. stackIds(game) .. ")")
|
||||
local top = game.stack:top()
|
||||
eq(top.screenId, "StartMenu", "QUIT lands back on the start menu")
|
||||
check(top ~= list, "the dex list is not what is left on the stack")
|
||||
eq(top.items[top.index].label, "POKéDEX",
|
||||
"the start menu cursor is still on POKéDEX (wBattleAndStartSavedMenuItem)")
|
||||
|
||||
-- ---------------------------------------------------------------- B
|
||||
-- The control case: B is b=2, which re-shows the list rather than leaving.
|
||||
-- If QUIT and B ever behave the same again, one of these two fails.
|
||||
local bGame = newGame()
|
||||
local bList = select(1, openSideMenu(bGame))
|
||||
press(bGame, "b")
|
||||
eq(#bGame.stack.states, 1, "B off the side menu keeps the dex list up")
|
||||
eq(bGame.stack:top(), bList, "B returns to the dex list, not the start menu")
|
||||
|
||||
T.finish("pokedex_quit_bug571")
|
||||
@@ -2,7 +2,10 @@
|
||||
-- screen that captured it is still steering by that map (#510). Swapping A
|
||||
-- and B used to close the screen mid-swap, because Input:applyBindings ran
|
||||
-- inside BindingsMenu:storeBinding and turned the player's next confirm
|
||||
-- press into a cancel. No pokered cite: rebinding is port-only (gap C2).
|
||||
-- press into a cancel. A capture commits on the RELEASE of its press, a
|
||||
-- second held input cancels it, and a captured input that another row owns
|
||||
-- swaps rather than steals (#589). No pokered cite: rebinding is
|
||||
-- port-only (gap C2).
|
||||
-- luajit tests/engine/rebind_capture_bug510.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
@@ -73,19 +76,29 @@ eq(game.wroteOptions, 0, "and does not touch options on disk")
|
||||
bm.index = ROW_B
|
||||
press(bm, "a")
|
||||
bm:onKeyPressed("z")
|
||||
eq(game.save.options.bindings.b.key, "z", "the capture stores B = Z")
|
||||
eq(bm.items[ROW_B].right, "Z", "the row shows the new key straight away")
|
||||
check(game.save.options.bindings == nil,
|
||||
"a capture holds its press; nothing stores before the release (#589)")
|
||||
bm:onKeyReleased("z")
|
||||
eq(game.save.options.bindings.b.key, "z", "releasing the press stores B = Z")
|
||||
eq(bm.items[ROW_B].right, "Z/B", "the row shows the new key straight away")
|
||||
eq(game.wroteOptions, 1, "the choice persists immediately")
|
||||
eq(Input.keyBindings["z"], "a",
|
||||
"but the live map still reads Z as A while the screen is open (#510)")
|
||||
eq(#game.stack.states, 1, "capturing Z does not close the screen")
|
||||
|
||||
-- Z was the A row's effective key, so the steal became a swap: the A row
|
||||
-- inherits B's previous key and no key serves two rows (#589)
|
||||
eq(game.save.options.bindings.a.key, "x",
|
||||
"capturing A's key for B hands A the old B key")
|
||||
eq(bm.items[ROW_A].right, "X/A", "and the A row redraws with it")
|
||||
|
||||
-- the next Z the player presses is still confirm, so the A row can be armed
|
||||
bm.index = ROW_A
|
||||
press(bm, "a")
|
||||
eq(bm.capture, bm.items[ROW_A], "the A row arms instead of the screen closing")
|
||||
bm:onKeyPressed("x")
|
||||
eq(game.save.options.bindings.a.key, "x", "the swap's other half stores")
|
||||
bm:onKeyReleased("x")
|
||||
eq(game.save.options.bindings.a.key, "x", "re-capturing A's own key keeps it")
|
||||
eq(Input.keyBindings["x"], "b", "and X is still cancel until the screen closes")
|
||||
|
||||
-- closing commits both halves at once, through ListMenu's onCancel
|
||||
@@ -107,8 +120,23 @@ local padBm = openMenu(padGame)
|
||||
padBm.index = ROW_B
|
||||
press(padBm, "a")
|
||||
padBm:onGamepadPressed("y")
|
||||
padBm:onGamepadReleased("y")
|
||||
eq(padGame.save.options.bindings.b.pad, "y", "a pad capture stores")
|
||||
eq(Input.padBindings["y"], nil, "and stays out of the live pad map until close")
|
||||
|
||||
-- a second input going down while the first is held backs the capture out
|
||||
-- with no keyboard in reach, the pad's Escape (#589)
|
||||
padBm.index = ROW_A
|
||||
press(padBm, "a")
|
||||
padBm:onGamepadPressed("x")
|
||||
padBm:onGamepadPressed("b")
|
||||
check(padBm.capture == nil, "a second press cancels the armed capture")
|
||||
-- Game only calls the hook while it is armed; the straggling release of
|
||||
-- the first button reaches a disarmed menu and stores nothing
|
||||
if padBm.onGamepadReleased then padBm:onGamepadReleased("x") end
|
||||
eq(padGame.save.options.bindings.a, nil,
|
||||
"a cancelled capture's release writes no binding")
|
||||
|
||||
press(padBm, "b")
|
||||
eq(Input.padBindings["y"], "b", "closing commits the pad half too")
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
-- CONTROLS rebinding, the #589 feature set beyond the capture deferral that
|
||||
-- tests/engine/rebind_capture_bug510.lua pins: a captured pad button another
|
||||
-- row effectively owns SWAPS with that row (no input serves two rows, no row
|
||||
-- goes empty), a second input of either kind cancels an armed capture,
|
||||
-- SELECT forgets one row's rebind so it falls back to the default, and START
|
||||
-- confirms then drops the whole overlay. Everything is driven through the
|
||||
-- entry points Game routes raw input to (onKeyPressed/onKeyReleased/
|
||||
-- onGamepadPressed/onGamepadReleased) and through update() for the menu
|
||||
-- keys. No pokered cite: rebinding is port-only (gap C2).
|
||||
-- luajit tests/engine/rebind_swap_clear_bug589.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 Input = require("src.core.Input")
|
||||
local Strings = require("src.core.Strings")
|
||||
local BindingsMenu = require("src.ui.BindingsMenu")
|
||||
|
||||
-- same doubles as rebind_capture_bug510: a stack the menu can pop itself
|
||||
-- off and an input whose queue is one fixed step of edges. data = {} keeps
|
||||
-- ChoiceBox's un-guarded Sound.play on the headless no-audio path.
|
||||
local function newGame()
|
||||
local game = { save = { options = {} }, data = {}, wroteOptions = 0 }
|
||||
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() self.wroteOptions = self.wroteOptions + 1 end
|
||||
return game
|
||||
end
|
||||
|
||||
local function press(state, btn)
|
||||
state.game.input.queue = { [btn] = true }
|
||||
state:update(1 / 60)
|
||||
state.game.input.queue = {}
|
||||
end
|
||||
|
||||
-- rows are BindingsMenu's BUTTONS order
|
||||
local ROW_A, ROW_B, ROW_SELECT = 5, 6, 8
|
||||
|
||||
-- ---- (a) pad capture swaps with the row that owns the button --------------
|
||||
|
||||
Input:init()
|
||||
local game = newGame()
|
||||
local bm = BindingsMenu.new(game)
|
||||
game.stack:push(bm)
|
||||
|
||||
bm.index = ROW_A
|
||||
press(bm, "a")
|
||||
eq(bm.capture, bm.items[ROW_A], "A arms the A row")
|
||||
bm:onGamepadPressed("b")
|
||||
check(game.save.options.bindings == nil,
|
||||
"a pad capture holds its press; nothing stores before the release")
|
||||
bm:onGamepadReleased("b")
|
||||
local bindings = game.save.options.bindings
|
||||
eq(bindings.a.pad, "b", "releasing pad B stores it on the A row")
|
||||
eq(bindings.b.pad, "a",
|
||||
"and the B row, which owned pad B, inherits the A row's old pad (#589)")
|
||||
eq(bm.items[ROW_A].right, "Z/B", "the A row redraws with the new pad")
|
||||
eq(bm.items[ROW_B].right, "X/A", "so does the B row")
|
||||
|
||||
-- after applying, every row's effective pad is unique and none is lost
|
||||
Input:applyBindings(bindings)
|
||||
eq(Input.padBindings["b"], "a", "applied: pad B is action A")
|
||||
eq(Input.padBindings["a"], "b", "applied: pad A is action B")
|
||||
local seen, actions = {}, {}
|
||||
for button, action in pairs(Input.padBindings) do
|
||||
check(not seen[action], "no action is reachable from two pad buttons: "
|
||||
.. tostring(action))
|
||||
seen[action] = button
|
||||
actions[#actions + 1] = action
|
||||
end
|
||||
eq(#actions, 8, "all eight actions still have exactly one pad button")
|
||||
Input:init()
|
||||
|
||||
-- ---- (b) a second input while the first is held cancels -------------------
|
||||
|
||||
-- key then key: the straggling release of the first press writes nothing
|
||||
bm.index = ROW_SELECT
|
||||
press(bm, "a")
|
||||
bm:onKeyPressed("q")
|
||||
bm:onKeyPressed("w")
|
||||
check(bm.capture == nil, "a second key cancels the armed capture")
|
||||
if bm.onKeyReleased then bm:onKeyReleased("q") end
|
||||
check(bindings.select == nil, "the cancelled key capture wrote nothing")
|
||||
|
||||
-- key then pad: cancel crosses input kinds too
|
||||
press(bm, "a")
|
||||
bm:onKeyPressed("q")
|
||||
bm:onGamepadPressed("x")
|
||||
check(bm.capture == nil, "a pad press cancels a held key capture")
|
||||
if bm.onKeyReleased then bm:onKeyReleased("q") end
|
||||
if bm.onGamepadReleased then bm:onGamepadReleased("x") end
|
||||
check(bindings.select == nil, "and still nothing stored")
|
||||
local writesAfterSwap = game.wroteOptions
|
||||
|
||||
-- ---- (c) commit happens on release, not press -----------------------------
|
||||
|
||||
press(bm, "a")
|
||||
bm:onKeyPressed("q")
|
||||
check(bindings.select == nil, "press alone commits nothing (#589)")
|
||||
eq(game.wroteOptions, writesAfterSwap, "and touches nothing on disk")
|
||||
bm:onKeyReleased("q")
|
||||
eq(bindings.select.key, "q", "the release is the commit")
|
||||
eq(bm.items[ROW_SELECT].right, "Q/BACK", "the row shows the new key")
|
||||
|
||||
-- ---- (d) SELECT on a row forgets its rebind -------------------------------
|
||||
|
||||
press(bm, "select")
|
||||
check(bindings.select == nil, "SELECT drops the row's overlay entry (#589)")
|
||||
eq(bm.items[ROW_SELECT].right, "TAB/BACK", "the row falls back to the default")
|
||||
-- the swapped B row clears the same way; a second SELECT on the now
|
||||
-- default row is a no-op, not a write
|
||||
local writes = game.wroteOptions
|
||||
bm.index = ROW_B
|
||||
press(bm, "select")
|
||||
eq(game.wroteOptions, writes + 1, "clearing the swapped B row writes once")
|
||||
check(game.save.options.bindings.b == nil, "and drops its overlay entry")
|
||||
press(bm, "select")
|
||||
eq(game.wroteOptions, writes + 1, "clearing an already-default row writes nothing")
|
||||
|
||||
-- ---- (e) START confirms, then drops the whole overlay ---------------------
|
||||
|
||||
-- rebuild a dirty overlay to reset
|
||||
bm.index = ROW_A
|
||||
press(bm, "a")
|
||||
bm:onKeyPressed("p")
|
||||
bm:onKeyReleased("p")
|
||||
eq(game.save.options.bindings.a.key, "p", "fixture rebind in place")
|
||||
|
||||
press(bm, "start")
|
||||
local box = game.stack:top()
|
||||
check(box ~= bm, "START pushes the confirm box instead of resetting outright")
|
||||
eq(bm.footer, Strings("RESET ALL BINDINGS?"),
|
||||
"the footer doubles as the prompt")
|
||||
|
||||
-- the box starts on NO: a bare A press must keep the overlay
|
||||
press(box, "a")
|
||||
eq(game.stack:top(), bm, "answering pops the box")
|
||||
check(game.save.options.bindings ~= nil, "NO keeps the bindings (defaultNo)")
|
||||
eq(bm.items[ROW_A].right, "P/B", "and the rows keep showing them")
|
||||
|
||||
-- again, flip to YES: the overlay goes away and every row reads default
|
||||
press(bm, "start")
|
||||
box = game.stack:top()
|
||||
press(box, "up")
|
||||
press(box, "a")
|
||||
check(game.save.options.bindings == nil, "YES clears options.bindings (#589)")
|
||||
eq(bm.items[ROW_A].right, "Z/A", "the A row reads its default again")
|
||||
eq(bm.items[ROW_B].right, "X/B", "so does the B row the swap had touched")
|
||||
|
||||
-- closing after the reset leaves the live map at the defaults
|
||||
press(bm, "b")
|
||||
eq(#game.stack.states, 0, "B closes the screen")
|
||||
eq(Input.keyBindings["z"], "a", "the live map is back to Z = A")
|
||||
eq(Input.padBindings["b"], "b", "and pad B = B")
|
||||
|
||||
Input:init()
|
||||
T.finish("rebind_swap_clear_bug589")
|
||||
@@ -0,0 +1,62 @@
|
||||
-- Unit coverage for the render.compose seam (D14: a public-API test names
|
||||
-- the hook, gate_hooks supplies the no-mod parity, docs/modding.md documents
|
||||
-- it). render.compose lets a mod take over window composition: a wrap that
|
||||
-- returns true without calling next owns the whole window; a wrap that calls
|
||||
-- next lets the engine's normal single-window composite run and decorates it.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
|
||||
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
|
||||
local bus = Hooks.new()
|
||||
Runtime.hooks = bus
|
||||
|
||||
-- the shape Renderer:endFrame hands the hook
|
||||
local function fakeCtx()
|
||||
return {
|
||||
renderer = {}, worldCanvas = {}, uiCanvas = {},
|
||||
worldActive = true, zones = {}, worldZones = nil,
|
||||
ww = 480, wh = 432, ox = 0, oy = 12, scale = 3,
|
||||
dpiX = 1, dpiY = 1, secondScreen = {},
|
||||
}
|
||||
end
|
||||
|
||||
-- takeover: a mod drawing its own layout returns true and never calls next,
|
||||
-- so the engine's vanilla composite is skipped entirely
|
||||
do
|
||||
local vanillaRan, gotCtx = false, nil
|
||||
bus:wrap("render.compose", function(next, renderer, ctx)
|
||||
gotCtx = ctx
|
||||
return true
|
||||
end, 0, "ds-mod")
|
||||
local handled = Runtime.call("render.compose",
|
||||
function() vanillaRan = true; return false end,
|
||||
{ tag = "renderer" }, fakeCtx())
|
||||
T.eq(handled, true, "a mod returning true signals full window takeover")
|
||||
T.eq(vanillaRan, false, "takeover skips the engine composite (vanilla not run)")
|
||||
T.check(gotCtx ~= nil and gotCtx.ww == 480 and gotCtx.secondScreen ~= nil,
|
||||
"the hook receives the frame ctx (metrics, canvases, secondScreen)")
|
||||
bus.chains["render.compose"] = nil
|
||||
end
|
||||
|
||||
-- decorate: a mod calling next lets the engine composite run, and the
|
||||
-- engine's not-handled return (false) flows back through the chain
|
||||
do
|
||||
local vanillaRan = false
|
||||
bus:wrap("render.compose", function(next, renderer, ctx)
|
||||
return next()
|
||||
end, 0, "ds-mod")
|
||||
local handled = Runtime.call("render.compose",
|
||||
function() vanillaRan = true; return false end,
|
||||
{ tag = "renderer" }, fakeCtx())
|
||||
T.eq(vanillaRan, true, "calling next runs the engine composite")
|
||||
T.eq(handled, false, "the engine's not-handled return flows back through next")
|
||||
bus.chains["render.compose"] = nil
|
||||
end
|
||||
|
||||
Runtime.events, Runtime.hooks = savedEvents, savedHooks
|
||||
|
||||
T.finish("render_compose_seam")
|
||||
@@ -0,0 +1,197 @@
|
||||
-- Soft reset: A+B+SELECT+START held together drops the game back to the
|
||||
-- title from anywhere, mid-battle included (#563). _Joypad
|
||||
-- (engine/joypad.asm:6) tests the raw read with `cp PAD_BUTTONS` -- an
|
||||
-- equality, so a d-pad direction in the mix cancels it -- and does so ahead
|
||||
-- of the wJoyIgnore / BIT_DISABLE_JOYPAD masking further down, which is why
|
||||
-- the combo still works where ordinary input is being thrown away.
|
||||
-- TrySoftReset then decrements hSoftReset, seeded with 16 by Init
|
||||
-- (home/init.asm:81), one poll at a time. The on-screen overlay's half is
|
||||
-- here too: one finger only ever claims one control.
|
||||
-- luajit tests/engine/soft_reset_bug563.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 Input = require("src.core.Input")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
local Game = require("src.core.Game")
|
||||
|
||||
-- pokered's 16 polls; the port counts fixed steps, which are the same 60Hz
|
||||
local HOLD_STEPS = 16
|
||||
|
||||
-- The keys the hand-off tells a player to press. Pinned because the combo
|
||||
-- is only reachable if all four GB buttons have a key, and because the
|
||||
-- letter A is bound to LEFT here -- a player reaching for "A" on the
|
||||
-- keyboard adds a direction and cancels the chord instead of arming it.
|
||||
Input:init()
|
||||
eq(Input.keyBindings["z"], "a", "Z is the A button")
|
||||
eq(Input.keyBindings["x"], "b", "X is the B button")
|
||||
eq(Input.keyBindings["escape"], "start", "Escape is START")
|
||||
eq(Input.keyBindings["tab"], "select", "Tab is SELECT")
|
||||
eq(Input.keyBindings["a"], "left", "the letter A is LEFT, not the A button")
|
||||
|
||||
-- ---- the chord itself --------------------------------------------------
|
||||
|
||||
local function holdKeys(...)
|
||||
Input:init()
|
||||
for _, key in ipairs({ ... }) do Input:keypressed(key) end
|
||||
end
|
||||
|
||||
holdKeys("z", "x", "escape", "tab")
|
||||
check(Input:softResetHeld(), "all four buttons down arms the combo")
|
||||
|
||||
for _, missing in ipairs({ "z", "x", "escape", "tab" }) do
|
||||
local keys = {}
|
||||
for _, key in ipairs({ "z", "x", "escape", "tab" }) do
|
||||
if key ~= missing then keys[#keys + 1] = key end
|
||||
end
|
||||
holdKeys(unpack(keys))
|
||||
check(not Input:softResetHeld(),
|
||||
"three of the four is not the combo (without " .. missing .. ")")
|
||||
end
|
||||
|
||||
-- `cp PAD_BUTTONS` is an equality: any direction held alongside the four
|
||||
-- takes the raw read off PAD_BUTTONS and TrySoftReset is never reached.
|
||||
for _, dir in ipairs({ "up", "down", "left", "right" }) do
|
||||
holdKeys("z", "x", "escape", "tab", dir)
|
||||
check(not Input:softResetHeld(), dir .. " held alongside cancels the combo")
|
||||
end
|
||||
|
||||
-- ---- the 16-step countdown ---------------------------------------------
|
||||
|
||||
-- one Game:step's worth of the chord; returns whether the reset fired
|
||||
local function stepChord()
|
||||
Input:step()
|
||||
return Input:softResetStep()
|
||||
end
|
||||
|
||||
holdKeys("z", "x", "escape", "tab")
|
||||
for i = 1, HOLD_STEPS - 1 do
|
||||
check(not stepChord(), "step " .. i .. " of the hold does not reset yet")
|
||||
end
|
||||
check(stepChord(), "the " .. HOLD_STEPS .. "th consecutive step resets")
|
||||
|
||||
-- hSoftReset is never re-seeded on release in the original, so its count
|
||||
-- leaks across a session; the port re-arms instead, or a session's worth of
|
||||
-- stray four-button presses would eventually add up to a reset.
|
||||
holdKeys("z", "x", "escape", "tab")
|
||||
for _ = 1, HOLD_STEPS - 1 do stepChord() end
|
||||
Input:keyreleased("tab")
|
||||
check(not stepChord(), "lifting SELECT one step short cancels")
|
||||
Input:keypressed("tab")
|
||||
for i = 1, HOLD_STEPS - 1 do
|
||||
check(not stepChord(),
|
||||
"re-pressing it restarts the count from 16, not from 1 (step " .. i .. ")")
|
||||
end
|
||||
check(stepChord(), "and a full fresh hold resets")
|
||||
|
||||
-- same re-arm for the direction case: a thumb brushing the d-pad mid-hold
|
||||
holdKeys("z", "x", "escape", "tab")
|
||||
for _ = 1, HOLD_STEPS - 1 do stepChord() end
|
||||
Input:keypressed("up")
|
||||
check(not stepChord(), "a direction one step short cancels")
|
||||
Input:keyreleased("up")
|
||||
for _ = 1, HOLD_STEPS - 1 do
|
||||
check(not stepChord(), "and the count starts over")
|
||||
end
|
||||
check(stepChord(), "reaching 16 again from the restart")
|
||||
|
||||
-- ---- Game:step routes it above the state stack --------------------------
|
||||
|
||||
-- Doubles for the two services Game:step touches on the reset path. The
|
||||
-- real stack is not needed: what is being pinned is that the combo is read
|
||||
-- before stack:update, so it fires from a battle, a menu or a cutscene and
|
||||
-- not just from the overworld (#563).
|
||||
local function fakeGame()
|
||||
local game = { input = Input, save = {}, returned = 0 }
|
||||
game.stack = {
|
||||
updates = 0,
|
||||
update = function(self) self.updates = self.updates + 1 end,
|
||||
}
|
||||
function game:returnToTitle() self.returned = self.returned + 1 end
|
||||
return game
|
||||
end
|
||||
|
||||
local game = fakeGame()
|
||||
holdKeys("z", "x", "escape", "tab")
|
||||
for _ = 1, HOLD_STEPS do Game.step(game, 1 / 60) end
|
||||
eq(game.returned, 1, "Game:step falls back to the title on the 16th step")
|
||||
eq(game.stack.updates, HOLD_STEPS - 1,
|
||||
"and the state on top never gets that step, whatever it was")
|
||||
check(not Input:isDown("a"),
|
||||
"the still-physically-held A is cleared, so the title screen does not "
|
||||
.. "read it as a menu choice on its first frame")
|
||||
|
||||
-- A tool mod may hand Game a stand-in input; Game guards on the method
|
||||
-- existing rather than assuming it, so those simply never soft reset.
|
||||
local bare = fakeGame()
|
||||
bare.input = { step = function() end, isDown = function() return false end }
|
||||
for _ = 1, HOLD_STEPS * 2 do Game.step(bare, 1 / 60) end
|
||||
eq(bare.returned, 0, "an input with no chord bookkeeping never resets")
|
||||
|
||||
-- ---- the on-screen overlay ---------------------------------------------
|
||||
|
||||
-- Force the overlay live the way a phone would have it. img is only ever
|
||||
-- tested for truthiness on the input path (drawing is what reads it), so a
|
||||
-- placeholder keeps this suite off love.graphics.newImage.
|
||||
Input:init()
|
||||
TouchControls:init()
|
||||
TouchControls.active, TouchControls.enabled = true, true
|
||||
TouchControls.img = { stub = true }
|
||||
local L = TouchControls:layout()
|
||||
|
||||
-- One finger, one control: touchpressed returns on its first hit, so no
|
||||
-- single touch can ever arm more than a quarter of the chord however the
|
||||
-- layout editor has moved the controls around.
|
||||
for _, btn in ipairs({ "a", "b", "start", "select" }) do
|
||||
Input:init()
|
||||
TouchControls:reset()
|
||||
TouchControls:touchpressed(1, L[btn].cx, L[btn].cy)
|
||||
local held = 0
|
||||
for _ in pairs(TouchControls.held) do held = held + 1 end
|
||||
eq(held, 1, "a finger on " .. btn:upper() .. " presses that button alone")
|
||||
check(not Input:softResetHeld(), "...which is not the combo")
|
||||
end
|
||||
|
||||
-- Four fingers on four controls is the only way there, and it still has to
|
||||
-- survive the same 16 steps -- better than a quarter second of everything
|
||||
-- staying put.
|
||||
Input:init()
|
||||
TouchControls:reset()
|
||||
local ids = { a = 1, b = 2, start = 3, select = 4 }
|
||||
for btn, id in pairs(ids) do
|
||||
TouchControls:touchpressed(id, L[btn].cx, L[btn].cy)
|
||||
end
|
||||
check(Input:softResetHeld(), "four fingers on A, B, START and SELECT arm it")
|
||||
for i = 1, HOLD_STEPS - 1 do
|
||||
check(not stepChord(), "the overlay holds the same countdown (step " .. i .. ")")
|
||||
end
|
||||
check(stepChord(), "and resets on the 16th")
|
||||
|
||||
-- the accidental version: one finger slips off part way through
|
||||
Input:init()
|
||||
TouchControls:reset()
|
||||
for btn, id in pairs(ids) do
|
||||
TouchControls:touchpressed(id, L[btn].cx, L[btn].cy)
|
||||
end
|
||||
for _ = 1, HOLD_STEPS - 1 do stepChord() end
|
||||
TouchControls:touchreleased(ids.select, L.select.cx, L.select.cy)
|
||||
check(not stepChord(), "a finger leaving SELECT one step short cancels")
|
||||
check(not Input:softResetHeld(), "and the chord is no longer armed")
|
||||
|
||||
-- the fifth finger a real two-handed grip has on the d-pad
|
||||
Input:init()
|
||||
TouchControls:reset()
|
||||
for btn, id in pairs(ids) do
|
||||
TouchControls:touchpressed(id, L[btn].cx, L[btn].cy)
|
||||
end
|
||||
TouchControls:touchpressed(5, L.dpad.cx, L.dpad.cy - L.dpad.w * 0.4)
|
||||
check(not Input:softResetHeld(),
|
||||
"a thumb on the d-pad cancels it the same way a keyboard direction does")
|
||||
|
||||
Input:init()
|
||||
TouchControls:reset()
|
||||
T.finish("soft_reset_bug563")
|
||||
@@ -0,0 +1,107 @@
|
||||
-- A beaten trainer's own line is spoken, so it carries their name tag.
|
||||
--
|
||||
-- TrainerEndBattleText (home/trainers.asm:381-386) prints _TrainerNameText
|
||||
-- -- wNameBuffer then ": " (data/text/text_1.asm:16-19) -- and only then
|
||||
-- the saved EndBattleText pointer, so the loss line opens
|
||||
-- "BUG CATCHER: ...". The port printed the pointer alone, leaving an
|
||||
-- unattributed sentence on the battle screen (#566). SaveTrainerName reads
|
||||
-- TrainerNamePointers, whose RIVAL1/2/3 entries aim at wTrainerName, so the
|
||||
-- rival tags with his name and never with the class id.
|
||||
--
|
||||
-- The tag belongs to the box, not to every page: a `para` inside the loss
|
||||
-- text is a second page and prints untagged.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Font = require("src.render.Font")
|
||||
Font.load(Data)
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local function freshGame()
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "RED"
|
||||
save.player.rival = "GARY"
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
|
||||
return { data = Data, save = save,
|
||||
stack = { top = function() return nil end,
|
||||
push = function() end, pop = function() end } }
|
||||
end
|
||||
|
||||
-- KO the whole enemy party and take the win branch of enemyMonFainted
|
||||
local function beat(game, oppClass, endText)
|
||||
local battle = BattleState.newTrainer(game, oppClass, 1)
|
||||
battle.participants = {}
|
||||
battle.playVictoryMusic = function() end
|
||||
battle.endBattleText = endText
|
||||
for _, mon in ipairs(battle.enemyParty) do mon.hp = 0 end
|
||||
battle.enemyIndex = #battle.enemyParty
|
||||
battle.enemy.mon = battle.enemyParty[battle.enemyIndex]
|
||||
battle:enemyMonFainted()
|
||||
local texts = {}
|
||||
for _, row in ipairs(battle.queue) do
|
||||
if row.text then texts[#texts + 1] = row.text end
|
||||
end
|
||||
return battle, texts
|
||||
end
|
||||
|
||||
-- index of the first queued message containing `needle`
|
||||
local function findText(texts, needle)
|
||||
for i, text in ipairs(texts) do
|
||||
if text:find(needle, 1, true) then return i end
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local game = freshGame()
|
||||
local battle, texts = beat(game, "OPP_FIX_YOUNGSTER",
|
||||
"Well fixed!\fBut I will win next time.")
|
||||
T.eq(battle.result, "win", "the last enemy mon fainting ends the battle")
|
||||
|
||||
local loss = findText(texts, "Well fixed!")
|
||||
T.check(loss ~= nil, "the trainer's loss line is queued")
|
||||
T.eq(texts[loss], battle.trainer.name .. ": Well fixed!",
|
||||
"the loss line opens with the trainer class and a colon")
|
||||
T.eq(texts[loss + 1], "But I will win next time.",
|
||||
"the `para` page is a page of the same box, so it is not tagged again")
|
||||
|
||||
-- TrainerBattleVictory (core.asm:915-949) order: TrainerDefeatedText,
|
||||
-- the pic scroll, PrintEndBattleText, then MoneyForWinningText
|
||||
local defeated = findText(texts, "defeated")
|
||||
local money = findText(texts, "for winning")
|
||||
T.check(defeated and defeated < loss, "the tag follows 'RED defeated ...'")
|
||||
T.check(money and money > loss + 1, "the prize line still comes last")
|
||||
end
|
||||
|
||||
-- RIVAL1/2/3 tag with the rival's name, not the class id: newTrainer
|
||||
-- overlays wRivalName the way GetTrainerName_ does.
|
||||
do
|
||||
local game = freshGame()
|
||||
Data.trainers.OPP_RIVAL1 = {
|
||||
id = "OPP_RIVAL1", index = 2, name = "RIVAL1", baseMoney = 15,
|
||||
parties = { { { level = 5, species = "FIXMON_C" } } },
|
||||
}
|
||||
local battle, texts = beat(game, "OPP_RIVAL1", "You're still learning!")
|
||||
T.eq(battle.trainer.name, "GARY", "the rival battles under his own name")
|
||||
local loss = findText(texts, "still learning")
|
||||
T.check(loss ~= nil, "the rival's loss line is queued")
|
||||
T.eq(texts[loss], "GARY: You're still learning!",
|
||||
"the rival tag is his name, never the RIVAL1 class id")
|
||||
end
|
||||
|
||||
-- A scripted battle that prints its own follow-up leaves endBattleText nil;
|
||||
-- nothing may invent a tagged empty box for it.
|
||||
do
|
||||
local game = freshGame()
|
||||
local _, texts = beat(game, "OPP_FIX_YOUNGSTER", nil)
|
||||
for _, text in ipairs(texts) do
|
||||
T.check(not text:match("^FIX YOUNGSTER: %s*$"),
|
||||
"no empty tagged box when the trainer has no loss line")
|
||||
end
|
||||
end
|
||||
|
||||
T.finish("trainer loss line bug566")
|
||||
@@ -0,0 +1,80 @@
|
||||
-- SHIFT's "about to use" offer is one box with a CONT, not two boxes.
|
||||
--
|
||||
-- _TrainerAboutToUseText (data/text/text_2.asm:911-921) is
|
||||
-- "<wTrainerName> is" / line "about to use" / cont "<wEnemyMonNick>!": the
|
||||
-- cont scrolls "X is" off the top so the nick arrives UNDER the words that
|
||||
-- explain it. Splitting that into two say() rows put the nick alone on a
|
||||
-- fresh page, which reads as a bare Pokemon name with no sentence around
|
||||
-- it (#565).
|
||||
--
|
||||
-- The rendered half is checked through BattleState:startMessage, the same
|
||||
-- parser the battle box types from, so this fails if \v ever stops meaning
|
||||
-- ContText there.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Font = require("src.render.Font")
|
||||
Font.load(Data)
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
-- SHIFT only offers the switch when the player has more than one party
|
||||
-- slot and the active mon is alive (core.asm:1366-1443)
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "RED"
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_B", 30) }
|
||||
save.options = save.options or {}
|
||||
save.options.battleStyle = "shift"
|
||||
|
||||
local game = { data = Data, save = save,
|
||||
stack = { top = function() return nil end,
|
||||
push = function() end, pop = function() end } }
|
||||
|
||||
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||
T.check(#battle.enemyParty >= 2, "the fixture trainer has a reserve to send out")
|
||||
battle.participants = {}
|
||||
|
||||
-- KO the lead so enemyMonFainted queues the send-out for slot 2
|
||||
battle.enemyParty[1].hp = 0
|
||||
battle.enemy.mon = battle.enemyParty[1]
|
||||
battle:enemyMonFainted()
|
||||
|
||||
local texts = {}
|
||||
for _, row in ipairs(battle.queue) do
|
||||
if row.text then texts[#texts + 1] = row.text end
|
||||
end
|
||||
|
||||
local offer
|
||||
for _, text in ipairs(texts) do
|
||||
if text:find("about to use", 1, true) then offer = text end
|
||||
end
|
||||
T.check(offer ~= nil, "SHIFT queues the about-to-use offer")
|
||||
|
||||
local nick = Data.pokemon[battle.enemyParty[2].species].name
|
||||
T.eq(offer, battle.trainer.name .. " is\nabout to use\v" .. nick .. "!",
|
||||
"one box: name, the line break, then a CONT to the nick")
|
||||
|
||||
-- the regression itself: the nick must never be a message of its own
|
||||
for _, text in ipairs(texts) do
|
||||
T.neq(text, nick .. "!", "no bare-nickname box follows the offer")
|
||||
end
|
||||
|
||||
-- and what the box actually renders: three lines, the third scrolled in,
|
||||
-- so the two visible rows when the nick lands are "about to use" / nick
|
||||
battle:startMessage({ text = offer })
|
||||
T.eq(#battle.lines, 3, "the offer types as three lines")
|
||||
T.check(not battle.lines[2].cont, "line 2 follows a plain line break")
|
||||
-- guarded so a regression reports the missing line instead of dying on it
|
||||
if battle.lines[3] then
|
||||
T.check(battle.lines[3].cont, "line 3 is a ContText scroll, not a new page")
|
||||
T.same(battle.lines[2].codes, Font.encode("about to use"),
|
||||
"the line above the nick still says 'about to use'")
|
||||
T.same(battle.lines[3].codes, Font.encode(nick .. "!"),
|
||||
"the scrolled-in line is the nick")
|
||||
end
|
||||
|
||||
T.finish("trainer shift prompt bug565")
|
||||
@@ -0,0 +1,156 @@
|
||||
-- A tap on the d-pad turns in place; only a held direction steps (#415).
|
||||
-- home/overworld.asm .handleDirectionButtonPress reaches the turn only while
|
||||
-- wCheckFor180DegreeTurn is set, and .noDirectionButtonsPressed -- the poll
|
||||
-- that finds nothing held -- is the one place that sets it. So the turn
|
||||
-- delay is spent once per press, not at every facing change: a direction
|
||||
-- swapped mid-walk keeps stepping. Player.turnArmed is that flag and
|
||||
-- Player:turnWindow is how long the fresh turn holds the step off.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Collision = require("src.world.Collision")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local Input = require("src.core.Input")
|
||||
local Player = require("src.world.Player")
|
||||
|
||||
Collision.load(Data)
|
||||
|
||||
-- the fixture dataset ships one player sheet under its own id; Player.new
|
||||
-- builds a SpriteRenderer off whatever field.playerSprites names
|
||||
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
|
||||
|
||||
-- Collision.canMove only ever asks a map these four things, so open floor
|
||||
-- is cheaper to state directly than to stand a real MapLoader up (that
|
||||
-- pulls the tileset atlas in, which is the asset pipeline, not this).
|
||||
local map = {
|
||||
def = { tileset = "FIX_OUT" },
|
||||
inBounds = function(_, x, y) return x >= 0 and y >= 0 and x < 20 and y < 20 end,
|
||||
isWalkableCell = function() return true end,
|
||||
isWaterCell = function() return false end,
|
||||
cellTile = function() return 0 end,
|
||||
}
|
||||
|
||||
local function newPlayer(facing)
|
||||
local p = Player.new(Data, 5, 5, facing or "down")
|
||||
Input:init()
|
||||
local Game = require("src.core.Game")
|
||||
Game.input = Input
|
||||
return p
|
||||
end
|
||||
|
||||
-- One fixed step of the overworld's input half: OverworldState:handleInput
|
||||
-- polls the held direction and re-arms turnArmed when it finds none, then
|
||||
-- Player:update ticks turnTimer down. `dir` nil is a released d-pad.
|
||||
local function step(p, dir)
|
||||
local result
|
||||
if dir then
|
||||
result = p:tryMove(dir, map, {})
|
||||
else
|
||||
p.turnArmed = true
|
||||
end
|
||||
p:update()
|
||||
return result
|
||||
end
|
||||
|
||||
-- Hold `dir` from a standstill until the step it starts lands, and stop on
|
||||
-- the landing poll: one more held poll would chain straight into the next
|
||||
-- tile, which is the walk this suite is not measuring.
|
||||
local function walkOneTile(p, dir)
|
||||
local fromX, fromY = p.cellX, p.cellY
|
||||
for _ = 1, 120 do
|
||||
step(p, dir)
|
||||
if not p.moving and (p.cellX ~= fromX or p.cellY ~= fromY) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ---- the tap: one poll with the direction down, then nothing -------------
|
||||
|
||||
do
|
||||
local p = newPlayer("down")
|
||||
T.eq(step(p, "up"), "turned", "the first poll of a fresh press turns")
|
||||
T.eq(p.facing, "up", "and the facing lands on the tapped direction")
|
||||
for _ = 1, 20 do step(p, nil) end
|
||||
T.eq(p.cellY, 5, "a released d-pad never commits the step")
|
||||
T.eq(p.cellX, 5, "and never drifts sideways either")
|
||||
T.eq(p.facing, "up", "the turn survives the release")
|
||||
end
|
||||
|
||||
-- ---- the hold: the same press kept down steps once the window closes -----
|
||||
|
||||
do
|
||||
local window = FieldDefaults.world(Data, "turnFrames")
|
||||
T.eq(window, 4, "the dataset's turn window is 4 fixed steps")
|
||||
|
||||
-- one short of the window: still turning, still on the tile
|
||||
local short = newPlayer("down")
|
||||
T.eq(step(short, "up"), "turned", "hold: the first poll turns")
|
||||
for _ = 1, window - 1 do
|
||||
T.eq(step(short, "up"), nil, "a hold inside the window takes no step")
|
||||
end
|
||||
T.eq(short.cellY, 5, "so the tile is unchanged after " .. window .. " polls")
|
||||
|
||||
-- the poll after the window closes is the one that moves
|
||||
T.eq(step(short, "up"), "moved", "the poll past the window commits the step")
|
||||
T.check(short.moving, "and the step animation is running")
|
||||
end
|
||||
|
||||
-- ---- the corner: a direction swapped without ever letting go -------------
|
||||
--
|
||||
-- This is what turnArmed buys. Walking a corner never lifts the d-pad, so
|
||||
-- .noDirectionButtonsPressed never runs and the original charges no turn
|
||||
-- delay for the new facing; gating on the timer alone stalled every corner.
|
||||
|
||||
do
|
||||
local p = newPlayer("down")
|
||||
T.check(walkOneTile(p, "up"), "the held direction walks a whole tile")
|
||||
T.eq(p.cellY, 4, "one tile north")
|
||||
|
||||
local turned = step(p, "right") -- swap without a release in between
|
||||
T.eq(p.facing, "right", "the swap changes the facing")
|
||||
T.eq(turned, "moved", "and steps straight away: no second turn delay")
|
||||
T.eq(p.turnTimer, 0, "the corner charges no turn window")
|
||||
end
|
||||
|
||||
-- ---- re-arming: the flag only comes back from a standstill ---------------
|
||||
|
||||
do
|
||||
local p = newPlayer("down")
|
||||
T.eq(p.turnArmed, true, "a fresh player starts armed")
|
||||
step(p, "left")
|
||||
T.eq(p.turnArmed, false, "the turn spends the arm")
|
||||
step(p, "left")
|
||||
T.eq(p.turnArmed, false, "a still-held direction does not re-arm it")
|
||||
step(p, nil)
|
||||
T.eq(p.turnArmed, true, "the poll that finds nothing held re-arms it")
|
||||
end
|
||||
|
||||
-- ---- the on-screen d-pad gets the longer window -------------------------
|
||||
--
|
||||
-- A finger cannot produce a 4-frame press, so Player:turnWindow widens it
|
||||
-- for the overlay only. Keyed on the live source (Input:isTouchDown), not
|
||||
-- on whether the overlay is visible, so a phone with a pad plugged in keeps
|
||||
-- the physical window.
|
||||
|
||||
do
|
||||
local p = newPlayer("down")
|
||||
p.facing = "up"
|
||||
T.eq(p:turnWindow(), 4, "keyboard and pad presses get the short window")
|
||||
|
||||
Input:overlayPressed("up")
|
||||
Input:step()
|
||||
T.check(Input:isTouchDown("up"), "the overlay registers as a touch source")
|
||||
T.eq(p:turnWindow(), 8, "an overlay press gets the long window")
|
||||
|
||||
Input:keypressed("up")
|
||||
Input:overlayReleased("up")
|
||||
Input:step()
|
||||
T.check(Input:isDown("up"), "the keyboard still holds the direction")
|
||||
T.check(not Input:isTouchDown("up"), "but the touch source is gone")
|
||||
T.eq(p:turnWindow(), 4, "so the window drops back to the short one")
|
||||
end
|
||||
|
||||
T.finish("turn in place")
|
||||
@@ -0,0 +1,180 @@
|
||||
-- BATTLE LAYOUT = WIDE, screen shakes (#562).
|
||||
--
|
||||
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window
|
||||
-- PredefShakeScreenVertically / PredefShakeScreenHorizontally /
|
||||
-- AnimationShakeScreenHorizontallySlow displace IS the whole screen: pics,
|
||||
-- both HUDs and the message window move together, which is what
|
||||
-- BattleState:drawClassic does by offsetting its whole BG canvas. The wide
|
||||
-- composition used to apply fx.shakeX/shakeY to the two pic regions only, so
|
||||
-- an applying-attack shake slid the monsters across a nailed-down HUD and
|
||||
-- text box -- TAIL WHIP's slow 3px creep (wAnimationType 6) being the case
|
||||
-- the report named.
|
||||
--
|
||||
-- Headless: WideBattle.draw is played into a recording love.graphics and the
|
||||
-- Font / HudTiles entry points, so what is asserted is where each piece of
|
||||
-- the composition landed, not pixels.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local WideBattle = require("src.battle.WideBattle")
|
||||
local Font = require("src.render.Font")
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
|
||||
-- ---------------------------------------------------------------- recorder
|
||||
-- The stub's push/pop keep no transform, so the translate stack is tracked
|
||||
-- here: `tx, ty` is the offset in force when a draw call is issued. Font and
|
||||
-- HudTiles are patched in place (WideBattle captured these very tables at
|
||||
-- require time, so swapping package.loaded would not reach it).
|
||||
local g = love.graphics
|
||||
local realPush, realPop = g.push, g.pop
|
||||
local tx, ty, stack = 0, 0, {}
|
||||
local scissors, marks
|
||||
|
||||
g.push = function(...) stack[#stack + 1] = { tx, ty }; realPush(...) end
|
||||
g.pop = function()
|
||||
local s = table.remove(stack)
|
||||
if s then tx, ty = s[1], s[2] end
|
||||
realPop()
|
||||
end
|
||||
g.translate = function(dx, dy) tx, ty = tx + (dx or 0), ty + (dy or 0) end
|
||||
-- restoreScissor calls setScissor() with no arguments to clear; only a real
|
||||
-- rect is a region being opened
|
||||
g.setScissor = function(x, y, w, h)
|
||||
if x then scissors[#scissors + 1] = { x = x, y = y, w = w, h = h } end
|
||||
end
|
||||
|
||||
local function mark(name) marks[#marks + 1] = { name = name, tx = tx, ty = ty } end
|
||||
Font.drawBox = function() mark("box") end
|
||||
Font.draw = function() mark("text") end
|
||||
Font.drawCode = function() mark("code") end
|
||||
Font.split = function() return {} end
|
||||
Font.spansFitting = function() return math.huge end
|
||||
HudTiles.tile = function() mark("tile") end
|
||||
HudTiles.drawHPBar = function() mark("hpbar") end
|
||||
|
||||
-- A battle stripped to what the composition reads. drawPicsLayer and
|
||||
-- drawAnimLayer only record the offset they were handed, which is the whole
|
||||
-- question: are the two pic regions and everything else moving as one?
|
||||
local function battleWith(fx, sprites)
|
||||
return {
|
||||
data = {},
|
||||
frame = 0,
|
||||
phase = "messages",
|
||||
current = {},
|
||||
shown = {},
|
||||
fx = fx,
|
||||
enemy = { name = "GENGAR", shownHP = 60,
|
||||
mon = { level = 25, stats = { hp = 60 } } },
|
||||
player = { name = "NIDORINO", shownHP = 55,
|
||||
mon = { level = 24, stats = { hp = 55 } } },
|
||||
animPlaying = sprites ~= nil,
|
||||
animPlayer = sprites and { stepIndex = 1, steps = { { sprites = sprites } } },
|
||||
drawPicsLayer = function() mark("pics") end,
|
||||
drawAnimLayer = function() mark("anim") end,
|
||||
growInScale = function() return nil end,
|
||||
drawBallRow = function() end,
|
||||
statusLabel = function() return "" end,
|
||||
}
|
||||
end
|
||||
|
||||
local function draw(fx, sprites)
|
||||
tx, ty, stack = 0, 0, {}
|
||||
scissors, marks = {}, {}
|
||||
WideBattle.draw(battleWith(fx, sprites))
|
||||
return scissors, marks
|
||||
end
|
||||
|
||||
local function marksNamed(list, name)
|
||||
local out = {}
|
||||
for _, m in ipairs(list) do if m.name == name then out[#out + 1] = m end end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Every piece of the composition drawn in wide-surface coordinates sits at
|
||||
-- (dx, dy). The two pic passes and the anim layer carry their own anchor on
|
||||
-- top of the shake and are checked against it by hand.
|
||||
local SELF_ANCHORED = { pics = true, anim = true }
|
||||
local function everythingAt(list, dx, dy, label)
|
||||
local seen = 0
|
||||
for _, m in ipairs(list) do
|
||||
if not SELF_ANCHORED[m.name] then
|
||||
seen = seen + 1
|
||||
if m.tx ~= dx or m.ty ~= dy then
|
||||
T.check(false, ("%s: %s drew at (%d, %d), wanted (%d, %d)")
|
||||
:format(label, m.name, m.tx, m.ty, dx, dy))
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
T.check(seen >= 6,
|
||||
label .. ": the HUDs, the status bars and the message box all drew")
|
||||
T.check(true, ("%s: all %d pieces moved to (%d, %d) together")
|
||||
:format(label, seen, dx, dy))
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------ still screen
|
||||
-- The baseline the shaken frames are measured against: the player's 160x144
|
||||
-- region is inset 32px from the top of the wider field, the enemy's starts at
|
||||
-- x=160, and both draw their classic-space pics at the wide anchors.
|
||||
local s, m = draw(nil)
|
||||
T.eq(s[1].x, 0, "unshaken, the player region clips from the left edge")
|
||||
T.eq(s[1].y, 32, "unshaken, the player region starts below the foe's HUD")
|
||||
T.eq(s[2].x, 160, "unshaken, the enemy region clips from the halfway mark")
|
||||
T.eq(s[2].y, 0, "unshaken, the enemy region starts at the top")
|
||||
local pics = marksNamed(m, "pics")
|
||||
T.eq(#pics, 2, "one pics pass per side")
|
||||
T.eq(pics[1].tx, 20, "unshaken, the player pic sits on the player anchor")
|
||||
T.eq(pics[1].ty, 8, "unshaken, the player pic sits on the player baseline")
|
||||
T.eq(pics[2].tx, 136, "unshaken, the enemy pic sits on the enemy anchor")
|
||||
everythingAt(m, 0, 0, "no shake")
|
||||
|
||||
-- -------------------------------------------------- horizontal shake (#562)
|
||||
-- TAIL WHIP is wAnimationType 6, AnimationShakeScreenHorizontallySlow b=3:
|
||||
-- the screen creeps out to 3px and back, twice, in silence. Before the fix
|
||||
-- the two pic regions took the 3px and drawHUDs / drawTextArea did not, so
|
||||
-- the foe glided sideways over a stationary HUD and message box.
|
||||
s, m = draw({ shakeX = 3, shakeY = 0 })
|
||||
T.eq(s[1].x, 3, "a horizontal shake carries the player region's clip with it")
|
||||
T.eq(s[2].x, 163, "...and the enemy region's")
|
||||
pics = marksNamed(m, "pics")
|
||||
T.eq(pics[1].tx, 23, "the player pic rides the shake")
|
||||
T.eq(pics[2].tx, 139, "the enemy pic rides the shake")
|
||||
everythingAt(m, 3, 0, "3px horizontal shake")
|
||||
|
||||
-- ---------------------------------------------------- vertical shake (#562)
|
||||
-- wAnimationType 1 (PredefShakeScreenVertically b=8) drops the whole window.
|
||||
-- The region rects move with their contents, so a pic pushed toward
|
||||
-- FIELD_BOTTOM is not sheared off its own window edge on the way down.
|
||||
s, m = draw({ shakeX = 0, shakeY = 8 })
|
||||
T.eq(s[1].y, 40, "a vertical shake carries the player region's clip down")
|
||||
T.eq(s[1].h, WideBattle.FIELD_BOTTOM - 32,
|
||||
"the player region keeps its height, so the pic is not cropped short")
|
||||
T.eq(s[2].y, 8, "the enemy region's clip drops the same 8px")
|
||||
pics = marksNamed(m, "pics")
|
||||
T.eq(pics[1].ty, 16, "the player pic drops with it")
|
||||
everythingAt(m, 0, 8, "8px vertical shake")
|
||||
|
||||
-- --------------------------------------------------- the animations-off path
|
||||
-- With ANIMATION = OFF an extracted anim that carries a shake flag falls back
|
||||
-- to fx.shake, a +-2 alternation read off battle.frame; it moves the screen
|
||||
-- through the same path.
|
||||
s, m = draw({ shake = 24 })
|
||||
T.eq(s[1].x, 2, "the animations-off fallback shakes the regions too")
|
||||
everythingAt(m, 2, 0, "animations-off fallback")
|
||||
|
||||
-- ------------------------------------------------------- the OAM anim layer
|
||||
-- Battle animation sprites are OAM, not BG, so the window shake never moves
|
||||
-- them -- drawClassic draws its anim layer outside the offset for the same
|
||||
-- reason. Their own offset is WideBattle.animationOffset, and that stays put
|
||||
-- while the screen under them shakes.
|
||||
local sprites = { { x = 104 }, { x = 144 } }
|
||||
local ax, ay = WideBattle.animationOffset(sprites)
|
||||
s, m = draw({ shakeX = 3, shakeY = 0 }, sprites)
|
||||
local anim = marksNamed(m, "anim")
|
||||
T.eq(#anim, 1, "the anim layer drew")
|
||||
T.eq(anim[1].tx, ax, "the anim layer keeps its own anchor, not the shake")
|
||||
T.eq(anim[1].ty, ay, "...on both axes")
|
||||
T.eq(s[#s].x, 0, "the anim region spans the field from the left edge")
|
||||
T.eq(s[#s].w, WideBattle.WIDTH, "...the full wide surface")
|
||||
|
||||
T.finish("wide battle shake")
|
||||
@@ -0,0 +1,143 @@
|
||||
-- Yellow's two extra pic rips and the plumbing that reaches for them.
|
||||
--
|
||||
-- #557: LoadPlayerBackPic (pokeyellow engine/battle/core.asm:6384-6391)
|
||||
-- loads OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for
|
||||
-- BATTLE_TYPE_PIKACHU, the Pallet Town catch scene, and DisplayBattleMenu
|
||||
-- splits the displayed thrower name on the same wBattleType. The port
|
||||
-- carries that split as makeOldManDemo's name argument.
|
||||
--
|
||||
-- #561: TalkToPikachu's framed portrait draws the chosen PikaPicAnimScript's
|
||||
-- own base frame (data/pikachu/pikachu_pic_animation.asm), not the battle
|
||||
-- front pic that stood in for all of them.
|
||||
--
|
||||
-- Both rips are gated on manifest symbols, so tools/rom_manifest_yellow.json
|
||||
-- is as much a part of these fixes as the Lua is: without the symbol the
|
||||
-- extractor writes nothing, the runtime existence check falls back, and the
|
||||
-- screen looks exactly like the bug report.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
|
||||
local PROF_BACK = "assets/generated/battle/profoakb.png"
|
||||
local OLDMAN_BACK = "assets/generated/battle/oldmanb.png"
|
||||
local RED_BACK = "assets/generated/battle/redb.png"
|
||||
|
||||
local function readFile(path)
|
||||
local handle = io.open(path, "r")
|
||||
if not handle then return nil end
|
||||
local text = handle:read("*a")
|
||||
handle:close()
|
||||
return text
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- #557: the back pic keys and the wBattleType split
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
T.eq(FieldDefaults.fieldValue(nil, "playerPics", "oakBack"), PROF_BACK,
|
||||
"playerPics carries a third back pic for the PROF.OAK demo")
|
||||
T.eq(FieldDefaults.fieldValue(nil, "playerPics", "demoBack"), OLDMAN_BACK,
|
||||
"the old man's back pic is untouched")
|
||||
|
||||
-- The love stub's filesystem is a table of written files, so writing the
|
||||
-- path IS "this cache was imported after the rip landed".
|
||||
love.filesystem.write(PROF_BACK, "png")
|
||||
T.eq(Sprites.playerPath(nil, "back", { demo = true, oakDemo = true }),
|
||||
PROF_BACK, "the Pallet catch demo fights behind Oak's own back pic")
|
||||
T.eq(Sprites.playerPath(nil, "back", { demo = true }), OLDMAN_BACK,
|
||||
"the Route 5 catch tutorial still gets the old man")
|
||||
T.eq(Sprites.playerPath(nil, "back", {}), RED_BACK,
|
||||
"an ordinary battle still gets the player")
|
||||
|
||||
love.filesystem.remove(PROF_BACK)
|
||||
T.eq(Sprites.playerPath(nil, "back", { demo = true, oakDemo = true }),
|
||||
OLDMAN_BACK,
|
||||
"a cache built before the rip falls back to the old man, not a missing file")
|
||||
|
||||
-- makeOldManDemo only fills fields when a player battler already exists, so
|
||||
-- a stub with one stays clear of Pokemon.new and the fixture dataset.
|
||||
local oak = { player = true }
|
||||
BattleState.makeOldManDemo(oak, "PROF.OAK")
|
||||
T.eq(oak.demoName, "PROF.OAK", "the Yellow demo names PROF.OAK as the thrower")
|
||||
T.eq(oak.oakDemo, true, "and asks for his back pic")
|
||||
|
||||
local oldMan = { player = true }
|
||||
BattleState.makeOldManDemo(oldMan)
|
||||
T.eq(oldMan.demoName, "OLD MAN", "the unnamed demo is still the old man")
|
||||
T.eq(oldMan.oakDemo, false, "and does not reach for Oak's pic")
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the symbol tables the two rips are gated on
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local extractor = readFile("src/import/RomExtractor.lua")
|
||||
T.check(extractor ~= nil, "src/import/RomExtractor.lua is readable")
|
||||
extractor = extractor or ""
|
||||
|
||||
local yellowManifest = readFile("tools/rom_manifest_yellow.json")
|
||||
T.check(yellowManifest ~= nil, "tools/rom_manifest_yellow.json is readable")
|
||||
yellowManifest = yellowManifest or ""
|
||||
|
||||
local generator = readFile("tools/make_yellow_manifest.py")
|
||||
T.check(generator ~= nil, "tools/make_yellow_manifest.py is readable")
|
||||
generator = generator or ""
|
||||
|
||||
-- Every symbol below is real in pokeyellow.sym; what decides whether the
|
||||
-- extractor can see it is whether the generator injects it. Red never
|
||||
-- referenced any of them, so make_yellow_manifest's Red-name remap cannot
|
||||
-- pick them up on its own -- they have to be listed in YELLOW_EXTRA_SYMBOLS.
|
||||
-- One line per group rather than per label: a missing symbol list reads as
|
||||
-- one fault to fix, not fifty-odd.
|
||||
local function missingFrom(text, labels)
|
||||
local missing = {}
|
||||
for _, label in ipairs(labels) do
|
||||
if not text:find('"' .. label .. '"', 1, true) then
|
||||
missing[#missing + 1] = label
|
||||
end
|
||||
end
|
||||
return missing
|
||||
end
|
||||
|
||||
local function requireSymbols(labels, why)
|
||||
local gone = missingFrom(yellowManifest, labels)
|
||||
T.check(#gone == 0, why .. ": rom_manifest_yellow.json is missing "
|
||||
.. (#gone == 0 and "nothing" or table.concat(gone, " ")))
|
||||
gone = missingFrom(generator, labels)
|
||||
T.check(#gone == 0, why .. ": make_yellow_manifest.py never asks for "
|
||||
.. (#gone == 0 and "nothing" or table.concat(gone, " "))
|
||||
.. " (add them to YELLOW_EXTRA_SYMBOLS)")
|
||||
end
|
||||
|
||||
T.check(extractor:find('self.symbols["ProfOakPicBack"]', 1, true) ~= nil,
|
||||
"the extractor gates Oak's back pic on the ProfOakPicBack symbol")
|
||||
requireSymbols({ "ProfOakPicBack" }, "#557")
|
||||
|
||||
-- Read the label list out of the extractor itself rather than repeating it:
|
||||
-- the contract under test is that the manifest answers whatever
|
||||
-- extractField asks for, so a later edit to PIKAPIC_BASE stays covered.
|
||||
local pikapicBlock = extractor:match("local PIKAPIC_BASE = {(.-)\n }")
|
||||
T.check(pikapicBlock ~= nil, "RomExtractor's PIKAPIC_BASE table is readable")
|
||||
local pikapic, seen = {}, {}
|
||||
for label in (pikapicBlock or ""):gmatch('"([%w_]+)"') do
|
||||
if not seen[label] then
|
||||
seen[label] = true
|
||||
pikapic[#pikapic + 1] = label
|
||||
end
|
||||
end
|
||||
-- 28 PikaPicAnimScripts, script 26 sharing script 11's base pic
|
||||
T.eq(#pikapic, 27, "PIKAPIC_BASE names 27 distinct base pics")
|
||||
requireSymbols(pikapic, "#561")
|
||||
|
||||
-- Red and Blue have neither pic, so neither manifest may grow one: a stray
|
||||
-- entry there would rip Yellow art out of the wrong ROM.
|
||||
for _, path in ipairs({ "tools/rom_manifest.json", "tools/rom_manifest_blue.json" }) do
|
||||
local text = readFile(path)
|
||||
T.check(text ~= nil, path .. " is readable")
|
||||
T.check(text == nil or text:find("ProfOakPicBack", 1, true) == nil,
|
||||
path .. " has no ProfOakPicBack, matching pokered")
|
||||
end
|
||||
|
||||
T.finish("yellow oak back pic and pikapic bases")
|
||||
@@ -716,6 +716,68 @@ do
|
||||
check(actBattle:enemyAction().hooked == true,
|
||||
"battle.enemy_action hook rewrites the choice")
|
||||
unsub()
|
||||
|
||||
-- battle.catch_exp: vanilla catches never grant exp; a mod can flip that
|
||||
unsub = hooks:wrap("battle.catch_exp", function() return true end)
|
||||
local catchExpParty = { Pokemon.new(Data, "BULBASAUR", 10) }
|
||||
local catchExpGame = makeGame(catchExpParty)
|
||||
local catchExpBattle = BattleState.newWild(catchExpGame, "RATTATA", 3)
|
||||
catchExpBattle.enemy.mon = Pokemon.new(Data, "RATTATA", 3)
|
||||
local expBeforeCatch = catchExpParty[1].exp
|
||||
catchExpBattle:storeCaughtMon()
|
||||
check(catchExpParty[1].exp > expBeforeCatch,
|
||||
"battle.catch_exp hook pays out exp on a catch")
|
||||
unsub()
|
||||
|
||||
-- battle.exp_award: a mod can replace the participant/EXP.ALL split
|
||||
-- wholesale via ctx.applyShare
|
||||
unsub = hooks:wrap("battle.exp_award", function(nextFn, ctx)
|
||||
ctx.applyShare(ctx.alive[1], 999, "flatShare")
|
||||
end)
|
||||
local awardParty = { Pokemon.new(Data, "BULBASAUR", 10) }
|
||||
local awardGame = makeGame(awardParty)
|
||||
local awardBattle = BattleState.newWild(awardGame, "RATTATA", 3)
|
||||
local expBeforeAward = awardParty[1].exp
|
||||
awardBattle:awardExp()
|
||||
check(awardParty[1].exp > expBeforeAward,
|
||||
"battle.exp_award hook replaces the award split")
|
||||
unsub()
|
||||
end
|
||||
|
||||
-- ------- battle.low_health_alarm hook: mirrors the siren toggle
|
||||
|
||||
do
|
||||
local Sound = require("src.core.Sound")
|
||||
local calls = {}
|
||||
local origStart, origStop = Sound.startLoop, Sound.stopLoop
|
||||
Sound.startLoop = function(data, name) calls[#calls + 1] = { "start", name } end
|
||||
Sound.stopLoop = function(name) calls[#calls + 1] = { "stop", name } end
|
||||
|
||||
local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) })
|
||||
local battle = BattleState.newWild(game, "RATTATA", 5)
|
||||
battle.player.mon.hp = 1
|
||||
battle.player.shownHP = 1
|
||||
|
||||
local seenOn = nil
|
||||
local unsub = hooks:wrap("battle.low_health_alarm", function(nextFn, ctx)
|
||||
seenOn = ctx.on
|
||||
return nextFn(ctx)
|
||||
end)
|
||||
battle:updateFx()
|
||||
check(seenOn == true, "battle.low_health_alarm hook sees the alarm toggle on")
|
||||
check(calls[#calls][1] == "start" and calls[#calls][2] == "Low_Health_Alarm",
|
||||
"an unmodified hook still starts the siren loop")
|
||||
unsub()
|
||||
|
||||
unsub = hooks:wrap("battle.low_health_alarm", function(nextFn, ctx)
|
||||
ctx.on = false
|
||||
return nextFn(ctx)
|
||||
end)
|
||||
battle:updateFx()
|
||||
check(calls[#calls][1] == "stop", "a mod can force the alarm off before vanilla acts")
|
||||
unsub()
|
||||
|
||||
Sound.startLoop, Sound.stopLoop = origStart, origStop
|
||||
end
|
||||
|
||||
-- ------- battle events: the scripted sequence
|
||||
|
||||
@@ -397,11 +397,11 @@ check(getmetatable(bm) == BindingsMenu,
|
||||
check(bm.screenId == "BindingsMenu",
|
||||
"the pushed rebind screen carries its screen id")
|
||||
check(#bm.items == 8, "one row per logical button")
|
||||
check(bm.items[1].label == "UP" and bm.items[1].right == "UP"
|
||||
and bm.items[5].label == "A" and bm.items[5].right == "Z"
|
||||
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE"
|
||||
check(bm.items[1].label == "UP" and bm.items[1].right == "UP/D-UP"
|
||||
and bm.items[5].label == "A" and bm.items[5].right == "Z/A"
|
||||
and bm.items[7].label == "START" and bm.items[7].right == "ESC/START"
|
||||
and bm.items[8].label == "SELECT" and bm.items[8].right == "TAB/BACK",
|
||||
"with no rebind the rows mirror the fixed map")
|
||||
"with no rebind the rows mirror the fixed map, key and pad both (#589)")
|
||||
check(cbGame.save.options.bindings == nil,
|
||||
"opening the screen alone writes nothing")
|
||||
check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil,
|
||||
@@ -412,18 +412,20 @@ check(bm.capture == bm.items[1] and bm.onKeyPressed ~= nil,
|
||||
local wroteOptions = false
|
||||
function cbGame:writeOptions() wroteOptions = true end
|
||||
bm:onKeyPressed("j")
|
||||
bm:onKeyReleased("j") -- a capture commits on the press's release (#589)
|
||||
check(cbGame.save.options.bindings.up.key == "j",
|
||||
"a captured key lands in options.bindings")
|
||||
check(bm.items[1].right == "J", "the row shows the new key")
|
||||
check(bm.items[1].right == "J/D-UP", "the row shows the new key")
|
||||
check(wroteOptions, "a rebind persists through writeOptions")
|
||||
check(bm.capture == nil and bm.onKeyPressed == nil
|
||||
and bm.onGamepadPressed == nil, "the capture disarms after one input")
|
||||
bm.index = 5
|
||||
press(bm, "a")
|
||||
bm:onGamepadPressed("y")
|
||||
bm:onGamepadReleased("y")
|
||||
check(cbGame.save.options.bindings.a.pad == "y",
|
||||
"a captured pad button lands beside the key slot")
|
||||
check(bm.items[5].right == "Z", "a pad rebind keeps the key column")
|
||||
check(bm.items[5].right == "Z/Y", "a pad rebind keeps the key column")
|
||||
press(bm, "b")
|
||||
check(#cbGame.stack.states == 0, "B closes the rebind screen")
|
||||
|
||||
|
||||
@@ -291,7 +291,7 @@ do
|
||||
check(data.field.palettes.byMap.PALLET_TOWN == "PALLET",
|
||||
"seed fills the missing siblings of a stamped record")
|
||||
check(data.constants.world.stepFrames == 4, "seed leaves a stamped constant")
|
||||
check(data.constants.world.turnFrames == 2, "seed fills the missing siblings")
|
||||
check(data.constants.world.turnFrames == 4, "seed fills the missing siblings")
|
||||
check(data.field.fishing.OLD_ROD.always.species == "MAGIKARP",
|
||||
"seed installs the whole missing key")
|
||||
local before = data.field.fishing
|
||||
|
||||
@@ -98,8 +98,9 @@ check(wild.queue[1] and wild.queue[1].fn ~= nil,
|
||||
check(wild.queue[2] and wild.queue[2].text == wild.introText,
|
||||
"the intro text is still the second queue row")
|
||||
|
||||
-- let the silhouette slide land so the intro box is genuinely up
|
||||
for _ = 1, 45 do wild:update(1 / 60) end
|
||||
-- let the silhouette slide land so the intro box is genuinely up (the slide
|
||||
-- now runs 80 frames at 2px/frame, matching the original ~2px/frame)
|
||||
for _ = 1, 85 do wild:update(1 / 60) end
|
||||
eq(wild.introSlide or 0, 0, "the silhouette slide has landed")
|
||||
eq(wild.introBalls, true, "the window is still open under the intro text")
|
||||
eq(currentText(wild), wild.introText, "the intro box is the row on screen")
|
||||
@@ -164,7 +165,7 @@ local tr = BattleState.newTrainer(game2, "OPP_YOUNGSTER", 1)
|
||||
tr.onFinish = function() end
|
||||
tr:enter()
|
||||
eq(tr.introBalls, true, "the trainer intro opens the same window")
|
||||
for _ = 1, 45 do tr:update(1 / 60) end
|
||||
for _ = 1, 85 do tr:update(1 / 60) end
|
||||
|
||||
local ok3, err3, rows3 = snapshotHUD(tr)
|
||||
check(ok3, "drawHUDs runs during the trainer intro: " .. tostring(err3))
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
-- Parity (#488): the Vermilion, Saffron and Fuchsia Pokemon Center bench
|
||||
-- guys answer an A press.
|
||||
--
|
||||
-- Oracle: engine/events/hidden_events/bench_guys.asm PrintBenchGuyText
|
||||
-- compares wSpritePlayerStateData1FacingDirection against the facing byte
|
||||
-- in BenchGuyTextPointers, which is SPRITE_FACING_LEFT for all twelve
|
||||
-- seats (data/events/bench_guys.asm). The SPRITE_FACING_* value in a map's
|
||||
-- hidden_event row is wHiddenEventFunctionArgument instead, and pokered's
|
||||
-- own macro comment says those "do not actually prevent the player from
|
||||
-- interacting with them in any direction" (data/events/hidden_events.asm).
|
||||
-- Four maps park SPRITE_FACING_UP there. Gating the seat on that byte
|
||||
-- rather than on the pointer table's facing asked for a facing that cannot
|
||||
-- reach the bench at all: (0,4) is a wall cell whose only walkable
|
||||
-- neighbour is (1,4) to the east, so those four guys were mute.
|
||||
--
|
||||
-- The manifest has carried both bytes as `facing` (hidden_event) and
|
||||
-- `textFacing` (BenchGuyTextPointers) all along; this pins which one the
|
||||
-- interact path is allowed to read.
|
||||
--
|
||||
-- Self-contained: `luajit tests/parity_bench_guy_facing_bug488.lua`; also
|
||||
-- globbed by tests/run_tests.lua.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
|
||||
local S = require("tests.harness").suite("parity bench guy facing (#488)")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
require("src.render.Font").load(Data)
|
||||
|
||||
-- tryHiddenObject reads the `Game` upvalue OverworldState:enter() normally
|
||||
-- sets during a real boot; rewire it at the one closure under test, the
|
||||
-- way tests/parity_D.lua does for the Safari counters.
|
||||
local pushed
|
||||
local fakeGame = {
|
||||
data = Data,
|
||||
save = SaveData.newGame(),
|
||||
stack = { push = function(_, state) pushed = state end },
|
||||
}
|
||||
local function bindGame(fn, game)
|
||||
local i = 1
|
||||
while true do
|
||||
local name = debug.getupvalue(fn, i)
|
||||
if not name then return false end
|
||||
if name == "Game" then debug.setupvalue(fn, i, game); return true end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
check(bindGame(OverworldState.tryHiddenObject, fakeGame),
|
||||
"tryHiddenObject binds the Game upvalue")
|
||||
|
||||
local benchGuys = Data.field.hiddenExtras.benchGuys
|
||||
|
||||
-- what the box that opens actually reads, so a seat that opens the wrong
|
||||
-- guy's line still fails
|
||||
local function boxText(box)
|
||||
local lines = {}
|
||||
for _, page in ipairs(box and box.pages or {}) do
|
||||
for _, line in ipairs(page) do lines[#lines + 1] = line end
|
||||
end
|
||||
return table.concat(lines, " ")
|
||||
end
|
||||
|
||||
local function pressA(mapId, x, y, facing)
|
||||
pushed = nil
|
||||
local self_ = setmetatable({
|
||||
map = { id = mapId },
|
||||
player = { facing = facing },
|
||||
}, { __index = OverworldState })
|
||||
local handled = self_:tryHiddenObject(x, y)
|
||||
return handled, pushed
|
||||
end
|
||||
|
||||
-- The four seats whose hidden_event byte is SPRITE_FACING_UP -- the ones
|
||||
-- that went silent -- plus Pewter as the control: it stores
|
||||
-- SPRITE_FACING_LEFT in both places, so it talked either way and proves
|
||||
-- the harness itself reaches the bench-guy branch.
|
||||
local SEATS = {
|
||||
{ "VERMILION_POKECENTER", "up", "universally" },
|
||||
{ "SAFFRON_POKECENTER", "up", "TEAM ROCKET" },
|
||||
{ "FUCHSIA_POKECENTER", "up", "SAFARI ZONE" },
|
||||
{ "CINNABAR_POKECENTER", "up", "canceling" },
|
||||
{ "PEWTER_POKECENTER", "left", "JIGGLYPUFF" },
|
||||
}
|
||||
|
||||
for _, seat in ipairs(SEATS) do
|
||||
local mapId, hiddenByte, phrase = seat[1], seat[2], seat[3]
|
||||
local h = benchGuys[mapId] and benchGuys[mapId][1]
|
||||
check(h ~= nil, mapId .. " has a bench guy entry")
|
||||
if h then
|
||||
eq(h.facing, hiddenByte, mapId .. " hidden_event byte is " .. hiddenByte)
|
||||
eq(h.textFacing, "left",
|
||||
mapId .. " BenchGuyTextPointers facing is SPRITE_FACING_LEFT")
|
||||
|
||||
-- the bug: facing the seat from the only cell you can stand on
|
||||
local handled, box = pressA(mapId, h.x, h.y, "left")
|
||||
check(handled, mapId .. " bench guy answers A from the east (#488)")
|
||||
check(getmetatable(box) == TextBox, mapId .. " bench guy opens a text box")
|
||||
check(boxText(box):find(phrase, 1, true) ~= nil,
|
||||
("%s bench guy says his own line (%s)"):format(mapId, phrase))
|
||||
|
||||
-- PrintBenchGuyText's facing test: every other direction falls through
|
||||
for _, wrong in ipairs({ "up", "down", "right" }) do
|
||||
local ok = pressA(mapId, h.x, h.y, wrong)
|
||||
check(not ok,
|
||||
("%s bench guy stays quiet when faced %s"):format(mapId, wrong))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Every labelled seat, not just the five above: reachable from the east,
|
||||
-- silent from anywhere else. A new center inherits the invariant.
|
||||
local labelled = 0
|
||||
for mapId, list in pairs(benchGuys) do
|
||||
for _, h in ipairs(list) do
|
||||
if h.text then
|
||||
labelled = labelled + 1
|
||||
eq(h.textFacing, "left", mapId .. " seat is faced from the left")
|
||||
check((pressA(mapId, h.x, h.y, "left")),
|
||||
mapId .. " seat answers A from the east")
|
||||
check(not (pressA(mapId, h.x, h.y, "up")),
|
||||
mapId .. " seat ignores an A press from below")
|
||||
end
|
||||
end
|
||||
end
|
||||
eq(labelled, 12, "all twelve BenchGuyTextPointers seats are covered")
|
||||
|
||||
-- The geometry behind the symptom: the seat cell is the bench wall, the
|
||||
-- floor tile east of it is where the player stands, and nothing is ever
|
||||
-- walkable to the south or west. A player who walks up from (0,3) and
|
||||
-- faces down gets nothing here, exactly as in the original -- the seat is
|
||||
-- keyed to one facing, not to whichever side you happen to arrive from.
|
||||
for mapId, list in pairs(benchGuys) do
|
||||
for _, h in ipairs(list) do
|
||||
if h.text then
|
||||
local map = MapLoader.load(Data, mapId)
|
||||
check(not map:isWalkableCell(h.x, h.y),
|
||||
mapId .. " seat cell (0,4) is the bench itself, not floor")
|
||||
check(map:isWalkableCell(h.x + 1, h.y),
|
||||
mapId .. " the cell east of the seat is where the player stands")
|
||||
check(not map:isWalkableCell(h.x, h.y + 1),
|
||||
mapId .. " nothing stands south of the seat")
|
||||
check(not map:isWalkableCell(h.x - 1, h.y),
|
||||
mapId .. " nothing stands west of the seat")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,253 @@
|
||||
-- Parity (#568): the bike shop clerk runs the original sale, price window
|
||||
-- and all.
|
||||
--
|
||||
-- Oracle: scripts/BikeShop.asm BikeShopClerkText. Three ways in --
|
||||
-- EVENT_GOT_BICYCLE, a BIKE VOUCHER in the bag, or the sales pitch -- and
|
||||
-- the pitch is a menu, not a line: TextBoxBorder hlcoord 0,0 b=4 c=15
|
||||
-- holding BikeShopMenuText / BikeShopMenuPrice, PrintText
|
||||
-- BikeShopClerkDoYouLikeItText, HandleMenuInput over two rows, then
|
||||
-- BikeShopCantAffordText for BICYCLE and BikeShopComeAgainText for either
|
||||
-- answer. Nothing erases that window before TextScriptEnd, so it stays up
|
||||
-- underneath both closing lines.
|
||||
--
|
||||
-- The voucher path's atomicity is pokered's `jr nc, .BagFull`: the voucher
|
||||
-- is only spent once GiveItem actually took the BICYCLE.
|
||||
--
|
||||
-- Drives the real StateStack and Input; the window's pixels are the
|
||||
-- driver's half (tests/drivers/bike_shop_bug568_test.lua).
|
||||
--
|
||||
-- Self-contained: `luajit tests/parity_bike_shop_clerk_bug568.lua`; also
|
||||
-- globbed by tests/run_tests.lua.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
|
||||
local S = require("tests.harness").suite("parity bike shop clerk (#568)")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Flags = require("src.script.Flags")
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local mapScripts = require("data.scripts.init")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
require("src.render.Font").load(Data)
|
||||
|
||||
local clerk = mapScripts.talkScript("BIKE_SHOP", "TEXT_BIKESHOP_CLERK")
|
||||
check(type(clerk) == "function",
|
||||
"the clerk is a hand-ported talk handler, not extracted flavor text")
|
||||
|
||||
-- what each box was built with, in order
|
||||
local boxes = {}
|
||||
local realNew = TextBox.new
|
||||
TextBox.new = function(game, text, onDone, opts)
|
||||
boxes[#boxes + 1] = text
|
||||
return realNew(game, text, onDone, opts)
|
||||
end
|
||||
|
||||
local done
|
||||
local function begin(save)
|
||||
Game.save = save
|
||||
StateStack:init()
|
||||
boxes, done = {}, false
|
||||
clerk(Game, { map = { id = "BIKE_SHOP", def = { label = "BikeShop" } },
|
||||
npcs = {}, entities = {} },
|
||||
{ def = {}, facePlayer = function() end },
|
||||
function() done = true end)
|
||||
end
|
||||
|
||||
local function step(frames, btn)
|
||||
for _ = 1, frames do
|
||||
Input.pressed = btn and { [btn] = true } or {}
|
||||
StateStack:update(1 / 60)
|
||||
Input.pressed = {}
|
||||
end
|
||||
end
|
||||
|
||||
-- mash A until the stack drains (the whole conversation) or we give up
|
||||
local function mash(limit)
|
||||
for _ = 1, limit or 2000 do
|
||||
if not StateStack:top() then return true end
|
||||
step(1, "a")
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- the price window is the one state on this stack that is not a text box
|
||||
local function windowState()
|
||||
for _, state in ipairs(StateStack.states) do
|
||||
if getmetatable(state) ~= TextBox then return state end
|
||||
end
|
||||
end
|
||||
|
||||
local function inStack(state)
|
||||
for _, s in ipairs(StateStack.states) do
|
||||
if s == state then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local t = Data.text
|
||||
|
||||
-- ------------------------------------------------- you already have one
|
||||
do
|
||||
local save = SaveData.newGame()
|
||||
save.inventory.BICYCLE = 1
|
||||
Flags.set(save, "EVENT_GOT_BICYCLE")
|
||||
begin(save)
|
||||
check(mash(), "the how-do-you-like-it talk ends")
|
||||
eq(#boxes, 1, "one box, no menu")
|
||||
eq(boxes[1], t._BikeShopClerkHowDoYouLikeYourBicycleText,
|
||||
"he asks how you like your new BICYCLE")
|
||||
check(done, "and hands input back")
|
||||
end
|
||||
|
||||
-- a save from before the clerk set the event still owns the bike
|
||||
do
|
||||
local save = SaveData.newGame()
|
||||
save.inventory.BICYCLE = 1
|
||||
begin(save)
|
||||
check(mash(), "the older-save talk ends")
|
||||
eq(boxes[1], t._BikeShopClerkHowDoYouLikeYourBicycleText,
|
||||
"the bag alone is enough to skip the sale")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------- the exchange
|
||||
do
|
||||
local save = SaveData.newGame()
|
||||
save.inventory.BIKE_VOUCHER = 1
|
||||
begin(save)
|
||||
check(mash(), "the voucher exchange runs to the end")
|
||||
eq(#boxes, 2, "two boxes: oh-that's-a-voucher, then the exchange")
|
||||
eq(boxes[1], t._BikeShopClerkOhThatsAVoucherText, "he spots the voucher")
|
||||
eq(boxes[2], t._BikeShopExchangedVoucherText, "and makes the trade")
|
||||
eq(save.inventory.BICYCLE, 1, "the BICYCLE is in the bag")
|
||||
check(not save.inventory.BIKE_VOUCHER, "the BIKE VOUCHER is spent")
|
||||
check(Flags.get(save, "EVENT_GOT_BICYCLE"), "EVENT_GOT_BICYCLE is set")
|
||||
check(done, "and the talk hands input back")
|
||||
end
|
||||
|
||||
-- .BagFull: GiveItem refused, so nothing is spent (jr nc, .BagFull)
|
||||
do
|
||||
local save = SaveData.newGame()
|
||||
save.inventory.BIKE_VOUCHER = 1
|
||||
local filler = 0
|
||||
for id in pairs(Data.items) do
|
||||
if id ~= "BICYCLE" and id ~= "BIKE_VOUCHER" and Bag.add(save, id, 1) then
|
||||
filler = filler + 1
|
||||
end
|
||||
end
|
||||
check(filler > 0 and not Bag.add(save, "BICYCLE", 1), "the bag is full")
|
||||
begin(save)
|
||||
check(mash(), "the full-bag talk ends")
|
||||
eq(boxes[2], t._BikeShopBagFullText, "he tells you to make room")
|
||||
check(not save.inventory.BICYCLE, "no BICYCLE while there is no room")
|
||||
eq(save.inventory.BIKE_VOUCHER, 1, "and the voucher is not burned")
|
||||
check(not Flags.get(save, "EVENT_GOT_BICYCLE"), "nor the event set")
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------- the sale
|
||||
-- Welcome, the pitch, the BICYCLE/CANCEL window, can't-afford, come-again.
|
||||
do
|
||||
local save = SaveData.newGame()
|
||||
local money = save.money
|
||||
begin(save)
|
||||
|
||||
-- the welcome line waits for a button like any other box
|
||||
eq(boxes[1], t._BikeShopClerkWelcomeText, "he welcomes you to the shop")
|
||||
eq(#StateStack.states, 1, "and nothing else is up yet")
|
||||
for _ = 1, 400 do
|
||||
if #StateStack.states > 1 then break end
|
||||
step(1, "a")
|
||||
end
|
||||
eq(boxes[2], t._BikeShopClerkDoYouLikeItText, "then the pitch")
|
||||
|
||||
-- the pitch box types out and pops itself (PrintText straight into
|
||||
-- HandleMenuInput), leaving the window holding the screen
|
||||
local window
|
||||
for _ = 1, 400 do
|
||||
window = windowState()
|
||||
if window and StateStack:top() == window then break end
|
||||
step(1)
|
||||
end
|
||||
check(window ~= nil, "a price window is pushed under the pitch (#568)")
|
||||
check(StateStack:top() == window, "and it takes over when the pitch pops")
|
||||
eq(#StateStack.states, 1, "the pitch box is gone, the window is not")
|
||||
check(type(window.footer) == "table" and #window.footer > 0,
|
||||
"the window keeps the pitch line in the bottom box, so it never blanks")
|
||||
eq(window.index, 1, "the cursor starts on BICYCLE (wCurrentMenuItem 0)")
|
||||
|
||||
-- HandleMenuInput with wMenuWrappingEnabled clear: two rows, no wrap
|
||||
step(1, "up")
|
||||
eq(window.index, 1, "up on the first row stays put")
|
||||
step(1, "down")
|
||||
eq(window.index, 2, "down moves to CANCEL")
|
||||
step(1, "down")
|
||||
eq(window.index, 2, "down again stays on CANCEL, it does not wrap")
|
||||
step(1, "up")
|
||||
eq(window.index, 1, "and up comes back to BICYCLE")
|
||||
|
||||
-- buy it: ¥1000000 is out of reach, then he sees you out
|
||||
step(1, "a")
|
||||
eq(boxes[3], t._BikeShopCantAffordText, "BICYCLE answers with can't-afford")
|
||||
check(inStack(window), "the window is still up behind that line")
|
||||
for _ = 1, 400 do
|
||||
if boxes[4] then break end
|
||||
step(1, "a")
|
||||
end
|
||||
eq(boxes[4], t._BikeShopComeAgainText, "then come back again some time")
|
||||
check(inStack(window), "and the window is still up behind that one too")
|
||||
|
||||
check(mash(), "the sale ends")
|
||||
eq(#boxes, 4, "four text boxes plus the window: the original's five")
|
||||
check(not inStack(window), "the window comes down with the last line")
|
||||
eq(#StateStack.states, 0, "the stack is back to the overworld")
|
||||
check(done, "and the talk hands input back")
|
||||
eq(save.money, money, "browsing costs nothing")
|
||||
check(not save.inventory.BICYCLE, "and sells nothing")
|
||||
check(not Flags.get(save, "EVENT_GOT_BICYCLE"), "the event stays clear")
|
||||
end
|
||||
|
||||
-- CANCEL skips the can't-afford line (.cancel jumps straight to come-again)
|
||||
do
|
||||
begin(SaveData.newGame())
|
||||
local window
|
||||
for _ = 1, 800 do
|
||||
window = windowState()
|
||||
if window and StateStack:top() == window then break end
|
||||
step(1, "a")
|
||||
end
|
||||
check(window ~= nil, "the window comes up on the cancel run too")
|
||||
step(1, "down")
|
||||
step(1, "a")
|
||||
eq(boxes[3], t._BikeShopComeAgainText,
|
||||
"CANCEL goes straight to come-again, no can't-afford")
|
||||
check(mash(), "the cancel run ends")
|
||||
eq(#boxes, 3, "three boxes on the cancel path")
|
||||
end
|
||||
|
||||
-- B is the same .cancel branch (bit B_PAD_B, a)
|
||||
do
|
||||
begin(SaveData.newGame())
|
||||
local window
|
||||
for _ = 1, 800 do
|
||||
window = windowState()
|
||||
if window and StateStack:top() == window then break end
|
||||
step(1, "a")
|
||||
end
|
||||
check(window ~= nil, "the window comes up on the B run too")
|
||||
step(1, "b")
|
||||
eq(boxes[3], t._BikeShopComeAgainText, "B backs out the same way CANCEL does")
|
||||
check(mash(), "the B run ends")
|
||||
eq(#boxes, 3, "three boxes when you back out")
|
||||
end
|
||||
|
||||
TextBox.new = realNew
|
||||
S.finish()
|
||||
@@ -0,0 +1,122 @@
|
||||
-- Parity (#567): the bike shop boy's line depends on whether the voucher
|
||||
-- has been redeemed.
|
||||
--
|
||||
-- Oracle: scripts/BikeShop.asm BikeShopYoungsterText -- CheckEvent
|
||||
-- EVENT_GOT_BICYCLE, jr nz .gotBike. Before the exchange he grumbles that
|
||||
-- BIKEs are way expensive; after it he admires yours. The port reads the
|
||||
-- bag instead of the event, because the BICYCLE is a key item and cannot
|
||||
-- be tossed, and because saves written before the clerk started setting
|
||||
-- the event still have the bike.
|
||||
--
|
||||
-- The near miss this pins is the one #535 hit: a row-list branch whose
|
||||
-- jump target is off by one runs off the end of the list and prints
|
||||
-- nothing at all, which looks exactly like the NPC having no script.
|
||||
--
|
||||
-- Self-contained: `luajit tests/parity_bike_shop_youngster_bug567.lua`;
|
||||
-- also globbed by tests/run_tests.lua.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
|
||||
local S = require("tests.harness").suite("parity bike shop youngster (#567)")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Commands = require("src.script.Commands")
|
||||
local Flags = require("src.script.Flags")
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local mapScripts = require("data.scripts.init")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
require("src.render.Font").load(Data)
|
||||
|
||||
local script = mapScripts.talkScript("BIKE_SHOP", "TEXT_BIKESHOP_YOUNGSTER")
|
||||
check(type(script) == "table", "the boy is a row-list talk script")
|
||||
|
||||
-- an unreachable or out-of-range jump is a silent NPC, so validate first
|
||||
do
|
||||
local problems = ScriptRunner.validate(script)
|
||||
eq(#problems, 0,
|
||||
"the row list validates (" .. (problems[1] or "no findings") .. ")")
|
||||
end
|
||||
|
||||
local shown = {}
|
||||
local origShow = Commands.show_text
|
||||
Commands.show_text = function(ctx, textId, subs)
|
||||
shown[#shown + 1] = textId
|
||||
return origShow(ctx, textId, subs)
|
||||
end
|
||||
|
||||
local function talk()
|
||||
shown = {}
|
||||
StateStack:init()
|
||||
local ow = { map = { id = "BIKE_SHOP", def = { label = "BikeShop" } },
|
||||
npcs = {}, entities = {} }
|
||||
local runner = ScriptRunner.new(Game, ow)
|
||||
runner:run(script, { npc = { def = {}, facePlayer = function() end },
|
||||
overworld = ow })
|
||||
local guard = 0
|
||||
while runner:isRunning() and guard < 3000 do
|
||||
guard = guard + 1
|
||||
Input.pressed = { a = true }
|
||||
StateStack:update(1 / 60)
|
||||
runner:update()
|
||||
end
|
||||
Input.pressed = {}
|
||||
return not runner:isRunning()
|
||||
end
|
||||
|
||||
local EXPENSIVE = "_BikeShopYoungsterTheseBikesAreExpensiveText"
|
||||
local COOL = "_BikeShopYoungsterCoolBikeText"
|
||||
|
||||
check(Data.text[EXPENSIVE] and Data.text[EXPENSIVE]:find("expensive", 1, true),
|
||||
"the pre-bike line is the way-expensive one")
|
||||
check(Data.text[COOL] and Data.text[COOL]:find("cool", 1, true),
|
||||
"the post-bike line admires your BIKE")
|
||||
|
||||
-- ------------------------------------------------- before the exchange
|
||||
do
|
||||
Game.save = SaveData.newGame()
|
||||
check(talk(), "the empty-handed talk completes")
|
||||
eq(table.concat(shown, ","), EXPENSIVE,
|
||||
"with no BICYCLE he complains about the price, once")
|
||||
end
|
||||
|
||||
-- holding the voucher is still "before": the exchange has not happened
|
||||
do
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.inventory.BIKE_VOUCHER = 1
|
||||
check(talk(), "the voucher-in-hand talk completes")
|
||||
eq(table.concat(shown, ","), EXPENSIVE,
|
||||
"a voucher is not a BICYCLE, so he still says they are expensive (#567)")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------- after the exchange
|
||||
do
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.inventory.BICYCLE = 1
|
||||
Flags.set(Game.save, "EVENT_GOT_BICYCLE")
|
||||
check(talk(), "the post-exchange talk completes")
|
||||
eq(table.concat(shown, ","), COOL,
|
||||
"with the BICYCLE he admires it, once, and nothing follows (#567)")
|
||||
check(talk() and table.concat(shown, ",") == COOL,
|
||||
"and he keeps saying it on every later visit")
|
||||
end
|
||||
|
||||
-- a save written before the clerk set EVENT_GOT_BICYCLE still has the bike
|
||||
do
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.inventory.BICYCLE = 1
|
||||
check(talk(), "the old-save talk completes")
|
||||
eq(table.concat(shown, ","), COOL,
|
||||
"the bag decides, so an older save reads right too")
|
||||
end
|
||||
|
||||
Commands.show_text = origShow
|
||||
S.finish()
|
||||
@@ -60,4 +60,30 @@ dismissText()
|
||||
eq(#states, 0, "backing out after viewing a badge leaves no menu behind")
|
||||
eq(done, true, "backing out after viewing a badge completes the NPC script")
|
||||
|
||||
-- PrintListMenuEntries writes ListMenuCancelText where the item list's $FF
|
||||
-- terminator lands (home/list_menu.asm), so a DisplayListMenuID list always
|
||||
-- offers CANCEL on screen; choosing it is `cp c / jp c, ExitListMenu`, which
|
||||
-- sets carry, so the badge script's `jr c, .done` treats it exactly like B
|
||||
-- (pokered scripts/CeruleanBadgeHouse.asm). The row was missing (#569).
|
||||
local check = S.check
|
||||
local cancelled = false
|
||||
script(game, nil, nil, function() cancelled = true end)
|
||||
dismissText()
|
||||
dismissText()
|
||||
|
||||
local menu = game.stack:top()
|
||||
eq(#menu.items, 9, "eight badges plus the CANCEL row")
|
||||
eq(menu.items[#menu.items].label, "CANCEL", "CANCEL is the last row")
|
||||
check(menu.items[#menu.items].value == nil,
|
||||
"the CANCEL row carries no badge, so it cannot print a description")
|
||||
for i = 1, 8 do
|
||||
check(menu.items[i].value ~= nil, "badge row " .. i .. " still picks a badge")
|
||||
end
|
||||
|
||||
menu.onChoose(menu.items[#menu.items])
|
||||
eq(#states, 1, "choosing CANCEL closes the list and leaves the goodbye line")
|
||||
dismissText()
|
||||
eq(#states, 0, "choosing CANCEL leaves no menu behind")
|
||||
eq(cancelled, true, "choosing CANCEL takes the same .done exit as B")
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
-- Parity (#552): the Game Corner coin clerk asks before he sells.
|
||||
--
|
||||
-- Oracle: scripts/GameCorner.asm GameCornerClerk1Text -- the offer, a
|
||||
-- YesNoChoice, then ¥1000 off the wallet for 50 coins, with the COIN CASE
|
||||
-- and Has9990Coins gates in between. Yellow spells the same object and
|
||||
-- the same six text labels without the "1" (GameCornerClerkText,
|
||||
-- pokeyellow/scripts/GameCorner.asm), so the Red/Blue text id never
|
||||
-- matched there: the clerk fell through to his extracted offer line with
|
||||
-- no prompt behind it, and no coins ever changed hands.
|
||||
--
|
||||
-- Drives the real StateStack and Input, so the YES/NO box under test is
|
||||
-- the one the player sees rather than a stand-in.
|
||||
--
|
||||
-- Self-contained: `luajit tests/parity_game_corner_clerk_bug552.lua`; also
|
||||
-- globbed by tests/run_tests.lua.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
|
||||
local S = require("tests.harness").suite("parity game corner clerk (#552)")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local mapScripts = require("data.scripts.init")
|
||||
|
||||
Game.data = Data
|
||||
Game.input = Input; Input:init()
|
||||
Game.stack = StateStack; StateStack:init()
|
||||
require("src.render.Font").load(Data)
|
||||
|
||||
local red = mapScripts.talkScript("GAME_CORNER", "TEXT_GAMECORNER_CLERK1")
|
||||
local yellow = mapScripts.talkScript("GAME_CORNER", "TEXT_GAMECORNER_CLERK")
|
||||
check(type(red) == "function", "the Red/Blue clerk has a hand-ported handler")
|
||||
check(yellow == red,
|
||||
"Yellow's TEXT_GAMECORNER_CLERK resolves to the same handler (#552)")
|
||||
|
||||
-- record what each box was built with; the handler resolves its labels at
|
||||
-- call time, so wrapping .new sees exactly the string that reached the box
|
||||
local shown = {}
|
||||
local realNew = TextBox.new
|
||||
TextBox.new = function(game, text, onDone, opts)
|
||||
shown[#shown + 1] = text
|
||||
return realNew(game, text, onDone, opts)
|
||||
end
|
||||
|
||||
local sawChoice = false
|
||||
|
||||
-- One conversation, start to finish. `answer` is which row of the YES/NO
|
||||
-- box to take; the loop mashes A the way a player does and nudges the
|
||||
-- cursor down once when the choice box comes up.
|
||||
local function talk(save, answer)
|
||||
Game.save = save
|
||||
StateStack:init()
|
||||
shown, sawChoice = {}, false
|
||||
local done = false
|
||||
local ow = { map = { id = "GAME_CORNER", def = Data.maps.GAME_CORNER },
|
||||
npcs = {}, entities = {} }
|
||||
yellow(Game, ow, { def = {} }, function() done = true end)
|
||||
|
||||
local moved = false
|
||||
for _ = 1, 2000 do
|
||||
local top = StateStack:top()
|
||||
if not top then break end
|
||||
if getmetatable(top) == ChoiceBox then
|
||||
sawChoice = true
|
||||
if answer == "no" and not moved then
|
||||
moved = true
|
||||
Input.pressed = { down = true }
|
||||
else
|
||||
Input.pressed = { a = true }
|
||||
end
|
||||
else
|
||||
Input.pressed = { a = true }
|
||||
end
|
||||
StateStack:update(1 / 60)
|
||||
end
|
||||
Input.pressed = {}
|
||||
return done
|
||||
end
|
||||
|
||||
local function newSave(money, coins, caseToo)
|
||||
local save = SaveData.newGame()
|
||||
save.money = money
|
||||
save.coins = coins
|
||||
if caseToo ~= false then save.inventory.COIN_CASE = 1 end
|
||||
return save
|
||||
end
|
||||
|
||||
local function said(fragment)
|
||||
for _, text in ipairs(shown) do
|
||||
if type(text) == "string" and text:find(fragment, 1, true) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------- the sale itself
|
||||
do
|
||||
local save = newSave(1000, 0)
|
||||
check(talk(save, "yes"), "the buy conversation runs to the end")
|
||||
check(sawChoice, "the clerk opens a real YES/NO box, not just a line (#552)")
|
||||
eq(#shown, 2, "two boxes: the offer and the receipt")
|
||||
check(said("¥1000 for 50"), "the offer quotes ¥1000 for 50 coins")
|
||||
eq(save.money, 0, "¥1000 leaves the wallet")
|
||||
eq(save.coins, 50, "50 coins land in the case")
|
||||
check(said("COINS: 50"), "the receipt prints the new coin total")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- saying no
|
||||
do
|
||||
local save = newSave(1000, 0)
|
||||
check(talk(save, "no"), "declining runs to the end")
|
||||
check(sawChoice, "NO is a choice the player actually makes")
|
||||
check(said("play sometime"), "he sees you out with the come-play-sometime line")
|
||||
eq(save.money, 1000, "declining costs nothing")
|
||||
eq(save.coins, 0, "and buys nothing")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- the gates
|
||||
do
|
||||
local save = newSave(999, 0)
|
||||
talk(save, "yes")
|
||||
check(said("afford"), "under ¥1000 he says you can't afford it")
|
||||
eq(save.money, 999, "and takes nothing")
|
||||
eq(save.coins, 0, "and gives nothing")
|
||||
end
|
||||
|
||||
do
|
||||
local save = newSave(1000, 9990)
|
||||
talk(save, "yes")
|
||||
check(said("full"), "a case at 9990 coins is full (Has9990Coins)")
|
||||
eq(save.money, 1000, "a full case costs nothing")
|
||||
eq(save.coins, 9990, "and adds nothing")
|
||||
end
|
||||
|
||||
do
|
||||
local save = newSave(1000, 0, false)
|
||||
talk(save, "yes")
|
||||
check(said("COIN CASE"), "no COIN CASE, no sale")
|
||||
eq(save.money, 1000, "and no money changes hands")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------- Yellow's label spelling
|
||||
-- Yellow's six labels drop the "1" and the port falls back to them, so a
|
||||
-- Yellow cache reaches the same prompt. Swap the cache's strings to the
|
||||
-- Yellow spelling to prove the fallback is the one doing the work.
|
||||
do
|
||||
local SUFFIXES = {
|
||||
"DoYouNeedSomeGameCoinsText", "ThanksHereAre50CoinsText",
|
||||
"PleaseComePlaySometimeText", "CantAffordTheCoinsText",
|
||||
"CoinCaseIsFullText", "DontHaveCoinCaseText",
|
||||
}
|
||||
local saved = {}
|
||||
for _, suffix in ipairs(SUFFIXES) do
|
||||
saved[suffix] = Data.text["_GameCornerClerk1" .. suffix]
|
||||
Data.text["_GameCornerClerk" .. suffix] =
|
||||
"YELLOW " .. suffix .. "\n¥1000 for 50."
|
||||
Data.text["_GameCornerClerk1" .. suffix] = nil
|
||||
end
|
||||
|
||||
local save = newSave(1000, 0)
|
||||
check(talk(save, "yes"), "the Yellow-spelled clerk runs to the end")
|
||||
check(sawChoice, "and still opens the YES/NO box")
|
||||
check(said("YELLOW DoYouNeedSomeGameCoinsText"),
|
||||
"the offer comes from _GameCornerClerkDoYouNeedSomeGameCoinsText")
|
||||
check(said("YELLOW ThanksHereAre50CoinsText"),
|
||||
"so does the receipt")
|
||||
eq(save.money, 0, "Yellow's clerk takes the ¥1000")
|
||||
eq(save.coins, 50, "and hands over the 50 coins")
|
||||
|
||||
for _, suffix in ipairs(SUFFIXES) do
|
||||
Data.text["_GameCornerClerk1" .. suffix] = saved[suffix]
|
||||
Data.text["_GameCornerClerk" .. suffix] = nil
|
||||
end
|
||||
end
|
||||
|
||||
TextBox.new = realNew
|
||||
S.finish()
|
||||
@@ -0,0 +1,187 @@
|
||||
-- Parity (#552, a regression of #105): the Rocket Hideout B4F grunt drops
|
||||
-- the LIFT KEY on Yellow too, and the ball he drops reaches the bag.
|
||||
--
|
||||
-- Oracle: pokeyellow/scripts/RocketHideoutB4F.asm. Yellow replaces
|
||||
-- Rocket1/Rocket2 with Jessie & James and leaves one grunt, spelled
|
||||
-- ROCKETHIDEOUTB4F_ROCKET / TEXT_ROCKETHIDEOUTB4F_ROCKET where Red and
|
||||
-- Blue say ROCKET3, so the hand-ported Red/Blue key never matched and the
|
||||
-- talk fell through to the generic trainer path: battle, no drop, no key,
|
||||
-- and the B1F elevator stays locked for good. Yellow also moves the drop
|
||||
-- off the follow-up talk -- RocketHideoutB4FRocketEndBattleText is
|
||||
-- text_promptbutton + text_asm, so its SetEvent EVENT_ROCKET_DROPPED_LIFT_KEY
|
||||
-- and ShowObject TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5 run the moment the
|
||||
-- battle ends, not on the next conversation.
|
||||
--
|
||||
-- The Red/Blue half stays in tests/parity_lift_key.lua; this one owns the
|
||||
-- Yellow spelling and the pickup that #105 was originally about.
|
||||
--
|
||||
-- Self-contained: `luajit tests/parity_lift_key_yellow_bug552.lua`; also
|
||||
-- globbed by tests/run_tests.lua.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
|
||||
local S = require("tests.harness").suite("parity Yellow lift key (#552)")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local mapScripts = require("data.scripts.init")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
require("src.render.Font").load(Data)
|
||||
|
||||
local MAP = "ROCKET_HIDEOUT_B4F"
|
||||
local BALL = "ROCKETHIDEOUTB4F_LIFT_KEY"
|
||||
|
||||
local ballObj
|
||||
for _, o in ipairs(Data.maps[MAP].objects) do
|
||||
if o.name == BALL then ballObj = o end
|
||||
end
|
||||
check(ballObj ~= nil, "the LIFT KEY ball is an object_event on B4F")
|
||||
check(ballObj and ballObj.hidden == true, "it starts hidden")
|
||||
eq(ballObj and ballObj.item, "LIFT_KEY", "and it carries the LIFT KEY")
|
||||
|
||||
-- Yellow's object_event names the same grunt trainer party the Red/Blue
|
||||
-- Rocket3 does (OPP_ROCKET, 18), so only the text id differs
|
||||
local script = mapScripts.talkScript(MAP, "TEXT_ROCKETHIDEOUTB4F_ROCKET")
|
||||
check(type(script) == "function",
|
||||
"TEXT_ROCKETHIDEOUTB4F_ROCKET resolves to a hand-ported handler (#552)")
|
||||
check(type(mapScripts.talkScript(MAP, "TEXT_ROCKETHIDEOUTB4F_ROCKET3")) == "function",
|
||||
"the Red/Blue ROCKET3 handler is still registered alongside it")
|
||||
|
||||
-- ------------------------------------------------------------------ setup
|
||||
local function newWorld(defeated)
|
||||
local save = SaveData.newGame()
|
||||
save.objectToggles = {}
|
||||
save.itemsTaken = {}
|
||||
save.defeatedTrainers = {}
|
||||
local pushed = {}
|
||||
local game = {
|
||||
data = Data,
|
||||
save = save,
|
||||
stack = { push = function(_, state) pushed[#pushed + 1] = state end },
|
||||
}
|
||||
local npc = {
|
||||
id = MAP .. "_obj_4",
|
||||
def = { name = "ROCKETHIDEOUTB4F_ROCKET", index = 4,
|
||||
text = "TEXT_ROCKETHIDEOUTB4F_ROCKET",
|
||||
trainerClass = "OPP_ROCKET", trainerParty = 18 },
|
||||
}
|
||||
if defeated then save.defeatedTrainers[npc.id] = true end
|
||||
local ow = {
|
||||
map = { id = MAP, def = Data.maps[MAP] },
|
||||
npcs = {}, entities = {},
|
||||
engagements = 0,
|
||||
trainerDefeated = function(_, who)
|
||||
return save.defeatedTrainers[who.id] == true
|
||||
end,
|
||||
}
|
||||
-- engageTrainer writes the win before it runs its callback, which is
|
||||
-- where the end-battle text's SetEvent + ShowObject belong
|
||||
ow.engageTrainer = function(self, who, after)
|
||||
self.engagements = self.engagements + 1
|
||||
save.defeatedTrainers[who.id] = true
|
||||
if after then after() end
|
||||
end
|
||||
return game, ow, npc, save, pushed
|
||||
end
|
||||
|
||||
-- ------------------------------------------- winning the battle drops it
|
||||
do
|
||||
local game, ow, npc, save, pushed = newWorld(false)
|
||||
local done = false
|
||||
check(not OverworldState.objectVisible(save, MAP, ballObj),
|
||||
"the ball is not on the floor before the fight")
|
||||
script(game, ow, npc, function() done = true end)
|
||||
|
||||
eq(ow.engagements, 1, "an undefeated grunt still starts his battle")
|
||||
check(save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY == true,
|
||||
"EVENT_ROCKET_DROPPED_LIFT_KEY is set the moment the battle ends")
|
||||
eq(save.objectToggles[MAP] and save.objectToggles[MAP][BALL], true,
|
||||
"ShowObject reveals ROCKETHIDEOUTB4F_LIFT_KEY (TOGGLE_..._ITEM_5)")
|
||||
check(OverworldState.objectVisible(save, MAP, ballObj),
|
||||
"the LIFT KEY ball is on the floor without a second conversation")
|
||||
check(done, "the talk finishes and hands input back")
|
||||
eq(#pushed, 0, "the end-battle text is the battle's, not a second box")
|
||||
end
|
||||
|
||||
-- --------------------------------------------- losing it drops nothing
|
||||
do
|
||||
local game, ow, npc, save = newWorld(false)
|
||||
ow.engageTrainer = function(self, _, after)
|
||||
self.engagements = self.engagements + 1
|
||||
if after then after() end -- blacked out: no win recorded
|
||||
end
|
||||
script(game, ow, npc, function() end)
|
||||
check(not save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY,
|
||||
"a lost battle drops no key")
|
||||
check(not OverworldState.objectVisible(save, MAP, ballObj),
|
||||
"and the ball stays hidden")
|
||||
end
|
||||
|
||||
-- ------------------------------------- later talks only reprint the line
|
||||
do
|
||||
local game, ow, npc, save, pushed = newWorld(true)
|
||||
save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
|
||||
save.objectToggles[MAP] = { [BALL] = true }
|
||||
local done = false
|
||||
script(game, ow, npc, function() done = true end)
|
||||
eq(ow.engagements, 0, "a beaten grunt does not re-engage")
|
||||
eq(#pushed, 1, "he says his after-battle line")
|
||||
eq(pushed[1] and getmetatable(pushed[1]), TextBox, "in a text box")
|
||||
if pushed[1] and pushed[1].onDone then pushed[1].onDone() end
|
||||
check(done, "closing that box hands input back to the overworld")
|
||||
eq(save.objectToggles[MAP][BALL], true, "the ball is not re-toggled")
|
||||
end
|
||||
|
||||
-- ------------------------------------------ the ball itself, #105's half
|
||||
-- talkTo's item-ball branch is what turns the revealed object into a bag
|
||||
-- entry; without it the fix above only puts a sprite on the floor. Same
|
||||
-- `Game` upvalue rewiring tests/parity_D.lua uses for the Safari counters.
|
||||
do
|
||||
local game, _, _, save, pushed = newWorld(true)
|
||||
save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
|
||||
save.objectToggles[MAP] = { [BALL] = true }
|
||||
|
||||
local function bind(fn, upvalue, value)
|
||||
local i = 1
|
||||
while true do
|
||||
local name = debug.getupvalue(fn, i)
|
||||
if not name then return false end
|
||||
if name == upvalue then debug.setupvalue(fn, i, value); return true end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
check(bind(OverworldState.talkTo, "Game", game), "talkTo binds the Game upvalue")
|
||||
check(bind(OverworldState.talkTo, "mapScripts", mapScripts),
|
||||
"talkTo binds the map-script registry")
|
||||
|
||||
local ball = {
|
||||
id = MAP .. "_obj_" .. ballObj.index,
|
||||
def = ballObj,
|
||||
cellX = ballObj.x, cellY = ballObj.y,
|
||||
}
|
||||
local world = setmetatable({
|
||||
map = { id = MAP, def = Data.maps[MAP] },
|
||||
npcs = { ball }, entities = { ball },
|
||||
}, { __index = OverworldState })
|
||||
|
||||
world:talkTo(ball)
|
||||
eq(save.inventory.LIFT_KEY, 1, "picking the ball up puts the LIFT KEY in the bag (#105)")
|
||||
eq(save.itemsTaken[ball.id], true, "the ball is marked taken")
|
||||
eq(#world.npcs, 0, "and it leaves the floor")
|
||||
check(not OverworldState.objectVisible(save, MAP, ballObj),
|
||||
"a taken ball does not respawn on the next map load")
|
||||
eq(#pushed, 1, "one box: the found-item line")
|
||||
local lines = {}
|
||||
for _, page in ipairs(pushed[1] and pushed[1].pages or {}) do
|
||||
for _, line in ipairs(page) do lines[#lines + 1] = line end
|
||||
end
|
||||
check(table.concat(lines, " "):find("LIFT KEY", 1, true) ~= nil,
|
||||
"the box names the LIFT KEY")
|
||||
eq(Data.items.LIFT_KEY and Data.items.LIFT_KEY.keyItem, true,
|
||||
"LIFT KEY is a key item, so it cannot be tossed away again")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -33,13 +33,16 @@ package.loaded["src.render.TextBox"] = {
|
||||
}
|
||||
|
||||
-- A recording stand-in for BattleState: newWild captures the species and
|
||||
-- level, makeGhost sets the flag the real one sets, and the test drives
|
||||
-- onFinish by hand.
|
||||
-- level, the two disguise entry points set the flags the real ones set, and
|
||||
-- the test drives onFinish by hand. The unveiled branch is the scope's
|
||||
-- (#492) -- it disguises the pic exactly like makeGhost but leaves
|
||||
-- IsGhostBattle false, so `ghost` stays down and `scopeReveal` comes up.
|
||||
local madeBattles = {}
|
||||
package.loaded["src.battle.BattleState"] = {
|
||||
newWild = function(_, species, level)
|
||||
local b = { species = species, level = level, ghost = false }
|
||||
b.makeGhost = function(self) self.ghost = true end
|
||||
b.makeUnveiledGhost = function(self) self.scopeReveal = true end
|
||||
madeBattles[#madeBattles + 1] = b
|
||||
return b
|
||||
end,
|
||||
@@ -107,13 +110,17 @@ do
|
||||
check(battle.ghost, "and it is ghost-disguised without the scope")
|
||||
end
|
||||
|
||||
-- ---- 3. scope: same battle, not disguised -------------------------------
|
||||
-- ---- 3. scope: same battle, unveiled instead of ghosted ------------------
|
||||
-- PrintBeginningBattleText .isMarowak (common_text.asm:49-60) still enters
|
||||
-- disguised and plays the unveil over it; only IsGhostBattle flips (#492).
|
||||
do
|
||||
local game, pushed = gameWith({ SILPH_SCOPE = 1 })
|
||||
local ow = owWith()
|
||||
local battle = trigger(game, pushed, ow, 10, 16)
|
||||
check(battle ~= nil and not battle.ghost,
|
||||
"with the scope the battle is not a ghost")
|
||||
"with the scope the battle is not a ghost battle")
|
||||
check(battle ~= nil and battle.scopeReveal,
|
||||
"but it still opens disguised, with the unveil armed (#492)")
|
||||
end
|
||||
|
||||
-- ---- 4. a win sets the event and shows the departed text ----------------
|
||||
|
||||
@@ -42,6 +42,9 @@ do
|
||||
newWild = function(_, species, level)
|
||||
local b = { species = species, level = level, ghost = false }
|
||||
b.makeGhost = function(self) self.ghost = true end
|
||||
-- the scope's branch (#492): disguised on entry, but IsGhostBattle
|
||||
-- false, which is exactly the state the dodge below has to survive
|
||||
b.makeUnveiledGhost = function(self) self.scopeReveal = true end
|
||||
made[#made + 1] = b
|
||||
return b
|
||||
end,
|
||||
@@ -70,7 +73,8 @@ do
|
||||
check(noScope.noCatch, "and noCatch is set")
|
||||
|
||||
local withScope = trigger({ SILPH_SCOPE = 1 })
|
||||
check(not withScope.ghost, "with the scope the disguise is gone")
|
||||
check(not withScope.ghost, "with the scope IsGhostBattle is false")
|
||||
check(withScope.scopeReveal, "and the unveil plays instead (#492)")
|
||||
check(withScope.noCatch,
|
||||
"but noCatch survives it -- balls are dodged either way")
|
||||
|
||||
|
||||
@@ -68,13 +68,36 @@ moves[2].onDone()
|
||||
check(ow.emote and ow.emote.bubble == 1,
|
||||
"Pikachu shows its confused reaction after reaching Bill")
|
||||
|
||||
-- BillsHouseScript3 seeds hl with ..._EnterCellSeparatorDown and only swaps
|
||||
-- in ..._EnterCellSeparatorNotDown on the facing-down fallthrough, so the
|
||||
-- table names are inverted relative to the branch that picks them: facing
|
||||
-- down takes the five-step detour, every other facing walks straight up
|
||||
-- (pokeyellow scripts/BillsHouse.asm:100-133) (#455).
|
||||
ow.player.facing = "down"
|
||||
PikachuFollower.onBillEnteredMachine(yellowGame, ow)
|
||||
check(#moves == 3 and moves[3].dir == "up" and moves[3].tiles == 3,
|
||||
"Pikachu walks to the cell separator after Bill enters it")
|
||||
check(#moves == 3 and moves[3].dir == "up" and moves[3].tiles == 1,
|
||||
"facing down, Pikachu starts the long way round the cell separator")
|
||||
moves[3].onDone()
|
||||
check(ow.emote and ow.emote.bubble == 1,
|
||||
"Pikachu wonders at the cell separator")
|
||||
check(#moves == 4 and moves[4].dir == "left" and moves[4].tiles == 1,
|
||||
"the detour steps aside before climbing")
|
||||
moves[4].onDone()
|
||||
check(#moves == 5 and moves[5].dir == "up" and moves[5].tiles == 2,
|
||||
"the detour climbs past the separator")
|
||||
moves[5].onDone()
|
||||
check(#moves == 6 and moves[6].dir == "right" and moves[6].tiles == 1,
|
||||
"the detour steps back in beside it")
|
||||
moves[6].onDone()
|
||||
check(ow.emote and ow.emote.bubble == 1 and npc.facing == "up",
|
||||
"Pikachu looks up and wonders at the cell separator")
|
||||
|
||||
-- the other branch: any non-down facing takes the straight three-step route
|
||||
local before = #moves
|
||||
ow.player.facing = "up"
|
||||
PikachuFollower.onBillEnteredMachine(yellowGame, ow)
|
||||
check(#moves == before + 1 and moves[before + 1].dir == "up"
|
||||
and moves[before + 1].tiles == 3,
|
||||
"facing up, Pikachu walks straight to the cell separator")
|
||||
moves[before + 1].onDone()
|
||||
|
||||
npc.goalX, npc.goalY = 9, 9
|
||||
PikachuFollower.update(yellowGame, ow)
|
||||
|
||||
@@ -36,4 +36,13 @@ ri:play("red")
|
||||
eq(booted, "red", "android play still boots")
|
||||
eq(currentCursor, "hand", "android play leaves the cursor alone")
|
||||
|
||||
booted = nil
|
||||
currentCursor = "hand"
|
||||
ri.android = false
|
||||
ri.arrowCursor = nil
|
||||
love.mouse.getSystemCursor = function() error("CreateSystemCursor is not currently supported") end
|
||||
ri:play("red")
|
||||
eq(booted, "red", "unsupported system cursors still allow boot")
|
||||
eq(currentCursor, "hand", "unsupported system cursors leave the existing cursor alone")
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local S = require("tests.harness").suite("rom importer iOS save export")
|
||||
local eq = S.eq
|
||||
local check = S.check
|
||||
|
||||
love.system = love.system or {}
|
||||
local saved = {
|
||||
createFile = love.system.createFile,
|
||||
read = love.filesystem.read,
|
||||
write = love.filesystem.write,
|
||||
getSaveDirectory = love.filesystem.getSaveDirectory,
|
||||
}
|
||||
|
||||
local writes = {}
|
||||
love.filesystem.read = function(name)
|
||||
eq(name, "exports/red.sav", "export reads the generated save")
|
||||
return "save-data"
|
||||
end
|
||||
love.filesystem.write = function(name, data)
|
||||
writes[#writes + 1] = { name = name, data = data }
|
||||
return true
|
||||
end
|
||||
love.filesystem.getSaveDirectory = function() return "/tmp/gen1recomp-save" end
|
||||
|
||||
local createCalls = {}
|
||||
love.system.createFile = function(name, saveDirectory)
|
||||
createCalls[#createCalls + 1] = { name = name, saveDirectory = saveDirectory }
|
||||
return true
|
||||
end
|
||||
|
||||
package.loaded["src.import.SaveFileIO"] = {
|
||||
exportActiveSlot = function(version)
|
||||
eq(version, "red", "exports the selected game save")
|
||||
return true, "exports/red.sav"
|
||||
end,
|
||||
}
|
||||
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
local ri = setmetatable({
|
||||
android = true,
|
||||
ios = true,
|
||||
workState = nil,
|
||||
saveNotice = {},
|
||||
}, RomImporter)
|
||||
|
||||
ri:exportSave("red")
|
||||
|
||||
eq(#writes, 1, "stages the save for iOS export")
|
||||
eq(writes[1].name, "pending_export.sav", "uses the native bridge staging name")
|
||||
eq(writes[1].data, "save-data", "preserves the exported save data")
|
||||
eq(#createCalls, 1, "opens the system destination picker")
|
||||
eq(createCalls[1].name, "red.sav", "suggests the original save name")
|
||||
eq(createCalls[1].saveDirectory, "/tmp/gen1recomp-save", "passes the exact staging directory")
|
||||
check(ri.saveNotice.red.ok, "reports that the destination picker opened")
|
||||
|
||||
love.system.createFile = saved.createFile
|
||||
love.filesystem.read = saved.read
|
||||
love.filesystem.write = saved.write
|
||||
love.filesystem.getSaveDirectory = saved.getSaveDirectory
|
||||
package.loaded["src.import.SaveFileIO"] = nil
|
||||
|
||||
S.finish()
|
||||
@@ -628,5 +628,324 @@ do
|
||||
Kit.focus = nil
|
||||
end
|
||||
|
||||
do
|
||||
-- #541: changing species took the editor down. The inspector's arrows
|
||||
-- called MonOps.setSpecies on whatever record came next in the catalog, and
|
||||
-- the stat recalculation indexes baseStats.<stat> unconditionally
|
||||
-- (src/pokemon/Stats.lua, ported from home/move_mon.asm CalcStat) because
|
||||
-- the asm's BaseStats is a fixed 151-entry table with no partial rows. The
|
||||
-- editor's catalog is NOT that table: it is every key in Data.pokemon after
|
||||
-- the mod merge, and a partial record survives that merge as a warning
|
||||
-- rather than a rejection (src/mods/Schemas.lua R.pokemon). So the sweep
|
||||
-- below is the real check -- every id the catalog offers has to either
|
||||
-- assign or refuse in words, and neither may raise.
|
||||
local S = State.new()
|
||||
S.data = Data
|
||||
S.save = SaveData.newGame()
|
||||
|
||||
-- a mod-shaped partial record: registered, listed, missing the stats the
|
||||
-- Gen1 formulas read
|
||||
Data.pokemon.TESTMON_PARTIAL = { name = "TESTMON", dex = 0,
|
||||
baseStats = { hp = 40, attack = 30 }, growthRate = "MEDIUM_FAST",
|
||||
types = { "NORMAL" }, learnset = {} }
|
||||
S.cat = Catalog.build(Data)
|
||||
|
||||
check(Ops.speciesUsable(S, "PIKACHU"), "a complete record is usable")
|
||||
check(Ops.speciesUsable(S, "TESTMON_PARTIAL") == false,
|
||||
"a record missing base stats is not usable")
|
||||
check(Ops.speciesUsable(S, "NO_SUCH_SPECIES") == false,
|
||||
"an id that is not in the data at all is not usable")
|
||||
|
||||
local mon = MonOps.create(Data, "WARTORTLE", 20)
|
||||
S.editingMon = mon
|
||||
local statsBefore = mon.stats.attack
|
||||
check(Ops.setSpecies(S, mon, "TESTMON_PARTIAL") == false,
|
||||
"setSpecies refuses a record the formulas cannot use")
|
||||
eq(mon.species, "WARTORTLE", "a refused setSpecies leaves the mon alone")
|
||||
eq(mon.stats.attack, statsBefore, "a refused setSpecies leaves the stats alone")
|
||||
check(S.status:match("base stats") ~= nil, "a refused setSpecies explains itself")
|
||||
check(S.dirty == false, "a refused setSpecies does not dirty the save")
|
||||
|
||||
-- the crash itself: walk the whole catalog the way the arrows did
|
||||
local landed = {}
|
||||
local walkOk, walkErr = pcall(function()
|
||||
for _ = 1, #S.cat.species do
|
||||
Ops.stepSpecies(S, mon, 1)
|
||||
landed[mon.species] = true
|
||||
assert(Ops.speciesUsable(S, mon.species),
|
||||
"stepSpecies parked on " .. tostring(mon.species))
|
||||
end
|
||||
end)
|
||||
check(walkOk, "cycling the whole catalog never errors: " .. tostring(walkErr))
|
||||
check(landed.TESTMON_PARTIAL == nil, "cycling steps over an unusable record")
|
||||
check(landed.PIKACHU, "cycling still reaches ordinary species")
|
||||
|
||||
local backOk, backErr = pcall(function()
|
||||
for _ = 1, #S.cat.species do Ops.stepSpecies(S, mon, -1) end
|
||||
end)
|
||||
check(backOk, "cycling backwards never errors: " .. tostring(backErr))
|
||||
|
||||
-- and every real id assigns, so "nothing crashes" cannot be bought by
|
||||
-- refusing everything
|
||||
local assigned, refused = 0, 0
|
||||
for _, id in ipairs(S.cat.species) do
|
||||
if id ~= "TESTMON_PARTIAL" then
|
||||
local ok, err = pcall(Ops.setSpecies, S, mon, id)
|
||||
check(ok, "setSpecies " .. id .. ": " .. tostring(err))
|
||||
if ok and mon.species == id then assigned = assigned + 1 else refused = refused + 1 end
|
||||
end
|
||||
end
|
||||
eq(refused, 0, "no real species is refused")
|
||||
check(assigned > 140, "the whole dex assigns (" .. assigned .. " species)")
|
||||
|
||||
Data.pokemon.TESTMON_PARTIAL = nil
|
||||
end
|
||||
|
||||
do
|
||||
-- #541 search predicate. The picker replaced the arrows, so the filter is
|
||||
-- the only way to reach a species now and its rules are worth pinning: ids
|
||||
-- and names substring-match case-insensitively, a dex number matches whole
|
||||
-- (a substring match would answer "25" with ELECTABUZZ, #125), and the
|
||||
-- query is plain text, not a Lua pattern.
|
||||
local S = State.new()
|
||||
S.data = Data
|
||||
S.cat = Catalog.build(Data)
|
||||
S.save = SaveData.newGame()
|
||||
|
||||
check(Ops.speciesMatches(S, "PIKACHU", "pika"), "lowercase query matches an id")
|
||||
check(Ops.speciesMatches(S, "PIKACHU", "KACH"), "a mid-word substring matches")
|
||||
check(Ops.speciesMatches(S, "PIKACHU", "25"), "a bare dex number matches")
|
||||
check(Ops.speciesMatches(S, "PIKACHU", "025"), "the padded dex number matches")
|
||||
check(Ops.speciesMatches(S, "ELECTABUZZ", "25") == false,
|
||||
"a dex number does not substring-match #125")
|
||||
check(Ops.speciesMatches(S, "PIKACHU", ""), "an empty query matches everything")
|
||||
|
||||
eq(#Ops.speciesSearch(S, ""), #S.cat.species, "an empty search lists the catalog")
|
||||
-- a dot is a literal, not a pattern wildcard: MR.MIME is the one species
|
||||
-- whose printed name carries one, so it is the whole result
|
||||
local dots = Ops.speciesSearch(S, ".")
|
||||
eq(#dots, 1, "a dot matches literally instead of matching everything")
|
||||
eq(dots[1], "MR_MIME", "and the literal it matched is MR.MIME's name")
|
||||
eq(#Ops.speciesSearch(S, "%a"), 0, "a pattern class is literal too")
|
||||
check(#Ops.speciesSearch(S, "zzzznope") == 0, "a miss returns nothing")
|
||||
local pika = Ops.speciesSearch(S, "pikachu")
|
||||
eq(#pika, 1, "an exact name search narrows to one")
|
||||
eq(pika[1], "PIKACHU", "and it is the right one")
|
||||
end
|
||||
|
||||
do
|
||||
-- #541 end to end through App: open the picker off a selected slot, type,
|
||||
-- commit with Enter. Enter and Escape have to be taken before the focused
|
||||
-- field sees them -- Kit maps both to the same "\r" edit, which cannot tell
|
||||
-- "commit the top match" from "give up".
|
||||
local Kit = require("Kit")
|
||||
local SpeciesPicker = require("SpeciesPicker")
|
||||
local tmpPath = os.tmpname() .. "-picker-save.lua"
|
||||
local data = SaveData.newGame()
|
||||
data.party = { MonOps.create(Data, "WARTORTLE", 20) }
|
||||
local f = io.open(tmpPath, "wb")
|
||||
f:write(SaveData.encode(data))
|
||||
f:close()
|
||||
|
||||
App.load(tmpPath, { version = "red" })
|
||||
local S = App.getState()
|
||||
S.tab = "party"
|
||||
|
||||
check(Ops.openSpeciesPicker(S, Kit) == false,
|
||||
"the picker refuses to open with no slot selected")
|
||||
check(S.speciesPicker == nil, "and it stayed closed")
|
||||
check(S.status:match("Pick a slot") ~= nil, "and it said why")
|
||||
|
||||
Ops.selectParty(S, 1)
|
||||
check(Ops.openSpeciesPicker(S, Kit) == true, "the picker opens on a selected slot")
|
||||
check(S.speciesPicker ~= nil, "the picker is up")
|
||||
eq(S.speciesPicker.query, "", "it opens with an empty query")
|
||||
eq(Kit.focus, "species-picker", "it opens with the field focused (#529 keyboard)")
|
||||
|
||||
local ok, err = pcall(App.draw)
|
||||
check(ok, "the picker draws headlessly: " .. tostring(err))
|
||||
|
||||
App.textinput("PIKACHU")
|
||||
ok, err = pcall(App.draw)
|
||||
check(ok, "the picker draws while typing: " .. tostring(err))
|
||||
eq(S.speciesPicker.query, "PIKACHU", "typing reaches the picker's field")
|
||||
eq(#SpeciesPicker.results(S), 1, "the list narrowed to the typed species")
|
||||
|
||||
App.keypressed("return")
|
||||
eq(S.save.party[1].species, "PIKACHU", "Enter commits the top match")
|
||||
check(S.speciesPicker == nil, "and closes the picker")
|
||||
check(S.dirty, "and the save is dirty")
|
||||
|
||||
-- Escape leaves without touching the mon
|
||||
Ops.openSpeciesPicker(S, Kit)
|
||||
App.textinput("BULBASAUR")
|
||||
App.draw()
|
||||
App.keypressed("escape")
|
||||
check(S.speciesPicker == nil, "Escape closes the picker")
|
||||
eq(S.save.party[1].species, "PIKACHU", "Escape did not commit anything")
|
||||
check(S.editingMon ~= nil, "Escape closed the picker, not the selection")
|
||||
|
||||
-- a query nothing matches cannot commit
|
||||
Ops.openSpeciesPicker(S, Kit)
|
||||
App.textinput("zzzznope")
|
||||
App.draw()
|
||||
App.keypressed("return")
|
||||
check(S.speciesPicker ~= nil, "Enter on an empty result set keeps the picker up")
|
||||
check(S.status:match("No species matches") ~= nil, "and says so")
|
||||
eq(S.save.party[1].species, "PIKACHU", "and changes nothing")
|
||||
Ops.closeSpeciesPicker(S, Kit)
|
||||
|
||||
os.remove(tmpPath)
|
||||
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
|
||||
end
|
||||
|
||||
do
|
||||
-- #541 modal shield. Kit hit-tests without a z-order, so the picker cannot
|
||||
-- simply be drawn last: the chrome and the panel underneath would take the
|
||||
-- same tap. App raises Kit.blockClicks around everything it draws before
|
||||
-- the picker and lowers it for the picker's own layer, which is asserted
|
||||
-- here by watching what Kit.press sees over one frame rather than by
|
||||
-- clicking coordinates the design is free to move.
|
||||
local Kit = require("Kit")
|
||||
local tmpPath = os.tmpname() .. "-shield-save.lua"
|
||||
local data = SaveData.newGame()
|
||||
data.party = { MonOps.create(Data, "WARTORTLE", 20) }
|
||||
local f = io.open(tmpPath, "wb")
|
||||
f:write(SaveData.encode(data))
|
||||
f:close()
|
||||
|
||||
App.load(tmpPath, { version = "red" })
|
||||
local S = App.getState()
|
||||
S.tab = "party"
|
||||
Ops.selectParty(S, 1)
|
||||
|
||||
-- the shield itself: a raised shield refuses a click that hits
|
||||
Kit.beginFrame(15, 15, true)
|
||||
Kit.blockClicks = false
|
||||
check(Kit.press(10, 10, 100, 20) == true, "an unshielded press takes the click")
|
||||
Kit.blockClicks = true
|
||||
check(Kit.press(10, 10, 100, 20) == false, "a shielded press refuses it")
|
||||
Kit.blockClicks = false
|
||||
Kit.endFrame()
|
||||
|
||||
local realPress = Kit.press
|
||||
local seen = {}
|
||||
Kit.press = function(...)
|
||||
seen[#seen + 1] = Kit.blockClicks
|
||||
return realPress(...)
|
||||
end
|
||||
|
||||
Ops.openSpeciesPicker(S, Kit)
|
||||
seen = {}
|
||||
App.draw()
|
||||
check(#seen > 0, "the opening frame dispatched clicks at all")
|
||||
local allShielded = true
|
||||
for _, v in ipairs(seen) do allShielded = allShielded and (v == true) end
|
||||
check(allShielded,
|
||||
"on the opening frame even the picker's own layer is shielded, so the "
|
||||
.. "click that opened it cannot read as a tap outside")
|
||||
|
||||
seen = {}
|
||||
App.draw()
|
||||
check(seen[1] == true, "the frame under an open picker is shielded")
|
||||
check(seen[#seen] == false, "the picker's own layer is not")
|
||||
check(Kit.blockClicks == false, "the shield is down again once the frame ends")
|
||||
|
||||
Kit.press = realPress
|
||||
|
||||
-- a Close taken with the picker up must not leak the shield into the next
|
||||
-- session, which would open deaf to every click
|
||||
Kit.blockClicks = true
|
||||
App.unload()
|
||||
check(Kit.blockClicks == false, "unload lowers the shield")
|
||||
|
||||
os.remove(tmpPath)
|
||||
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
|
||||
end
|
||||
|
||||
do
|
||||
-- #497: the editor drew a desktop layout into a phone window. Kit.layout
|
||||
-- scaled off height alone, and a phone in portrait (720x1560) is TALLER
|
||||
-- than the 768px desktop reference while being barely half as wide, so the
|
||||
-- scale came back clamped at 1.6 and every right-aligned cluster in the
|
||||
-- chrome landed on top of the block to its left. Both axes now pay.
|
||||
local Kit = require("Kit")
|
||||
local Theme = require("Theme")
|
||||
local function about(got, want, msg)
|
||||
check(math.abs(got - want) < 0.001,
|
||||
msg .. string.format(" (got %.4f, want %.4f)", got, want))
|
||||
end
|
||||
|
||||
about(Kit.layout(720, 1560), 0.72, "portrait phone scales off its width")
|
||||
check(Kit.layout(720, 1560) < 1.0,
|
||||
"a portrait phone no longer draws a larger-than-desktop layout")
|
||||
about(Kit.layout(1560, 720), 720 / 768, "landscape phone still scales off height")
|
||||
about(Kit.layout(360, 640), 0.62, "a tiny window stops at the floor")
|
||||
|
||||
-- desktop and laptop sizes have to be pixel-identical to before the fix:
|
||||
-- everything at or above the 1000px reference width lands on the height
|
||||
-- term, exactly as it always did
|
||||
for _, size in ipairs({ { 1280, 800 }, { 1024, 768 }, { 1920, 1080 },
|
||||
{ 1440, 900 }, { 2560, 1440 } }) do
|
||||
about(Kit.layout(size[1], size[2]), Theme.clamp(size[2] / 768, 0.7, 1.6),
|
||||
("%dx%d keeps its old height-only scale"):format(size[1], size[2]))
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
-- #497 draw pass at the two shapes the report came in on (720x1560 and
|
||||
-- 1560x720, an A20s held either way). The layout is what a human has to
|
||||
-- judge, but a panel that lays itself out at a negative width is machine
|
||||
-- visible: LOVE rejects a negative scissor, so the inspector's own clip
|
||||
-- catches the collapse the roster used to cause by keeping an absolute
|
||||
-- 300px floor on a 720px-wide window.
|
||||
local Kit = require("Kit")
|
||||
local Theme = require("Theme")
|
||||
local tmpPath = os.tmpname() .. "-phone-save.lua"
|
||||
local data = SaveData.newGame()
|
||||
data.party = { MonOps.create(Data, "CHARIZARD", 100),
|
||||
MonOps.create(Data, "PIDGEY", 5) }
|
||||
local f = io.open(tmpPath, "wb")
|
||||
f:write(SaveData.encode(data))
|
||||
f:close()
|
||||
|
||||
local oldDimensions = love.graphics.getDimensions
|
||||
local oldScissor = love.graphics.setScissor
|
||||
love.graphics.setScissor = function(_, _, width, height)
|
||||
if width and (width < 0 or height < 0) then
|
||||
error(("negative scissor %sx%s"):format(tostring(width), tostring(height)))
|
||||
end
|
||||
end
|
||||
|
||||
for _, size in ipairs({ { 720, 1560 }, { 1560, 720 }, { 480, 1040 },
|
||||
{ 1280, 800 } }) do
|
||||
love.graphics.getDimensions = function() return size[1], size[2] end
|
||||
App.load(tmpPath, { version = "red" })
|
||||
local S = App.getState()
|
||||
local label = ("%dx%d"):format(size[1], size[2])
|
||||
for _, tab in ipairs({ "party", "boxes", "items", "events", "map", "dex" }) do
|
||||
S.tab = tab
|
||||
local ok, err = pcall(App.draw)
|
||||
check(ok, ("the %s tab draws at %s: %s"):format(tab, label, tostring(err)))
|
||||
end
|
||||
S.tab = "party"
|
||||
Ops.selectParty(S, 1)
|
||||
local ok, err = pcall(App.draw)
|
||||
check(ok, ("the inspector draws at %s: %s"):format(label, tostring(err)))
|
||||
Ops.openSpeciesPicker(S, Kit)
|
||||
ok, err = pcall(App.draw)
|
||||
check(ok, ("the species picker draws at %s: %s"):format(label, tostring(err)))
|
||||
ok, err = pcall(App.draw)
|
||||
check(ok, ("the species picker redraws at %s: %s"):format(label, tostring(err)))
|
||||
Ops.closeSpeciesPicker(S, Kit)
|
||||
end
|
||||
|
||||
love.graphics.getDimensions = oldDimensions
|
||||
love.graphics.setScissor = oldScissor
|
||||
|
||||
os.remove(tmpPath)
|
||||
for _, bak in ipairs(FsIo.globPrefix(tmpPath .. ".bak-")) do os.remove(bak) end
|
||||
end
|
||||
|
||||
print(string.format("save editor tests: %d passed, %d failed", passed, failed))
|
||||
if failed > 0 then os.exit(1) end
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Yellow is a separate pret tree (pokeyellow), not a `_YELLOW` flip of pokered.
|
||||
Most of the ~3268 Red manifest symbols still exist under the same names in
|
||||
pokeyellow.sym (~3123 with shifted addresses). The remainder need aliases,
|
||||
synthetic addresses (Mew in BaseStats), or omission (FightIntro* — Yellow's
|
||||
synthetic addresses (Mew in BaseStats), or omission (FightIntro* -- Yellow's
|
||||
intro movie is different; RomExtractor must skip those).
|
||||
|
||||
Map/object/sprite/tileset/text metadata diverge enough that those sections are
|
||||
@@ -48,7 +48,7 @@ from yellow_symbol_aliases import ( # noqa: E402
|
||||
|
||||
try:
|
||||
from rom_data import CANONICAL_YELLOW_SHA1
|
||||
except ImportError: # pragma: no cover — constant lands with GameVersion work
|
||||
except ImportError: # pragma: no cover -- constant lands with GameVersion work
|
||||
CANONICAL_YELLOW_SHA1 = "cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1"
|
||||
|
||||
DEV = "/Users/bryanbassett/Documents/development"
|
||||
@@ -88,6 +88,21 @@ YELLOW_EXTRA_SYMBOLS = (
|
||||
"SurfingPikachu1Graphics1",
|
||||
"SurfingPikachu1Graphics2",
|
||||
"SurfingPikachu1Graphics3",
|
||||
# Oak's own battle 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).
|
||||
"ProfOakPicBack",
|
||||
# Base frames for the framed portrait TalkToPikachu draws, one per
|
||||
# PikaPicAnimScript (data/pikachu/pikachu_pic_animation.asm). These are
|
||||
# raw address labels because the pikapic blobs carry no named symbols;
|
||||
# RomExtractor's PIKAPIC_BASE table indexes them positionally, so the
|
||||
# order here is not load bearing but every entry must resolve (#561).
|
||||
"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_f0abf", "Pic_f0cf4",
|
||||
)
|
||||
|
||||
# Yellow-only dialogue whose bank labels carry no leading underscore, so
|
||||
|
||||
@@ -21661,6 +21661,22 @@
|
||||
29,
|
||||
18588
|
||||
],
|
||||
"GFX_e6020": [
|
||||
57,
|
||||
24608
|
||||
],
|
||||
"GFX_e6e6f": [
|
||||
57,
|
||||
28271
|
||||
],
|
||||
"GFX_e718f": [
|
||||
57,
|
||||
29071
|
||||
],
|
||||
"GFX_e74af": [
|
||||
57,
|
||||
29871
|
||||
],
|
||||
"GamblerPic": [
|
||||
19,
|
||||
21537
|
||||
@@ -23085,6 +23101,98 @@
|
||||
7,
|
||||
20102
|
||||
],
|
||||
"Pic_e4000": [
|
||||
57,
|
||||
16384
|
||||
],
|
||||
"Pic_e411c": [
|
||||
57,
|
||||
16668
|
||||
],
|
||||
"Pic_e4272": [
|
||||
57,
|
||||
17010
|
||||
],
|
||||
"Pic_e4383": [
|
||||
57,
|
||||
17283
|
||||
],
|
||||
"Pic_e458b": [
|
||||
57,
|
||||
17803
|
||||
],
|
||||
"Pic_e467b": [
|
||||
57,
|
||||
18043
|
||||
],
|
||||
"Pic_e476e": [
|
||||
57,
|
||||
18286
|
||||
],
|
||||
"Pic_e49d1": [
|
||||
57,
|
||||
18897
|
||||
],
|
||||
"Pic_e4b39": [
|
||||
57,
|
||||
19257
|
||||
],
|
||||
"Pic_e4c3e": [
|
||||
57,
|
||||
19518
|
||||
],
|
||||
"Pic_e5000": [
|
||||
57,
|
||||
20480
|
||||
],
|
||||
"Pic_e523f": [
|
||||
57,
|
||||
21055
|
||||
],
|
||||
"Pic_e548e": [
|
||||
57,
|
||||
21646
|
||||
],
|
||||
"Pic_e56d1": [
|
||||
57,
|
||||
22225
|
||||
],
|
||||
"Pic_e5924": [
|
||||
57,
|
||||
22820
|
||||
],
|
||||
"Pic_e5b7d": [
|
||||
57,
|
||||
23421
|
||||
],
|
||||
"Pic_e5ddd": [
|
||||
57,
|
||||
24029
|
||||
],
|
||||
"Pic_e6340": [
|
||||
57,
|
||||
25408
|
||||
],
|
||||
"Pic_e6587": [
|
||||
57,
|
||||
25991
|
||||
],
|
||||
"Pic_e67d6": [
|
||||
57,
|
||||
26582
|
||||
],
|
||||
"Pic_e77cf": [
|
||||
57,
|
||||
30671
|
||||
],
|
||||
"Pic_f0abf": [
|
||||
60,
|
||||
19135
|
||||
],
|
||||
"Pic_f0cf4": [
|
||||
60,
|
||||
19700
|
||||
],
|
||||
"PickUpItemText": [
|
||||
0,
|
||||
9199
|
||||
@@ -23505,6 +23613,10 @@
|
||||
19,
|
||||
24890
|
||||
],
|
||||
"ProfOakPicBack": [
|
||||
61,
|
||||
17618
|
||||
],
|
||||
"PsychicPic": [
|
||||
19,
|
||||
22312
|
||||
|
||||
+92
-43
@@ -32,6 +32,8 @@ local Items = require("Items")
|
||||
local Events = require("Events")
|
||||
local MapBrowser = require("MapBrowser")
|
||||
local Dex = require("Dex")
|
||||
-- chrome, not a tab panel, so deliberately kept out of PANELS below (#541)
|
||||
local SpeciesPicker = require("SpeciesPicker")
|
||||
|
||||
local App = {}
|
||||
local S
|
||||
@@ -222,8 +224,11 @@ function App.unload()
|
||||
-- Kit is never evicted from package.loaded, so a Close taken while a text
|
||||
-- field still owns focus would leak Kit.focus and a raised soft keyboard
|
||||
-- (against a rect that is gone) into the launcher and the next session
|
||||
-- (#529).
|
||||
-- (#529). A Close taken on the frame the species picker went up would
|
||||
-- likewise leave its modal shield raised, and the next session would open
|
||||
-- deaf to every click (#541).
|
||||
Kit.blur()
|
||||
Kit.blockClicks = false
|
||||
end
|
||||
|
||||
function App.save()
|
||||
@@ -345,35 +350,11 @@ local function drawTitleBar(x, y, w, h)
|
||||
{ 159, 180, 221 })
|
||||
cx = cx + badge + 10 * s
|
||||
|
||||
local wordH = Kit.textHeight("wordmark")
|
||||
local brandH = Kit.textHeight("brand")
|
||||
local blockY = y + (h - (wordH + 2 * s + brandH)) / 2
|
||||
love.graphics.setFont(Kit.fonts.wordmark)
|
||||
Theme.col(PAL.heading, 1)
|
||||
local wordW = Theme.spaced(Kit.fonts.wordmark, "SAVE EDITOR", cx, blockY, 2 * s)
|
||||
love.graphics.setFont(Kit.fonts.brand)
|
||||
Theme.col(PAL.caption, 1)
|
||||
local brandW = Theme.spaced(Kit.fonts.brand, "GEN1RECOMP", cx,
|
||||
blockY + wordH + 2 * s, 1 * s)
|
||||
cx = cx + math.max(wordW, brandW) + 12 * s
|
||||
|
||||
-- version chip: which game this save belongs to (from the launcher slot,
|
||||
-- or the save's own header in a standalone run)
|
||||
if S.version then
|
||||
local name = S.version:upper()
|
||||
local c = (S.version == "blue") and PAL.blue or PAL.red
|
||||
local cw = Kit.textWidth("chip", name) + 16 * s
|
||||
local ch = 22 * s
|
||||
local cy = y + (h - ch) / 2
|
||||
Theme.col(c, 0.1)
|
||||
love.graphics.rectangle("fill", cx, cy, cw, ch, 6 * s, 6 * s)
|
||||
Theme.stroke(cx, cy, cw, ch, 6 * s, c, 0.5, 1)
|
||||
Kit.textCenter("chip", name, cx, cy + (ch - Kit.textHeight("chip")) / 2, cw, c)
|
||||
cx = cx + cw + 12 * s
|
||||
end
|
||||
|
||||
-- right-aligned action cluster, laid out from the right edge inward so the
|
||||
-- file chip can absorb whatever is left
|
||||
-- The right-aligned action cluster is laid out from the right edge inward
|
||||
-- BEFORE anything on the left is drawn: the buttons are the one thing in
|
||||
-- this bar that must always be reachable, so on a phone the identity block,
|
||||
-- the version chip and the file chip are what yield. Measuring them last
|
||||
-- is why they used to paint straight through the buttons (#497).
|
||||
local btnH = 38 * s
|
||||
local btnY = y + (h - btnH) / 2
|
||||
local rightEdge = x + w - pad
|
||||
@@ -395,6 +376,41 @@ local function drawTitleBar(x, y, w, h)
|
||||
local reloadX = openX - gap - reloadW
|
||||
local saveX = reloadX - gap - saveW
|
||||
|
||||
local wordH = Kit.textHeight("wordmark")
|
||||
local brandH = Kit.textHeight("brand")
|
||||
local blockY = y + (h - (wordH + 2 * s + brandH)) / 2
|
||||
local wordW = math.max(
|
||||
Theme.spacedWidth(Kit.fonts.wordmark, "SAVE EDITOR", 2 * s),
|
||||
Theme.spacedWidth(Kit.fonts.brand, "GEN1RECOMP", 1 * s))
|
||||
if cx + wordW + 12 * s < saveX then
|
||||
love.graphics.setFont(Kit.fonts.wordmark)
|
||||
Theme.col(PAL.heading, 1)
|
||||
Theme.spaced(Kit.fonts.wordmark, "SAVE EDITOR", cx, blockY, 2 * s)
|
||||
love.graphics.setFont(Kit.fonts.brand)
|
||||
Theme.col(PAL.caption, 1)
|
||||
Theme.spaced(Kit.fonts.brand, "GEN1RECOMP", cx,
|
||||
blockY + wordH + 2 * s, 1 * s)
|
||||
cx = cx + wordW + 12 * s
|
||||
end
|
||||
|
||||
-- version chip: which game this save belongs to (from the launcher slot,
|
||||
-- or the save's own header in a standalone run)
|
||||
if S.version then
|
||||
local name = S.version:upper()
|
||||
local c = (S.version == "blue") and PAL.blue or PAL.red
|
||||
local cw = Kit.textWidth("chip", name) + 16 * s
|
||||
local ch = 22 * s
|
||||
local cy = y + (h - ch) / 2
|
||||
if cx + cw + 12 * s < saveX then
|
||||
Theme.col(c, 0.1)
|
||||
love.graphics.rectangle("fill", cx, cy, cw, ch, 6 * s, 6 * s)
|
||||
Theme.stroke(cx, cy, cw, ch, 6 * s, c, 0.5, 1)
|
||||
Kit.textCenter("chip", name, cx, cy + (ch - Kit.textHeight("chip")) / 2,
|
||||
cw, c)
|
||||
cx = cx + cw + 12 * s
|
||||
end
|
||||
end
|
||||
|
||||
-- Save is the only green-filled control in the chrome; a corrupt load
|
||||
-- renders it steel with the reason parked in the status bar rather than
|
||||
-- hiding it (rule 3 of the design spec).
|
||||
@@ -481,9 +497,11 @@ local function railDetail(x, pillX)
|
||||
widths.glyph = widths.glyph + tile + 12 * s
|
||||
end
|
||||
local avail = pillX - 14 * s - (x + 22 * s)
|
||||
if widths.full <= avail then return "full" end
|
||||
if widths.nocount <= avail then return "nocount" end
|
||||
return "glyph"
|
||||
-- the widths come back too: the caller needs the glyph-mode figure to decide
|
||||
-- whether the pill still has room, and measuring the rail twice is waste
|
||||
if widths.full <= avail then return "full", widths end
|
||||
if widths.nocount <= avail then return "nocount", widths end
|
||||
return "glyph", widths
|
||||
end
|
||||
|
||||
local function drawTabRail(x, y, w, h)
|
||||
@@ -496,7 +514,15 @@ local function drawTabRail(x, y, w, h)
|
||||
local ph = 26 * s
|
||||
local pw = Kit.textWidth("small", label) + 28 * s
|
||||
local px = x + w - pad - pw
|
||||
local detail = railDetail(x, px)
|
||||
local detail, widths = railDetail(x, px)
|
||||
-- Last stop before the tiles and the pill collide: at phone widths even the
|
||||
-- 2-letter glyph tiles need the room the pill is sitting in, and the pill
|
||||
-- only repeats a line the status bar already prints on load, so the pill is
|
||||
-- what goes (#497). With it gone the tiles get the full bar back.
|
||||
local showPill = widths.glyph <= px - 14 * s - (x + 22 * s)
|
||||
if not showPill then
|
||||
detail = railDetail(x, x + w - pad)
|
||||
end
|
||||
|
||||
local tile = 40 * s
|
||||
local cx = x + pad
|
||||
@@ -546,15 +572,19 @@ local function drawTabRail(x, y, w, h)
|
||||
cx = cx + cellW
|
||||
end
|
||||
|
||||
local py = y + h - 14 * s - ph
|
||||
Theme.col(pillColor, clean and 0.08 or 0.1)
|
||||
love.graphics.rectangle("fill", px, py, pw, ph, ph / 2, ph / 2)
|
||||
Theme.stroke(px, py, pw, ph, ph / 2, pillColor, clean and 0.45 or 0.5, 1)
|
||||
Kit.textCenter("small", label, px, py + (ph - Kit.textHeight("small")) / 2, pw,
|
||||
pillColor)
|
||||
if target and Kit.press(px, py, pw, ph) then
|
||||
S.tab = target
|
||||
Ops.say(S, "Jumped to the tab holding the first quarantine warning")
|
||||
if showPill then
|
||||
local py = y + h - 14 * s - ph
|
||||
Theme.col(pillColor, clean and 0.08 or 0.1)
|
||||
love.graphics.rectangle("fill", px, py, pw, ph, ph / 2, ph / 2)
|
||||
Theme.stroke(px, py, pw, ph, ph / 2, pillColor, clean and 0.45 or 0.5, 1)
|
||||
Kit.textCenter("small", label, px, py + (ph - Kit.textHeight("small")) / 2,
|
||||
pw, pillColor)
|
||||
-- paint and hit target are suppressed together, so a hidden pill cannot
|
||||
-- still eat a tap meant for the DEX tile
|
||||
if target and Kit.press(px, py, pw, ph) then
|
||||
S.tab = target
|
||||
Ops.say(S, "Jumped to the tab holding the first quarantine warning")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -592,6 +622,11 @@ function App.draw()
|
||||
local mx, my = love.mouse.getPosition()
|
||||
Kit.beginFrame(mx, my, mouseClicked)
|
||||
mouseClicked = false
|
||||
-- Modal shield. Kit has no z-order, so the picker cannot simply be drawn
|
||||
-- last: the chrome and the panel underneath would take the same tap. The
|
||||
-- shield goes up before anything dispatches and comes down only for the
|
||||
-- picker's own layer at the bottom of this function (#541).
|
||||
Kit.blockClicks = (S.speciesPicker ~= nil)
|
||||
|
||||
Theme.field(width, height)
|
||||
|
||||
@@ -613,6 +648,8 @@ function App.draw()
|
||||
end
|
||||
|
||||
drawStatusBar(0, height - statusH, width, statusH)
|
||||
Kit.blockClicks = false
|
||||
SpeciesPicker.draw(S, Kit, width, height)
|
||||
Kit.endFrame()
|
||||
|
||||
-- Only now, with the whole frame painted, is it safe to drop the editor.
|
||||
@@ -621,6 +658,18 @@ end
|
||||
|
||||
function App.keypressed(key)
|
||||
if not S then return end
|
||||
-- The picker takes Enter and Escape before the focused field does: Kit maps
|
||||
-- both to the same "\r" edit (a blur), which cannot tell "commit the top
|
||||
-- match" apart from "give up" (#541).
|
||||
if S.speciesPicker then
|
||||
if key == "return" or key == "kpenter" then
|
||||
SpeciesPicker.commitFirst(S, Kit)
|
||||
return
|
||||
elseif key == "escape" then
|
||||
Ops.closeSpeciesPicker(S, Kit)
|
||||
return
|
||||
end
|
||||
end
|
||||
-- A focused text field eats the keys it cares about (typing "s" into the
|
||||
-- map filter must not trigger Save).
|
||||
if Kit.keypressed(key) then return end
|
||||
|
||||
@@ -29,10 +29,11 @@ local kbField = nil -- id of the field the OS soft keyboard is raised for
|
||||
-- Mobile LOVE only delivers love.textinput while setTextInput(true) is
|
||||
-- active, and that call is what raises the Android/iOS soft keyboard; the
|
||||
-- rect keeps the focused field visible above it. Desktop has text input on
|
||||
-- by default and the launcher hosting this editor depends on that -- nothing
|
||||
-- in src/import/RomImporter.lua (slot rename #205, ROM finder, mod index
|
||||
-- prompt) ever enables it -- so the editor only ever raises there and never
|
||||
-- lowers, since setTextInput is global SDL state, not per-widget (#529).
|
||||
-- by default and the launcher hosting this editor depends on that -- the
|
||||
-- launcher's own fields (slot rename #205, mod index prompt, find search)
|
||||
-- follow the same rule since #578: arm on open, lower only on mobile -- so
|
||||
-- neither side ever turns desktop text input off, since setTextInput is
|
||||
-- global SDL state, not per-widget (#529).
|
||||
local function mobile()
|
||||
local osName = love and love.system and love.system.getOS
|
||||
and love.system.getOS()
|
||||
@@ -71,16 +72,22 @@ function Kit.endFrame()
|
||||
for i = #edits, 1, -1 do edits[i] = nil end
|
||||
end
|
||||
|
||||
-- Rebuild the font set when the window size changes. `s` matches the
|
||||
-- launcher's height/768 scale so both windows step together.
|
||||
-- Rebuild the font set when the window size changes. `s` matched the
|
||||
-- launcher's height/768 scale alone until #497: a phone in portrait
|
||||
-- (720x1560) is TALLER than the desktop reference and barely half as wide, so
|
||||
-- a height-only scale drew a 1.6x desktop layout into a 720px window and every
|
||||
-- right-aligned cluster in the chrome landed on top of the block to its left.
|
||||
-- The layout needs roughly 1000 logical px of width, so the window now pays
|
||||
-- for both axes. Every desktop and landscape size still lands on the height
|
||||
-- term, which is why they stay pixel-identical to before.
|
||||
function Kit.layout(width, height)
|
||||
local s = Theme.clamp(height / 768, 0.7, 1.6)
|
||||
local s = Theme.clamp(math.min(width / 1000, height / 768), 0.62, 1.6)
|
||||
local key = ("%dx%d"):format(width, height)
|
||||
if Kit._fontKey ~= key then
|
||||
Kit._fontKey = key
|
||||
Kit.fonts = Theme.fonts(s)
|
||||
Kit.scale = s
|
||||
end
|
||||
Kit.scale = s
|
||||
return s
|
||||
end
|
||||
|
||||
@@ -124,7 +131,15 @@ function Kit.hover(x, y, w, h)
|
||||
return Kit.hit(x, y, w, h)
|
||||
end
|
||||
|
||||
-- Kit hit-tests without a z-order, so an overlay cannot just be drawn last:
|
||||
-- every widget underneath it would still take the same click. A modal raises
|
||||
-- this shield over the layers it covers (App.draw does it around the chrome
|
||||
-- and the panel while the species picker is up) and lowers it for its own
|
||||
-- layer (#541).
|
||||
Kit.blockClicks = false
|
||||
|
||||
function Kit.press(x, y, w, h)
|
||||
if Kit.blockClicks then return false end
|
||||
return Kit.mouseClicked and Kit.hit(x, y, w, h)
|
||||
end
|
||||
|
||||
|
||||
+106
-3
@@ -156,16 +156,119 @@ function Ops.setLevel(S, mon, level)
|
||||
return Ops.mark(S, ("%s is now Lv%d"):format(mon.species, mon.level))
|
||||
end
|
||||
|
||||
-- A catalog id is only usable as a real mon when its record carries what the
|
||||
-- Gen1 formulas read: Stats.calc indexes baseStats.<stat> unconditionally
|
||||
-- (src/pokemon/Stats.lua, home/move_mon.asm CalcStat), because the asm's
|
||||
-- BaseStats is a fixed 151-entry table and every row is complete. The
|
||||
-- editor's list is NOT that table -- it is every key in Data.pokemon after
|
||||
-- the mod merge -- and a mod loaded at api 1 can leave a partial record in
|
||||
-- there, since the schema violation downgrades to a warning rather than a
|
||||
-- rejection (src/mods/Schemas.lua R.pokemon). So the editor tests the record
|
||||
-- instead of trusting the list: without this, picking such a species walked
|
||||
-- Stats.calc into `speciesDef.baseStats[key]` on a nil and took the window
|
||||
-- down (#541).
|
||||
local BASE_STAT_KEYS = { "hp", "attack", "defense", "speed", "special" }
|
||||
|
||||
function Ops.speciesUsable(S, id)
|
||||
local def = id and S.data.pokemon[id]
|
||||
if type(def) ~= "table" or type(def.baseStats) ~= "table" then return false end
|
||||
for _, key in ipairs(BASE_STAT_KEYS) do
|
||||
if type(def.baseStats[key]) ~= "number" then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- The one funnel every species change goes through (the picker, the stepper,
|
||||
-- anything later). MonOps asserts and recalculates, so an unusable record is
|
||||
-- refused before it runs, and the round trip itself is fenced: a record that
|
||||
-- passes the check above but still trips a formula has to leave the mon
|
||||
-- exactly as it was and speak in the status bar, not take the editor with it.
|
||||
function Ops.setSpecies(S, mon, id)
|
||||
if not mon then return false end
|
||||
if id == mon.species then
|
||||
return Ops.say(S, ("Already a %s"):format(tostring(id)))
|
||||
end
|
||||
if not Ops.speciesUsable(S, id) then
|
||||
return Ops.say(S, ("%s has no usable base stats, cannot assign it")
|
||||
:format(tostring(id)))
|
||||
end
|
||||
-- MonOps.recalc replaces mon.stats with a fresh table rather than editing
|
||||
-- it in place, so holding the old reference is a real rollback.
|
||||
local wasSpecies, wasLevel, wasExp = mon.species, mon.level, mon.exp
|
||||
local wasStats, wasHp = mon.stats, mon.hp
|
||||
local ok, err = pcall(MonOps.setSpecies, S.data, mon, id)
|
||||
if not ok then
|
||||
mon.species, mon.level, mon.exp = wasSpecies, wasLevel, wasExp
|
||||
mon.stats, mon.hp = wasStats, wasHp
|
||||
return Ops.say(S, ("Could not set %s: %s"):format(tostring(id), tostring(err)))
|
||||
end
|
||||
return Ops.mark(S, ("Species set to %s"):format(id))
|
||||
end
|
||||
|
||||
-- Kept for the keyboard and test path; the inspector opens the searchable
|
||||
-- picker instead of walking the catalog one arrow at a time (#541). Skips
|
||||
-- ids Ops.setSpecies would refuse, so one bad record cannot park the walk.
|
||||
function Ops.stepSpecies(S, mon, delta)
|
||||
if not mon then return false end
|
||||
local list = S.cat.species
|
||||
local n = #list
|
||||
if n == 0 then return Ops.say(S, "No species in the catalog") end
|
||||
local idx = 1
|
||||
for i, id in ipairs(list) do
|
||||
if id == mon.species then idx = i break end
|
||||
end
|
||||
local nextId = list[((idx - 1 + delta) % #list) + 1]
|
||||
MonOps.setSpecies(S.data, mon, nextId)
|
||||
return Ops.mark(S, ("Species set to %s"):format(nextId))
|
||||
for step = 1, n do
|
||||
local nextId = list[((idx - 1 + delta * step) % n) + 1]
|
||||
if nextId ~= mon.species and Ops.speciesUsable(S, nextId) then
|
||||
return Ops.setSpecies(S, mon, nextId)
|
||||
end
|
||||
end
|
||||
return Ops.say(S, "No other species in the catalog can be assigned")
|
||||
end
|
||||
|
||||
-- Search predicate behind the picker's field: the id, the display name, and a
|
||||
-- bare dex number ("25" finds PIKACHU), all case-insensitive and plain (no
|
||||
-- pattern magic, so a "." typed by accident matches a literal dot).
|
||||
function Ops.speciesMatches(S, id, query)
|
||||
if not query or query == "" then return true end
|
||||
local q = tostring(query):lower()
|
||||
if id:lower():find(q, 1, true) then return true end
|
||||
local def = S.data.pokemon[id]
|
||||
local name = def and def.name
|
||||
if name and tostring(name):lower():find(q, 1, true) then return true end
|
||||
local dex = tonumber(def and def.dex)
|
||||
-- dex matches exactly, in either the bare or the padded form the inspector
|
||||
-- prints ("25" and "025" both find PIKACHU). A substring match here would
|
||||
-- pull in ELECTABUZZ (#125) on a search for 25, which reads as a bug.
|
||||
return dex ~= nil and (q == tostring(dex) or q == ("%03d"):format(dex))
|
||||
end
|
||||
|
||||
function Ops.speciesSearch(S, query)
|
||||
local out = {}
|
||||
for _, id in ipairs(S.cat.species) do
|
||||
if Ops.speciesMatches(S, id, query) then out[#out + 1] = id end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- The picker is modal editor chrome, not a save mutation, so its flag lives
|
||||
-- with the other view state on S (State.new). Ops owns the door only because
|
||||
-- both the inspector and App need one and neither should require the other.
|
||||
-- `opened` marks the frame the picker went up: the click that opened it is
|
||||
-- still live when the overlay draws later in that same frame (#541).
|
||||
function Ops.openSpeciesPicker(S, Kit)
|
||||
if not S.editingMon then
|
||||
return Ops.say(S, "Pick a slot first, then choose its species")
|
||||
end
|
||||
S.speciesPicker = { query = "", offset = 0, opened = true }
|
||||
-- focus the field on open so the mobile soft keyboard rises with it (#529)
|
||||
if Kit then Kit.focus = "species-picker" end
|
||||
return true
|
||||
end
|
||||
|
||||
function Ops.closeSpeciesPicker(S, Kit)
|
||||
S.speciesPicker = nil
|
||||
if Kit and Kit.blur then Kit.blur() end
|
||||
end
|
||||
|
||||
function Ops.setDv(S, mon, key, value)
|
||||
|
||||
@@ -41,9 +41,13 @@ If the file isn't there (or you want another copy), use **Open...**, drop a
|
||||
| `App.lua` | chrome (version rail, title bar, tab rail, status bar) and the panel router |
|
||||
| `panels/` | one file per tab; pure layout that dispatches into Ops |
|
||||
|
||||
The design reference is `SaveEditor.dc.html` in the Claude Design project
|
||||
that this port transcribes; its measurements are in the same pixel space
|
||||
`App.lua` draws in.
|
||||
`panels/SpeciesPicker.lua` is the one exception to "one file per tab": it is
|
||||
the modal species search the inspector opens, drawn by `App.draw` after the
|
||||
panel rather than routed through the tab table. Kit has no z-order, so while
|
||||
it is up `Kit.blockClicks` shields every widget underneath it.
|
||||
|
||||
The design reference is the `SaveEditor.dc.html` mockup that this port
|
||||
transcribes; its measurements are in the same pixel space `App.lua` draws in.
|
||||
|
||||
Two rules the code enforces and the tests assert:
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@ function State.new()
|
||||
-- party / inspector
|
||||
selectedParty = 1,
|
||||
editingMon = nil, -- reference into party or a box
|
||||
-- species picker overlay: nil when closed, otherwise { query, offset }.
|
||||
-- Modal in the literal sense -- App shields every widget under it for the
|
||||
-- frame -- because Kit hit-tests without a z-order (#541).
|
||||
speciesPicker = nil,
|
||||
|
||||
-- boxes
|
||||
selectedBox = 1,
|
||||
|
||||
@@ -85,6 +85,10 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
local def = S.data.pokemon[mon.species]
|
||||
local cx, cy = x + pad, y + pad
|
||||
local inner = w - 2 * pad
|
||||
-- Backstop for a window too short for even the compacted rhythm below:
|
||||
-- nothing this panel draws may land outside its own card (#497). Party
|
||||
-- draws the inspector last, so no outer clip is lost by the pop at the end.
|
||||
Kit.pushClip(x, y, w, h)
|
||||
|
||||
-- ---------------------------------------------------------- header row
|
||||
local sprite = 96 * s
|
||||
@@ -97,17 +101,22 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
Kit.text("tiny", ("#%03d"):format(def and def.dex or 0), hx + nameW + 12 * s,
|
||||
cy + Kit.textHeight("title") - Kit.textHeight("tiny") - 2 * s, PAL.caption)
|
||||
|
||||
local stepW, stepH = 28 * s, 26 * s
|
||||
local sx = hx + hw - 2 * stepW - 6 * s
|
||||
local sy = cy + (Kit.textHeight("title") - stepH) / 2
|
||||
if Kit.stepper(sx, sy, stepW, stepH, "<", { radius = 7 * s }) then
|
||||
Ops.stepSpecies(S, mon, -1)
|
||||
-- One control instead of a pair of arrows: cycling walked the catalog an
|
||||
-- entry at a time (151 taps to cross the dex) and ran a full MonOps
|
||||
-- recalculation on every step, including on records the Gen1 formulas
|
||||
-- cannot use, which is what crashed the editor (#541). This opens the
|
||||
-- searchable picker; the species name itself is a second, larger target.
|
||||
local pickH = 30 * s
|
||||
local pickW = math.min(150 * s, math.max(90 * s, hw * 0.6))
|
||||
local px = hx + hw - pickW
|
||||
local py = cy + (Kit.textHeight("title") - pickH) / 2
|
||||
local openPicker = Kit.button(px, py, pickW, pickH, "Change species",
|
||||
{ kind = "accent", font = "small", radius = 8 * s })
|
||||
if not openPicker then
|
||||
openPicker = Kit.press(hx, cy, math.max(0, px - hx - 10 * s),
|
||||
Kit.textHeight("title"))
|
||||
end
|
||||
if Kit.stepper(sx + stepW + 6 * s, sy, stepW, stepH, ">", { radius = 7 * s }) then
|
||||
Ops.stepSpecies(S, mon, 1)
|
||||
end
|
||||
Kit.textRight("tiny", "species", sx - 8 * s,
|
||||
sy + (stepH - Kit.textHeight("tiny")) / 2, PAL.caption)
|
||||
if openPicker then Ops.openSpeciesPicker(S, Kit) end
|
||||
|
||||
-- level stepper: -5 -1 [Lv] +1 +5, matching MonOps.setLevel's 1..100 clamp
|
||||
local ly = cy + Kit.textHeight("title") + 14 * s
|
||||
@@ -139,7 +148,20 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
statsY = statsY + Kit.textHeight("caption") + 10 * s
|
||||
local gap = 12 * s
|
||||
local cellW = (inner - gap * 4) / 5
|
||||
local cellH = 68 * s
|
||||
-- Everything below the header competes for one vertical budget. At the
|
||||
-- design size it is generous; in a 720px-tall window (a phone held
|
||||
-- sideways) it is not, and the DV / move rows used to run past the card and
|
||||
-- paint over the status bar (#497). Shrink the two flexible blocks -- the
|
||||
-- stat tiles and the DV / move rows -- instead of overflowing, with floors
|
||||
-- that keep every row the 26px target Kit's rule 6 promises. statsY is
|
||||
-- already past the STATS caption here, so only the DVs / MOVES caption is
|
||||
-- subtracted.
|
||||
local actH = 34 * s
|
||||
local rowGap = 8 * s
|
||||
local budget = (y + h - pad) - statsY - (Kit.textHeight("caption") + 10 * s)
|
||||
- 18 * s - actH - 4 * s
|
||||
local cellH = Theme.clamp(budget * 0.3, 46 * s, 68 * s)
|
||||
local rowH = Theme.clamp((budget - cellH) / 4 - rowGap, 26 * s, 34 * s)
|
||||
for i, st in ipairs(STAT_KEYS) do
|
||||
local bx = cx + (i - 1) * (cellW + gap)
|
||||
Theme.row(bx, statsY, cellW, cellH, 10 * s, 0.6)
|
||||
@@ -164,8 +186,6 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
Kit.textRight("tiny", "click a slot to cycle", rightX + colW, colY, PAL.caption)
|
||||
|
||||
local rowY = colY + Kit.textHeight("caption") + 10 * s
|
||||
local rowH = 34 * s
|
||||
local rowGap = 8 * s
|
||||
|
||||
for i, key in ipairs(DV_KEYS) do
|
||||
local ry = rowY + (i - 1) * (rowH + rowGap)
|
||||
@@ -221,7 +241,6 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
end
|
||||
|
||||
local actY = rowY + 4 * (rowH + rowGap) + 4 * s
|
||||
local actH = 34 * s
|
||||
local actW = (colW - 10 * s) / 2
|
||||
if Kit.button(rightX, actY, actW, actH, "Reset to learnset",
|
||||
{ font = "small", radius = 9 * s }) then
|
||||
@@ -231,6 +250,7 @@ function MonEditor.draw(S, Kit, x, y, w, h)
|
||||
{ kind = "good", font = "small", radius = 9 * s }) then
|
||||
Ops.healMon(S, mon)
|
||||
end
|
||||
Kit.popClip()
|
||||
end
|
||||
|
||||
return MonEditor
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user