mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac01135ca8 | |||
| 5e89e35e02 | |||
| 7e7afeaf82 | |||
| c6fa6d294b | |||
| fc2d17fc78 | |||
| 9f072285a6 | |||
| c58ebe4b9b | |||
| 0dd187fe30 | |||
| ea78792c03 | |||
| 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 | |||
| 898bf0c71e | |||
| 5a7add8eaa | |||
| 66334dfacd | |||
| a33f3b1ceb | |||
| 6e724cedf7 | |||
| fccb122c59 | |||
| 33899c237e | |||
| 0fad222569 | |||
| ddcce5b95a | |||
| 6fd9741fab | |||
| caa65182dc |
@@ -45,6 +45,12 @@ jobs:
|
||||
runs-on: [self-hosted, macOS]
|
||||
|
||||
steps:
|
||||
# The self-hosted runner lives under the machine owner's home
|
||||
# directory; mask it first so absolute paths in every later step's
|
||||
# output show up as *** in the public workflow logs.
|
||||
- name: Mask runner paths
|
||||
run: echo "::add-mask::$HOME"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -170,6 +176,11 @@ jobs:
|
||||
set -euo pipefail
|
||||
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Install xcbeautify
|
||||
run: |
|
||||
set -euo pipefail
|
||||
brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
|
||||
|
||||
- name: Build iOS
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -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!",
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
-- takes it ("I'll take this one, then!") and both balls disappear.
|
||||
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
|
||||
-- OaksLabRivalTakePokeBallScript.
|
||||
-- * Leftover ball (after the pick): Oak turns and reads the last-mon
|
||||
-- line instead of re-offering the starter (OaksLabLastMonScript, #601).
|
||||
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
|
||||
-- has walked you in, else "gramps isn't around" (#218); with
|
||||
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
|
||||
@@ -23,7 +25,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ "jump_if_true", 20 }, -- 2
|
||||
-- no picking until Oak has walked you in (OaksLabScript gating)
|
||||
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
|
||||
{ "jump_if_false", 20 }, -- 4
|
||||
{ "jump_if_false", 22 }, -- 4
|
||||
-- the Pokédex "new species" entry shows before the ask (predef
|
||||
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
|
||||
-- sets the owned bits so ShowPokedexData prints height/weight/text;
|
||||
@@ -31,7 +33,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ "push_screen", "DexEntryMenu",
|
||||
{ species = species, forceOwned = true } }, -- 5
|
||||
{ "ask", askText }, -- 6
|
||||
{ "jump_if_false", 21 }, -- 7
|
||||
{ "jump_if_false", "end" }, -- 7
|
||||
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
|
||||
-- inside give_pokemon). Show the received text first so the
|
||||
-- nickname prompt follows "you got X", matching Gen1.
|
||||
@@ -52,9 +54,16 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||
or "BULBASAUR" } }, -- 17
|
||||
{ "jump", 21 }, -- 18
|
||||
{ "jump", 21 }, -- 19 (spacer)
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
|
||||
{ "jump", "end" }, -- 18
|
||||
{ "jump", "end" }, -- 19 (spacer)
|
||||
-- a leftover ball after the player's pick: Oak turns to face the
|
||||
-- player and reads the last-mon line instead of re-offering the
|
||||
-- starter (scripts/OaksLab.asm OaksLabSelectedPokeBallScript ->
|
||||
-- OaksLabLastMonScript; #601). The ROM's "#MON" ligature is spelled
|
||||
-- out as Pokémon here.
|
||||
{ "face_object", 5, "down" }, -- 20
|
||||
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 21
|
||||
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 22
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -12,7 +12,7 @@ this port.
|
||||
|
||||
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) |
|
||||
| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
|
||||
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
|
||||
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
|
||||
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
|
||||
@@ -38,6 +38,10 @@ this port.
|
||||
|
||||
## Notes
|
||||
|
||||
- Mods may patch `constants.bagSize` through the public content registry. The
|
||||
native `save.lua` format keeps every existing item when the configured
|
||||
limit changes; exporting to a cartridge `.sav` still writes only the first
|
||||
20 bag slots because the original SRAM layout has no room for more.
|
||||
- PC Box **overflow handling** was deliberately changed even though the
|
||||
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
|
||||
or blocks the deposit," this port spills into the next box with room.
|
||||
|
||||
@@ -187,5 +187,18 @@ composited and before touch controls draw. The window-space viewport contains
|
||||
and `dpiY`, so a tool can use the letterbox margins without drawing over the
|
||||
playfield or pushing an updating game state.
|
||||
|
||||
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
|
||||
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
|
||||
the mod full control of the window, while calling `next` runs the engine's
|
||||
normal single-window composite so the mod can decorate around it. `ctx` carries
|
||||
the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
|
||||
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
|
||||
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
|
||||
palette-correct blit of either canvas into an arbitrary screen rect, and the
|
||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`)
|
||||
for driving a second physical display. This is what lets a mod lay the two
|
||||
passes out as two stacked Game Boy screens, or push one onto a second screen,
|
||||
without the engine knowing the layout.
|
||||
|
||||
Developer mode also arms the mod loader's dev tripwire, which flags mods
|
||||
that reach outside their permission set.
|
||||
|
||||
+30
-2
@@ -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
|
||||
@@ -380,7 +380,8 @@ semantics - so the two windows read as one app. Six tabs:
|
||||
party dock, so deposit and withdraw live in one place. Empty slots are
|
||||
clickable and create a mon there.
|
||||
- **Items**: money, a searchable item picker (replacing the arrows that
|
||||
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
|
||||
cycled one id at a time through ~250 items), the configurable bag (20 slots
|
||||
by default), PC storage
|
||||
with no slot cap, and the eight badges as toggle chips.
|
||||
- **Events**: flags, defeated trainers, taken items and per-map object
|
||||
toggles, with a real filter field and a two-column paged grid.
|
||||
@@ -432,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.
|
||||
|
||||
@@ -476,10 +476,14 @@ function love.mousepressed(x, y, button, istouch)
|
||||
-- meant two choose() calls and two stacked SAF picker activities: the
|
||||
-- player picked their ROM, the top picker closed, and the second was still
|
||||
-- underneath asking for it again, which is the "import the file twice"
|
||||
-- in #553. Filtering on istouch rather than on the OS keeps a real mouse
|
||||
-- (DeX, a Chromebook, a USB mouse) working, which an Android-wide return
|
||||
-- would have broken.
|
||||
if istouch then return end
|
||||
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
|
||||
-- USB mouse) working, which an Android-wide return would have broken.
|
||||
--
|
||||
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
|
||||
-- returns early on iOS and never forwards, so there the synthesized mouse
|
||||
-- press is the ONLY event the launcher gets. Filtering istouch on both
|
||||
-- killed every tap on iOS outright.
|
||||
if istouch and love.system.getOS() == "Android" then return end
|
||||
return Importer:mousepressed(x, y, button)
|
||||
end
|
||||
if editorMode and EditorApp.mousepressed then
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
11.5
|
||||
12.0
|
||||
|
||||
+17
-38
@@ -1,4 +1,4 @@
|
||||
# iOS build (LÖVE 11.5)
|
||||
# iOS build (LÖVE 12.0)
|
||||
|
||||
> **Native ROM/mod/save import.** The iOS build ships a Swift
|
||||
> document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`)
|
||||
@@ -20,20 +20,17 @@
|
||||
> The note below about a missing "UIDocumentPicker handoff" is
|
||||
> resolved by this bridge.
|
||||
|
||||
macOS + Xcode only. Pins the official **LÖVE 11.5** iOS Xcode tree
|
||||
(`love-11.5-ios-source.zip` from [love2d/love releases](https://github.com/love2d/love/releases/tag/11.5)),
|
||||
matching `conf.lua`'s `t.version = "11.5"`.
|
||||
macOS + Xcode only. Fetches the **LÖVE 12.0** source tree and matching Apple
|
||||
dependencies from the official [LÖVE source](https://github.com/love2d/love)
|
||||
and [Apple dependencies](https://github.com/love2d/love-apple-dependencies)
|
||||
repositories. `conf.lua` declares LÖVE 12.0 on iOS and 11.5 elsewhere.
|
||||
|
||||
There is no separate `love2d/love-ios` GitHub repo for 11.5; the release zip
|
||||
**is** the vendored iOS project (Xcode project under
|
||||
`love-src/platform/xcode/love.xcodeproj`, target `love-ios`).
|
||||
|
||||
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `11.5`.
|
||||
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `12.0`.
|
||||
|
||||
## Quick start (simulator)
|
||||
|
||||
```bash
|
||||
# Fetch LÖVE 11.5 iOS sources (once) + build for Simulator
|
||||
# Fetch LÖVE 12.0 iOS sources and dependencies (once) + build for Simulator
|
||||
scripts/build_ios.sh --fetch
|
||||
```
|
||||
|
||||
@@ -80,10 +77,10 @@ Manual out-of-band steps:
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `LOVE_VERSION` | Engine pin (`11.5`) |
|
||||
| `LOVE_VERSION` | Engine pin (`12.0`) |
|
||||
| `overlays/love-ios.plist` | Portrait-only Info.plist + display name **Pokemon Red** (copied over the upstream plist every build) |
|
||||
| `love-src/` | Downloaded `love-11.5-ios-source` tree (**gitignored**, do not commit) |
|
||||
| `cache/` | Downloaded zips (**gitignored**) |
|
||||
| `love-src/` | Downloaded LÖVE 12.0 source tree (**gitignored**, do not commit) |
|
||||
| `cache/` | Temporary source and dependency checkout data (**gitignored**) |
|
||||
| `build/` | `xcodebuild` derived data (**gitignored**) |
|
||||
|
||||
Game payload lands at:
|
||||
@@ -94,28 +91,10 @@ and is fused into the built `.app` (LÖVE auto-runs any bundled `*.love`).
|
||||
|
||||
## Apple libraries dependency
|
||||
|
||||
The official `love-11.5-ios-source.zip` already ships prebuilt iOS
|
||||
xcframeworks under `platform/xcode/ios/libraries/` (SDL2, LuaJIT, freetype,
|
||||
ogg, vorbis, theora, modplug).
|
||||
|
||||
If that folder is missing or incomplete (e.g. you cloned sources without
|
||||
libs), download the matching prebuilts and install them:
|
||||
|
||||
```bash
|
||||
curl -fL -o mobile/ios/cache/love-11.5-apple-libraries.zip \
|
||||
https://github.com/love2d/love/releases/download/11.5/love-11.5-apple-libraries.zip
|
||||
unzip -q mobile/ios/cache/love-11.5-apple-libraries.zip -d mobile/ios/cache
|
||||
rm -rf mobile/ios/love-src/platform/xcode/ios/libraries
|
||||
cp -R mobile/ios/cache/love-apple-dependencies/iOS/libraries \
|
||||
mobile/ios/love-src/platform/xcode/ios/libraries
|
||||
```
|
||||
|
||||
`scripts/build_ios.sh` checks for `libraries/SDL2.xcframework` and fails with
|
||||
these instructions if it is absent.
|
||||
|
||||
Upstream also documents
|
||||
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies)
|
||||
as an alternate source of the same libraries.
|
||||
`scripts/build_ios.sh --fetch` retrieves the matching iOS libraries and the
|
||||
SDL3 framework from
|
||||
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies).
|
||||
Re-run it if either dependency directory is absent.
|
||||
|
||||
## App identity
|
||||
|
||||
@@ -134,7 +113,7 @@ so refreshing `love-src/` does not lose branding.
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| *(default)* | Simulator, Debug, no signing |
|
||||
| `--fetch` | Download/extract `love-11.5-ios-source.zip` if `love-src/` is missing |
|
||||
| `--fetch` | Fetch the LÖVE 12.0 source tree and Apple dependencies if `love-src/` is missing |
|
||||
| `--device` | Build against `iphoneos` instead of `iphonesimulator` |
|
||||
| `--release` | `Release` configuration instead of `Debug` |
|
||||
| `--package-only` | Zip `game.love` + apply plist overlay; skip `xcodebuild` |
|
||||
@@ -147,5 +126,5 @@ Also: `scripts/build.sh ios` delegates here (`--release` is forwarded).
|
||||
- iOS platform installed in Xcode (Settings → Platforms). `xcodebuild -showsdks`
|
||||
should list `iphonesimulator` / `iphoneos`. A partial install can fail IB/xib
|
||||
compiles with `iOS … Platform Not Installed` even when the SDK name appears.
|
||||
- `love-src/` present (`--fetch` or manual unzip of `love-11.5-ios-source.zip`)
|
||||
- iOS libraries under `love-src/platform/xcode/ios/libraries/` (see above)
|
||||
- `love-src/` present (`--fetch`)
|
||||
- iOS libraries under `love-src/platform/xcode/ios/libraries/` and SDL3 under `love-src/platform/xcode/shared/Frameworks/`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 11.5
|
||||
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 12.0
|
||||
source tree (mobile/ios/love-src/). Idempotent AND re-appliable: the first
|
||||
run stashes a pristine `.orig` copy of every file it rewrites, and later
|
||||
runs always start over from that copy — so editing the patch content here
|
||||
@@ -39,6 +39,7 @@ WRAP_INCLUDES = """
|
||||
#ifdef LOVE_IOS
|
||||
#include <objc/runtime.h>
|
||||
#include <objc/message.h>
|
||||
#include <string>
|
||||
#include "filesystem/Filesystem.h"
|
||||
#endif
|
||||
""" % MARKER
|
||||
@@ -53,8 +54,12 @@ WRAP_FUNCS = """
|
||||
#ifdef LOVE_IOS
|
||||
static const char *gr_saveDirectory()
|
||||
{
|
||||
static std::string saveDirectory;
|
||||
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
|
||||
return fs != nullptr ? fs->getSaveDirectory() : "";
|
||||
if (fs == nullptr)
|
||||
return "";
|
||||
saveDirectory = fs->getSaveDirectory();
|
||||
return saveDirectory.c_str();
|
||||
}
|
||||
|
||||
static int gr_callBridge(lua_State *L, const char *className,
|
||||
|
||||
@@ -156,7 +156,7 @@ return function(mod)
|
||||
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
|
||||
mod.save:set("caught_areas", caughtAreas())
|
||||
end
|
||||
Bag.add(self.game.save, ball, 1)
|
||||
Bag.add(self.game.save, ball, 1, self.game.data)
|
||||
self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
|
||||
or "You already have\nthis POKéMON family!")
|
||||
return
|
||||
|
||||
@@ -35,6 +35,7 @@ TARGET="all"
|
||||
NOTARY_PROFILE="notary-profile"
|
||||
NOTARIZE=true
|
||||
IOS_RELEASE=false
|
||||
IOS_IPA=false
|
||||
|
||||
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
|
||||
@@ -48,6 +49,7 @@ while [ $# -gt 0 ]; do
|
||||
--notary-profile) NOTARY_PROFILE="$2"; shift ;;
|
||||
--no-notarize) NOTARIZE=false ;;
|
||||
--release) IOS_RELEASE=true ;;
|
||||
--ipa) IOS_IPA=true ;;
|
||||
*) fail "unknown argument: $1" ;;
|
||||
esac
|
||||
shift
|
||||
@@ -298,6 +300,9 @@ build_android() {
|
||||
if [ "$VERSION_EXPLICIT" = true ]; then
|
||||
args+=(--version "$VERSION")
|
||||
fi
|
||||
if [ "$IOS_IPA" = true ]; then
|
||||
args+=(--ipa)
|
||||
fi
|
||||
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +198,10 @@ pack_game_love() {
|
||||
rm -f "$LOVE_FILE"
|
||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||
# row opens it in-process, so it must be inside the archive (see build.sh).
|
||||
# Deliberately NO fused mods: a mod inside game.love sits in the read-only
|
||||
# APK, so the mod manager's Delete can't remove it and it reappears every
|
||||
# launch. Pokewalker ships as an importable .zip instead, which gives it
|
||||
# a real install/upgrade/delete lifecycle.
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
@@ -290,10 +294,32 @@ require_android_sdk() {
|
||||
# --------------------------------------------------------------- gradle
|
||||
run_gradle() {
|
||||
local task="assembleEmbedNoRecordDebug"
|
||||
say "building APK ($task)"
|
||||
local build_dir="$ANDROID_DIR"
|
||||
|
||||
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
|
||||
# the project path ("Your APP_BUILD_SCRIPT points to an unknown file").
|
||||
# When this checkout lives at a spaced path (e.g. "~/xCode Projects/..."),
|
||||
# shadow the android tree to a space-free location and build there; the
|
||||
# shadow persists across runs so gradle/ndk builds stay incremental.
|
||||
case "$ANDROID_DIR" in
|
||||
*" "*)
|
||||
build_dir="${TMPDIR:-/tmp}/gen1recomp-android-shadow"
|
||||
say "path contains spaces (ndk-build cannot handle them);"
|
||||
say "shadow-building in: $build_dir"
|
||||
mkdir -p "$build_dir"
|
||||
rsync -a --delete \
|
||||
--exclude=".gradle" --exclude="app/build" --exclude="love/build" \
|
||||
--exclude="local.properties" \
|
||||
"$ANDROID_DIR/" "$build_dir/"
|
||||
if [ -f "$ANDROID_DIR/local.properties" ]; then
|
||||
cp "$ANDROID_DIR/local.properties" "$build_dir/local.properties"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
say "building APK ($task)"
|
||||
if ! (
|
||||
cd "$ANDROID_DIR"
|
||||
cd "$build_dir"
|
||||
./gradlew --no-daemon "$task"
|
||||
); then
|
||||
fail "gradle $task failed.
|
||||
@@ -302,7 +328,7 @@ run_gradle() {
|
||||
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
|
||||
fi
|
||||
|
||||
local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
|
||||
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
|
||||
if [ -d "$out_dir" ]; then
|
||||
say "APK output:"
|
||||
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
|
||||
|
||||
+81
-55
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 11.5's
|
||||
# official iOS Xcode project (love-11.5-ios-source.zip).
|
||||
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 12.0's
|
||||
# iOS Xcode project.
|
||||
#
|
||||
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--install]
|
||||
# [--version X.Y.Z] [--package-only]
|
||||
@@ -12,13 +12,12 @@
|
||||
# first connected iPhone/iPad (unlock it first)
|
||||
# --release Release configuration
|
||||
# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION
|
||||
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/
|
||||
# --fetch Fetch LÖVE 12.0 sources and Apple dependencies into mobile/ios/love-src/
|
||||
# --package-only Zip game.love + apply plist overlay; skip xcodebuild
|
||||
#
|
||||
# Prerequisites:
|
||||
# - macOS + Xcode (xcodebuild)
|
||||
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
|
||||
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
|
||||
#
|
||||
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
|
||||
# dist/ios/gen1recomp.ipa (device builds only)
|
||||
@@ -50,21 +49,22 @@ 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
|
||||
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)"
|
||||
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip"
|
||||
APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip"
|
||||
IOS_SOURCE_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${IOS_SOURCE_ZIP}"
|
||||
APPLE_LIBS_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${APPLE_LIBS_ZIP}"
|
||||
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 12.0)"
|
||||
LOVE_SOURCE_REF="${LOVE_SOURCE_REF:-main}"
|
||||
APPLE_DEPENDENCIES_REF="${APPLE_DEPENDENCIES_REF:-main}"
|
||||
LOVE_SOURCE_REPO="https://github.com/love2d/love.git"
|
||||
APPLE_DEPENDENCIES_REPO="https://github.com/love2d/love-apple-dependencies.git"
|
||||
|
||||
FETCH=false
|
||||
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 +82,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 +93,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
|
||||
@@ -154,25 +159,19 @@ fi
|
||||
# --------------------------------------------------------------- fetch love-src
|
||||
fetch_love_ios() {
|
||||
mkdir -p "$CACHE"
|
||||
local zip_path="$CACHE/$IOS_SOURCE_ZIP"
|
||||
if [ ! -f "$zip_path" ]; then
|
||||
say "downloading $IOS_SOURCE_ZIP (LÖVE $LOVE_VERSION iOS sources)"
|
||||
curl -fL --progress-bar "$IOS_SOURCE_URL" -o "$zip_path" \
|
||||
|| fail "download failed: $IOS_SOURCE_URL"
|
||||
else
|
||||
say "using cached $zip_path"
|
||||
fi
|
||||
|
||||
say "extracting into $LOVE_SRC"
|
||||
rm -rf "$LOVE_SRC"
|
||||
local tmp
|
||||
tmp="$(mktemp -d "$CACHE/extract.XXXXXX")"
|
||||
unzip -q "$zip_path" -d "$tmp"
|
||||
# Zip root is love-<version>-ios-source/
|
||||
local extracted
|
||||
extracted="$(find "$tmp" -maxdepth 1 -mindepth 1 -type d ! -name '__MACOSX' | head -1)"
|
||||
[ -n "$extracted" ] || fail "unexpected layout inside $IOS_SOURCE_ZIP"
|
||||
mv "$extracted" "$LOVE_SRC"
|
||||
say "fetching LÖVE $LOVE_VERSION sources ($LOVE_SOURCE_REF)"
|
||||
git clone --depth 1 --branch "$LOVE_SOURCE_REF" "$LOVE_SOURCE_REPO" "$tmp/love" \
|
||||
|| fail "failed to fetch LÖVE sources from $LOVE_SOURCE_REPO"
|
||||
say "fetching Apple dependencies ($APPLE_DEPENDENCIES_REF)"
|
||||
git clone --depth 1 --branch "$APPLE_DEPENDENCIES_REF" "$APPLE_DEPENDENCIES_REPO" "$tmp/dependencies" \
|
||||
|| fail "failed to fetch Apple dependencies from $APPLE_DEPENDENCIES_REPO"
|
||||
rm -rf "$LOVE_SRC"
|
||||
mv "$tmp/love" "$LOVE_SRC"
|
||||
mkdir -p "$LIBS_DIR" "$XCODE_DIR/shared"
|
||||
cp -R "$tmp/dependencies/iOS/libraries/." "$LIBS_DIR"
|
||||
cp -R "$tmp/dependencies/shared/." "$XCODE_DIR/shared"
|
||||
rm -rf "$tmp"
|
||||
say "love-src ready (LÖVE $LOVE_VERSION)"
|
||||
}
|
||||
@@ -182,18 +181,12 @@ if [ ! -d "$XCODE_DIR/love.xcodeproj" ]; then
|
||||
fetch_love_ios
|
||||
else
|
||||
fail "LÖVE $LOVE_VERSION iOS sources not found at mobile/ios/love-src/.
|
||||
Fetch them (documented download of love-${LOVE_VERSION}-ios-source.zip):
|
||||
Fetch them:
|
||||
scripts/build_ios.sh --fetch
|
||||
Or manually:
|
||||
mkdir -p mobile/ios/cache
|
||||
curl -fL -o mobile/ios/cache/$IOS_SOURCE_ZIP \\
|
||||
$IOS_SOURCE_URL
|
||||
unzip -q mobile/ios/cache/$IOS_SOURCE_ZIP -d mobile/ios/cache
|
||||
mv mobile/ios/cache/love-${LOVE_VERSION}-ios-source mobile/ios/love-src
|
||||
See mobile/ios/README.md."
|
||||
fi
|
||||
elif $FETCH; then
|
||||
say "love-src already present; skipping download (delete mobile/ios/love-src to refresh)"
|
||||
say "love-src already present; skipping fetch (delete mobile/ios/love-src to refresh)"
|
||||
fi
|
||||
|
||||
[ -d "$XCODE_DIR/love.xcodeproj" ] \
|
||||
@@ -201,25 +194,16 @@ fi
|
||||
|
||||
# --------------------------------------------------------------- apple libraries
|
||||
require_ios_libraries() {
|
||||
if [ -d "$LIBS_DIR/SDL2.xcframework" ]; then
|
||||
if [ -d "$LIBS_DIR/SDL2.xcframework" ] && [ -d "$XCODE_DIR/shared/Frameworks/SDL3.xcframework" ]; then
|
||||
return 0
|
||||
fi
|
||||
fail "prebuilt iOS libraries missing at:
|
||||
$LIBS_DIR
|
||||
love-ios expects SDL2.xcframework (and friends) there.
|
||||
and shared/Frameworks.
|
||||
|
||||
The official love-${LOVE_VERSION}-ios-source.zip normally includes them.
|
||||
If they are absent, install love-${LOVE_VERSION}-apple-libraries.zip:
|
||||
Re-fetch the LÖVE $LOVE_VERSION source tree and its Apple dependencies:
|
||||
|
||||
mkdir -p mobile/ios/cache
|
||||
curl -fL -o mobile/ios/cache/$APPLE_LIBS_ZIP \\
|
||||
$APPLE_LIBS_URL
|
||||
unzip -q mobile/ios/cache/$APPLE_LIBS_ZIP -d mobile/ios/cache
|
||||
rm -rf mobile/ios/love-src/platform/xcode/ios/libraries
|
||||
cp -R mobile/ios/cache/love-apple-dependencies/iOS/libraries \\
|
||||
mobile/ios/love-src/platform/xcode/ios/libraries
|
||||
|
||||
See mobile/ios/README.md (Apple libraries dependency)."
|
||||
scripts/build_ios.sh --fetch"
|
||||
}
|
||||
|
||||
require_ios_libraries
|
||||
@@ -232,6 +216,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 +334,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
|
||||
@@ -534,11 +543,27 @@ run_xcodebuild() {
|
||||
|
||||
say "xcodebuild love-ios ($config / $sdk)"
|
||||
set +e
|
||||
(
|
||||
cd "$XCODE_DIR"
|
||||
xcodebuild "${args[@]}"
|
||||
)
|
||||
local xc_status=$?
|
||||
local xc_status
|
||||
if command -v xcbeautify >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$XCODE_DIR"
|
||||
xcodebuild "${args[@]}"
|
||||
) 2>&1 | xcbeautify
|
||||
local pipeline_status=("${PIPESTATUS[@]}")
|
||||
local xcode_status=${pipeline_status[0]}
|
||||
local beautify_status=${pipeline_status[1]}
|
||||
if [ "$xcode_status" -ne 0 ]; then
|
||||
xc_status=$xcode_status
|
||||
else
|
||||
xc_status=$beautify_status
|
||||
fi
|
||||
else
|
||||
(
|
||||
cd "$XCODE_DIR"
|
||||
xcodebuild "${args[@]}"
|
||||
)
|
||||
xc_status=$?
|
||||
fi
|
||||
set -e
|
||||
if [ "$xc_status" -ne 0 ]; then
|
||||
fail "xcodebuild failed (exit $xc_status).
|
||||
@@ -640,6 +665,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)
|
||||
|
||||
+5
-5
@@ -14,12 +14,12 @@ local MODULES = {
|
||||
-- Optional for compatibility with developer and stale caches.
|
||||
local OPTIONAL = { "audio", "palettes", "icons" }
|
||||
|
||||
-- The rules the engine still carries as literals. The constants registry
|
||||
-- deep-merges over these, so a value has to exist before a mod can patch
|
||||
-- it; each one is the number the engine hard-codes today, so seeding them
|
||||
-- changes nothing on a mod-free boot.
|
||||
-- Vanilla defaults for rules exposed through the constants registry. A
|
||||
-- value has to exist before a mod can patch it; each one matches the
|
||||
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
|
||||
-- boot.
|
||||
local CONSTANT_DEFAULTS = {
|
||||
bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua)
|
||||
bagSize = 20, -- BAG_ITEM_CAPACITY (Bag.capacity fallback)
|
||||
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
|
||||
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
|
||||
moveMax = 4,
|
||||
|
||||
@@ -178,6 +178,22 @@ function Game:step(dt)
|
||||
-- this logic tick, not one tick later. With no wrapper this is a no-op.
|
||||
ModRuntime.call("input.step", function() end, self, dt)
|
||||
self.input:step()
|
||||
-- A+B+SELECT+START held for 16 steps: SoftReset (home/init.asm) stops the
|
||||
-- audio, whites the palettes out and falls through into Init, i.e. the
|
||||
-- power-on boot, so everything unsaved is gone and the title sequence
|
||||
-- comes back -- exactly what returnToTitle does for the START menu's QUIT.
|
||||
-- The check lives here, above stack:update, so it fires from any state:
|
||||
-- overworld, battle, a menu or a cutscene, the way _Joypad's does (#563).
|
||||
-- Input:reset clears the four still-physically-held buttons so the title
|
||||
-- screen does not read A as a menu choice on its first frame.
|
||||
-- a tool mod (or a harness) may hand Game a stand-in input with no
|
||||
-- chord bookkeeping; those simply never soft reset
|
||||
if self.input.softResetStep and self.input:softResetStep() then
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
self:returnToTitle()
|
||||
return
|
||||
end
|
||||
-- serviced unconditionally: a link battle's ENet transport must not
|
||||
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
||||
-- on top of BattleState (see LinkBattle.new)
|
||||
@@ -475,8 +491,16 @@ function Game:restartWithMods()
|
||||
require("src.core.HostShell").restart()
|
||||
end
|
||||
|
||||
-- Releases reach Input even while a top state captures raw input: a
|
||||
-- swallowed key-up would strand a held-state flag for a key Input saw go
|
||||
-- down before the capture armed (the stuck-flag hazard Input:reset
|
||||
-- exists for). The top state only OBSERVES the release afterwards,
|
||||
-- unlike onKeyPressed above which owns the press, so BindingsMenu can
|
||||
-- commit a capture on the key-up (#589).
|
||||
function Game:keyreleased(key)
|
||||
Input:keyreleased(key)
|
||||
local top = self.stack and self.stack:top()
|
||||
if top and top.onKeyReleased then top:onKeyReleased(key) end
|
||||
end
|
||||
|
||||
function Game:gamepadpressed(joystick, button)
|
||||
@@ -493,7 +517,10 @@ function Game:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
function Game:gamepadreleased(joystick, button)
|
||||
-- same observe-after-Input contract as Game:keyreleased (#589)
|
||||
Input:gamepadreleased(joystick, button)
|
||||
local top = self.stack and self.stack:top()
|
||||
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
|
||||
end
|
||||
|
||||
function Game:gamepadaxis(joystick, axis, value)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Usable window rect for mobile chrome (notch / Dynamic Island / home
|
||||
-- indicator / Android display cutouts). Wraps love.window.getSafeArea when
|
||||
-- the engine provides it; otherwise the full graphics window.
|
||||
--
|
||||
-- Desktop and headless stubs return the full window, so callers can always
|
||||
-- layout against this rect without platform branches. Interactive chrome
|
||||
-- (touch overlay, launcher) should prefer this over getDimensions; the game
|
||||
-- canvas may still letterbox into the full framebuffer for immersion.
|
||||
|
||||
local SafeArea = {}
|
||||
|
||||
function SafeArea.rect()
|
||||
local ww, wh = 0, 0
|
||||
if love and love.graphics and love.graphics.getDimensions then
|
||||
ww, wh = love.graphics.getDimensions()
|
||||
end
|
||||
if ww <= 0 then ww = 1 end
|
||||
if wh <= 0 then wh = 1 end
|
||||
|
||||
if not (love and love.window and love.window.getSafeArea) then
|
||||
return 0, 0, ww, wh
|
||||
end
|
||||
|
||||
local x, y, w, h = love.window.getSafeArea()
|
||||
if type(x) ~= "number" or type(y) ~= "number"
|
||||
or type(w) ~= "number" or type(h) ~= "number"
|
||||
or w <= 0 or h <= 0 then
|
||||
return 0, 0, ww, wh
|
||||
end
|
||||
|
||||
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
|
||||
-- push layout outside the surface.
|
||||
x = math.max(0, math.min(x, ww))
|
||||
y = math.max(0, math.min(y, wh))
|
||||
w = math.max(1, math.min(w, ww - x))
|
||||
h = math.max(1, math.min(h, wh - y))
|
||||
return x, y, w, h
|
||||
end
|
||||
|
||||
return SafeArea
|
||||
@@ -1058,7 +1058,7 @@ local function reclaim(save, data, report)
|
||||
if type(entry) == "table" and known(data.items, entry.id) then
|
||||
table.remove(orphaned.items, i)
|
||||
if entry.from == "pcItems" or type(save.inventory) ~= "table"
|
||||
or not Bag.add(save, entry.id, entry.count or 1) then
|
||||
or not Bag.add(save, entry.id, entry.count or 1, data) then
|
||||
save.pcItems = save.pcItems or {}
|
||||
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
|
||||
end
|
||||
|
||||
+42
-26
@@ -23,6 +23,7 @@
|
||||
-- and a player rebind can never detach the overlay.
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local TouchControls = {}
|
||||
|
||||
@@ -92,20 +93,23 @@ function TouchControls.normalizeConfig(tc)
|
||||
return out
|
||||
end
|
||||
|
||||
-- Pure default layout in LOVE units for a given window size. Shared by
|
||||
-- layout() and the editor's Reset path so defaults stay in one place.
|
||||
function TouchControls.defaultLayout(ww, wh)
|
||||
-- Pure default layout in LOVE units for a usable rect of size ww x wh at
|
||||
-- origin (ox, oy). Shared by layout() and the editor's Reset path so
|
||||
-- defaults stay in one place. ox/oy default to 0 for the headless tests
|
||||
-- and for callers that already pass a full-window size.
|
||||
function TouchControls.defaultLayout(ww, wh, ox, oy)
|
||||
ox, oy = ox or 0, oy or 0
|
||||
local short = math.min(ww, wh)
|
||||
local dpadW = math.min(180, short * 0.34)
|
||||
local abW = dpadW * 0.46
|
||||
local ssW = dpadW * 0.30
|
||||
local margin = dpadW * 0.12
|
||||
return {
|
||||
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
|
||||
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
|
||||
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
|
||||
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
|
||||
b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW },
|
||||
start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
|
||||
select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
|
||||
}
|
||||
end
|
||||
|
||||
@@ -155,6 +159,7 @@ function TouchControls:applyOptions(opts)
|
||||
self.enabled = cfg.enabled
|
||||
self.positions = cfg.positions
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
if not self.enabled then
|
||||
self.controllerHidden = false
|
||||
self:reset()
|
||||
@@ -185,30 +190,37 @@ function TouchControls:visible()
|
||||
and not self.controllerHidden
|
||||
end
|
||||
|
||||
local function clampZone(zone, ww, wh)
|
||||
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
|
||||
local function clampZone(zone, x0, y0, x1, y1)
|
||||
local half = zone.w * 0.5
|
||||
zone.cx = math.max(half, math.min(ww - half, zone.cx))
|
||||
zone.cy = math.max(half, math.min(wh - half, zone.cy))
|
||||
zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx))
|
||||
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
|
||||
end
|
||||
|
||||
-- Layout in LOVE units (density-independent on mobile), recomputed when
|
||||
-- the window size changes (rotation, resize). Default: d-pad bottom-left,
|
||||
-- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT
|
||||
-- flanking the bottom center. Custom positions (normalized 0..1) override
|
||||
-- centers while sizes stay derived from the short edge.
|
||||
-- the window or safe area changes (rotation, resize, notch insets).
|
||||
-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
|
||||
-- diagonal), START/SELECT flanking the bottom center -- all inside the
|
||||
-- device safe area so thumbs clear the home indicator / cutouts.
|
||||
-- Custom positions (normalized 0..1 within the safe rect) override centers
|
||||
-- while sizes stay derived from the short edge.
|
||||
function TouchControls:layout()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end
|
||||
self.layoutW, self.layoutH = ww, wh
|
||||
self.L = TouchControls.defaultLayout(ww, wh)
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
if self.layoutW == sw and self.layoutH == sh
|
||||
and self.layoutOx == ox and self.layoutOy == oy and self.L then
|
||||
return self.L
|
||||
end
|
||||
self.layoutW, self.layoutH = sw, sh
|
||||
self.layoutOx, self.layoutOy = ox, oy
|
||||
self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
|
||||
if self.positions then
|
||||
for _, name in ipairs(CONTROLS) do
|
||||
local p = self.positions[name]
|
||||
local zone = self.L[name]
|
||||
if p and zone then
|
||||
zone.cx = p.x * ww
|
||||
zone.cy = p.y * wh
|
||||
clampZone(zone, ww, wh)
|
||||
zone.cx = ox + p.x * sw
|
||||
zone.cy = oy + p.y * sh
|
||||
clampZone(zone, ox, oy, ox + sw, oy + sh)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -222,21 +234,25 @@ function TouchControls:layout()
|
||||
end
|
||||
|
||||
-- Move one control to a screen-space point and persist its normalized
|
||||
-- position. Used by the layout editor while dragging.
|
||||
-- position within the safe rect. Used by the layout editor while dragging.
|
||||
function TouchControls:setControlCenter(name, cx, cy)
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
local L = self:layout()
|
||||
local zone = L[name]
|
||||
if not zone then return end
|
||||
zone.cx, zone.cy = cx, cy
|
||||
clampZone(zone, ww, wh)
|
||||
clampZone(zone, ox, oy, ox + sw, oy + sh)
|
||||
self.positions = self.positions or {}
|
||||
self.positions[name] = { x = zone.cx / ww, y = zone.cy / wh }
|
||||
self.positions[name] = {
|
||||
x = sw > 0 and (zone.cx - ox) / sw or 0,
|
||||
y = sh > 0 and (zone.cy - oy) / sh or 0,
|
||||
}
|
||||
end
|
||||
|
||||
function TouchControls:clearPositions()
|
||||
self.positions = nil
|
||||
self.layoutW, self.layoutH = nil, nil
|
||||
self.layoutOx, self.layoutOy = nil, nil
|
||||
end
|
||||
|
||||
local function inCircle(zone, x, y, slop)
|
||||
|
||||
+1
-1
@@ -214,7 +214,7 @@ function VERBS.give(self, rest)
|
||||
end
|
||||
elseif game.data.items and game.data.items[id] then
|
||||
local n = tonumber(count) or 1
|
||||
if require("src.inventory.Bag").add(save, id, n) then
|
||||
if require("src.inventory.Bag").add(save, id, n, game.data) then
|
||||
self:print(("%s x%d added"):format(id, n))
|
||||
else
|
||||
self:print("bag full")
|
||||
|
||||
@@ -1179,6 +1179,13 @@ function RomExtractor:extractPokemon()
|
||||
completed = completed + 1
|
||||
self:tick("Pokemon", completed, #self.manifest.dexOrder + 10)
|
||||
end
|
||||
-- Yellow-only third back pic: LoadPlayerBackPic (engine/battle/core.asm)
|
||||
-- picks OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for
|
||||
-- BATTLE_TYPE_PIKACHU, the Pallet Town catch scene (#557). Outside the
|
||||
-- ticked loop so the progress denominator stays as it was.
|
||||
if self.symbols["ProfOakPicBack"] then
|
||||
self:writeCompressedPic("ProfOakPicBack", "battle/profoakb.png")
|
||||
end
|
||||
local balls = self:symbol("PokeballTileGraphics")
|
||||
self:write2bpp(self.rom:bytes(balls.bank, balls.address, 64),
|
||||
32, 8, "battle/balls.png", true)
|
||||
@@ -1826,6 +1833,35 @@ function RomExtractor:extractField()
|
||||
end
|
||||
end
|
||||
|
||||
-- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per
|
||||
-- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in
|
||||
-- data/pikachu/pikachu_pic_animation.asm, the pic PikaPicAnimBGFrames_4
|
||||
-- (PikaAnimTilemap_1, column order) paints. Scripts 18/22/23/24 have no
|
||||
-- compressed base and take PikaPicAnimBGFrames_5 -> PikaAnimTilemap_9,
|
||||
-- which is ROW order over a raw 25-tile sheet, hence no columns flag.
|
||||
-- Script 26 shares script 11's base. The pikaframe overlays each script
|
||||
-- draws ON TOP of the base are a second full pose out of the same blob and
|
||||
-- stay unripped: PikachuFollower.picLift stands in for their motion
|
||||
-- (#561, still on #407's stand-in).
|
||||
local PIKAPIC_BASE = {
|
||||
"Pic_e4000", "Pic_e411c", "Pic_e4272", "Pic_e4383", "Pic_e458b",
|
||||
"Pic_e467b", "Pic_e476e", "Pic_e49d1", "Pic_e4b39", "Pic_e4c3e",
|
||||
"Pic_e5000", "Pic_e523f", "Pic_e548e", "Pic_e56d1", "Pic_e5924",
|
||||
"Pic_e5b7d", "Pic_e5ddd", "GFX_e6020", "Pic_e6340", "Pic_e6587",
|
||||
"Pic_e67d6", "GFX_e6e6f", "GFX_e718f", "GFX_e74af", "Pic_e77cf",
|
||||
"Pic_e5000", "Pic_f0abf", "Pic_f0cf4",
|
||||
}
|
||||
if self.symbols[PIKAPIC_BASE[1]] then
|
||||
for script, label in ipairs(PIKAPIC_BASE) do
|
||||
local path = "pikachu/pikapic_" .. script .. ".png"
|
||||
if label:sub(1, 4) == "GFX_" then
|
||||
self:raw2bpp(label, 40, 40, path, { matte = true })
|
||||
else
|
||||
self:writeCompressedPic(label, path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
self:raw1bpp("LedgeHoppingShadow", 8, 8,
|
||||
"fx/shadow.png", true); tick()
|
||||
for _, spec in ipairs({
|
||||
|
||||
+207
-66
@@ -1,6 +1,7 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Strings = require("src.core.Strings")
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
|
||||
local RomImporter = {}
|
||||
RomImporter.__index = RomImporter
|
||||
@@ -60,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 = {
|
||||
@@ -636,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.
|
||||
@@ -817,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
|
||||
|
||||
@@ -1003,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
|
||||
@@ -1017,7 +1043,6 @@ function RomImporter:chooseMod()
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.pickElapsed = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -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
|
||||
@@ -1082,7 +1117,6 @@ function RomImporter:chooseSaveImport(version)
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.pickElapsed = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1118,10 +1152,9 @@ 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.pickElapsed = 0
|
||||
self.saveNotice[version] = { ok = true,
|
||||
text = "Pick where to save " .. suggested .. "..." }
|
||||
else
|
||||
@@ -1156,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
|
||||
@@ -1177,7 +1218,6 @@ function RomImporter:choose(version)
|
||||
else
|
||||
self.pickPending = true
|
||||
self.pickTimer = 0
|
||||
self.pickElapsed = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -1225,18 +1265,18 @@ end
|
||||
-- for the next tap to find, which is what made users import twice and
|
||||
-- what made it look random: it depends on memory pressure (#553).
|
||||
--
|
||||
-- Disarms after PICK_TIMEOUT so a cancelled picker (which delivers nothing,
|
||||
-- ever) does not leave this scanning the save directory for the whole session.
|
||||
local PICK_TIMEOUT = 120
|
||||
-- Deliberately NO timeout. A version of this disarmed the poll after 120s so a
|
||||
-- cancelled picker would stop scanning, which was wrong on iOS: the picker there
|
||||
-- is an in-process modal sheet, so update() keeps running while it is open and
|
||||
-- the window burned down while the player was still browsing Files. The pick
|
||||
-- then landed with nothing armed to consume it, and because every path here is
|
||||
-- silent on success the import just did not happen, with no error shown. A
|
||||
-- half-second directory listing on a menu screen is far cheaper than an import
|
||||
-- that vanishes, so the poll stays armed until something is actually consumed.
|
||||
|
||||
function RomImporter:_pollPickedFiles(dt)
|
||||
if not self.pickPending then return end
|
||||
if self.workState == "working" then return end
|
||||
self.pickElapsed = (self.pickElapsed or 0) + dt
|
||||
if self.pickElapsed > PICK_TIMEOUT then
|
||||
self.pickPending, self.pickElapsed = nil, nil
|
||||
return
|
||||
end
|
||||
self.pickTimer = (self.pickTimer or 0) + dt
|
||||
if self.pickTimer < 0.5 then return end
|
||||
self.pickTimer = 0
|
||||
@@ -1246,7 +1286,7 @@ function RomImporter:_pollPickedFiles(dt)
|
||||
local pickError = love.filesystem.read("pick_error.txt")
|
||||
if pickError then
|
||||
love.filesystem.remove("pick_error.txt")
|
||||
self.pickPending, self.pickElapsed = nil, nil
|
||||
self.pickPending = nil
|
||||
self.modNotice = { ok = false, text = pickError }
|
||||
self.notice = { version = self.chooseVersion or "red",
|
||||
status = "File import failed:", detail = pickError }
|
||||
@@ -1263,7 +1303,7 @@ function RomImporter:_pollPickedFiles(dt)
|
||||
end
|
||||
end
|
||||
if found then
|
||||
self.pickPending, self.pickElapsed = nil, nil
|
||||
self.pickPending = nil
|
||||
self:focus(true)
|
||||
end
|
||||
end
|
||||
@@ -1271,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()
|
||||
@@ -1295,10 +1366,10 @@ local PAD_DPAD_SPEED = 420
|
||||
|
||||
function RomImporter:_activatePadCursor()
|
||||
if self._padCursorActive then return end
|
||||
local w, h = love.graphics.getDimensions()
|
||||
local ox, oy, w, h = SafeArea.rect()
|
||||
if not self._padInited then
|
||||
self._padCursor.x = w * 0.5
|
||||
self._padCursor.y = h * 0.45
|
||||
self._padCursor.x = ox + w * 0.5
|
||||
self._padCursor.y = oy + h * 0.45
|
||||
self._padInited = true
|
||||
end
|
||||
self._padCursorActive = true
|
||||
@@ -1315,6 +1386,7 @@ function RomImporter:_cycleTab(delta)
|
||||
self._slotPress = nil
|
||||
self._modPress = nil
|
||||
self._findSearchFocus = false
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_updatePadCursor(dt)
|
||||
@@ -1344,11 +1416,11 @@ function RomImporter:_updatePadCursor(dt)
|
||||
if mag > 1 then dx, dy = dx / mag, dy / mag end
|
||||
local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD)
|
||||
and PAD_SPEED or PAD_DPAD_SPEED
|
||||
local w, h = love.graphics.getDimensions()
|
||||
local ox, oy, w, h = SafeArea.rect()
|
||||
local nx = self._padCursor.x + dx * speed * dt
|
||||
local ny = self._padCursor.y + dy * speed * dt
|
||||
self._padCursor.x = math.max(0, math.min(w, nx))
|
||||
self._padCursor.y = math.max(0, math.min(h, ny))
|
||||
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
|
||||
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
|
||||
end
|
||||
|
||||
-- Right stick scrolls the active list (save slots or mods), or the whole page
|
||||
@@ -1726,7 +1798,10 @@ function RomImporter:_resetFrameRects()
|
||||
end
|
||||
|
||||
function RomImporter:draw()
|
||||
local width, height = love.graphics.getDimensions()
|
||||
-- Full window for immersive backdrop; safe rect for interactive chrome so
|
||||
-- notch / Dynamic Island / home indicator / Android cutouts are respected.
|
||||
local fullW, fullH = love.graphics.getDimensions()
|
||||
local ox, oy, width, height = SafeArea.rect()
|
||||
local s = clamp(height / 768, 0.7, 1.6)
|
||||
local pulse = self.pulse
|
||||
self._s = s
|
||||
@@ -1745,8 +1820,9 @@ function RomImporter:draw()
|
||||
self._anyHover = false
|
||||
self:_resetFrameRects()
|
||||
|
||||
-- Fonts + size-dependent scenery, rebuilt only when the window size changes.
|
||||
local fontKey = ("%dx%d"):format(width, height)
|
||||
-- Fonts + size-dependent scenery, rebuilt only when the window / safe
|
||||
-- area changes (rotation, resize, inset changes).
|
||||
local fontKey = ("%dx%d@%d,%d"):format(fullW, fullH, ox, oy)
|
||||
if self.fontKey ~= fontKey then
|
||||
self.fontKey = fontKey
|
||||
local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end
|
||||
@@ -1770,10 +1846,10 @@ function RomImporter:draw()
|
||||
-- Background: a radial gradient (bright navy at top-centre -> near black).
|
||||
-- A triangle fan from the top-centre gives the radial falloff; the screen
|
||||
-- is cleared to the outer colour first so the corners it does not reach
|
||||
-- match seamlessly.
|
||||
-- match seamlessly. Sized to the full window so unsafe edges stay filled.
|
||||
do
|
||||
local cx, cy = width / 2, 0
|
||||
local rx, ry = width * 1.3, height * 1.08
|
||||
local cx, cy = fullW / 2, 0
|
||||
local rx, ry = fullW * 1.3, fullH * 1.08
|
||||
local n = 72
|
||||
local verts = { { cx, cy, 0, 0,
|
||||
PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } }
|
||||
@@ -1787,8 +1863,8 @@ function RomImporter:draw()
|
||||
|
||||
-- CRT vignette: a gentle edge darkening, centred slightly above the middle.
|
||||
do
|
||||
local cx, cy = width / 2, height * 0.45
|
||||
local rx, ry = width * 0.78, height * 0.78
|
||||
local cx, cy = fullW / 2, fullH * 0.45
|
||||
local rx, ry = fullW * 0.78, fullH * 0.78
|
||||
local n = 72
|
||||
local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } }
|
||||
for i = 0, n do
|
||||
@@ -1810,7 +1886,7 @@ function RomImporter:draw()
|
||||
self.scanlineImage:setWrap("repeat", "repeat")
|
||||
self.scanlineImage:setFilter("nearest", "nearest")
|
||||
end
|
||||
self.scanlineQuad = love.graphics.newQuad(0, 0, width, height, 1, 3)
|
||||
self.scanlineQuad = love.graphics.newQuad(0, 0, fullW, fullH, 1, 3)
|
||||
end
|
||||
|
||||
-- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it
|
||||
@@ -1836,21 +1912,23 @@ function RomImporter:draw()
|
||||
}
|
||||
]])
|
||||
|
||||
-- background
|
||||
-- background (full window — unsafe edges stay painted)
|
||||
col(PAL.bgBot)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.bgMesh)
|
||||
|
||||
-- Centered content container (max ~1440 scaled units on very wide windows)
|
||||
-- with a responsive side gutter; every column below derives from these.
|
||||
-- Origin is the safe-area top-left so chrome clears device insets.
|
||||
local appW = math.min(width, 1440 * s)
|
||||
local appX = (width - appW) / 2
|
||||
local appX = ox + (width - appW) / 2
|
||||
local padH = clamp(appW * 0.03, 12 * s, 26 * s)
|
||||
local third = appW / 3
|
||||
|
||||
-- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom
|
||||
local stripH = math.max(4, 6 * s)
|
||||
local stripY = oy
|
||||
local segs = {
|
||||
{ PAL.red, appX, third },
|
||||
{ PAL.blue, appX + third, third },
|
||||
@@ -1858,11 +1936,11 @@ function RomImporter:draw()
|
||||
}
|
||||
love.graphics.setBlendMode("add")
|
||||
for _, seg in ipairs(segs) do
|
||||
fillGrad(seg[2], stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
|
||||
fillGrad(seg[2], stripY + stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
|
||||
end
|
||||
love.graphics.setBlendMode("alpha")
|
||||
for _, seg in ipairs(segs) do
|
||||
col(seg[1]); love.graphics.rectangle("fill", seg[2], 0, seg[3], stripH)
|
||||
col(seg[1]); love.graphics.rectangle("fill", seg[2], stripY, seg[3], stripH)
|
||||
end
|
||||
|
||||
-- Footer (Boi's Club Games logo + trust warning), measured first so the
|
||||
@@ -1884,7 +1962,7 @@ function RomImporter:draw()
|
||||
math.min(330 * s, appW - 32 * s))
|
||||
local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH)
|
||||
local logoDW, logoDH = logoW * logoScale, logoH * logoScale
|
||||
local logoY = stripH + 14 * s
|
||||
local logoY = stripY + stripH + 14 * s
|
||||
|
||||
-- Tab bar: R/B/Y/divider/MODS chips (label + underline on the active one),
|
||||
-- with "N of 3 ready" right-aligned.
|
||||
@@ -1914,7 +1992,7 @@ function RomImporter:draw()
|
||||
local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s
|
||||
local cX = appX + padH
|
||||
local cW = appW - 2 * padH
|
||||
local contentBottom = height - footerH - bannerBand
|
||||
local contentBottom = oy + height - footerH - bannerBand
|
||||
local cH = math.max(0, contentBottom - contentTop)
|
||||
|
||||
-- Page scroll. Everything under the tab bar -- panel, updater banner and
|
||||
@@ -1925,14 +2003,14 @@ function RomImporter:draw()
|
||||
-- the previous frame's measurement, the same one-frame settle the slot and
|
||||
-- mod lists already rely on. While the page fits, `paged` is false and every
|
||||
-- measurement below is what it always was.
|
||||
local viewportH = math.max(0, height - contentTop)
|
||||
local viewportH = math.max(0, oy + height - contentTop)
|
||||
self._panelNaturalH = self._panelNaturalH or {}
|
||||
local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH
|
||||
local paged, pageScroll, maxPage =
|
||||
RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll)
|
||||
self.pageScroll, self._pageMax = pageScroll, maxPage
|
||||
-- read by the hit tests; a scrolled control is live only inside the viewport
|
||||
pageBand = paged and { contentTop, height } or nil
|
||||
pageBand = paged and { contentTop, oy + height } or nil
|
||||
|
||||
-- tab bar (rebuilds self.tabRects). Pinned: it is the launcher's navigation,
|
||||
-- and it sits above the scrolling viewport.
|
||||
@@ -2103,10 +2181,10 @@ function RomImporter:draw()
|
||||
|
||||
-- logo, over the split, with a gentle bob + gold glow + sweeping shine
|
||||
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s
|
||||
local lx, ly = (width - logoDW) / 2, logoY + bob
|
||||
local lx, ly = ox + (width - logoDW) / 2, logoY + bob
|
||||
love.graphics.setBlendMode("add")
|
||||
love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6)))
|
||||
love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
|
||||
love.graphics.draw(self.logo, ox + (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
|
||||
logoScale * 1.05, logoScale * 1.05)
|
||||
love.graphics.setBlendMode("alpha")
|
||||
local shineW = 0.16
|
||||
@@ -2139,11 +2217,11 @@ function RomImporter:draw()
|
||||
-- save-slot rename modal (#205), drawn over everything
|
||||
if self._rename then
|
||||
col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
local dw = math.min(appW - 32 * s, 420 * s)
|
||||
local dh = 128 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4)
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85)
|
||||
@@ -2185,11 +2263,11 @@ function RomImporter:draw()
|
||||
-- it is typed.
|
||||
if self._indexPrompt then
|
||||
col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
local dw = math.min(appW - 32 * s, 520 * s)
|
||||
local dh = 168 * s
|
||||
local dh = 176 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4)
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.9, 0.9)
|
||||
@@ -2231,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"),
|
||||
@@ -2241,7 +2327,7 @@ function RomImporter:draw()
|
||||
if self._modConfirm or self._modVersions or self._modReleaseNotes
|
||||
or self._findDetails then
|
||||
col(PAL.bgBot, 0.72)
|
||||
love.graphics.rectangle("fill", 0, 0, width, height)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
end
|
||||
if self._modConfirm then
|
||||
local c = self._modConfirm
|
||||
@@ -2249,7 +2335,7 @@ function RomImporter:draw()
|
||||
local lineH = self.hintFont:getHeight() + 4 * s
|
||||
local dh = 36 * s + (#c.lines) * lineH + 56 * s
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2291,7 +2377,7 @@ function RomImporter:draw()
|
||||
local dw = math.min(appW - 32 * s, 480 * s)
|
||||
local dh = math.min(height - 48 * s, 360 * s)
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2336,7 +2422,7 @@ function RomImporter:draw()
|
||||
local dw = math.min(appW - 32 * s, 520 * s)
|
||||
local dh = math.min(height - 48 * s, 420 * s)
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2393,7 +2479,7 @@ function RomImporter:draw()
|
||||
listH = listN * rowH
|
||||
dh = headerH + listH + footerH
|
||||
local dx = appX + (appW - dw) / 2
|
||||
local dy = (height - dh) / 2
|
||||
local dy = oy + (height - dh) / 2
|
||||
local rr = 12 * s
|
||||
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96)
|
||||
love.graphics.setLineWidth(math.max(1, 1.2 * s))
|
||||
@@ -2402,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)
|
||||
@@ -2509,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
|
||||
@@ -2569,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
|
||||
@@ -2676,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
|
||||
@@ -2797,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
|
||||
@@ -2829,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
|
||||
@@ -2845,6 +2948,7 @@ function RomImporter:keypressed(key)
|
||||
self:_commitRename()
|
||||
elseif key == "escape" then
|
||||
self._rename = nil
|
||||
self:_disarmTextInput()
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -2855,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
|
||||
@@ -2885,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
|
||||
@@ -3438,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
|
||||
@@ -3445,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
|
||||
@@ -3472,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)
|
||||
@@ -3623,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,
|
||||
@@ -4515,11 +4654,13 @@ end
|
||||
-- would make the launcher's choice look like an endorsement.
|
||||
function RomImporter:_promptAddIndex()
|
||||
self._indexPrompt = { text = "" }
|
||||
self:_armTextInput()
|
||||
end
|
||||
|
||||
function RomImporter:_commitAddIndex()
|
||||
local prompt = self._indexPrompt
|
||||
self._indexPrompt = nil
|
||||
self:_disarmTextInput()
|
||||
if not prompt then return end
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
local row, err = ModIndex.addSource(prompt.text or "")
|
||||
|
||||
+23
-7
@@ -1,11 +1,26 @@
|
||||
-- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm):
|
||||
-- a distinct item id occupies one slot regardless of quantity; badges
|
||||
-- live in the inventory table but are not bag items. save.bagOrder
|
||||
-- keeps acquisition order like wBagItems (SELECT can reorder it).
|
||||
-- The bag defaults to 20 slots (BAG_ITEM_CAPACITY,
|
||||
-- constants/menu_constants.asm), but mods may replace that limit through
|
||||
-- Data.constants.bagSize. A distinct item id occupies one slot regardless
|
||||
-- of quantity; badges live in the inventory table but are not bag items.
|
||||
-- save.bagOrder keeps acquisition order like wBagItems (SELECT can reorder
|
||||
-- it).
|
||||
|
||||
local Bag = {}
|
||||
|
||||
Bag.CAPACITY = 20
|
||||
local DEFAULT_CAPACITY = 20
|
||||
|
||||
-- `data` is injectable for the save editor and headless mod tests. Normal
|
||||
-- gameplay may omit it because the loader merges mods into the Data
|
||||
-- singleton before any item can be added. The fallback keeps old/stale
|
||||
-- generated caches and isolated callers at the vanilla limit.
|
||||
function Bag.capacity(data)
|
||||
data = data or require("src.core.Data")
|
||||
local configured = data and data.constants and data.constants.bagSize
|
||||
if type(configured) == "number" and configured >= 1 then
|
||||
return math.floor(configured)
|
||||
end
|
||||
return DEFAULT_CAPACITY
|
||||
end
|
||||
|
||||
local function isBadge(id)
|
||||
return id:find("BADGE", 1, true) ~= nil
|
||||
@@ -55,9 +70,10 @@ end
|
||||
-- Add qty of an item; returns false (and adds nothing) when a new slot
|
||||
-- is needed and the bag is full, or when the stack would pass 99
|
||||
-- (AddItemToInventory's per-slot quantity cap).
|
||||
function Bag.add(save, id, qty)
|
||||
function Bag.add(save, id, qty, data)
|
||||
local inv = save.inventory
|
||||
if not inv[id] and not isBadge(id) and Bag.slots(save) >= Bag.CAPACITY then
|
||||
if not inv[id] and not isBadge(id)
|
||||
and Bag.slots(save) >= Bag.capacity(data) then
|
||||
return false
|
||||
end
|
||||
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
|
||||
|
||||
@@ -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"
|
||||
|
||||
+76
-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,12 @@ 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)
|
||||
local loveMajor = love.getVersion()
|
||||
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
|
||||
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
|
||||
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
|
||||
@@ -210,11 +210,12 @@ end
|
||||
-- text (label or literal; {RAM:wStringBuffer} becomes the item name);
|
||||
-- pass false when the script shows its own received-text row.
|
||||
function Commands.give_item(ctx, itemId, count, gotText)
|
||||
-- the 20-slot bag can refuse (BAG_ITEM_CAPACITY): say so and halt
|
||||
-- the bag can refuse at its configured capacity (20 in vanilla): halt
|
||||
-- the script, so later set_flag rows don't burn the gift -- make
|
||||
-- room and talk again, like the original (pokered's `jr nc, .bag_full`
|
||||
-- skips the received text entirely when AddItemToInventory refuses)
|
||||
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
|
||||
if not require("src.inventory.Bag").add(
|
||||
ctx.save, itemId, count or 1, ctx.game.data) then
|
||||
Commands.show_text(ctx, ctx.game.data.text
|
||||
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
|
||||
return math.huge
|
||||
|
||||
+151
-27
@@ -6,6 +6,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local Input = require("src.core.Input")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
@@ -13,16 +14,18 @@ local BindingsMenu = setmetatable({}, { __index = ListMenu })
|
||||
BindingsMenu.__index = BindingsMenu
|
||||
|
||||
-- Input.lua's map, primary key first where several keys share a button.
|
||||
-- `pad` is the default SDL gamecontroller button (see Input.lua); shown
|
||||
-- on the SELECT row so controller Back/View is discoverable (#73).
|
||||
-- `pad` mirrors DEFAULT_GAMEPAD_BINDINGS in src/core/Input.lua row for
|
||||
-- row; keep the two in sync. Every row shows its key and pad so the
|
||||
-- controller side is discoverable (#73, #589), and the swap in
|
||||
-- storeBinding leans on each row holding a value in both slots.
|
||||
local BUTTONS = {
|
||||
{ id = "up", label = "UP", key = "up" },
|
||||
{ id = "down", label = "DOWN", key = "down" },
|
||||
{ id = "left", label = "LEFT", key = "left" },
|
||||
{ id = "right", label = "RIGHT", key = "right" },
|
||||
{ id = "a", label = "A", key = "z" },
|
||||
{ id = "b", label = "B", key = "x" },
|
||||
{ id = "start", label = "START", key = "escape" },
|
||||
{ id = "up", label = "UP", key = "up", pad = "dpup" },
|
||||
{ id = "down", label = "DOWN", key = "down", pad = "dpdown" },
|
||||
{ id = "left", label = "LEFT", key = "left", pad = "dpleft" },
|
||||
{ id = "right", label = "RIGHT", key = "right", pad = "dpright" },
|
||||
{ id = "a", label = "A", key = "z", pad = "a" },
|
||||
{ id = "b", label = "B", key = "x", pad = "b" },
|
||||
{ id = "start", label = "START", key = "escape", pad = "start" },
|
||||
{ id = "select", label = "SELECT", key = "tab", pad = "back" },
|
||||
}
|
||||
|
||||
@@ -41,14 +44,33 @@ local function boundPad(overlay, def)
|
||||
return def.pad
|
||||
end
|
||||
|
||||
-- Key column for every row. SELECT also appends "/PAD" (default BACK)
|
||||
-- so controller Select/View is visible without opening a second legend.
|
||||
-- The right column is KEY/PAD (e.g. "Z/A"). The row is 20 tiles and the
|
||||
-- widest label ("SELECT") ends at x=64, so each half is clamped to 5
|
||||
-- glyphs: 5+1+5 right-aligned at x=152 starts no further left than x=64.
|
||||
-- SDL names longer than 5 get a fixed short form before the clamp.
|
||||
local KEY_SHORT = {
|
||||
escape = "ESC", backspace = "BKSP", ["return"] = "ENTER",
|
||||
kpenter = "ENTER", space = "SPACE",
|
||||
}
|
||||
local PAD_SHORT = {
|
||||
dpup = "D-UP", dpdown = "D-DN", dpleft = "D-LT", dpright = "D-RT",
|
||||
leftshoulder = "LB", rightshoulder = "RB",
|
||||
leftstick = "LS", rightstick = "RS", guide = "GUIDE",
|
||||
}
|
||||
local function shortName(name, shorts)
|
||||
local s = shorts[name]
|
||||
if s then return s end
|
||||
s = name:upper()
|
||||
return #s > 5 and s:sub(1, 5) or s
|
||||
end
|
||||
|
||||
-- Right column for every row: effective key and pad together, so a
|
||||
-- controller player can read the whole map without a second legend (#589).
|
||||
local function boundRight(overlay, def)
|
||||
local key = boundKey(overlay, def)
|
||||
if def.id ~= "select" then return key:upper() end
|
||||
local key = shortName(boundKey(overlay, def), KEY_SHORT)
|
||||
local pad = boundPad(overlay, def)
|
||||
if pad then return (key .. "/" .. pad):upper() end
|
||||
return key:upper()
|
||||
if pad then return key .. "/" .. shortName(pad, PAD_SHORT) end
|
||||
return key
|
||||
end
|
||||
|
||||
function BindingsMenu.new(game)
|
||||
@@ -61,9 +83,17 @@ function BindingsMenu.new(game)
|
||||
items[i] = { label = Strings(def.label),
|
||||
right = boundRight(overlay, def), button = def }
|
||||
end
|
||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
||||
BindingsMenu)
|
||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {
|
||||
-- 6 rows leaves the bottom two lines free for the hint; a clear or
|
||||
-- reset nobody can see on screen may as well not exist (#589)
|
||||
rows = 6,
|
||||
footer = Strings("SELECT:CLEAR ROW\nSTART:RESET ALL"),
|
||||
}), BindingsMenu)
|
||||
self.onChoose = function(item) self:beginCapture(item) end
|
||||
-- SELECT deletes one row's rebind: dropping the overlay entry is enough
|
||||
-- because Input:applyBindings rebuilds the whole map from the defaults
|
||||
-- on every call (#589)
|
||||
self.onSelectKey = function(item) self:clearBinding(item) end
|
||||
-- A rebind reaches Input only when this screen closes (#510). The menu
|
||||
-- steers by the live map, so applying "B = Z" the instant it was captured
|
||||
-- turned the player's next confirm press into a cancel and shut the
|
||||
@@ -82,12 +112,28 @@ function BindingsMenu:commitBindings()
|
||||
if opts then Input:applyBindings(opts.bindings) end
|
||||
end
|
||||
|
||||
-- the capture handlers are per-instance slots, so Game's raw-input
|
||||
-- routing only ever sees this screen while a capture is armed
|
||||
-- The capture handlers are per-instance slots, so Game's raw-input
|
||||
-- routing only ever sees this screen while a capture is armed. A capture
|
||||
-- no longer commits on the press: it commits when that press is RELEASED,
|
||||
-- and a second key or pad button going down while the first is still held
|
||||
-- cancels instead. That gives a bare controller a way to back out of an
|
||||
-- armed row, where Escape cannot help (#589).
|
||||
function BindingsMenu:beginCapture(item)
|
||||
self.capture = item
|
||||
self.pending = nil
|
||||
self.onKeyPressed = BindingsMenu.captureKey
|
||||
self.onGamepadPressed = BindingsMenu.capturePad
|
||||
self.onKeyReleased = BindingsMenu.captureKeyRelease
|
||||
self.onGamepadReleased = BindingsMenu.capturePadRelease
|
||||
end
|
||||
|
||||
function BindingsMenu:endCapture()
|
||||
self.capture = nil
|
||||
self.pending = nil
|
||||
self.onKeyPressed = nil
|
||||
self.onGamepadPressed = nil
|
||||
self.onKeyReleased = nil
|
||||
self.onGamepadReleased = nil
|
||||
end
|
||||
|
||||
-- Escape is the capture's way out, so it is never captured: every other
|
||||
@@ -95,23 +141,64 @@ end
|
||||
-- to bind something (#510). Escape stays START in Input's default map,
|
||||
-- which no rebind removes, so reserving it costs the player nothing.
|
||||
function BindingsMenu:captureKey(key)
|
||||
if key == "escape" then return self:storeBinding("key", nil) end
|
||||
self:storeBinding("key", key)
|
||||
if key == "escape" or self.pending then return self:endCapture() end
|
||||
self.pending = { slot = "key", value = key }
|
||||
end
|
||||
|
||||
function BindingsMenu:capturePad(button)
|
||||
self:storeBinding("pad", button)
|
||||
if self.pending then return self:endCapture() end
|
||||
self.pending = { slot = "pad", value = button }
|
||||
end
|
||||
|
||||
-- Game forwards every release to Input BEFORE these hooks (see
|
||||
-- Game:keyreleased): the capture observes releases, it never owns them,
|
||||
-- so Input's held-state stays honest for keys it saw go down before the
|
||||
-- capture armed. A release that does not match the pending input (the
|
||||
-- press that armed the row, a cancelled capture's stragglers) is noise.
|
||||
function BindingsMenu:captureKeyRelease(key)
|
||||
local p = self.pending
|
||||
if p and p.slot == "key" and p.value == key then
|
||||
self:storeBinding("key", key)
|
||||
end
|
||||
end
|
||||
|
||||
function BindingsMenu:capturePadRelease(button)
|
||||
local p = self.pending
|
||||
if p and p.slot == "pad" and p.value == button then
|
||||
self:storeBinding("pad", button)
|
||||
end
|
||||
end
|
||||
|
||||
function BindingsMenu:storeBinding(slot, value)
|
||||
local item = self.capture
|
||||
self.capture = nil
|
||||
self.onKeyPressed = nil
|
||||
self.onGamepadPressed = nil
|
||||
self:endCapture()
|
||||
local game = self.game
|
||||
if not (item and value and game.save and game.save.options) then return end
|
||||
local opts = game.save.options
|
||||
opts.bindings = opts.bindings or {}
|
||||
-- Swap, never steal (#589): when the captured input is another row's
|
||||
-- effective binding in this slot, that row inherits this row's previous
|
||||
-- binding. Every BUTTONS row has a default in both slots, so `prev`
|
||||
-- always exists: no row goes empty and no input serves two rows.
|
||||
-- Default key ALIASES (W beside Up, Space beside Z; DEFAULT_BINDINGS in
|
||||
-- Input.lua) are not effective bindings, so capturing one costs the
|
||||
-- other row a spare alias, never its shown key.
|
||||
local effective = (slot == "key") and boundKey or boundPad
|
||||
local prev = effective(opts.bindings, item.button)
|
||||
if value ~= prev then
|
||||
for _, other in ipairs(self.items) do
|
||||
if other ~= item and effective(opts.bindings, other.button) == value then
|
||||
local ob = opts.bindings[other.button.id]
|
||||
if type(ob) ~= "table" then
|
||||
ob = { key = type(ob) == "string" and ob or nil }
|
||||
end
|
||||
ob[slot] = prev
|
||||
opts.bindings[other.button.id] = ob
|
||||
other.right = boundRight(opts.bindings, other.button)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
local b = opts.bindings[item.button.id]
|
||||
if type(b) ~= "table" then
|
||||
-- keep a direct-edited plain key string when only the pad changes
|
||||
@@ -123,18 +210,55 @@ function BindingsMenu:storeBinding(slot, value)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end
|
||||
|
||||
-- SELECT: forget one row's rebind and fall back to the defaults. #510's
|
||||
-- deferral still holds: only options change here, the live map catches up
|
||||
-- in commitBindings on close.
|
||||
function BindingsMenu:clearBinding(item)
|
||||
local game = self.game
|
||||
local opts = game and game.save and game.save.options
|
||||
if not (opts and opts.bindings and opts.bindings[item.button.id]) then
|
||||
return
|
||||
end
|
||||
opts.bindings[item.button.id] = nil
|
||||
item.right = boundRight(opts.bindings, item.button)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end
|
||||
|
||||
-- START: confirm, then drop the whole overlay (#589). The footer doubles
|
||||
-- as the prompt while the YES/NO box is up, the same bottom-line pattern
|
||||
-- the mart and PC screens use.
|
||||
function BindingsMenu:confirmReset()
|
||||
local game = self.game
|
||||
local hint = self.footer
|
||||
self.footer = Strings("RESET ALL BINDINGS?")
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
self.footer = hint
|
||||
if not yes then return end
|
||||
local opts = game.save and game.save.options
|
||||
if opts then opts.bindings = nil end
|
||||
for _, it in ipairs(self.items) do
|
||||
it.right = boundRight(nil, it.button)
|
||||
end
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end, { defaultNo = true }))
|
||||
end
|
||||
|
||||
function BindingsMenu:update(dt)
|
||||
if self.capture then return end -- the raw capture owns the input
|
||||
if self.game.input:wasPressed("start") then
|
||||
return self:confirmReset()
|
||||
end
|
||||
ListMenu.update(self, dt)
|
||||
end
|
||||
|
||||
function BindingsMenu:draw()
|
||||
ListMenu.draw(self)
|
||||
if self.capture then
|
||||
Font.drawBox(1, 6, 18, 5)
|
||||
Font.drawBox(1, 6, 18, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
|
||||
Font.draw(Strings("ESC TO CANCEL"), 24, 72)
|
||||
Font.draw(Strings("RELEASE TO SET"), 24, 72)
|
||||
Font.draw(Strings("ESC/2ND CANCELS"), 24, 84)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -66,6 +66,7 @@ local function withdraw(game)
|
||||
end
|
||||
game.stack:push(ListMenu.new(game,
|
||||
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
local mon = box[item.value]
|
||||
if not mon then return end
|
||||
@@ -112,6 +113,7 @@ local function deposit(game)
|
||||
table.insert(items, { label = monLabel(game, mon), value = i })
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
local mon = game.save.party[item.value]
|
||||
if not mon then return end
|
||||
@@ -157,6 +159,7 @@ local function release(game)
|
||||
end
|
||||
game.stack:push(ListMenu.new(game,
|
||||
Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(_, list)
|
||||
local mon = box[list.index]
|
||||
if not mon then return end
|
||||
@@ -189,6 +192,7 @@ local function changeBox(game)
|
||||
})
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
|
||||
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
-- the original asks BEFORE switching ("When you change a #MON
|
||||
-- BOX, data will be saved. OK?"); declining aborts the change
|
||||
|
||||
@@ -78,6 +78,10 @@ function ListMenu.new(game, title, items, opts)
|
||||
-- bottom text box -- same row budget as the mart, without the money box.
|
||||
self.messageBox = opts.messageBox
|
||||
self.money = opts.money -- () -> current money for the box
|
||||
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): both PC sessions hold the flag
|
||||
-- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm),
|
||||
-- so their lists opt out of the A/B beep the same way Menu's noSound does
|
||||
self.noSound = opts.noSound or false
|
||||
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
|
||||
return self
|
||||
end
|
||||
@@ -101,6 +105,17 @@ local function syncScroll(self)
|
||||
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
|
||||
end
|
||||
|
||||
-- HandleMenuInput_ (home/window.asm) replays SFX_PRESS_AB for any watched
|
||||
-- A or B press; DisplayListMenuID's mask is PAD_A | PAD_B | PAD_SELECT
|
||||
-- (home/list_menu.asm), so the SELECT swap key stays silent (#570)
|
||||
local function beep(self)
|
||||
-- game.data is nil under the UI harnesses that drive a list with a stub
|
||||
-- game (tests/engine/rebind_capture_bug510.lua), where there is no audio
|
||||
-- cache to play out of
|
||||
if self.noSound or not (self.game and self.game.data) then return end
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
|
||||
-- edge press or key-repeat tick for a held direction
|
||||
local function navPressed(self, dir)
|
||||
if dir == "up" then
|
||||
@@ -126,6 +141,7 @@ function ListMenu:update(dt)
|
||||
local input = self.game.input
|
||||
if #self.items == 0 then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
beep(self)
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
@@ -148,10 +164,12 @@ function ListMenu:update(dt)
|
||||
elseif self.onSelectKey and input:wasPressed("select") then
|
||||
self.onSelectKey(self.items[self.index], self)
|
||||
elseif input:wasPressed("b") then
|
||||
beep(self)
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
return
|
||||
elseif input:wasPressed("a") then
|
||||
beep(self)
|
||||
local item = self.items[self.index]
|
||||
if self.onChoose then
|
||||
self.onChoose(item, self)
|
||||
|
||||
+18
-4
@@ -108,20 +108,34 @@ function Menu:draw()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
|
||||
or #self.items
|
||||
-- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior
|
||||
-- row and let any slack fall as a blank row under the top edge --
|
||||
-- draw_start_menu.asm (TextBoxBorder 10,0, then hlcoord 12,2 /
|
||||
-- wTopMenuItemY 2), players_pc.asm and bills_pc.asm all do the same. The
|
||||
-- bottom border is ty + th - 1, so the last choice sits on ty + th - 2 and
|
||||
-- row r counts back up from there. Anchoring from the top instead only
|
||||
-- agrees when th is exactly visible * rowStep + 2, which is Menu.new's
|
||||
-- default but NOT what a caller sizing its own box passes: BagMenu's
|
||||
-- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's
|
||||
-- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto
|
||||
-- the bottom border (#564, #572).
|
||||
for row = 1, visible do
|
||||
local item = self.items[self.scroll + row]
|
||||
if not item then break end
|
||||
Font.draw(item.label, (self.tx + 2) * 8,
|
||||
(self.ty + row * self.rowStep - (self.rowStep - 1)) * 8)
|
||||
(self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8)
|
||||
end
|
||||
local cursorRow = self.index - self.scroll
|
||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
|
||||
(self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8)
|
||||
(self.ty + self.th - 2 - (visible - cursorRow) * self.rowStep) * 8)
|
||||
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
|
||||
-- use, sat on the bottom border like TextBox's page-advance cursor
|
||||
-- use, sat on the bottom border like TextBox's page-advance cursor. It
|
||||
-- has to be the border row, not ty + th - 2: that is the last interior
|
||||
-- row, which the last choice now occupies, and Menu.new widens the box to
|
||||
-- widest + 3 so tx + tw - 2 is exactly that label's final glyph (#564).
|
||||
if self.maxVisible and self.scroll + self.maxVisible < #self.items then
|
||||
Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8,
|
||||
(self.ty + self.th - 2) * 8)
|
||||
(self.ty + self.th - 1) * 8)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -73,10 +73,11 @@ local function withdraw(game)
|
||||
local pc = game.save.pcItems
|
||||
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
|
||||
messageBox = true,
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
if not Bag.add(game.save, item.value, qty) then
|
||||
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||
list.footer = Strings("You can't carry\nany more items.")
|
||||
return
|
||||
end
|
||||
@@ -110,6 +111,7 @@ local function deposit(game)
|
||||
end
|
||||
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
|
||||
messageBox = true,
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
|
||||
if pcFull(game, pc, item.value) then
|
||||
@@ -130,6 +132,7 @@ local function toss(game)
|
||||
local pc = game.save.pcItems
|
||||
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
|
||||
messageBox = true,
|
||||
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
|
||||
onChoose = function(item, list)
|
||||
local def = game.data.items[item.value]
|
||||
if (def and def.keyItem) or item.value:find("^HM_") then
|
||||
|
||||
+11
-3
@@ -50,11 +50,13 @@ function PokedexMenu.new(game, opts)
|
||||
footer = Strings("SEEN %d OWNED %d", seen, owned),
|
||||
pageJump = true, -- Left/Right page jumps like the original
|
||||
onCancel = opts.onCancel, -- B returns to the start menu when opened from it
|
||||
onChoose = function(item)
|
||||
onChoose = function(item, dexList)
|
||||
if not item.value then return end
|
||||
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
|
||||
-- PokedexMenuItemsText); CRY keeps the side menu open like the
|
||||
-- original, QUIT returns to the list
|
||||
-- original. B is what returns to the list: HandlePokedexSideMenu
|
||||
-- hands back b=2 for B and b=1 for QUIT, and ShowPokedexMenu sends
|
||||
-- b=1 to .exitPokedex, so QUIT closes the whole Pokédex (#571)
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local entries = {
|
||||
@@ -91,7 +93,13 @@ function PokedexMenu.new(game, opts)
|
||||
or Strings("Printer error!\n%s", tostring(err))))
|
||||
end }
|
||||
end
|
||||
entries[#entries + 1] = { label = Strings("QUIT") }
|
||||
entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
|
||||
-- .exitPokedex: drop the dex list too and hand back to whoever
|
||||
-- opened it (the start menu, whose saved cursor is still on
|
||||
-- POKéDEX -- wBattleAndStartSavedMenuItem)
|
||||
dexList:close()
|
||||
if opts.onCancel then opts.onCancel() end
|
||||
end }
|
||||
game.stack:push(Menu.new(game, entries,
|
||||
{ tx = 12, ty = 8, tw = 8,
|
||||
th = #entries * 2 + 2 }))
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ local function buy(game, stock)
|
||||
list.footer = notEnough
|
||||
return
|
||||
end
|
||||
if not Bag.add(game.save, item.value, qty) then
|
||||
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||
list.footer = txt(game, "_PokemartItemBagFullText",
|
||||
Strings("You can't carry\nany more items."))
|
||||
return
|
||||
|
||||
@@ -109,34 +109,36 @@ function Editor.update(_dt)
|
||||
end
|
||||
|
||||
function Editor.draw()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
local fullW, fullH = love.graphics.getDimensions()
|
||||
local ox, oy, ww, wh = SafeArea.rect()
|
||||
local s = math.max(0.75, math.min(1.4, wh / 768))
|
||||
Editor.rects = {}
|
||||
|
||||
-- radial-ish navy field (two stacked fills; matches launcher atmosphere)
|
||||
col(PAL.bgBot)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
|
||||
col(PAL.bgTop, 0.85)
|
||||
love.graphics.circle("fill", ww * 0.5, wh * 0.15, math.max(ww, wh) * 0.55)
|
||||
love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
|
||||
|
||||
local pad = 18 * s
|
||||
local barH = 56 * s
|
||||
local btnH = 40 * s
|
||||
local btnW = 100 * s
|
||||
|
||||
-- top bar
|
||||
-- top bar (inside the safe area so it clears the notch / status bar)
|
||||
col(PAL.card, 0.92)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, barH + pad)
|
||||
love.graphics.rectangle("fill", 0, 0, fullW, oy + barH + pad)
|
||||
col(PAL.stroke, 0.35)
|
||||
love.graphics.setLineWidth(1)
|
||||
love.graphics.line(0, barH + pad, ww, barH + pad)
|
||||
love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
|
||||
|
||||
love.graphics.setFont(Editor.fonts.title)
|
||||
col(PAL.white)
|
||||
love.graphics.print("Touch Controls", pad, pad + 4 * s)
|
||||
love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
|
||||
|
||||
-- Done / Reset
|
||||
local done = { x = ww - pad - btnW, y = pad + (barH - btnH) / 2,
|
||||
local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
|
||||
w = btnW, h = btnH }
|
||||
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
|
||||
Editor.rects.done, Editor.rects.reset = done, reset
|
||||
@@ -156,9 +158,9 @@ function Editor.draw()
|
||||
chromeBtn(done, "Done", PAL.green)
|
||||
|
||||
-- enable toggle card
|
||||
local cardY = barH + pad + 14 * s
|
||||
local cardY = oy + barH + pad + 14 * s
|
||||
local cardH = 64 * s
|
||||
local cardX, cardW = pad, ww - 2 * pad
|
||||
local cardX, cardW = ox + pad, ww - 2 * pad
|
||||
col(PAL.card, 0.88)
|
||||
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
|
||||
col(PAL.stroke, 0.4)
|
||||
@@ -190,7 +192,7 @@ function Editor.draw()
|
||||
local hint = on
|
||||
and "Drag each button to reposition. Layout is saved when you tap Done."
|
||||
or "Controls are hidden in-game. Enable them to show and edit the layout."
|
||||
love.graphics.printf(hint, pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
|
||||
love.graphics.printf(hint, ox + pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
|
||||
|
||||
-- the overlay itself (preview mode; dimmed when disabled)
|
||||
TouchControls:draw()
|
||||
|
||||
@@ -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
|
||||
@@ -1803,7 +1810,7 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
if h.x == fx and h.y == fy then
|
||||
save.hiddenTaken = save.hiddenTaken or {}
|
||||
if save.hiddenTaken[key] then return false end
|
||||
if not require("src.inventory.Bag").add(save, h.item, 1) then
|
||||
if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then
|
||||
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
||||
return true
|
||||
end
|
||||
@@ -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))
|
||||
@@ -2423,7 +2441,7 @@ function OverworldState:talkTo(npc)
|
||||
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
|
||||
-- the string "0" as truthy, so screen it out and fall through to text.
|
||||
if d.item and d.item ~= "0" and d.item ~= 0 then
|
||||
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
|
||||
if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then
|
||||
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
|
||||
return
|
||||
end
|
||||
@@ -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,112 @@
|
||||
-- Driver: regression coverage for #601 "Wrong dialogue when interacting
|
||||
-- with Prof. Oak's last ball".
|
||||
--
|
||||
-- After the player picks a starter and the rival takes his, the leftover
|
||||
-- ball on the lab table must show "That's PROF.OAK's last Pokémon!" --
|
||||
-- pret/pokered scripts/OaksLab.asm OaksLabSelectedPokeBallScript jumps
|
||||
-- every ball handler to OaksLabLastMonScript once EVENT_GOT_STARTER is
|
||||
-- set (Oak turns to face the player first). The buggy port fell through
|
||||
-- to _OaksLabThoseArePokeBallsText ("Those are POKé BALLs...") instead.
|
||||
--
|
||||
-- Scenario A (the #601 regression): with a starter already picked, talk
|
||||
-- to the leftover ball -> Oak faces down, box says "last Pokémon!",
|
||||
-- and no starter offer/dex appears. Fails before the fix (the box
|
||||
-- says "Those are POKé BALLs").
|
||||
-- Scenario B (guard): with NO starter and not escorted in, the ball still
|
||||
-- says "Those are POKé BALLs". Passes before and after the fix.
|
||||
--
|
||||
-- Setup: flags are set directly (pick flow never runs), so all three
|
||||
-- balls stay visible; the player stands left of the Charmander ball
|
||||
-- (cell 6,3), the leftover slot for the Squirtle pick (rival took the
|
||||
-- Bulbasaur ball). The lab battle flag is set so the rival is gone and
|
||||
-- cannot intercept the talk. TextBox.new is hooked to capture the raw
|
||||
-- box text.
|
||||
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local origNew = TextBox.new
|
||||
local lastText
|
||||
TextBox.new = function(g, text, ...)
|
||||
lastText = text
|
||||
return origNew(g, text, ...)
|
||||
end
|
||||
|
||||
local function restore()
|
||||
TextBox.new = origNew
|
||||
end
|
||||
|
||||
local function setFlags(postPick)
|
||||
local flags = game.save.flags or {}
|
||||
game.save.flags = flags
|
||||
flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
|
||||
if postPick then
|
||||
flags.EVENT_GOT_STARTER = true
|
||||
flags.EVENT_CHOSE_SQUIRTLE = true
|
||||
else
|
||||
flags.EVENT_GOT_STARTER = nil
|
||||
end
|
||||
-- rival already fought + gone, so he cannot intercept the talk
|
||||
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
|
||||
end
|
||||
|
||||
-- Talk to the ball at cell 6,3 (Charmander slot): stand one cell left
|
||||
-- facing right and press A. Returns once a TextBox has been built.
|
||||
local function talkToBall()
|
||||
lastText = nil
|
||||
U.teleport(game, "OAKS_LAB", 5, 3, "right")
|
||||
U.wait(6)
|
||||
for _ = 1, 8 do
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 30 do
|
||||
if lastText then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
return lastText ~= nil
|
||||
end
|
||||
|
||||
-- ---- Scenario A: leftover ball after the pick
|
||||
setFlags(true)
|
||||
local aBoxOpened = talkToBall()
|
||||
U.wait(30) -- let the typewriter reveal the line
|
||||
U.shot(game, DIR .. "/a_last_ball.png")
|
||||
local aText = lastText or "<none>"
|
||||
local aPass = aBoxOpened
|
||||
and aText:find("last Pokémon!", 1, true) ~= nil
|
||||
and aText:find("Those are", 1, true) == nil
|
||||
U.log("SCENARIO A box:", aText)
|
||||
U.log("SCENARIO A", aPass and "PASS" or "FAIL")
|
||||
|
||||
-- close the box
|
||||
for _ = 1, 10 do
|
||||
if game.stack:top() == game.overworld then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
|
||||
-- ---- Scenario B: pre-escort ball text unchanged
|
||||
setFlags(false)
|
||||
local bBoxOpened = talkToBall()
|
||||
U.wait(30)
|
||||
U.shot(game, DIR .. "/b_pre_escort.png")
|
||||
local bText = lastText or "<none>"
|
||||
local bPass = bBoxOpened
|
||||
and bText:find("ThoseArePokeBalls", 1, true) ~= nil
|
||||
or bText:find("Those are", 1, true) ~= nil
|
||||
U.log("SCENARIO B box:", bText)
|
||||
U.log("SCENARIO B", bPass and "PASS" or "FAIL")
|
||||
|
||||
-- restore hooks before any assert so a failure can't leave them installed
|
||||
restore()
|
||||
|
||||
U.log("RESULT bug601", (aPass and bPass) and "PASS" or "FAIL")
|
||||
assert(aPass,
|
||||
"Leftover ball after the pick must say 'That's PROF.OAK's last "
|
||||
.. "Pokémon!' (no 'Those are POKé BALLs'); got: " .. aText)
|
||||
assert(bPass,
|
||||
"Pre-escort balls must keep the 'Those are POKé BALLs' line; got: "
|
||||
.. bText)
|
||||
end
|
||||
@@ -0,0 +1,262 @@
|
||||
-- Manual check of Yellow's two extra pic rips: PROF.OAK's own back pic in
|
||||
-- the Pallet catch scene (#557, LoadPlayerBackPic's BATTLE_TYPE_PIKACHU
|
||||
-- branch, pokeyellow engine/battle/core.asm:6384-6391) and the framed
|
||||
-- portrait TalkToPikachu raises (#561, each PikaPicAnimScript's own base
|
||||
-- frame, data/pikachu/pikachu_pic_animation.asm). Both are gated on files
|
||||
-- the importer writes, so the asset check comes before any staging. No
|
||||
-- POKEPORT_IDENTITY: a sandbox save dir carries no Yellow cache at all.
|
||||
-- POKEPORT_DRIVER=tests/drivers/oak_pikapic_bug557_bug561_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local PROF_BACK = "assets/generated/battle/profoakb.png"
|
||||
local OLDMAN_BACK = "assets/generated/battle/oldmanb.png"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local function idle()
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
|
||||
GameVersion.isYellow()) then
|
||||
U.log("Neither pic exists in Red or Blue. Re-run with")
|
||||
U.log("POKEPORT_VERSION=yellow.")
|
||||
idle()
|
||||
end
|
||||
|
||||
-- ---- the two rips, before anything is staged ----
|
||||
-- The importer writes these only when the manifest carries the symbol, and
|
||||
-- both call sites fall back silently when the file is absent, so a missing
|
||||
-- file looks on screen exactly like the bug being unfixed.
|
||||
local oakPicOk = love.filesystem.getInfo(PROF_BACK) ~= nil
|
||||
check("Oak's back pic is in the cache (" .. PROF_BACK .. ")", oakPicOk)
|
||||
if not oakPicOk then
|
||||
U.log("The importer skipped it: tools/rom_manifest_yellow.json has no")
|
||||
U.log("ProfOakPicBack entry, so RomExtractor's gate never fires and")
|
||||
U.log("Sprites.playerPath falls back to " .. OLDMAN_BACK .. ".")
|
||||
U.log("Re-importing will not help until the symbol is in the manifest.")
|
||||
end
|
||||
|
||||
local pikapicFound = {}
|
||||
for script = 1, 28 do
|
||||
local path = "assets/generated/pikachu/pikapic_" .. script .. ".png"
|
||||
if love.filesystem.getInfo(path) then
|
||||
pikapicFound[#pikapicFound + 1] = script
|
||||
end
|
||||
end
|
||||
local pikapicOk = #pikapicFound > 0
|
||||
check("the pikapic base frames are in the cache (assets/generated/pikachu/)",
|
||||
pikapicOk)
|
||||
if pikapicOk then
|
||||
U.log("found base frames for scripts:", table.concat(pikapicFound, " "))
|
||||
else
|
||||
U.log("The importer skipped all 28: the Pic_e4000..Pic_f0cf4 labels are")
|
||||
U.log("absent from tools/rom_manifest_yellow.json, so PikachuFollower")
|
||||
U.log("keeps standing the battle front pic in the frame, which is #561")
|
||||
U.log("unchanged. Not staging the talk: it cannot look right yet.")
|
||||
end
|
||||
U.log("tests/engine/yellow_oak_back_pikapic.lua asserts both symbol lists.")
|
||||
|
||||
-- ---- #557: the Pallet catch scene ----
|
||||
-- Same two calls data/scripts/story2.lua:255 makes once Oak has said
|
||||
-- "That was close!" Standing in Pallet Town first so the battle draws
|
||||
-- over a real map, as it does in play.
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "up")
|
||||
U.wait(10)
|
||||
game.save.player.name = "bryan"
|
||||
|
||||
local backPath = Sprites.playerPath(game.data, "back",
|
||||
{ kind = "battle", demo = true, oakDemo = true })
|
||||
U.log("the demo resolves its back pic to:", tostring(backPath))
|
||||
check("it is Oak's pic, not the old man's", backPath == PROF_BACK)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
||||
battle:makeOldManDemo("PROF.OAK")
|
||||
check("the thrower is named PROF.OAK", battle.demoName == "PROF.OAK")
|
||||
check("the battle asks for the BATTLE_TYPE_PIKACHU back pic",
|
||||
battle.oakDemo == true)
|
||||
|
||||
-- record what the scripted throw actually prints, so the name half can be
|
||||
-- judged without reading glyph codes off the canvas
|
||||
local said = {}
|
||||
local realSay = battle.say
|
||||
battle.say = function(self, text)
|
||||
said[#said + 1] = tostring(text)
|
||||
return realSay(self, text)
|
||||
end
|
||||
|
||||
game.stack:push(battle)
|
||||
U.wait(10)
|
||||
|
||||
-- clear the "Wild PIKACHU appeared!" box; the scripted cursor script only
|
||||
-- starts once the intro chrome is gone
|
||||
for _ = 1, 90 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
if not check("the demo battle reached its scripted menu",
|
||||
battle.phase == "menu") then
|
||||
U.log("phase is", tostring(battle.phase), "- the intro never cleared.")
|
||||
idle()
|
||||
end
|
||||
U.wait(20)
|
||||
if U.shot(game, SHOT_DIR .. "/bug557_back_pic.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug557_back_pic.png")
|
||||
end
|
||||
|
||||
-- The other half of the issue title, "referred to as Pikachu".
|
||||
-- DisplayBattleMenu's simulated branch puts BATTLE_MENU_TEMPLATE on screen
|
||||
-- and nothing else (data/text_boxes.asm:31 -- FIGHT/PKMN/ITEM/RUN from
|
||||
-- column 8), so no name is printed at all: there is no player mon in this
|
||||
-- battle to name. The classic layout ports that branch; WideBattle's menu
|
||||
-- has no demo case and prints "What will <battle.player.name> do?" over the
|
||||
-- hidden placeholder battler, which is a level 5 PIKACHU.
|
||||
local options = game.save.options or {}
|
||||
U.log("battle layout option:", tostring(options.battleLayout or "classic"))
|
||||
local wide = battle:isWideBattleLayout()
|
||||
check("the layout on screen leaves the scripted menu unnamed", not wide)
|
||||
if wide then
|
||||
U.log("The wide layout is on, so the menu reads \"What will PIKACHU do?\"")
|
||||
U.log("beside Oak's throw. Switch OPTIONS to the classic battle layout")
|
||||
U.log("and re-run to see the box the original draws.")
|
||||
end
|
||||
|
||||
-- the rest runs itself: 130 frames of cursor, the forced ITEM list, then
|
||||
-- the throw. Never tap through it -- the timing is the port of
|
||||
-- DisplayBattleMenu's simulated keystrokes.
|
||||
local function thrownLine()
|
||||
for _, text in ipairs(said) do
|
||||
if text:find("POK", 1, true) and text:find("PROF.OAK", 1, true) then
|
||||
return text
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
for _ = 1, 600 do
|
||||
if thrownLine() then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local line = thrownLine()
|
||||
check("the throw is announced under PROF.OAK's name", line ~= nil)
|
||||
if line then U.log("the box reads:", (line:gsub("\n", " / "))) end
|
||||
U.wait(45)
|
||||
if U.shot(game, SHOT_DIR .. "/bug557_throw.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug557_throw.png")
|
||||
end
|
||||
|
||||
if oakPicOk then
|
||||
U.log("Both #557 shots are taken. The back pic in the lower left is the")
|
||||
U.log("man throwing the ball: Oak in his lab coat, and the throw line")
|
||||
U.log("names PROF.OAK. The near miss is a back pic that reads as a")
|
||||
U.log("person and looks plausible while still being oldmanb.png --")
|
||||
U.log("compare against the catch tutorial north of Viridian, which")
|
||||
U.log("SHOULD be the old man and proves the demo path works at all.")
|
||||
else
|
||||
U.log("The shots are evidence of the FAIL, not of a fix: the back pic in")
|
||||
U.log("the lower left is the bald OLD MAN from the Route 5 tutorial,")
|
||||
U.log("exactly as #557 reported him. The throw line above it does name")
|
||||
U.log("PROF.OAK, so the thrower half is right and only the pic is stuck.")
|
||||
end
|
||||
|
||||
-- ---- #561: the framed portrait ----
|
||||
if not pikapicOk then
|
||||
U.log("Skipping the Pikachu portrait: see the FAIL above.")
|
||||
idle()
|
||||
end
|
||||
|
||||
for _ = 1, 300 do
|
||||
if game.stack:top() ~= battle then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(20)
|
||||
|
||||
-- ShouldPikachuSpawn (pokeyellow engine/pikachu/pikachu_follow.asm): the
|
||||
-- lab gift happened and a healthy Pikachu leads the party. Pallet's
|
||||
-- objects sit at (10,4), (3,8) and (11,14), so (10,8) and the road cells
|
||||
-- around it are clear.
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) }
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(12)
|
||||
|
||||
local ow = game.overworld
|
||||
local function follower()
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.pikachuFollower then return n end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local npc = follower()
|
||||
if not check("the follower spawned", npc ~= nil) then idle() end
|
||||
|
||||
-- one step then a turn back, so the follower is on the cell just vacated
|
||||
local DIRS = { { "down", 0, 1 }, { "up", 0, -1 },
|
||||
{ "left", -1, 0 }, { "right", 1, 0 } }
|
||||
local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local stepDir
|
||||
for _, d in ipairs(DIRS) do
|
||||
local cx, cy = ow.player.cellX + d[2], ow.player.cellY + d[3]
|
||||
if ow.map:inBounds(cx, cy) and ow.map:isWalkableCell(cx, cy)
|
||||
and not ow:npcAtCell(cx, cy) then
|
||||
stepDir = d[1]
|
||||
break
|
||||
end
|
||||
end
|
||||
if not check("a walkable neighbour to step into exists", stepDir ~= nil) then
|
||||
idle()
|
||||
end
|
||||
U.hold(game, stepDir, 24)
|
||||
U.wait(6)
|
||||
U.tap(game, OPPOSITE[stepDir])
|
||||
U.wait(6)
|
||||
|
||||
local function facingFollower()
|
||||
local fx, fy = ow.player:facingCell()
|
||||
return ow:npcAtCell(fx, fy) == npc
|
||||
end
|
||||
if not facingFollower() then
|
||||
for _, d in ipairs(DIRS) do
|
||||
if npc.cellX == ow.player.cellX + d[2]
|
||||
and npc.cellY == ow.player.cellY + d[3] then
|
||||
U.tap(game, d[1])
|
||||
U.wait(6)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
check("player is facing the follower", facingFollower())
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
local emote = ow.emote
|
||||
if not check("the A press raised a portrait", emote ~= nil and emote.pikaPic) then
|
||||
U.log("Nothing answered the press, which is #407 territory, not #561.")
|
||||
idle()
|
||||
end
|
||||
U.log("the frame is drawing:", tostring(emote.pikaPic))
|
||||
check("it is this script's own base frame, not the battle front pic",
|
||||
emote.pikaPic:find("pikachu/pikapic_", 1, true) ~= nil)
|
||||
if U.shot(game, SHOT_DIR .. "/bug561_portrait.png") then
|
||||
U.log("captured", SHOT_DIR .. "/bug561_portrait.png")
|
||||
end
|
||||
|
||||
U.log("The portrait box is up over the map now. Inside the 5x5 frame is a")
|
||||
U.log("posed Pikachu drawn for that emotion, not the battle front pic")
|
||||
U.log("standing to attention -- that stand-in is the screenshot on #561.")
|
||||
U.log("Face it and press A again for another emotion; the pose should")
|
||||
U.log("change with it. A frame that never changes between emotions is the")
|
||||
U.log("near miss: the path resolved but every script picked one base.")
|
||||
|
||||
idle()
|
||||
end
|
||||
@@ -2414,14 +2414,15 @@ local function sellItem(id)
|
||||
return sold
|
||||
end
|
||||
|
||||
-- Free up bag slots so `needed` NEW item kinds fit (Bag.CAPACITY is 20
|
||||
-- slots; a stack of an item already held costs nothing). This is why
|
||||
-- Free up bag slots so `needed` NEW item kinds fit (20 slots without a
|
||||
-- capacity mod; a stack of an item already held costs nothing). This is why
|
||||
-- every restock had been reporting "HYPER_POTION x0": the buy list
|
||||
-- opened, the quantity was set, the engine said "no room" -- and the run
|
||||
-- walked into the Mansion with FULL_HEALs but not one HP restore.
|
||||
local function freeBagSlots(needed, where)
|
||||
local used = #(G.save.bagOrder or {})
|
||||
local free = 20 - used
|
||||
local capacity = require("src.inventory.Bag").capacity(G.data)
|
||||
local free = capacity - used
|
||||
for _, id in ipairs(SELLABLE_JUNK) do
|
||||
if free >= needed then break end
|
||||
if ((G.save.inventory or {})[id] or 0) > 0 then
|
||||
@@ -2632,7 +2633,8 @@ function ops.shop(s, where)
|
||||
end
|
||||
end
|
||||
local used = #(G.save.bagOrder or {})
|
||||
if newKinds > 0 and 20 - used < newKinds then
|
||||
local capacity = require("src.inventory.Bag").capacity(G.data)
|
||||
if newKinds > 0 and capacity - used < newKinds then
|
||||
freeBagSlots(newKinds, where)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
-- Soft reset from inside a battle (#563): A+B+SELECT+START held together
|
||||
-- drops back to the title, the way _Joypad's `cp PAD_BUTTONS` (pokered
|
||||
-- engine/joypad.asm:6) reads the raw pad ahead of the wJoyIgnore masking
|
||||
-- that a battle turn leans on. Counting and touch-overlay halves are
|
||||
-- asserted in tests/engine/soft_reset_bug563.lua; this is the ear and eye
|
||||
-- half -- music cutting to the title theme, unsaved progress gone.
|
||||
-- POKEPORT_DRIVER=tests/drivers/soft_reset_bug563_test.lua POKEPORT_IDENTITY=bug563 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Input = require("src.core.Input")
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function check(label, ok)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
local CHORD = { "a", "b", "start", "select" }
|
||||
|
||||
-- The driver presses GB buttons by name, so it proves the chord is read
|
||||
-- but says nothing about which keys reach it. Print the live map for the
|
||||
-- hand-off instead of hard-coding key names into the notes.
|
||||
local function keysFor(btn)
|
||||
local keys = {}
|
||||
for key, action in pairs(Input.keyBindings or {}) do
|
||||
if action == btn then keys[#keys + 1] = key end
|
||||
end
|
||||
table.sort(keys)
|
||||
return table.concat(keys, " / ")
|
||||
end
|
||||
|
||||
U.log("#563 soft reset: machine checks")
|
||||
check("Input:softResetHeld exists", type(Input.softResetHeld) == "function")
|
||||
check("Input:softResetStep exists", type(Input.softResetStep) == "function")
|
||||
for _, btn in ipairs(CHORD) do
|
||||
local keys = keysFor(btn)
|
||||
check(btn:upper() .. " has a key bound (" .. keys .. ")", keys ~= "")
|
||||
end
|
||||
|
||||
local musicVol = game.save.options and game.save.options.musicVol
|
||||
if musicVol == 0 then
|
||||
U.log("musicVol is 0: the battle music cutting to the title theme is the")
|
||||
U.log("main thing to listen for, so raise it in OPTION before judging")
|
||||
else
|
||||
U.log("musicVol", tostring(musicVol),
|
||||
"-- the battle theme should stop dead and the title theme start")
|
||||
end
|
||||
|
||||
-- ---- a battle to reset out of -----------------------------------------
|
||||
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "SQUIRTLE", 30),
|
||||
Pokemon.new(game.data, "PIDGEOTTO", 28),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
|
||||
-- data/generated/maps.lua ROUTE_1: (5, 5) is open walkable ground. The
|
||||
-- battle is pushed straight in rather than encountered, so the cell only
|
||||
-- has to be somewhere the player can legally stand.
|
||||
local START = { x = 5, y = 5 }
|
||||
U.teleport(game, "ROUTE_1", START.x, START.y, "down")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if ow and not ow.map:isWalkableCell(START.x, START.y) then
|
||||
-- a map edit moved the path: take the nearest walkable cell instead
|
||||
local sx, sy
|
||||
for r = 1, 6 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
if not sx and ow.map:isWalkableCell(START.x + dx, START.y + dy) then
|
||||
sx, sy = START.x + dx, START.y + dy
|
||||
end
|
||||
end
|
||||
end
|
||||
if sx then break end
|
||||
end
|
||||
if sx then
|
||||
U.log(("(%d, %d) is blocked, standing on"):format(START.x, START.y), sx, sy)
|
||||
U.teleport(game, "ROUTE_1", sx, sy, "down")
|
||||
U.wait(10)
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
check("the overworld is up on ROUTE_1", ow ~= nil)
|
||||
|
||||
-- something to reset away from: a fight in progress and 2 unsaved mon
|
||||
local function enterBattle()
|
||||
local battle = BattleState.newWild(game, "RATTATA", 12)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
return battle
|
||||
end
|
||||
|
||||
local battle = enterBattle()
|
||||
check("the battle reached the screen", game.stack:top() == battle)
|
||||
check("battle screenshot reached disk",
|
||||
U.shot(game, DIR .. "/bug563_battle.png"))
|
||||
|
||||
-- ---- hold the chord ----------------------------------------------------
|
||||
|
||||
-- A real hold is one press edge and then sustained state, so queue the
|
||||
-- edge once and only keep `state` alive after that. Re-queueing every
|
||||
-- frame would feed the battle 40 A presses and run the fight away before
|
||||
-- the countdown ever finished. Stops the moment the title appears: the
|
||||
-- driver has no key-up, so a hold left running past the reset would press
|
||||
-- A on the title's first frame and start a new game by itself.
|
||||
local function holdChord(frames, extra)
|
||||
local buttons = { unpack(CHORD) }
|
||||
if extra then buttons[#buttons + 1] = extra end
|
||||
for frame = 1, frames do
|
||||
for _, btn in ipairs(buttons) do
|
||||
if frame == 1 then table.insert(game.input.pressQueue, btn) end
|
||||
game.input.state[btn] = true
|
||||
end
|
||||
coroutine.yield()
|
||||
local top = game.stack:top()
|
||||
if top and top.screenId == "TitleState" then break end
|
||||
end
|
||||
for _, btn in ipairs(buttons) do game.input.state[btn] = false end
|
||||
end
|
||||
|
||||
-- Control case first: `cp PAD_BUTTONS` is an equality, so the same four
|
||||
-- buttons plus a direction is not the combo however long it is held. If
|
||||
-- this one resets, the port is masking the read instead of comparing it.
|
||||
holdChord(40, "up")
|
||||
U.wait(5)
|
||||
check("four buttons plus UP for 40 frames does NOT reset",
|
||||
game.stack:top() == battle)
|
||||
|
||||
-- 16 polls is the countdown Init seeds hSoftReset with (home/init.asm:81);
|
||||
-- 60 frames leaves room for it without hiding an off-by-one, which the
|
||||
-- engine suite pins exactly.
|
||||
holdChord(60)
|
||||
U.wait(20)
|
||||
|
||||
local top = game.stack:top()
|
||||
check("the chord dropped the battle for the title",
|
||||
top ~= nil and top.screenId == "TitleState")
|
||||
check("the battle state is gone from the stack", top ~= battle)
|
||||
check("no button is left held into the title", not game.input:isDown("a"))
|
||||
check("title screenshot reached disk", U.shot(game, DIR .. "/bug563_title.png"))
|
||||
|
||||
-- the title must not act on the still-held buttons on its own first frames
|
||||
U.wait(60)
|
||||
check("the title is still up a second later, not a menu it auto-picked",
|
||||
game.stack:top() == top)
|
||||
|
||||
-- ---- hand off inside a fresh battle ------------------------------------
|
||||
|
||||
-- Handing the pad over needs a party again. A player who did this for
|
||||
-- real would be picking CONTINUE here and getting whatever was last saved,
|
||||
-- which is the point of the reset -- this is only the driver restocking.
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "SQUIRTLE", 30),
|
||||
Pokemon.new(game.data, "PIDGEOTTO", 28),
|
||||
}
|
||||
U.teleport(game, "ROUTE_1", START.x, START.y, "down")
|
||||
U.wait(10)
|
||||
local again = enterBattle()
|
||||
check("a second battle is up for the manual attempt", game.stack:top() == again)
|
||||
|
||||
U.log("The driver already did this once: /tmp/shots/bug563_battle.png is the")
|
||||
U.log("fight it was in, bug563_title.png is where it landed. There is a")
|
||||
U.log("fresh RATTATA battle on screen now for you to repeat it by hand.")
|
||||
U.log("Hold " .. keysFor("a") .. " (A) + " .. keysFor("b") .. " (B) + "
|
||||
.. keysFor("start") .. " (START) + " .. keysFor("select") .. " (SELECT)")
|
||||
U.log("together for about a third of a second. The battle theme should cut")
|
||||
U.log("out and the title come back with its own music; CONTINUE from there")
|
||||
U.log("gives you the last save, so the fight and anything after it is gone.")
|
||||
U.log("Add any arrow key to the four and nothing should happen at all. A")
|
||||
U.log("quick tap of all four in passing must not reset either, and letting")
|
||||
U.log("one go a beat early must start the count over rather than resume it.")
|
||||
U.log("Control case if it does nothing: QUIT in the START menu runs the same")
|
||||
U.log("return-to-title, so if QUIT works the pad read is what to look at.")
|
||||
U.log("For the on-screen overlay, re-run with POKEPORT_TOUCH=1: the mouse is")
|
||||
U.log("one finger there, so the combo cannot be reached at all on desktop.")
|
||||
U.log(("checks: %d passed, %d failed"):format(pass, fail))
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,223 @@
|
||||
-- Manual check of the two trainer-battle text boxes, #565 and #566.
|
||||
-- #565 _TrainerAboutToUseText (data/text/text_2.asm:911-921): the SHIFT
|
||||
-- offer is one box with a `cont`, so the nick scrolls in UNDER "about to
|
||||
-- use" instead of landing alone on a fresh page.
|
||||
-- #566 TrainerEndBattleText (home/trainers.asm:381-386) prints
|
||||
-- _TrainerNameText -- the name then ": " -- ahead of the loss line.
|
||||
-- The string half is asserted in tests/engine/trainer_shift_prompt_bug565.lua
|
||||
-- and tests/engine/trainer_loss_line_bug566.lua; this is the look of it.
|
||||
-- POKEPORT_DRIVER=tests/drivers/trainer_battle_text_bug565_bug566_test.lua POKEPORT_IDENTITY=bug565 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Font = require("src.render.Font")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
|
||||
-- pokered data/maps/objects/Route3.asm: ROUTE3_YOUNGSTER2 is object 3, at
|
||||
-- (14, 4), STAY, facing DOWN, OPP_YOUNGSTER party 1 (RATTATA + EKANS). Its
|
||||
-- TrainerHeader gives sight range 3, so the cone is (14,5)..(14,7); y = 7 is
|
||||
-- the ledge row and is solid, so the walk-in is east-to-west along y = 6.
|
||||
local MAP, MAP_LABEL = "ROUTE_3", "Route3"
|
||||
local NPC_NAME = "ROUTE3_YOUNGSTER2"
|
||||
local OBJ_INDEX = 3
|
||||
local START = { x = 16, y = 6, facing = "left" }
|
||||
|
||||
local fails = 0
|
||||
local function check(label, ok)
|
||||
if not ok then fails = fails + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Everything the two boxes are assembled from. A renamed text label, an
|
||||
-- opponent whose party shrank to one mon or a species constant that no
|
||||
-- longer resolves all show up on screen as exactly the silence the bugs
|
||||
-- did, so name them here before anyone squints at a screenshot.
|
||||
local header = game.data:trainerHeader(MAP_LABEL, OBJ_INDEX)
|
||||
check("Route3 object " .. OBJ_INDEX .. " has a trainer header", header ~= nil)
|
||||
local lossText = header and header.won and game.data.text[header.won]
|
||||
check("its EndBattleText resolves to a string",
|
||||
type(lossText) == "string" and lossText ~= "")
|
||||
|
||||
local trainer = game.data.trainers.OPP_YOUNGSTER
|
||||
check("OPP_YOUNGSTER is a known trainer class", trainer ~= nil)
|
||||
local party = trainer and trainer.parties[1]
|
||||
check("its party 1 has a reserve to send out (SHIFT needs one)",
|
||||
party ~= nil and #party >= 2)
|
||||
local secondName
|
||||
if party and party[2] then
|
||||
local species = game.data.pokemon[party[2].species]
|
||||
secondName = species and species.name
|
||||
end
|
||||
check("the reserve's species resolves to a name", secondName ~= nil)
|
||||
|
||||
local TAG = trainer and trainer.name
|
||||
local firstLine = TAG and lossText
|
||||
and (TAG .. ": " .. lossText:match("^[^\n\f\v]*"))
|
||||
if firstLine then
|
||||
-- the tag is prose the box has to hold: 18 columns, TextBox MAX_COLS
|
||||
local spans = Font.split(firstLine)
|
||||
check("the tagged first line fits the 18-column box (" .. #spans .. " cols)",
|
||||
Font.spansFitting(spans, 18 * 8) >= #spans)
|
||||
U.log("loss box should read:",
|
||||
((TAG .. ": " .. lossText):gsub("[\n\v]", " / "):gsub("\f", " || ")))
|
||||
end
|
||||
|
||||
-- SHIFT only offers the switch when the player holds more than one party
|
||||
-- slot and the active mon is alive (core.asm:1366-1443)
|
||||
game.save.player.name = "RED"
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 40),
|
||||
Pokemon.new(game.data, "BLASTOISE", 40),
|
||||
}
|
||||
game.save.party[1].moves[1] = { id = "SLASH", pp = 20 }
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.battleStyle = "shift"
|
||||
check("two party slots and SHIFT style are set",
|
||||
#game.save.party > 1 and game.save.options.battleStyle == "shift")
|
||||
check("the window is open and drawing",
|
||||
love.window and love.window.isOpen and love.window.isOpen())
|
||||
|
||||
if fails > 0 then
|
||||
U.log(fails .. " preflight check(s) failed; the boxes below cannot be trusted.")
|
||||
end
|
||||
|
||||
U.teleport(game, MAP, START.x, START.y, START.facing)
|
||||
U.wait(15)
|
||||
|
||||
local function findNpc()
|
||||
for _, n in ipairs((game.overworld and game.overworld.npcs) or {}) do
|
||||
if n.def and n.def.name == NPC_NAME then return n end
|
||||
end
|
||||
end
|
||||
local npc = findNpc()
|
||||
check("the YOUNGSTER is on the map", npc ~= nil)
|
||||
|
||||
-- A map edit that moves him invalidates the hand-derived walk-in, so fall
|
||||
-- back to standing next to wherever he ended up and talking: engageTrainer
|
||||
-- is the same call the line-of-sight approach makes.
|
||||
local talkedTo = false
|
||||
if npc and (npc.cellX ~= 14 or npc.cellY ~= 4) then
|
||||
local ow = game.overworld
|
||||
-- {dx, dy, facing to look back at him}
|
||||
for _, side in ipairs({ { 0, 1, "up" }, { 0, -1, "down" },
|
||||
{ 1, 0, "left" }, { -1, 0, "right" } }) do
|
||||
local cx, cy = npc.cellX + side[1], npc.cellY + side[2]
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("he has moved to", npc.cellX, npc.cellY, "- standing at", cx, cy)
|
||||
U.teleport(game, MAP, cx, cy, side[3])
|
||||
U.wait(10)
|
||||
talkedTo = true
|
||||
U.tap(game, "a")
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function battleNow()
|
||||
local top = game.stack:top()
|
||||
if type(top) == "table" and top.kind == "trainer" then return top end
|
||||
end
|
||||
|
||||
-- walk west into the sight cone; two cells, plus slack for the "!" bubble
|
||||
if not talkedTo then
|
||||
for _ = 1, 6 do
|
||||
if game.overworld and game.overworld.engaging then break end
|
||||
U.hold(game, "left", 18)
|
||||
U.wait(4)
|
||||
end
|
||||
end
|
||||
check("the trainer engaged",
|
||||
(game.overworld and game.overworld.engaging) or battleNow() ~= nil
|
||||
or talkedTo)
|
||||
|
||||
-- Play it out. Nothing here is fast-forwarded: the taps are A presses at
|
||||
-- the same cadence a player makes them, and the loop stops on the row
|
||||
-- under test before it can press the box away.
|
||||
local function currentRow()
|
||||
local b = battleNow()
|
||||
local row = b and b.current
|
||||
return b, row and row.text
|
||||
end
|
||||
local function pump(pred, maxFrames)
|
||||
local cool = 0
|
||||
for _ = 1, maxFrames or 4000 do
|
||||
if pred() then return true end
|
||||
if cool > 0 then
|
||||
cool = cool - 1
|
||||
U.wait(1)
|
||||
else
|
||||
U.tap(game, "a")
|
||||
local b = battleNow()
|
||||
cool = (b and (b.phase == "menu" or b.phase == "moveSelect")) and 8 or 5
|
||||
end
|
||||
end
|
||||
return pred()
|
||||
end
|
||||
|
||||
local ok = pump(function()
|
||||
local _, text = currentRow()
|
||||
return text ~= nil and text:find("about to use", 1, true) ~= nil
|
||||
end)
|
||||
check("the SHIFT offer box came up after the first KO", ok)
|
||||
|
||||
-- Hold at the `cont`: the box has typed "X is" / "about to use" and is
|
||||
-- blinking the arrow, waiting for A before it scrolls (ContText).
|
||||
pump(function()
|
||||
local b = currentRow()
|
||||
return b and b.msgWaiting
|
||||
end, 400)
|
||||
U.shot(game, SHOT_DIR .. "/bug565_1_cont_wait.png")
|
||||
local b = battleNow()
|
||||
if b and b.current then
|
||||
U.log("offer box holds:", (b.current.text:gsub("\n", " / "):gsub("\v", " CONT ")))
|
||||
end
|
||||
U.tap(game, "a")
|
||||
|
||||
-- and after the scroll: "about to use" on top, the nick beneath it
|
||||
pump(function()
|
||||
local bb = battleNow()
|
||||
return bb and (bb.charIndex or 0) >= (bb.total or 1) and not bb.msgWaiting
|
||||
end, 400)
|
||||
U.shot(game, SHOT_DIR .. "/bug565_2_scrolled.png")
|
||||
|
||||
-- decline the switch (B is NO, ChoiceBox) and finish the fight
|
||||
pump(function() return getmetatable(game.stack:top()) == ChoiceBox end, 600)
|
||||
U.wait(10)
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
|
||||
local tagged = TAG and (TAG .. ": ") or "YOUNGSTER: "
|
||||
ok = pump(function()
|
||||
local _, text = currentRow()
|
||||
return text ~= nil and text:sub(1, #tagged) == tagged
|
||||
end)
|
||||
check("the loss line came up tagged with the trainer class", ok)
|
||||
pump(function()
|
||||
local bb = battleNow()
|
||||
return bb and bb.msgPrompt
|
||||
end, 400)
|
||||
U.shot(game, SHOT_DIR .. "/bug566_loss_line.png")
|
||||
local lb = battleNow()
|
||||
if lb and lb.current then
|
||||
U.log("loss box holds:", (lb.current.text:gsub("\n", " / ")))
|
||||
end
|
||||
|
||||
U.log("Shots are in " .. SHOT_DIR .. ".")
|
||||
U.log("bug565_1 should read \"YOUNGSTER is\" / \"about to use\" with the")
|
||||
U.log("blinking arrow; bug565_2 the same box after the scroll, \"about to")
|
||||
U.log("use\" over \"EKANS!\". The near miss is a box whose two lines are")
|
||||
U.log("\"about to use\" and nothing, or a page holding only \"EKANS!\".")
|
||||
U.log("bug566 should read \"YOUNGSTER: I don't\" / \"believe it!\" -- the")
|
||||
U.log("colon and the class, not a bare \"I don't believe it!\".")
|
||||
U.log("The prize line follows it; the tag never repeats on that one.")
|
||||
U.log("A is yours now: press through the money line back to the route.")
|
||||
U.log("ROUTE3_YOUNGSTER1 at (10,6) has three mons, so the SHIFT box comes")
|
||||
U.log("up twice. ROUTE3_YOUNGSTER4 at (22,9) is the control: one mon, so")
|
||||
U.log("he must show the tagged loss line and no SHIFT offer at all.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -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,129 @@
|
||||
-- Regression coverage for #601 "Wrong dialogue when interacting with Prof.
|
||||
-- Oak's last ball" (T2, ROM-free).
|
||||
--
|
||||
-- pret/pokered scripts/OaksLab.asm OaksLabSelectedPokeBallScript: once
|
||||
-- EVENT_GOT_STARTER is set, EVERY ball's text handler jumps to
|
||||
-- OaksLabLastMonScript -- Oak turns to face the player and reads
|
||||
-- "_OaksLabLastMonText" ("That's PROF.OAK's last #MON!") instead of
|
||||
-- re-offering the starter. The buggy port fell through to
|
||||
-- _OaksLabThoseArePokeBallsText ("Those are POKé BALLs...") on every ball
|
||||
-- once a starter had been picked. The fix also spells the ROM's "#MON"
|
||||
-- ligature out as "Pokémon".
|
||||
--
|
||||
-- The three ball scripts share one table (starterBall), so this suite
|
||||
-- drives that table through a mini ScriptRunner-compatible executor:
|
||||
-- flag checks, jumps, "end" halts and text rows are executed, UI-heavy
|
||||
-- commands (push_screen, ask, give_pokemon, npc moves) are no-ops with
|
||||
-- ask recording the offer text. It then asserts the whole flow:
|
||||
-- * GOT_STARTER + talk -> Oak faces down + "last Pokémon!" line, ends
|
||||
-- * no GOT_STARTER, not escorted in -> "Those are POKé BALLs"
|
||||
-- * no GOT_STARTER, escorted in -> the dex/ask offer (unchanged path)
|
||||
-- plus MapScripts.validateContribution stays clean (the pre-fix table
|
||||
-- carried nine out-of-range "jump 21" findings -- its run-time "end").
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
|
||||
local contribution = dofile("data/scripts/oaks_lab.lua")
|
||||
local problems = MapScripts.validateContribution(contribution)
|
||||
T.eq(#problems, 0, "oaks_lab contribution validates cleanly")
|
||||
for _, p in ipairs(problems) do
|
||||
T.check(false, "unexpected finding: " .. p)
|
||||
end
|
||||
|
||||
local BALL = "TEXT_OAKSLAB_CHARMANDER_POKE_BALL"
|
||||
|
||||
-- ---- mini executor over the talk rows (ScriptRunner semantics: a jump
|
||||
-- command returns the next row index or "end" to halt)
|
||||
|
||||
local function run(script, flags, answer)
|
||||
local pc, texts, offers = 1, {}, {}
|
||||
local lastCheck = nil
|
||||
while pc <= #script do
|
||||
local row = script[pc]
|
||||
local verb = row[1]
|
||||
if verb == "check_flag" then
|
||||
lastCheck = flags[row[2]] == true
|
||||
elseif verb == "jump_if_true" then
|
||||
if lastCheck then
|
||||
if row[2] == "end" then break end
|
||||
pc = row[2] goto next
|
||||
end
|
||||
elseif verb == "jump_if_false" then
|
||||
if not lastCheck then
|
||||
if row[2] == "end" then break end
|
||||
pc = row[2] goto next
|
||||
end
|
||||
elseif verb == "jump" then
|
||||
if row[2] == "end" then break end
|
||||
pc = row[2]
|
||||
goto next
|
||||
elseif verb == "show_text" then
|
||||
texts[#texts + 1] = row[2]
|
||||
elseif verb == "ask" then
|
||||
offers[#offers + 1] = row[2]
|
||||
if answer == false then
|
||||
pc = pc + 1 -- decline: the next row's jump_if_false decides
|
||||
goto next
|
||||
end
|
||||
end
|
||||
-- push_screen / give_pokemon / set_flag / hide_object / move_npc_to /
|
||||
-- face_object: no-op here (set_flag is exercised via the fixture
|
||||
-- flags table instead of being run)
|
||||
pc = pc + 1
|
||||
::next::
|
||||
end
|
||||
return texts, offers
|
||||
end
|
||||
|
||||
local function concat(list)
|
||||
return table.concat(list, "\n")
|
||||
end
|
||||
|
||||
-- ---- leftover ball after the pick: Oak faces down + the last-mon line
|
||||
local got = { EVENT_GOT_STARTER = true, EVENT_FOLLOWED_OAK_INTO_LAB = true }
|
||||
local texts, offers = run(contribution.talk[BALL], got, true)
|
||||
T.eq(#offers, 0, "no starter offer after the pick")
|
||||
local box = concat(texts)
|
||||
T.check(box:find("last Pokémon!", 1, true) ~= nil,
|
||||
"leftover ball says the last-mon line (got: " .. box .. ")")
|
||||
T.check(box:find("Those are", 1, true) == nil,
|
||||
"leftover ball no longer says 'Those are POKé BALLs'")
|
||||
T.check(box:find("#MON", 1, true) == nil,
|
||||
"the ROM #MON ligature is spelled out as Pokémon")
|
||||
|
||||
-- the pokered beat also turns Oak to face the player
|
||||
T.check(contribution.talk[BALL][20][1] == "face_object"
|
||||
and contribution.talk[BALL][20][2] == 5
|
||||
and contribution.talk[BALL][20][3] == "down",
|
||||
"row 20 faces Oak down before the line (OaksLabLastMonScript)")
|
||||
|
||||
-- ---- pre-escort: still the vanilla "Those are POKé BALLs" line
|
||||
local pre = { EVENT_GOT_STARTER = false, EVENT_FOLLOWED_OAK_INTO_LAB = false }
|
||||
local t2, o2 = run(contribution.talk[BALL], pre, true)
|
||||
T.eq(#o2, 0, "no offer before Oak escorts the player in")
|
||||
T.check(concat(t2):find("ThoseArePokeBalls", 1, true) ~= nil,
|
||||
"pre-escort balls keep the 'Those are POKé BALLs' line")
|
||||
|
||||
-- ---- escorted in but no pick yet: the dex + "You want X?" offer
|
||||
local mid = { EVENT_GOT_STARTER = false, EVENT_FOLLOWED_OAK_INTO_LAB = true }
|
||||
local t3, o3 = run(contribution.talk[BALL], mid, true)
|
||||
T.eq(#o3, 1, "the starter offer still runs before the pick")
|
||||
T.check(concat(t3):find("last Pokémon!", 1, true) == nil,
|
||||
"no last-mon line before the pick")
|
||||
|
||||
-- ---- all three balls share the same table shape (last-mon beat present)
|
||||
for _, key in ipairs({
|
||||
"TEXT_OAKSLAB_CHARMANDER_POKE_BALL",
|
||||
"TEXT_OAKSLAB_SQUIRTLE_POKE_BALL",
|
||||
"TEXT_OAKSLAB_BULBASAUR_POKE_BALL",
|
||||
}) do
|
||||
local script = contribution.talk[key]
|
||||
T.check(script and script[21] and script[21][2] and
|
||||
script[21][2]:find("Pokémon", 1, true) ~= nil,
|
||||
key .. " carries the last-mon line")
|
||||
end
|
||||
|
||||
T.finish("oaks_lab_last_ball_bug601")
|
||||
@@ -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")
|
||||
@@ -203,4 +203,12 @@ stub.mouse = {
|
||||
|
||||
stub.timer = { getTime = function() return 0 end }
|
||||
|
||||
-- Desktop / headless: full-window safe area (matches LÖVE's fallback).
|
||||
stub.window = {
|
||||
getSafeArea = function()
|
||||
local ww, wh = stub.graphics.getDimensions()
|
||||
return 0, 0, ww, wh
|
||||
end,
|
||||
}
|
||||
|
||||
return stub
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user