Compare commits

...

7 Commits

Author SHA1 Message Date
bryanthaboi 9ceec3070d Merge pull request #742 from ShaneMcGovernIE/speed-triggers
Cycle game speed from the analog triggers too (L2/R2)
2026-08-03 16:13:27 -04:00
github-actions 32fa215243 chore(ios): update app-repo.json [skip ci] 2026-08-03 13:15:01 -04:00
bryanthaboi 3aec2ca89b Merge pull request #746 from bryanthaboi/dev
scrolling and bugs
2026-08-03 13:08:44 -04:00
bryanthaboi 0f7261dd92 CLOSES #623, CLOSES #624, CLOSES #636, CLOSES #637, CLOSES #639, CLOSES #650, CLOSES #697, CLOSES #704, CLOSES #722 2026-08-03 13:05:57 -04:00
bryanthaboi 5c041ea857 snip for scroll 2026-08-03 12:39:36 -04:00
github-actions a528868656 chore(ios): update app-repo.json [skip ci] 2026-08-03 12:30:20 -04:00
Shane McGovern a3cd5a132e Cycle game speed from the analog triggers too (L2/R2) 2026-08-03 17:19:53 +01:00
28 changed files with 2079 additions and 205 deletions
+67 -24
View File
@@ -766,6 +766,28 @@ local function silphRocketsLeave(game, ow, onlyMap)
end
end
-- SilphCo11FGiovanniAfterBattleScript (scripts/SilphCo11F.asm) is the whole
-- aftermath: DisplayTextID TEXT_SILPHCO11F_GIOVANNI_YOU_RUINED_OUR_PLANS,
-- GBFadeOutToBlack, SilphCo11FTeamRocketLeavesScript, Delay3,
-- GBFadeInFromBlack, then SetEvent. The port had only the hide pass, so the
-- speech never played and every rocket blinked out in front of the player
-- (#722). Same hide list as silphRocketsLeave, spelled as script rows so the
-- fade can hold over it.
local function silphAftermathRows()
local rows = {
{ "show_text", "_SilphCo11FGiovanniYouRuinedOurPlansText" },
{ "fade", "out" },
}
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
for _, name in ipairs(floor[2]) do
rows[#rows + 1] = { "hide_object", floor[1], name }
end
end
rows[#rows + 1] = { "wait", 3 } -- Delay3
rows[#rows + 1] = { "fade", "in" }
return rows
end
M.SILPH_CO_11F = {
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
@@ -792,11 +814,15 @@ M.SILPH_CO_11F = {
ow:scriptMove(gio, "down", 3, function()
gio:facePlayer(ow.player)
ow:engageTrainer(gio, function()
-- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves
-- after the loss (the street rockets are handled by
-- M.SAFFRON_CITY.onEnter in story4.lua).
-- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech,
-- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph
-- rocket leaves off-screen (the street rockets are handled by
-- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the
-- battle's own callbacks are still unwinding, so queueScript starts
-- it on the first idle overworld frame -- after the end-battle
-- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722).
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
silphRocketsLeave(game, ow)
ow:queueScript(silphAftermathRows())
end
end)
end)
@@ -981,7 +1007,11 @@ M.VICTORY_ROAD_3F = {
local championsRoomRivalScript = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 26 }, -- 3 past end
-- "end" rather than a row number past the tail: this script grew by a row
-- when the follow-Oak walk landed (#704), which silently turned the old
-- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past
-- it, so a returning champion warped straight into the induction.
{ "jump_if_true", "end" }, -- 3
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
@@ -989,37 +1019,50 @@ local championsRoomRivalScript = {
-- continuous into the fight
{ "play_music", "Music_FinalBattle" }, -- 5
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 6
{ "jump_if_false", 26 }, -- 6 past end
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- losing halts here; the numeric target this replaced pointed at the
-- closing warp, which inducted a player who had just lost the fight (#704)
{ "jump_if_false", "end" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 8
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 9
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
-- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in
{ "play_music", "Music_Cities1", { keep = true } }, -- 10
{ "show_text", "_ChampionsRoomOakText" }, -- 11
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12
{ "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement
{ "play_music", "Music_Cities1", { keep = true } }, -- 11
{ "show_text", "_ChampionsRoomOakText" }, -- 12
{ "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 13
{ "move_npc", 2, "up", 5 }, -- 14 OakEntranceAfterVictoryMovement
-- OakCongratulatesPlayerScript: rival faces left, Oak faces down
{ "face_object", 1, "left" }, -- 14
{ "face_object", 2, "down" }, -- 15
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16
{ "face_object", 1, "left" }, -- 15
{ "face_object", 2, "down" }, -- 16
{ "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 17
-- OakDisappointedWithRivalScript: Oak turns to the rival (right)
{ "face_object", 2, "right" }, -- 17
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18
{ "face_object", 2, "right" }, -- 18
{ "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 19
-- OakComeWithMeScript: Oak faces down again, then exits up
{ "face_object", 2, "down" }, -- 19
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20
{ "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22
{ "face_object", 2, "down" }, -- 20
{ "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 21
{ "move_npc", 2, "up", 2 }, -- 22 OakExitChampionsRoomMovement
{ "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 23
-- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement
-- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the
-- screen just fading on the spot (#704). The entrance walk leaves the
-- player at (4,3) and both north-wall warps sit on row 0, so the original
-- only ever spends three of those simulated steps -- CheckWarpsNoCollision
-- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the
-- trailing UP/LEFT are dropped. Scripted steps ignore collision here just
-- as they do in the original (CollisionCheckOnLand skips its checks while
-- wSimulatedJoypadStatesIndex is non-zero), so stepping through the
-- rival's cell at (4,2) is the ported behavior, not a clip.
{ "move_player", "up", 3 }, -- 24
-- hand the induction off to the HALL_OF_FAME room (consumed by its
-- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up)
{ "set_field", "pendingHallOfFame", true }, -- 23
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24
{ "set_field", "pendingHallOfFame", true }, -- 25
{ "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 26
}
M.CHAMPIONS_ROOM = {
+206 -80
View File
@@ -549,21 +549,57 @@ M.GAME_CORNER = {
-- 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 Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local t = game.data.text
local function line(suffix, fallback)
return t["_GameCornerClerk1" .. suffix]
or t["_GameCornerClerk" .. suffix]
or fallback
end
-- GameCornerDrawCoinBox (scripts/GameCorner.asm; pokeyellow's copy is
-- identical): TextBoxBorder at hlcoord 11,0 with b=5 c=7, a 9x7-tile
-- window in the top right holding MONEY at (12,2) over the amount on
-- row 3 and COIN at (12,4) over the count on row 5. Both
-- PrintBCDNumber calls pass LEADING_ZEROES, whose bit 7 SUPPRESSES
-- leading zeroes (home/print_bcd.asm), and neither passes LEFT_ALIGN,
-- so both numbers read plain and right-aligned against the inner edge
-- at column 18. The asm draws the box before the offer and redraws it
-- after the purchase, so it stands for the whole exchange: a draw-only
-- state under the dialogue gets that lifetime, since StateStack draws
-- every state above the last opaque one and updates only the top
-- (src/core/StateStack.lua), and reading save each frame is the
-- redraw (#624).
local coinBox = { draw = function()
Font.drawBox(11, 0, 9, 7)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("MONEY"), 96, 16)
local money = ("¥%d"):format(game.save.money or 0)
Font.draw(money, 152 - Font.width(money), 24)
Font.draw(Strings("COIN"), 96, 32)
local coins = ("%d"):format(game.save.coins or 0)
Font.draw(coins, 152 - Font.width(coins), 40)
love.graphics.setColor(1, 1, 1, 1)
end }
game.stack:push(coinBox)
-- Every branch below finishes here. A TextBox pops itself before its
-- onDone runs, so the coin box is top of the stack again by then and
-- this pop takes it down, never someone else's state.
local function finish()
game.stack:pop()
done()
end
-- YesNoChoice is called with the offer still printed, so the prompt
-- has to ride the open text box (opts.choice) instead of being pushed
-- after it closes, which is what made the question vanish (#624).
game.stack:push(TextBox.new(game,
line("DoYouNeedSomeGameCoinsText",
"Do you need some\ngame coins?\f¥1000 for 50."), function()
game.stack:push(ChoiceBox.new(game, function(yes)
"Do you need some\ngame coins?\f¥1000 for 50."),
nil, { choice = function(yes)
if not yes then
game.stack:push(TextBox.new(game,
line("PleaseComePlaySometimeText",
"No? Please come\nplay sometime!"), done))
"No? Please come\nplay sometime!"), finish))
return
end
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
@@ -571,29 +607,30 @@ M.GAME_CORNER = {
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
line("DontHaveCoinCaseText",
"You don't have a\nCOIN CASE!"), done))
"You don't have a\nCOIN CASE!"), finish))
return
end
if (game.save.coins or 0) >= 9990 then
game.stack:push(TextBox.new(game,
line("CoinCaseIsFullText",
"Oops! Your COIN\nCASE is full."), done))
"Oops! Your COIN\nCASE is full."), finish))
return
end
if game.save.money < 1000 then
game.stack:push(TextBox.new(game,
line("CantAffordTheCoinsText",
"You can't afford\nthe coins!"), done))
"You can't afford\nthe coins!"), finish))
return
end
game.save.money = game.save.money - 1000
game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
-- the thanks text is the plain _GameCornerClerk1ThanksHereAre50-
-- CoinsText; the new count belongs in the coin box the asm
-- redraws here, not appended to the line (#624)
game.stack:push(TextBox.new(game,
line("ThanksHereAre50CoinsText",
"Thanks! Here are\nyour 50 coins!")
.. ("\fCOINS: %d"):format(game.save.coins), done))
end))
end))
"Thanks! Here are\nyour 50 coins!"), finish))
end }))
end,
},
}
@@ -606,99 +643,188 @@ 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.
-- Each counter owns ONE window of three prizes, not the whole catalogue:
-- GetPrizeMenuId (engine/events/prize_menu.asm) subtracts
-- TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 from hTextID and indexes
-- PrizeDifferentMenuPtrs with the result, so vendor 1 sells
-- PrizeMenuMon1Entries, vendor 2 PrizeMenuMon2Entries and vendor 3
-- PrizeMenuTMsEntries (#623). The mon windows and their levels differ per
-- version; the TM window is identical in all three, so it is shared.
local PRIZE_TMS = {
{ kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 },
{ kind = "item", item = "TM_HYPER_BEAM", cost = 5500 },
{ kind = "item", item = "TM_SUBSTITUTE", cost = 7700 },
}
local RED_PRIZES = {
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
local RED_PRIZE_WINDOWS = {
{
{ kind = "mon", species = "ABRA", level = 9, cost = 180 },
{ kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 },
{ kind = "mon", species = "NIDORINA", level = 17, cost = 1200 },
},
{
{ kind = "mon", species = "DRATINI", level = 18, cost = 2800 },
{ kind = "mon", species = "SCYTHER", level = 25, cost = 5500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
},
PRIZE_TMS,
}
local BLUE_PRIZES = {
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3],
local BLUE_PRIZE_WINDOWS = {
{
{ kind = "mon", species = "ABRA", level = 6, cost = 120 },
{ kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 },
{ kind = "mon", species = "NIDORINO", level = 17, cost = 1200 },
},
{
{ kind = "mon", species = "PINSIR", level = 20, cost = 2500 },
{ kind = "mon", species = "DRATINI", level = 24, cost = 4600 },
{ kind = "mon", species = "PORYGON", level = 18, cost = 6500 },
},
PRIZE_TMS,
}
-- Yellow keeps the three windows but restocks both mon counters
-- (pokeyellow/data/events/prizes.asm, prize_mon_levels.asm)
local YELLOW_PRIZE_WINDOWS = {
{
{ kind = "mon", species = "ABRA", level = 15, cost = 230 },
{ kind = "mon", species = "VULPIX", level = 18, cost = 1000 },
{ kind = "mon", species = "WIGGLYTUFF", level = 22, cost = 2680 },
},
{
{ kind = "mon", species = "SCYTHER", level = 30, cost = 6500 },
{ kind = "mon", species = "PINSIR", level = 30, cost = 6500 },
{ kind = "mon", species = "PORYGON", level = 26, cost = 9999 },
},
PRIZE_TMS,
}
local function activePrizes()
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
local function prizeWindow(n)
local GameVersion = require("src.core.GameVersion")
local windows = RED_PRIZE_WINDOWS
if GameVersion.isBlue() then
windows = BLUE_PRIZE_WINDOWS
elseif GameVersion.isYellow() then
windows = YELLOW_PRIZE_WINDOWS
end
return windows[n]
end
-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize
-- Prize counters (engine/events/prize_menu.asm CeladonPrizeMenu; the prize
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
-- with no case prints RequireCoinCaseText and returns without ever opening a
-- window; only with the case does it print ExchangeCoinsForPrizesText and then
-- show the prizes. #194: the port used to open the window unconditionally and
-- skip both text boxes.
local function prizeCounter(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands")
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- IsItemInBag COIN_CASE: without the case, deny and open no window
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
if not game.save.inventory.COIN_CASE then
-- skip both text boxes. wMaxMenuItem is 3, i.e. this window's three prizes
-- plus the NO THANKS row, and HandlePrizeChoice confirms the pick with
-- SoYouWantPrizeText + YesNoChoice before any coins move; every branch then
-- rets out of CeladonPrizeMenu, so one transaction ends the conversation and
-- buying again means talking to the counter again (#623).
local function prizeCounter(window)
return function(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands")
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- IsItemInBag COIN_CASE: without the case, deny and open no window
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
return
end
-- ExchangeCoinsForPrizesText plays before the prize window opens.
game.stack:push(TextBox.new(game,
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
return
end
-- ExchangeCoinsForPrizesText plays before the prize window opens.
game.stack:push(TextBox.new(game,
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
function()
local items = {}
for _, p in ipairs(activePrizes()) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
function()
local items = {}
for _, p in ipairs(prizeWindow(window)) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
end
table.insert(items,
{ label = label, right = tostring(p.cost), value = p })
end
table.insert(items,
{ label = label, right = tostring(p.cost), value = p })
end
local list
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
-- NoThanksText (data/events/prizes.asm) sits under the three prizes
table.insert(items, { label = "NO THANKS" })
local list
-- close the window first: every ending in HandlePrizeChoice leaves
-- the menu for good, and the closing line belongs over the map
local function finish(msg)
list:close()
game.stack:push(TextBox.new(game, msg, done))
end
local function buy(p)
if (game.save.coins or 0) < p.cost then
list.footer = "Not enough coins!"
finish(t._SorryNeedMoreCoinsText or "Sorry, you need\nmore coins.")
return
end
-- HasEnoughCoins passed, so hand the prize over first and only
-- subtract once it landed: the asm rets before .subtractCoins when
-- the bag is full, or when both the party and every box are full
local roomless = t._OopsYouDontHaveEnoughRoomText
or "Oops! You don't\nhave enough room."
if p.kind == "mon" then
-- no runner here, so give_pokemon reports through ctx.lastCheck
-- and skips the AskName prompt (Commands.give_pokemon)
local ctx = { save = game.save, game = game }
Commands.give_pokemon(ctx, p.species, p.level)
if not ctx.lastCheck then
finish(roomless)
return
end
elseif not require("src.inventory.Bag").add(
game.save, p.item, 1, game.data) then
finish(roomless)
return
end
game.save.coins = game.save.coins - p.cost
if p.kind == "mon" then
Commands.give_pokemon({ save = game.save, game = game },
p.species, p.level)
else
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
end
list.footer = ("Got it! COINS %d"):format(game.save.coins)
end,
onCancel = done,
})
game.stack:push(list)
end))
-- no thank-you line: HereYouGoText is unreferenced in the asm,
-- which just redraws the coin box (PrintPrizePrice) and returns
list:close()
done()
end
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
if not p then -- NO THANKS is the B exit (cp 3 -> .noChoice)
list:close()
done()
return
end
local name = (p.kind == "mon")
and game.data.pokemon[p.species].name
or game.data.items[p.item].name
-- SoYouWantPrizeText names the prize out of wNameBuffer, which
-- is not one of TextBox's RAM tokens, so fill it in here
local ask = (t._SoYouWantPrizeText
or "So, you want\n{RAM:wNameBuffer}?")
:gsub("{RAM:wNameBuffer}", name)
game.stack:push(TextBox.new(game, ask, nil, {
choice = function(yes)
if not yes then
finish(t._OhFineThenText or "Oh, fine then.")
return
end
buy(p)
end,
}))
end,
onCancel = done,
})
game.stack:push(list)
end))
end
end
M.GAME_CORNER_PRIZE_ROOM = {
talk = { -- the three prize counters are bg events
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter,
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter,
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter,
talk = { -- the three prize counters are bg events, one window each
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter(1),
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter(2),
TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter(3),
},
}
+10 -2
View File
@@ -108,8 +108,16 @@ return {
"_ViridianGymGiovanniTM27ExplanationText",
} },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift.
-- SilphCo11FGiovanniStartBattleScript (scripts/SilphCo11F.asm) hands the
-- battle SilphCo10FGiovanniILostAgainText through SaveEndBattleTextPointers,
-- but he has no def_trainers header on 11F, so engageTrainer finds no
-- header.won to give it -- this chain is the port's stand-in for that loss
-- line (#722). The "Blast it all!" speech, the fade and the rockets
-- leaving are SilphCo11FGiovanniAfterBattleScript, ported in M.SILPH_CO_11F
-- (data/scripts/story.lua).
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI",
dialogue = { "_SilphCo10FGiovanniILostAgainText" } },
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
+8 -2
View File
@@ -24,7 +24,8 @@
-- ViridianCityPostInitialCatchTraining): stepping into (19,9) -- the gap
-- east of the sleeper's cell -- faces the old man right and the player
-- left, prints the apology, and without any choice runs the demo battle
-- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5). After it, the same text pointer
-- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5), which he FAILS -- the ball shakes
-- three times and breaks open. After it, the same text pointer
-- now prints _ViridianCityOldManLosingMyTouchText ("That didn't work!
-- I must be losing my touch."), the old man walks off (down 6 with the
-- player on (19,9), right 1 otherwise, Pikachu nudged out of the way
@@ -63,7 +64,12 @@ end
local function oldMan2Rows(game, ow, npc)
local rows = {
{ "show_text", "_ViridianCityOldManHadMyCoffeeNowText" },
{ "old_man_demo" },
-- ViridianCityOldManInitialCatchTrainingScript sets
-- EVENT_INITIAL_CATCH_TRAINING before the battle runs, and
-- ItemUseBall's .oldManBattle branch turns that event into anim data
-- $63: three shakes, then the ball breaks open. The losing-my-touch
-- line below only follows a throw that failed (#636).
{ "old_man_demo", "fail" },
{ "set_flag", "EVENT_COMPLETED_CATCH_TRAINING" },
{ "show_text", "_ViridianCityOldManLosingMyTouchText" },
}
+121 -21
View File
@@ -189,6 +189,12 @@ flexlove._accumulatedDt = 0
---@type table<string, Element>
flexlove._touchOwners = {}
-- Touch-drag scroll tracking: survives immediate-mode element recreation.
-- Maps touch ID -> { id, lastX, lastY }. Scroll position is persisted into
-- StateManager on every move (same contract as wheelmoved).
---@type table<string, {id: string, lastX: number, lastY: number}>
flexlove._touchScroll = {}
---@type table<number, boolean>
flexlove._mouseButtonStates = {}
@@ -1412,6 +1418,82 @@ function flexlove._getTouchElementAtPosition(x, y)
return candidates[1]
end
local function elementIsTouchScrollable(element)
if not element or not element._scrollManager then
return false
end
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
return overflowX == "scroll"
or overflowX == "auto"
or overflowY == "scroll"
or overflowY == "auto"
end
-- Walk parents from a hit target so a finger on a button/row still scrolls
-- the containing list. Falls back to the wheel path's scrollable lookup when
-- the press lands on empty space inside a scroller.
local function findTouchScrollTarget(x, y, startElement)
local el = startElement
while el do
if elementIsTouchScrollable(el) then
return el
end
el = el.parent
end
return Context.findScrollableAtPosition(x, y)
end
local function findElementByStateId(stateId)
if not stateId or stateId == "" then
return nil
end
local function walk(element)
if element.id == stateId or element._stateId == stateId then
return element
end
for _, child in ipairs(element.children or {}) do
local found = walk(child)
if found then
return found
end
end
return nil
end
for _, element in ipairs(flexlove.topElements or {}) do
local found = walk(element)
if found then
return found
end
end
for _, element in ipairs(flexlove._currentFrameElements or {}) do
local found = walk(element)
if found then
return found
end
end
return nil
end
local function persistTouchScroll(element)
if flexlove._immediateMode and element and element._stateId and element._scrollManager then
StateManager.updateState(element._stateId, {
scrollManager = element._scrollManager:getState(),
})
end
end
local function resolveTouchScrollElement(track)
if not track then
return nil
end
local element = findElementByStateId(track.id)
if elementIsTouchScrollable(element) then
return element
end
return nil
end
--- Handle touch press events from LÖVE's touch input system
--- Routes touch to the topmost element at the touch position and assigns touch ownership
--- Hook this to love.touchpressed() to enable touch interaction
@@ -1452,15 +1534,22 @@ function flexlove.touchpressed(id, x, y, dx, dy, pressure)
end
end
end
end
-- Route to scroll manager for scrollable elements
if element._scrollManager then
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then
element._scrollManager:handleTouchPress(touchX, touchY)
end
-- Scroll target is the nearest scrollable ancestor (or the scroller under
-- empty space). Tracked by stable id so immediate-mode recreation can resume.
local scrollEl = findTouchScrollTarget(touchX, touchY, element)
if scrollEl and scrollEl._scrollManager then
local scrollId = scrollEl._stateId or scrollEl.id
if scrollId and scrollId ~= "" then
flexlove._touchScroll[touchId] = {
id = scrollId,
lastX = touchX,
lastY = touchY,
}
end
scrollEl._scrollManager:handleTouchPress(touchX, touchY)
persistTouchScroll(scrollEl)
end
end
@@ -1500,15 +1589,21 @@ function flexlove.touchmoved(id, x, y, dx, dy, pressure)
end
end
end
end
-- Route to scroll manager for scrollable elements
if element._scrollManager then
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then
element._scrollManager:handleTouchMove(touchX, touchY)
end
local track = flexlove._touchScroll[touchId]
local scrollEl = resolveTouchScrollElement(track)
if track and scrollEl then
local sm = scrollEl._scrollManager
-- Immediate mode recreates managers each frame; re-arm drag from the
-- last persisted touch point so a move after beginFrame still scrolls.
if not sm._touchScrolling then
sm:handleTouchPress(track.lastX, track.lastY)
end
sm:handleTouchMove(touchX, touchY)
persistTouchScroll(scrollEl)
track.lastX = touchX
track.lastY = touchY
end
end
@@ -1548,19 +1643,23 @@ function flexlove.touchreleased(id, x, y, dx, dy, pressure)
end
end
end
end
-- Route to scroll manager for scrollable elements
if element._scrollManager then
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then
element._scrollManager:handleTouchRelease()
end
local track = flexlove._touchScroll[touchId]
local scrollEl = resolveTouchScrollElement(track)
if track and scrollEl then
local sm = scrollEl._scrollManager
if not sm._touchScrolling then
sm:handleTouchPress(track.lastX, track.lastY)
end
sm:handleTouchMove(touchX, touchY)
sm:handleTouchRelease()
persistTouchScroll(scrollEl)
end
-- Clean up touch ownership (touch is complete)
flexlove._touchOwners[touchId] = nil
flexlove._touchScroll[touchId] = nil
end
--- Get the number of currently active touches being tracked
@@ -1659,6 +1758,7 @@ function flexlove.destroy()
-- Clean up touch state
flexlove._touchOwners = {}
flexlove._touchScroll = {}
flexlove._mouseButtonStates = {}
if flexlove._gestureRecognizer then
flexlove._gestureRecognizer:reset()
+12 -26
View File
@@ -422,10 +422,10 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
return TouchEditor.touchpressed(id, x, y)
end
if Importer then
if love.system.getOS() == "iOS" then
return Importer:touchpressed(id, x, y)
end
return Importer:mousepressed(x, y, 1)
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
-- polled inside the view; the istouch filter on mousepressed still drops
-- Android's synthesized mouse twin so Import cannot double-fire (#553).
return Importer:touchpressed(id, x, y, dx, dy, pressure)
end
Game:touchpressed(id, x, y)
end
@@ -437,10 +437,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
return TouchEditor.touchmoved(id, x, y)
end
if Importer then
if love.system.getOS() == "iOS" then
return Importer:touchmoved(id, x, y)
end
return
return Importer:touchmoved(id, x, y, dx, dy, pressure)
end
Game:touchmoved(id, x, y)
end
@@ -452,10 +449,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
return TouchEditor.touchreleased(id, x, y)
end
if Importer then
if love.system.getOS() == "iOS" then
return Importer:touchreleased(id, x, y)
end
return
return Importer:touchreleased(id, x, y, dx, dy, pressure)
end
Game:touchreleased(id, x, y)
end
@@ -478,20 +472,12 @@ function love.mousepressed(x, y, button, istouch)
return TouchEditor.mousepressed(x, y, button)
end
if Importer then
-- The same double-fire TouchEditor guards against, which the launcher was
-- missing: love.touchpressed above forwards the primary touch to the
-- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that
-- same touch, so one tap ran every launcher button twice. On Import that
-- meant two choose() calls and two stacked SAF picker activities: the
-- player picked their ROM, the top picker closed, and the second was still
-- underneath asking for it again, which is the "import the file twice"
-- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a
-- USB mouse) working, which an Android-wide return would have broken.
--
-- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above
-- returns early on iOS and never forwards, so there the synthesized mouse
-- press is the ONLY event the launcher gets. Filtering istouch on both
-- killed every tap on iOS outright.
-- love.touchpressed already forwards the primary touch into FlexLove for
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
-- reached a press handler, one tap ran every launcher button twice and
-- stacked two SAF pickers (#553). Clicks are polled inside FlexLove from
-- love.touch / mouse.isDown, so dropping the synthesized istouch press is
-- safe. A real mouse (DeX, Chromebook, USB) still reaches mousepressed.
if istouch and (love.system.getOS() == "Android"
or love.system.getOS() == "iOS") then return end
return Importer:mousepressed(x, y, button)
+14
View File
@@ -12,6 +12,20 @@
"tintColor": "3b5ca8",
"category": "games",
"versions": [
{
"version": "0.1.63",
"date": "2026-08-03",
"size": 9316977,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.63/gen1recomp-0.1.63-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #623 Game Corner Prize Exchange various issues\n- #624 Coins purschase menu missing your current money and coins\n- #636 Inaccurate Old Man tutorial in yellow + misc issues relating to him\n- #637 True color broken on Hall of Fame and Party Stats when using the wide battle option\n- #639 Pokédex can't show the correct stats when you catch 100 or more pokémon + overly simplified layout\n- #650 Rocket Hideout Progression Bug - Stuck with door closed after defeating Jessie and James in Yellow\n- #697 Missaligned HUD on Hall of Fame\n- #704 Player doesn't follow Prof. Oak to the Hall of Fame on the first screen\n- #722 Issues with Giovanni in Silph C.O.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.62",
"date": "2026-08-03",
"size": 9309934,
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.62/gen1recomp-0.1.62-ios.ipa",
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
},
{
"version": "0.1.61",
"date": "2026-08-03",
+24 -3
View File
@@ -768,13 +768,23 @@ end
-- engine/battle/core.asm DisplayBattleMenu .oldManName branch): no
-- player mon; the battle menu appears under the OLD MAN's name and a
-- scripted cursor hovers FIGHT, hops to ITEM and forces the item menu
-- (one POKé BALL x50). The throw always catches; nothing is kept.
-- (one POKé BALL x50). Nothing is kept.
-- Yellow's Pallet intro (BATTLE_TYPE_PIKACHU) is the same simulated
-- script under "PROF.OAK" (pokeyellow core.asm .profOakName), so the
-- displayed thrower name is a parameter.
function BattleState:makeOldManDemo(name)
-- The throw catches everywhere except Yellow's FIRST Viridian training.
-- ItemUseBall's .oldManBattle branch checks EVENT_INITIAL_CATCH_TRAINING
-- and, when it is set, stores anim data $63 in place of the $43 capture
-- value -- three shakes, then a breakout (pokeyellow
-- engine/items/item_effects.asm). Red/Blue's ItemUseBall has no such
-- branch and jumps straight to .captured, and Yellow's repeat "Watch
-- closely!" demo resets the event before its battle
-- (ViridianCityOldManStartCatchTrainingScript), so only the initial
-- tutorial passes failThrow -- it stands in for that event (#636).
function BattleState:makeOldManDemo(name, failThrow)
self.demo = true
self.demoName = name or "OLD MAN"
self.demoFails = failThrow and true or false
-- 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
@@ -2061,7 +2071,10 @@ end
-- skipped -- the old man branch jumps straight to .captured, $43 anim
-- data = 3 shakes and caught (:155-164 + :193-200) -- and
-- .oldManCaughtMon prints the caught text WITHOUT adding the mon to
-- the party or the dex (:568-570). The "used" line reads OLD MAN
-- the party or the dex (:568-570). Yellow's initial training is the one
-- exception (demoFails, #636): its .oldManBattle branch forces $63, so
-- the same chain ends in a breakout and ItemUseBallText04 instead.
-- The "used" line reads OLD MAN
-- because DisplayBattleMenu swapped wPlayerName (core.asm:2024-2037);
-- no ball is consumed (.done returns early, :576-578).
function BattleState:oldManThrow()
@@ -2074,6 +2087,14 @@ function BattleState:oldManThrow()
-- ItemUseBall's beat before the toss chain (like throwBall)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 20 })
if self.demoFails then
-- $63 instead of $43: the same three shakes, then POOF+SHOWPIC and
-- ItemUseBallText04. No sound_caught_mon, and .captured is never
-- reached, so nothing touches the party or the dex either (#636).
self:ballChain("TOSS_ANIM", false, 3, "POKE_BALL")
self:sayNext(self:ballMissMessage(3))
return
end
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
self:actNext(function()
require("src.core.Sound").play(self.data, "Caught_Mon")
+15 -5
View File
@@ -441,7 +441,14 @@ function Game:draw()
if classicOffset ~= 0 and not wideState then
love.graphics.push()
love.graphics.translate(classicOffset, 0)
-- a classic state reports its trueColor rects in its own 160x144
-- coordinates, so they take the same shift its pixels just got --
-- centerClassicZones already does exactly this to its zone list,
-- and without the pair the unshaded re-blit misses the pic (#637)
local P = require("src.render.PaletteFX")
P.setMarkOffset(classicOffset)
state:draw()
P.setMarkOffset(0)
love.graphics.pop()
else
state:draw()
@@ -566,7 +573,7 @@ function Game:keypressed(key)
return
elseif key == "1" then
-- cycle GAME SPEED (0.25X → 200X, logic only; audio unaffected);
-- R2/L2 on gamepad do the same (see gamepadpressed)
-- shoulders/triggers on gamepad do the same (see gamepadpressed)
self:_cycleSpeed(1)
return
elseif key == "2" then
@@ -649,12 +656,15 @@ function Game:gamepadpressed(joystick, button)
-- a controller is being used: the touch overlay steps aside until the
-- next screen touch (mobile only; a no-op elsewhere)
TouchControls:noteGamepad()
-- shoulder buttons cycle GAME SPEED (R2/rightshoulder = faster,
-- L2/leftshoulder = slower; same as keyboard hotkey 1)
if button == "rightshoulder" then
-- shoulder buttons and analog triggers cycle GAME SPEED (R1/RB or
-- R2/RT = faster, L1/LB or L2/LT = slower; same as keyboard hotkey
-- 1). LÖVE reports an analog trigger as gamepadpressed once it
-- crosses the press threshold, so a trigger pull lands here like any
-- other pad button.
if button == "rightshoulder" or button == "righttrigger" then
self:_cycleSpeed(1)
return
elseif button == "leftshoulder" then
elseif button == "leftshoulder" or button == "lefttrigger" then
self:_cycleSpeed(-1)
return
end
+77 -10
View File
@@ -54,10 +54,11 @@ local function C(name, a)
return rgba(c[1], c[2], c[3], a)
end
-- Every element in this view refuses to flex-shrink: overflow is always a
-- scroll container's job here, and the engine otherwise compresses
-- Non-scroll elements refuse to flex-shrink: the engine otherwise compresses
-- auto-height children inside height-constrained columns until their text
-- overlaps (the portrait single-column layout was the visible case).
-- overlaps (the portrait single-column layout was the visible case). Scroll
-- regions are the opposite — they MUST shrink to the viewport, or their
-- height grows with content, maxScrollY stays 0, and drag/wheel do nothing.
-- horizontal padding of a props table, for content-width bookkeeping
local function propsPadH(p)
local pad = p.padding
@@ -68,8 +69,18 @@ local function propsPadH(p)
return 0
end
local function isScrollOverflow(props)
local o = props.overflowY or props.overflowX or props.overflow
return o == "scroll" or o == "auto"
end
local function mk(props)
if props.flexShrink == nil then props.flexShrink = 0 end
if props.flexShrink == nil then
props.flexShrink = isScrollOverflow(props) and 1 or 0
end
if isScrollOverflow(props) and props.minHeight == nil then
props.minHeight = 0
end
-- Resolve "100%" here, against the parent's CONTENT width: the engine
-- resolves a percentage against the parent's border box and ignores its
-- padding, so every percent child of a padded container overflowed to the
@@ -146,11 +157,48 @@ function LauncherView.update(imp, dt)
end
end
-- One dedup window covers a touch release plus the mouse click SDL
-- synthesizes for the same tap.
local ACT_DEDUP = 0.35
-- Finger travel past this (px) is a scroll drag, not a tap — so dragging a
-- list row does not also fire that row's button.
local TAP_SLOP2 = 16 * 16
function LauncherView.wheelmoved(imp, dx, dy)
if not imp._flex then return end
pcall(FlexLove.wheelmoved, dx, dy)
end
-- Touch drag scroll: FlexLove's ScrollManager only moves when these are
-- hooked. Clicks still come from EventHandler's love.touch / mouse polling;
-- the view's action dedupe covers a tap that also synthesizes a mouse click,
-- and a drag past TAP_SLOP suppresses the click that would otherwise fire
-- on the row under the finger.
function LauncherView.touchpressed(imp, id, x, y, dx, dy, pressure)
if not imp._flex then return end
imp._touchAt = imp._touchAt or {}
imp._touchAt[tostring(id)] = { x = x, y = y }
pcall(FlexLove.touchpressed, id, x, y, dx, dy, pressure)
end
function LauncherView.touchmoved(imp, id, x, y, dx, dy, pressure)
if not imp._flex then return end
local start = imp._touchAt and imp._touchAt[tostring(id)]
if start then
local ddx, ddy = x - start.x, y - start.y
if ddx * ddx + ddy * ddy > TAP_SLOP2 then
imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP
end
end
pcall(FlexLove.touchmoved, id, x, y, dx, dy, pressure)
end
function LauncherView.touchreleased(imp, id, x, y, dx, dy, pressure)
if not imp._flex then return end
if imp._touchAt then imp._touchAt[tostring(id)] = nil end
pcall(FlexLove.touchreleased, id, x, y, dx, dy, pressure)
end
-- Synthetic click for the gamepad virtual cursor: find the element under the
-- pad pointer and run its handler with a click-shaped event.
function LauncherView.clickAt(imp, x, y)
@@ -166,10 +214,6 @@ end
-- ------- shared widget helpers
-- One dedup window covers a touch release plus the mouse click SDL
-- synthesizes for the same tap.
local ACT_DEDUP = 0.35
local function queueAction(imp, key, fn, keepArm)
local now = love.timer.getTime()
local last = imp._actAt[key]
@@ -188,6 +232,15 @@ local function handler(imp, key, action, keepArm)
elseif ev.type == "unhover" then
imp._hot[key] = nil
elseif action and (ev.type == "click" or ev.type == "touchrelease") then
if ev.type == "touchrelease" then
local dx, dy = ev.dx or 0, ev.dy or 0
if dx * dx + dy * dy > TAP_SLOP2 then
imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP
return
end
end
local untilT = imp._suppressClickUntil
if untilT and love.timer.getTime() < untilT then return end
queueAction(imp, key, action, keepArm)
end
end
@@ -743,11 +796,23 @@ local function buildSlotCard(imp, parent, m, version)
math.ceil(textHeight(pillSize)) + 8)
local metaH = math.ceil(textHeight(metaSize))
local rowH = 10 + headH + 5 + metaH + 8 + btnH + 10
-- Fixed-height scroller so 40 slots actually overflow (page-level flex
-- scroll alone was growing with content, leaving nothing to drag).
local listParent = c
if n > 0 then
local listH = math.floor(clamp(m.h * (m.twoCol and 0.58 or 0.42), 200, 720))
listParent = mk({
parent = c, id = "slots-" .. version, width = "100%", height = listH,
overflowY = "scroll", hideScrollbars = true,
positioning = "flex", flexDirection = "vertical", gap = 10 * m.s,
padding = { right = 4 },
})
end
for _, slot in ipairs(slots) do
local selected = slot.id == active
local rowKey = "slot-" .. version .. "-" .. slot.id
local row = mk({
parent = c, width = "100%", height = rowH,
parent = listParent, width = "100%", height = rowH,
backgroundColor = C("rowBg", imp._hot[rowKey] and 0.85 or 0.6),
border = 1,
borderColor = selected and C("green", 0.9) or C("border", 0.25),
@@ -1782,9 +1847,11 @@ function LauncherView.draw(imp)
-- One scroll region per tab (stable id keeps its offset across frames and
-- separate per tab), holding the panel, the updater banner and the footer.
-- flexShrink/minHeight keep this viewport-sized so overflowY can scroll.
local page = mk({
parent = root, id = "page-" .. imp.tab,
width = "100%", flex = 1, overflowY = "scroll", hideScrollbars = true,
width = "100%", flex = 1, flexShrink = 1, minHeight = 0,
overflowY = "scroll", hideScrollbars = true,
positioning = "flex", flexDirection = "vertical",
gap = 12 * m.s,
padding = { left = m.pad, right = m.pad + m.gutter,
+25 -12
View File
@@ -617,12 +617,9 @@ function RomImporter.new(onComplete, opts)
-- boot armed and let the first poll tick consume it, rather than making the
-- player tap Import a second time to trigger the scan by hand (#553).
pickPending = android or nil,
-- Android drag: the launcher is handed no move events at all (main.lua
-- forwards neither touchmoved nor mousemoved while it is up), and its mouse
-- emulation is what "no reliable pointer polling" below refers to.
-- love.touch IS pollable, so where it exists a touch drag can be resolved
-- inside draw the same way the desktop mouse is. Where it does not, every
-- Android path stays exactly as it was: act on press, never arm.
-- Mobile drag-scroll goes through FlexLove.touch* (main.lua forwards the
-- full touch stream while the launcher is up). love.touch remains pollable
-- for click hit-testing inside EventHandler.
touchPollable = android and love.touch ~= nil
and love.touch.getTouches ~= nil and love.touch.getPosition ~= nil,
tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods"
@@ -1703,13 +1700,29 @@ function RomImporter:pressDelete(kind, id, version, commit)
return false
end
-- Pointer input is polled by the FlexLove view (mouse and touch alike), so
-- the host-forwarded press events are inert. The methods stay because
-- main.lua forwards to them unconditionally while the launcher is up.
-- Clicks are polled inside FlexLove (mouse + love.touch); host-forwarded
-- mousepressed stays inert so Android's synthesized mouse path cannot
-- double-fire a tap (#553). Touch move/press/release must still reach
-- FlexLove.touch* or scroll containers never drag on phones.
function RomImporter:mousepressed() end
function RomImporter:touchpressed() end
function RomImporter:touchmoved() end
function RomImporter:touchreleased() end
function RomImporter:touchpressed(id, x, y, dx, dy, pressure)
if not self._flex then return end
require("src.import.LauncherView").touchpressed(
self, id, x, y, dx, dy, pressure)
end
function RomImporter:touchmoved(id, x, y, dx, dy, pressure)
if not self._flex then return end
require("src.import.LauncherView").touchmoved(
self, id, x, y, dx, dy, pressure)
end
function RomImporter:touchreleased(id, x, y, dx, dy, pressure)
if not self._flex then return end
require("src.import.LauncherView").touchreleased(
self, id, x, y, dx, dy, pressure)
end
-- Switch the active tab (chips, shoulder buttons). The find search caret and
-- the soft keyboard drop with the panel they belonged to; each tab's scroll
+17
View File
@@ -240,6 +240,14 @@ end
-- and the zone lists are exactly the ones the states returned.
local trueColorRects = { ui = {}, world = {} }
local currentPass = nil
-- Horizontal shift applied to UI-pass marks. A wide battle keeps its 304px
-- surface through every classic state it opens, and Game:draw centres each
-- of those with a translate while centerClassicZones shifts their zone list
-- by the same amount. A rect reported from inside that translate has to
-- move with it, or the unshaded re-blit lands 72 columns off and the pic
-- keeps the shade remap -- the party STATS screen in a wide battle (#637).
-- World-pass marks are already in world-canvas space and are never centred.
local markOffsetX = 0
-- which canvas the renderer is filling. nil for a pass that composites
-- with no zone list of its own (tilt's upright billboards carry their own
@@ -252,11 +260,20 @@ function PaletteFX.clearTrueColor()
for _, rects in pairs(trueColorRects) do
for i = #rects, 1, -1 do rects[i] = nil end
end
markOffsetX = 0
end
-- Game:draw declares the translate it is drawing a classic state under, so
-- that state's marks land where its pixels did. Cleared with the rects at
-- the top of every frame (Renderer:beginFrame). #637
function PaletteFX.setMarkOffset(dx)
markOffsetX = tonumber(dx) or 0
end
function PaletteFX.markTrueColor(x, y, w, h)
local rects = currentPass and trueColorRects[currentPass]
if not rects or w <= 0 or h <= 0 then return end
if currentPass == "ui" then x = x + markOffsetX end
rects[#rects + 1] = { colors = false, x = x, y = y, w = w, h = h }
end
+7 -2
View File
@@ -784,12 +784,17 @@ end
-- The Viridian old man's catch tutorial (scripts/ViridianCity.asm
-- BATTLE_TYPE_OLD_MAN): a demo wild battle where the old man throws
-- one POKé BALL; nothing is kept.
function Commands.old_man_demo(ctx)
-- `outcome` == "fail" is Yellow's initial training: ItemUseBall's
-- .oldManBattle branch reads EVENT_INITIAL_CATCH_TRAINING and stores anim
-- data $63, so the ball shakes three times and breaks open (pokeyellow
-- engine/items/item_effects.asm). Red, Blue and Yellow's repeat "Watch
-- closely!" demo all catch, and all of them omit the argument (#636).
function Commands.old_man_demo(ctx, outcome)
local BattleState = require("src.battle.BattleState")
local runner = ctx.runner
local om = ctx.game.data.field.oldManBattle or { species = "WEEDLE", level = 5 }
local battle = BattleState.newWild(ctx.game, om.species, om.level)
battle:makeOldManDemo()
battle:makeOldManDemo(nil, outcome == "fail")
battle.onFinish = function() runner:resume() end
-- InitWildBattle calls DoBattleTransitionAndInitBattleVariables
-- unconditionally (core.asm:6699) -- there is no BATTLE_TYPE_OLD_MAN
+32 -11
View File
@@ -73,8 +73,12 @@ function HallOfFame.new(game, onDone)
self.timer = 0
self.phase = "mons"
self.sprites = {} -- species -> image or false
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "hof" }))
self.spriteTrueColor = {} -- species -> full-color art flag (#637)
local playerPath, playerTrueColor =
require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "hof" })
self.playerPic = tryImage(playerPath)
self.playerTrueColor = self.playerPic and playerTrueColor or false
self.scrollX = PIC_X
self.showHofBanner = false
self.fade = 0
@@ -113,10 +117,11 @@ end
function HallOfFame:spriteFor(species)
local cached = self.sprites[species]
if cached == nil then
local path = require("src.pokemon.Sprites").path(
local path, trueColor = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "hof" })
cached = tryImage(path) or false
self.sprites[species] = cached
self.spriteTrueColor[species] = cached and trueColor or false
end
return cached or nil
end
@@ -225,13 +230,18 @@ function HallOfFame:drawMonInfo(mon)
love.graphics.setColor(0, 0, 0, 1)
local name = mon.nickname or (def and def.name) or mon.species
Font.draw(name, 1 * 8, 4 * 8)
-- HoFMonInfoText is placed at (2,6) with "next" separators, and PlaceNextChar
-- advances 2 * SCREEN_WIDTH per <NEXT> unless BIT_SINGLE_SPACED_LINES is set
-- (home/text.asm); nothing on the HoF path sets it, so the labels sit on rows
-- 6/8/10 lined up with their values, not 6/7/8 (#697).
Font.draw(Strings("LEVEL/"), 2 * 8, 6 * 8)
Font.draw(Strings("TYPE1/"), 2 * 8, 7 * 8)
Font.draw(Strings("TYPE1/"), 2 * 8, 8 * 8)
local t1 = def and def.types and def.types[1]
local t2 = def and def.types and def.types[2]
local dual = t2 and t2 ~= t1
if dual then
Font.draw(Strings("TYPE2/"), 2 * 8, 8 * 8)
-- EraseType2Text blanks 6 tiles at hl+$13 from (3,9), so TYPE2/ is at (2,10)
Font.draw(Strings("TYPE2/"), 2 * 8, 10 * 8)
end
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
@@ -251,10 +261,20 @@ function HallOfFame:drawHofBanner()
Font.draw(Strings("HALL OF FAME"), 4 * 8, 15 * 8)
end
function HallOfFame:drawPic(img)
function HallOfFame:drawPic(img, trueColor)
if not img then return end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, self.scrollX or PIC_X, PIC_Y)
local x = self.scrollX or PIC_X
love.graphics.draw(img, x, PIC_Y)
-- SET_PAL_POKEMON_WHOLE_SCREEN (HoFShowMonOrPlayer) colors the WHOLE
-- screen in the mon's palette, so sgbPalettes above hands the blit a
-- whole-canvas zone -- and a full-color pic has to sit that remap out.
-- Report the rect the pic covers for the unshaded pass, the way
-- SummaryMenu does for the status screen pic (#637; #430).
if trueColor then
require("src.render.PaletteFX").markTrueColor(x, PIC_Y,
img:getDimensions())
end
end
-- HoFDisplayPlayerStats boxes + labels (player pic already on the right)
@@ -284,7 +304,8 @@ function HallOfFame:draw()
if self.phase == "mons" or self.phase == "fade" then
local mon = self.game.save.party[self.index]
if mon then
self:drawPic(self:spriteFor(mon.species))
self:drawPic(self:spriteFor(mon.species),
self.spriteTrueColor[mon.species])
if self.scrollX >= PIC_X then
self:drawMonInfo(mon)
if self.showHofBanner then
@@ -297,13 +318,13 @@ function HallOfFame:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144)
end
elseif self.phase == "player" then
self:drawPic(self.playerPic)
self:drawPic(self.playerPic, self.playerTrueColor)
elseif self.phase == "player_stats" then
self:drawPic(self.playerPic)
self:drawPic(self.playerPic, self.playerTrueColor)
self:drawPlayerStats()
elseif self.phase == "player_dex" or self.phase == "player_rating" then
-- the TextBox chain draws the dex texts over the stat boxes
self:drawPic(self.playerPic)
self:drawPic(self.playerPic, self.playerTrueColor)
self:drawPlayerStats()
end
+8 -1
View File
@@ -47,7 +47,14 @@ function PokedexMenu.new(game, opts)
end
end
local list = ListMenu.new(game, "POKéDEX", items, {
footer = Strings("SEEN %d OWNED %d", seen, owned),
-- SEEN / OWN in the original's fixed three-digit field: engine/menus/
-- pokedex.asm HandlePokedexListMenu prints both counts with
-- `lb bc, 1, 3` (hlcoord 16,3 and 16,6), labelled by PokedexSeenText
-- and PokedexOwnText ("OWN", not "OWNED"). The width is load bearing
-- here: a bare ListMenu footer goes through the 18-column text wrap,
-- so the old 19-glyph "SEEN 100 OWNED 100" split in two and its first
-- half landed on the list's last row at y=120 (#639).
footer = Strings("SEEN %3d OWN %3d", 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, dexList)
+8
View File
@@ -155,6 +155,14 @@ FieldDefaults.FIELD = {
"EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" } },
},
},
-- Floors whose door callback a version does not have. Yellow dropped
-- RocketHideoutB4FDoorCallbackScript entirely (pokeyellow
-- scripts/RocketHideoutB4F.asm goes straight to EnableAutoTextBoxDrawing)
-- and its .blk ships the same open $0e doorway, so B4F's lift gate is
-- never barred there. Every manifest still carries the row above, so
-- this is what stops a Yellow cache walling Giovanni off behind two
-- guard flags Jessie & James never set (#650).
skipMaps = { yellow = { ROCKET_HIDEOUT_B4F = true } },
},
-- VermilionGymSetDoorTile opens the motorized door once both locks are hit
hiddenExtras = {
+8
View File
@@ -8,6 +8,7 @@ local Camera = require("src.render.Camera")
local Collision = require("src.world.Collision")
local Encounter = require("src.world.Encounter")
local FieldDefaults = require("src.world.FieldDefaults")
local GameVersion = require("src.core.GameVersion")
local Logger = require("src.core.Logger")
local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader")
@@ -224,6 +225,13 @@ function OverworldState:stampClosedDoors()
"closedDoors")
local floorDoors = self.map and closedDoors and closedDoors[self.map.id]
if not floorDoors then return end
-- ...and floors the running version has no callback for: Yellow's B4F
-- lift gate stands open from the first visit, since Jessie & James take
-- the two guard slots there and set neither guard flag (#650)
local skipMaps = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
"skipMaps")
local skipped = skipMaps and skipMaps[GameVersion.get()]
if skipped and skipped[self.map.id] then return end
local stamped, unlocked = false, false
for _, door in ipairs(floorDoors) do
local open
+267
View File
@@ -0,0 +1,267 @@
-- Eyes on the Celadon Game Corner counters: the coin clerk's MONEY/COIN
-- windows and his question staying up under YES/NO (#624), then a prize
-- counter's three-prize window and its confirmation (#623).
-- Oracle: scripts/GameCorner.asm GameCornerClerk1Text + GameCornerDrawCoinBox,
-- engine/events/prize_menu.asm CeladonPrizeMenu / HandlePrizeChoice.
-- No POKEPORT_SPEED here: it scales only the logic clock while audio runs on
-- its own accumulator, so the menu beeps drift out of the presses.
-- POKEPORT_DRIVER=tests/drivers/game_corner_bug624_test.lua POKEPORT_IDENTITY=bug624 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local ChoiceBox = require("src.ui.ChoiceBox")
local ListMenu = require("src.ui.ListMenu")
local TextBox = require("src.render.TextBox")
local mapScripts = require("data.scripts.init")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local ok = true
local function check(label, pass)
U.log(pass and "PASS" or "FAIL", label)
ok = ok and pass and true or false
return pass
end
-- Read a page, press A, read the next one: tapping blind at a fixed rate
-- drops presses, because a TextBox only takes A once it has finished
-- typing (self.waiting). Runs until `pred` holds or the frames run out.
local function mashUntil(pred, frames)
for _ = 1, frames or 900 do
if pred() then return true end
local top = game.stack:top()
if top and (top.waiting or top.done
or getmetatable(top) == ChoiceBox) then
U.tap(game, "a")
else
U.wait(1)
end
end
return pred()
end
local function topIsChoice()
return getmetatable(game.stack:top()) == ChoiceBox
end
-- ---------------------------------------------------------------- wiring
-- a renamed text id, a dropped vendor binding and an unresolvable string
-- all look exactly like the bug from the outside: nothing on screen
local clerk = mapScripts.talkScript("GAME_CORNER", "TEXT_GAMECORNER_CLERK1")
check("the coin clerk has a hand-ported handler", type(clerk) == "function")
local vendors = {}
for i = 1, 3 do
vendors[i] = mapScripts.talkScript("GAME_CORNER_PRIZE_ROOM",
"TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_" .. i)
end
check("all three prize counters have handlers",
type(vendors[1]) == "function" and type(vendors[2]) == "function"
and type(vendors[3]) == "function")
-- GetPrizeMenuId indexes PrizeDifferentMenuPtrs off the vendor's text id,
-- so the three counters must not share one closure (#623)
check("each counter is its own window, not one shared list",
vendors[1] ~= vendors[2] and vendors[2] ~= vendors[3]
and vendors[1] ~= vendors[3])
local t = game.data.text
local NEEDED = {
"_GameCornerClerk1DoYouNeedSomeGameCoinsText",
"_ExchangeCoinsForPrizesText", "_SoYouWantPrizeText",
"_SorryNeedMoreCoinsText", "_OopsYouDontHaveEnoughRoomText",
"_OhFineThenText",
}
local missing = {}
for _, key in ipairs(NEEDED) do
if type(t[key]) ~= "string" or t[key] == "" then
missing[#missing + 1] = key
end
end
check("every counter line resolves out of the cache (no silent fallback): "
.. (#missing == 0 and "all present" or table.concat(missing, ", ")),
#missing == 0)
local vol = game.save.options and game.save.options.sfxVol
if vol == 0 then
U.log("sfx volume is 0, so the menu beeps under all this are muted.")
end
-- --------------------------------------------------------------- wallet
-- enough of both to reach every branch: ¥1000 buys coins, 500 coins buys
-- the cheapest mon on vendor 1 (ABRA, 180)
game.save.money = 3000
game.save.coins = 500
game.save.inventory.COIN_CASE = 1
-- ------------------------------------------------------------ the clerk
-- pokered data/maps/objects/GameCorner.asm: GAMECORNER_CLERK1 stands at
-- (5, 6) facing DOWN, so the approach is the cell below him.
local function clerkNpc()
for _, n in ipairs((game.overworld or {}).npcs or {}) do
if n.def and n.def.name == "GAMECORNER_CLERK1" then return n end
end
return nil
end
local function facingClerk()
local ow = game.overworld
local man = ow and clerkNpc()
if not man then return false end
local fx, fy = ow.player:facingCell()
return ow:npcAtCell(fx, fy) == man
end
U.teleport(game, "GAME_CORNER", 5, 7, "up")
U.wait(10)
local man = clerkNpc()
check("the clerk object loaded on GAME_CORNER", man ~= nil)
if man and not facingClerk() then
-- a map edit moved him: take any free walkable neighbour instead of
-- talking to a wall. {dx, dy, facing} is the offset from the clerk to
-- the stand cell plus the way to look back at him.
local sides = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
local ow = game.overworld
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("(5, 7) is not against the clerk any more, standing on",
cx, cy, "facing", s[3])
U.teleport(game, "GAME_CORNER", cx, cy, s[3])
U.wait(10)
break
end
end
end
check("standing at the coin counter", facingClerk())
U.tap(game, "a")
U.wait(20)
-- GameCornerDrawCoinBox is a plain TextBoxBorder with no input of its own:
-- it has to sit under the dialogue for the whole exchange, i.e. the state
-- pushed straight on top of the overworld draws and never updates (#624)
local coinBox = game.stack.states[2]
check("a draw-only MONEY/COIN window is under the conversation",
game.stack.states[1] == game.overworld and type(coinBox) == "table"
and coinBox.draw ~= nil and coinBox.update == nil)
-- read through the offer the way a player does; it runs three pages
check("the offer ends in a YES/NO box", mashUntil(topIsChoice, 900))
-- the question used to close before the prompt opened; opts.choice keeps
-- the box that asked it on screen underneath (#624)
local under = game.stack.states[#game.stack.states - 1]
check("the question is still on screen under YES/NO",
getmetatable(under) == TextBox)
check("captured the coin counter",
U.shot(game, SHOT_DIR .. "/bug624_coin_clerk.png"))
-- answer NO and let the goodbye line close itself out
U.tap(game, "down")
U.wait(6)
mashUntil(function() return game.stack:top() == game.overworld end, 600)
check("declining puts the coin window away with the dialogue",
game.stack:top() == game.overworld and game.save.money == 3000)
-- ------------------------------------------------------- a prize window
-- pokered data/maps/objects/GameCornerPrizeRoom.asm: PRIZE_VENDOR_1 is a
-- bg event at (2, 2), read by facing up from the tile below it.
local SIGN = "TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1"
local function facingVendor()
local ow = game.overworld
if not ow then return false end
local fx, fy = ow.player:facingCell()
local sign = ow.map:signAtCell(fx, fy)
return sign ~= nil and sign.text == SIGN
end
U.teleport(game, "GAME_CORNER_PRIZE_ROOM", 2, 3, "up")
U.wait(10)
if not facingVendor() then
local ow = game.overworld
local sides = {
{ 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" },
}
for _, s in ipairs(sides) do
local cx, cy = 2 + s[1], 2 + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log("(2, 3) no longer reads the first counter, standing on",
cx, cy, "facing", s[3])
U.teleport(game, "GAME_CORNER_PRIZE_ROOM", cx, cy, s[3])
U.wait(10)
break
end
end
end
check("standing at the first prize counter", facingVendor())
U.tap(game, "a")
U.wait(20)
local function listUp()
return getmetatable(game.stack:top()) == ListMenu
end
mashUntil(listUp, 600)
local list = listUp() and game.stack:top() or nil
check("the exchange line opens a prize list", list ~= nil)
if list then
-- wMaxMenuItem is 3: this counter's three prizes and NO THANKS, never
-- the whole catalogue (#623)
local rows = list.items or {}
local names = {}
for _, item in ipairs(rows) do
names[#names + 1] = item.label ..
(item.right and (" " .. item.right) or "")
end
U.log("counter 1 offers:", table.concat(names, ", "))
check("four rows: three prizes and NO THANKS", #rows == 4)
check("the last row is the NO THANKS exit",
rows[4] ~= nil and rows[4].label == "NO THANKS"
and rows[4].value == nil)
local priced = true
for i = 1, math.min(3, #rows) do
if not (rows[i].value and tonumber(rows[i].right)) then priced = false end
end
check("each prize names a real species or TM and a coin price", priced)
check("captured the prize list",
U.shot(game, SHOT_DIR .. "/bug623_prize_list.png"))
-- take the top prize so the confirmation comes up
U.tap(game, "a")
U.wait(12)
check("picking a prize asks before it takes the coins",
mashUntil(topIsChoice, 600))
local ask = game.stack.states[#game.stack.states - 1]
local said = ""
if getmetatable(ask) == TextBox then
for _, page in ipairs(ask.pages or {}) do
for _, l in ipairs(page) do said = said .. " " .. l end
end
end
U.log("it asks:", (said:gsub("^%s+", "")))
check("the question names the prize, not {RAM:wNameBuffer}",
said:find("wNameBuffer", 1, true) == nil
and rows[1] ~= nil and said:find(rows[1].label:match("^%S+"), 1, true)
~= nil)
check("captured the confirmation",
U.shot(game, SHOT_DIR .. "/bug623_prize_confirm.png"))
end
U.log(ok and "checks are green, the screen is worth looking at."
or "something above says FAIL, do not trust what is on screen.")
U.log("shots are in " .. SHOT_DIR .. ".")
U.log("")
U.log("on screen is counter 1's confirmation (#623): three prizes and NO")
U.log("THANKS, and a YES/NO over \"So, you want ABRA?\" with nothing bought")
U.log("yet. Answer either way and the conversation ends, then talk to the")
U.log("counters again; each of the three sells its own window.")
U.log("the clerk shot (#624) is the other half: the question still typed")
U.log("out under the YES/NO, MONEY ¥3000 and COIN 500 in the top-right")
U.log("window. the near-miss to look for is that window going stale, still")
U.log("reading ¥3000 after the 50 coins are bought.")
U.log("the prize list is still full-screen and the YES/NO lands on top of")
U.log("the third row. the small windowed menu half of #623 was left alone.")
while true do
coroutine.yield()
end
end
+294
View File
@@ -0,0 +1,294 @@
-- Driver: the Hall of Fame induction, end to end (#704, #697, #637).
-- Runs the tail of ChampionsRoomRivalDefeatedScript (scripts/ChampionsRoom.asm)
-- from just after the rival battle, so the walk out after Oak, the LEVEL/TYPE
-- box (engine/movie/hall_of_fame.asm HoFDisplayMonInfo) and the pic's true
-- color exemption all play at real speed. Do NOT add POKEPORT_SPEED: it
-- scales the logic clock only, and the cries here run off the audio clock.
-- POKEPORT_DRIVER=tests/drivers/hall_of_fame_bug704_test.lua \
-- POKEPORT_IDENTITY=hof704 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 Sprites = require("src.pokemon.Sprites")
local PaletteFX = require("src.render.PaletteFX")
local Renderer = require("src.render.Renderer")
local BattleState = require("src.battle.BattleState")
local SummaryMenu = require("src.ui.SummaryMenu")
local Font = require("src.render.Font")
local HallOfFame = require("src.ui.HallOfFame")
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
-- HoFLoadMonPlayerPicTileIDs rests the front pic at hlcoord 12,5
local PIC_X, PIC_Y = 96, 40
-- ---------------------------------------------------------------
-- setup
-- ---------------------------------------------------------------
local options = game.save.options
options.battleLayout = "wide" -- #637 is reported with BATTLE LAYOUT = WIDE
-- CHARIZARD is dual type (FIRE/FLYING) so TYPE2/ is drawn; SNORLAX is
-- single type so the run also shows the row EraseType2Text blanks.
local SPECIES = { "CHARIZARD", "SNORLAX", "PIKACHU" }
game.save.party = {}
for i, name in ipairs(SPECIES) do
game.save.party[i] = Pokemon.new(game.data, name, 100 - (i - 1) * 27)
end
game.save.player.name = "BRYAN"
game.save.money = 54321
game.save.playTime = 9 * 3600 + 42 * 60
-- DisplayDexRating tiers off owned; give it something past the first row
game.save.pokedex = game.save.pokedex or { seen = {}, owned = {} }
for i = 1, 60 do
game.save.pokedex.seen[i] = true
if i <= 47 then game.save.pokedex.owned[i] = true end
end
-- A stock ROM import carries no full-color art, so def.trueColor is false
-- for every species and the #637 exemption never engages. Flip it on for
-- this party the way an art-pack mod does (mods/examples/example_shiny_
-- palette patches the same field), or there is nothing to look at.
for _, name in ipairs(SPECIES) do
local def = game.data.pokemon[name]
if def then def.trueColor = true end
end
if (options.sfxVol or 0) == 0 then
U.log("WARNING sfxVol is 0: the cries are muted, so the run is silent")
end
if (options.musicVol or 0) == 0 then
U.log("WARNING musicVol is 0: Music_HallOfFame will not be audible")
end
U.log("colors option is", tostring(options.colors),
"-- the pic exemption only shows while the palette shader is on")
-- ---------------------------------------------------------------
-- machine checks: everything an eye cannot tell from the bug
-- ---------------------------------------------------------------
for _, name in ipairs(SPECIES) do
local path, trueColor = Sprites.path(game.data, name, "front",
{ kind = "hof" })
check(name .. " has a front pic for the hof screen",
type(path) == "string" and path ~= "")
check(name .. " reports the full-color flag through Sprites.path",
trueColor == true)
end
-- #704: the walk-out rows. Read the live registry rather than the module,
-- so a mod's map_scripts contribution is what gets inspected.
local mapScripts = require("data.scripts.init")
local champ = mapScripts.get("CHAMPIONS_ROOM")
local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL
check("CHAMPIONS_ROOM keeps its rival script", type(rows) == "table")
rows = rows or {}
local iWalk, iWarp
for i, row in ipairs(rows) do
if row[1] == "move_player" and row[2] == "up" and not iWarp then
iWalk = i
elseif row[1] == "warp" and row[2] == "HALL_OF_FAME" then
iWarp = iWarp or i
end
end
check("the script walks the player up before it warps (#704)",
iWalk ~= nil and iWarp ~= nil and iWalk < iWarp)
-- #637, the other half: a classic state drawn inside a wide battle reports
-- its rects in 160px coordinates, and Game:draw shifts them with the
-- translate it centred the state under.
check("PaletteFX takes a mark offset", type(PaletteFX.setMarkOffset) == "function")
if type(PaletteFX.setMarkOffset) == "function" then
PaletteFX.setPass("ui")
PaletteFX.setMarkOffset(72) -- (304 - 160) / 2
PaletteFX.markTrueColor(8, 24, 56, 56)
local probe = PaletteFX.trueColorRects("ui")
local last = probe[#probe]
check("a ui mark moves with the centring translate",
last ~= nil and last.x == 80)
PaletteFX.setMarkOffset(0)
PaletteFX.clearTrueColor()
end
-- ---------------------------------------------------------------
-- the wide-battle STATS screen (#637, second screen in the issue)
-- ---------------------------------------------------------------
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(30)
local battle = BattleState.newWild(game, "PIDGEY", 3, { onFinish = function() end })
game.overworld:pushBattle(battle)
U.wait(300)
check("the wide battle took the 304x144 surface",
Renderer.uiWidth == 304 and Renderer.uiHeight == 144)
game.stack:push(SummaryMenu.new(game, game.save.party[1]))
U.wait(8)
check("stats screenshot", U.shot(game, DIR .. "/hof637_wide_stats.png"))
local statRect
for _, r in ipairs(PaletteFX.trueColorRects("ui")) do
if r.colors == false and r.w > 32 then statRect = r end
end
-- StatusScreen draws the pic at x = 8; centred in the wide surface that is 80
check("the STATS pic is exempted at its centred x (#637)",
statRect ~= nil and statRect.x == 80)
game.stack:pop() -- summary
game.stack:pop() -- battle
U.wait(20)
check("the Game Boy surface came back",
Renderer.uiWidth == 160 and Renderer.uiHeight == 144)
-- ---------------------------------------------------------------
-- the champion's room walk-out
-- ---------------------------------------------------------------
-- pokered data/maps/objects/ChampionsRoom.asm: the rival stands at (4,2),
-- both HALL_OF_FAME warps are on row 0, and ChampionsRoomPlayerEntersScript
-- leaves the player at (4,3). Start there, facing the rival.
local STAND = { x = 4, y = 3 }
U.teleport(game, "CHAMPIONS_ROOM", STAND.x, STAND.y, "up")
U.wait(20)
local ow = game.overworld
local rival
for _, npc in ipairs(ow.npcs or {}) do
if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then rival = npc end
end
check("the rival object is on the map", rival ~= nil)
if ow.player.cellY ~= STAND.y then
-- a map edit moved the entrance walk's landing cell: stand anywhere the
-- warp row is still straight above, rather than face a wall
U.log("player did not land on (4,3); it is at",
ow.player.cellX, ow.player.cellY)
end
-- Run the script from the row after the rival battle, so no one has to win
-- OPP_RIVAL3 to see the cutscene. Rows before that point are the only ones
-- carrying jump targets, so the slice needs no reindexing -- assert that.
local from
for i, row in ipairs(rows) do
if row[1] == "show_text"
and row[2] == "_ChampionsRoomRivalAfterBattleText" then
from = i
break
end
end
check("found the post-battle row to start from", from ~= nil)
local slice, jumpy = {}, false
for i = from or 1, #rows do
local row = rows[i]
if row[1] == "jump" or row[1] == "jump_if_true"
or row[1] == "jump_if_false" then
jumpy = true
end
slice[#slice + 1] = row
end
check("the tail of the script has no jump targets to reindex", not jumpy)
if failures > 0 then
U.log("stopping before the cutscene:", failures,
"check(s) failed above, so what you would see means nothing")
while true do coroutine.yield() end
end
ow:queueScript(slice, { npc = rival })
local startY = ow.player.cellY
local minY, walkShot = startY, false
local hof
for i = 1, 5000 do
local top = game.stack:top()
if getmetatable(top) == HallOfFame or (top and top.drawMonInfo) then
hof = top
break
end
local w = game.overworld
if w and w.map and w.map.id == "CHAMPIONS_ROOM" then
local y = w.player.cellY
if y < minY then minY = y end
if y <= 2 and not walkShot then
walkShot = U.shot(game, DIR .. "/hof704_follows_oak.png")
end
end
if i % 6 == 0 then U.tap(game, "a") else U.wait(1) end
end
check("the player walked out of the room before the warp (#704)",
minY < startY)
check("walk-out screenshot", walkShot)
check("the induction started", hof ~= nil)
if not hof then
while true do coroutine.yield() end
end
-- ---------------------------------------------------------------
-- the induction screen
-- ---------------------------------------------------------------
-- mid scroll: .ScrollPic nudges hSCX 4px a frame, and the exemption has to
-- travel with the pic instead of sitting at its resting column (#637)
for _ = 1, 200 do
if (hof.scrollX or PIC_X) > 8 then break end
U.wait(1)
end
check("scrolling-in screenshot", U.shot(game, DIR .. "/hof637_scroll.png"))
for _ = 1, 300 do
if (hof.scrollX or 0) >= PIC_X and hof.phase == "mons" then break end
U.wait(1)
end
-- read back where the labels actually land: on screen a row collision and
-- a missing label look the same once the box is drawn over them
local realDraw = Font.draw
local seen = {}
Font.draw = function(text, x, y, ...)
seen[#seen + 1] = { tostring(text), x, y }
return realDraw(text, x, y, ...)
end
U.wait(4)
Font.draw = realDraw
local function rowOf(needle)
for _, d in ipairs(seen) do
if d[1]:find(needle, 1, true) then return d[3] end
end
return nil
end
local yLevel, yLevelVal = rowOf("LEVEL"), rowOf("100")
local yType1, yType1Val = rowOf("TYPE1"), rowOf("FIRE")
local yType2, yType2Val = rowOf("TYPE2"), rowOf("FLYING")
U.log("label rows:", tostring(yLevel), tostring(yType1), tostring(yType2),
"value rows:", tostring(yLevelVal), tostring(yType1Val),
tostring(yType2Val))
check("all six LEVEL/TYPE cells were drawn",
yLevel and yLevelVal and yType1 and yType1Val and yType2 and yType2Val)
check("no label shares a row with a value (#697)",
yLevel ~= yLevelVal and yType1 ~= yType1Val and yType2 ~= yType2Val
and yType1 ~= yLevelVal and yType2 ~= yType1Val)
check("the labels sit on rows 6/8/10 like PlaceNextChar's double spacing",
yLevel == 6 * 8 and yType1 == 8 * 8 and yType2 == 10 * 8)
local picRect
for _, r in ipairs(PaletteFX.trueColorRects("ui")) do
if r.colors == false and r.y == PIC_Y then picRect = r end
end
check("the settled pic is exempted from the whole-screen palette (#637)",
picRect ~= nil and picRect.x == PIC_X)
check("settled screenshot", U.shot(game, DIR .. "/hof697_mon_info.png"))
U.log(failures == 0 and "all checks passed" or ("FAILURES: " .. failures))
U.log("walked you into the hall of fame (#704, #697, #637).")
U.log("the player should have followed oak up and out of the champion's")
U.log("room before the fade, the TYPE1/TYPE2 rows should sit under LEVEL")
U.log("rather than on top of the level and type values, and each pic")
U.log("should keep its own colors while the rest of the screen carries")
U.log("the mon's tint, from the first scrolled-in pixel to the fade.")
U.log("the near miss to watch for: the pic goes right but a column at its")
U.log("left edge stays tinted while it slides in.")
U.log("the other two party members and the player stats page follow on")
U.log("their own; re-run the command to watch the whole thing again.")
while true do
coroutine.yield()
end
end
+238
View File
@@ -0,0 +1,238 @@
-- The Yellow Viridian catch tutorial has to FAIL its throw (#636).
-- pokeyellow scripts/ViridianCity.asm:168 (ViridianCityCheckWaitingOldMan)
-- starts the demo from (19,9); ItemUseBall's .oldManBattle branch reads
-- EVENT_INITIAL_CATCH_TRAINING and stores anim data $63, so the ball shakes
-- three times and breaks open, and only then does the losing-my-touch line
-- make sense. No POKEPORT_SPEED here: the shakes and the ball sounds run on
-- the real-time audio clock and fast-forward pulls them apart.
-- POKEPORT_DRIVER=tests/drivers/oldman_yellow_bug636_test.lua \
-- POKEPORT_VERSION=yellow POKEPORT_IDENTITY=bug636 SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local mapScripts = require("data.scripts.init")
local GameVersion = require("src.core.GameVersion")
local BattleState = require("src.battle.BattleState")
local TextBox = require("src.render.TextBox")
local Pokemon = require("src.pokemon.Pokemon")
local MAP = "VIRIDIAN_CITY"
local OLD_MAN2 = "VIRIDIANCITY_OLD_MAN2"
local TEXT = "TEXT_VIRIDIANCITY_OLD_MAN2"
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- ../pokeyellow/data/maps/objects/ViridianCity.asm:37 puts OLD_MAN2 on
-- (18,9); the trigger cell is the gap east of him, (19,9). Each candidate
-- is {stand x, stand y, direction that walks into (19,9)}.
local TRIGGER = { x = 19, y = 9 }
local APPROACHES = {
{ 19, 10, "up" }, { 19, 8, "down" }, { 20, 9, "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
local function flat(s) return (tostring(s):gsub("[\n\v\f]", " / ")) end
-- ---- what the eye cannot see -------------------------------------------
check("booted on Yellow (the fail branch is Yellow-only)",
GameVersion.get() == "yellow")
local hooks = mapScripts.get(MAP)
check(MAP .. " has a map-script table", type(hooks) == "table")
check("the (19,9) step hook survived the base-script merge",
type(hooks) == "table" and type(hooks.onStep) == "function")
check(TEXT .. " has a talk handler",
type(hooks) == "table" and type(hooks.talk) == "table"
and type(hooks.talk[TEXT]) == "function")
-- a species constant that no longer resolves builds a nameless battler and
-- looks exactly like the demo never starting
local om = game.data.field.oldManBattle
check("field.oldManBattle exists", type(om) == "table")
check("it is Yellow's RATTATA, not Red's WEEDLE",
type(om) == "table" and om.species == "RATTATA")
check("that species resolves in the pokemon table",
type(om) == "table" and game.data.pokemon[om.species] ~= nil)
-- the flag itself, off a throwaway battle that is never pushed
local probe = BattleState.newWild(game, (om and om.species) or "RATTATA",
(om and om.level) or 5)
probe:makeOldManDemo(nil, true)
check("makeOldManDemo(name, true) sets demoFails", probe.demoFails == true)
probe:makeOldManDemo(nil, false)
check("and without it the demo still catches, for Red and the reruns",
probe.demoFails == false)
local miss = game.data.text._ItemUseBallText04
check("_ItemUseBallText04 (three shakes, then free) resolves",
type(miss) == "string" and miss ~= "")
local touch = game.data.text._ViridianCityOldManLosingMyTouchText
check("_ViridianCityOldManLosingMyTouchText resolves",
type(touch) == "string" and touch ~= "")
check("it really is the losing-my-touch line",
type(touch) == "string" and touch:find("losing", 1, true) ~= nil)
if type(miss) == "string" then U.log("breakout line reads:", flat(miss)) end
if type(touch) == "string" then U.log("old man then says:", flat(touch)) end
local sfx = (game.save.options or {}).sfxVol
if sfx == 0 then
U.log("FAIL sfx volume is 0, so the ball wobbles and the break will be silent")
else
U.log("sfx volume is", tostring(sfx), "-- the ball should be audible")
end
-- ---- put the tutorial back to its untouched state -----------------------
game.save.party = {
Pokemon.new(game.data, "PIKACHU", 6),
}
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_POKEDEX = true
game.save.flags.EVENT_COMPLETED_CATCH_TRAINING = nil
game.save.objectToggles = {}
local ownedBefore = 0
for _ in pairs((game.save.pokedex and game.save.pokedex.owned) or {}) do
ownedBefore = ownedBefore + 1
end
local partyBefore = #game.save.party
local function npcNamed(name)
local ow = game.overworld
for _, n in ipairs((ow and ow.npcs) or {}) do
if n.def and n.def.name == name then return n end
end
return nil
end
-- walk into (19,9) from the first free neighbour, so a later map edit that
-- walls off the south approach degrades instead of pressing into a fence
local stand
U.teleport(game, MAP, APPROACHES[1][1], APPROACHES[1][2], "up")
U.wait(20)
local ow = game.overworld
check("the tutorial old man is on the map", npcNamed(OLD_MAN2) ~= nil)
for _, a in ipairs(APPROACHES) do
if ow and ow.map:isWalkableCell(a[1], a[2]) and not ow:npcAtCell(a[1], a[2]) then
stand = a
break
end
end
check(("a free cell next to (%d,%d) to walk in from")
:format(TRIGGER.x, TRIGGER.y), stand ~= nil)
stand = stand or APPROACHES[1]
if stand ~= APPROACHES[1] then
U.log(("south approach blocked, walking in from (%d,%d)"):format(stand[1], stand[2]))
U.teleport(game, MAP, stand[1], stand[2], stand[3])
U.wait(15)
end
-- ---- step on the trigger and let the demo run ---------------------------
local function topIsBattle()
local top = game.stack:top()
return (top and top.demo and top.demoName) and top or nil
end
local battle
for _ = 1, 40 do
U.hold(game, stand[3], 6)
U.wait(4)
battle = topIsBattle()
if battle then break end
-- the apology speech comes first; page through it to reach the battle
if getmetatable(game.stack:top()) == TextBox then
U.tap(game, "a")
U.wait(6)
end
end
if not battle then
for _ = 1, 200 do
battle = topIsBattle()
if battle then break end
if getmetatable(game.stack:top()) == TextBox then U.tap(game, "a") end
U.wait(6)
end
end
check("stepping onto (19,9) opened the catch demo", battle ~= nil)
check("the demo is the failing one (#636)",
battle ~= nil and battle.demoFails == true)
if battle then
U.log("demo opponent is", tostring(battle.enemy and battle.enemy.name))
end
-- Advance only pages that have finished typing (msgPrompt / msgWaiting are
-- the same holds the player would clear by hand), so the toss animation and
-- its sounds play at their own speed.
local sawMiss, sawCaught, shot = false, false, false
if battle then
for _ = 1, 3000 do
if game.stack:top() ~= battle then break end
local cur = battle.current
local line = cur and cur.text
if type(line) == "string" then
if miss and line == miss then sawMiss = true end
if line:find("caught", 1, true) then sawCaught = true end
end
if battle.msgPrompt and sawMiss and not shot then
shot = U.shot(game, SHOT_DIR .. "/bug636_ball_broke_open.png")
if shot then U.log("captured", SHOT_DIR .. "/bug636_ball_broke_open.png") end
end
if battle.msgPrompt or battle.msgWaiting then
U.tap(game, "a")
end
U.wait(2)
end
end
check("the ball broke open and printed the three-shake line", sawMiss)
check("nothing was caught", not sawCaught)
-- ---- the line that only follows a failed throw --------------------------
local box
for _ = 1, 400 do
local top = game.stack:top()
if getmetatable(top) == TextBox then box = top break end
U.wait(4)
end
check("the old man speaks again after the battle", box ~= nil)
local shown = {}
for _, page in ipairs((box and box.pages) or {}) do
for _, l in ipairs(page) do shown[#shown + 1] = l end
end
local joined = table.concat(shown, " / ")
if box then U.log("his box reads:", joined) end
check("he says he must be losing his touch",
joined:find("losing", 1, true) ~= nil)
if box then
if U.shot(game, SHOT_DIR .. "/bug636_losing_my_touch.png") then
U.log("captured", SHOT_DIR .. "/bug636_losing_my_touch.png")
end
end
local ownedAfter = 0
for _ in pairs((game.save.pokedex and game.save.pokedex.owned) or {}) do
ownedAfter = ownedAfter + 1
end
check("the demo added nothing to the party", #game.save.party == partyBefore)
check("and nothing to the dex's owned list", ownedAfter == ownedBefore)
-- ---- rewind so it can be watched again ----------------------------------
game.save.flags.EVENT_COMPLETED_CATCH_TRAINING = nil
game.save.objectToggles = {}
U.teleport(game, MAP, stand[1], stand[2], stand[3])
U.wait(15)
if fails > 0 then
U.log(fails, "check(s) above say FAIL; the screen is not worth watching yet.")
end
U.log(("ran the tutorial once and rewound it: you are on (%d,%d), so")
:format(stand[1], stand[2]))
U.log("holding " .. stand[3] .. " into (19,9) starts the demo again. The ball")
U.log("should wobble three times and pop open, then he blames his touch. The")
U.log("near miss to watch for is three wobbles, the caught jingle, and the")
U.log("same line anyway.")
while true do
coroutine.yield()
end
end
@@ -0,0 +1,279 @@
-- Manual check on the Silph Co. Giovanni aftermath (#722). pokered
-- scripts/SilphCo11F.asm SilphCo11FGiovanniAfterBattleScript: the loss line,
-- then TEXT_SILPHCO11F_GIOVANNI_YOU_RUINED_OUR_PLANS, GBFadeOutToBlack, the
-- rockets leaving, Delay3, GBFadeInFromBlack. The order and the fade are the
-- whole report, so no POKEPORT_SPEED here -- it scales only the logic clock.
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/silph_giovanni_bug722_test.lua POKEPORT_IDENTITY=bug722 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local ScriptRunner = require("src.script.ScriptRunner")
local mapScripts = require("data.scripts.init")
local victories = require("data.scripts.victories")
-- pokered data/maps/objects/SilphCo11F.asm: GIOVANNI (6,9), ROCKET1 (3,16),
-- ROCKET2 (15,9). SilphCo11FDefaultScript.PlayerCoordsArray is (6,13) and
-- (7,12); each is stepped onto from the cell directly below it, and the
-- trigger only reads the cell the player lands on.
local MAP = "SILPH_CO_11F"
local TRIGGERS = { { stand = { 7, 13 }, cell = { 7, 12 } },
{ stand = { 6, 14 }, cell = { 6, 13 } } }
local ELEVENTH = { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1",
"SILPHCO11F_ROCKET2" }
local SPEECH = "_SilphCo11FGiovanniYouRuinedOurPlansText"
local LOSS = "_SilphCo10FGiovanniILostAgainText"
-- SilphCo11FTeamRocketLeavesScript hides 31 toggles across 2F-11F; the
-- Saffron street half is M.SAFFRON_CITY.onEnter (data/scripts/story4.lua).
local HIDE_ROWS = 31
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 function spawned(name)
for _, n in ipairs(game.overworld and game.overworld.npcs or {}) do
if n.def and n.def.name == name then return n end
end
return nil
end
local function boxText(top)
local lines = {}
for _, page in ipairs(top.pages or {}) do
for _, line in ipairs(page) do lines[#lines + 1] = line end
end
return table.concat(lines, " ")
end
local function topBox()
local top = game.stack:top()
if getmetatable(top) == TextBox then return top end
return nil
end
-- ---- the half the eye cannot judge -------------------------------------
-- A missing text key, a renamed command and a script that never gets queued
-- all look the same on screen: the battle ends and the rockets are gone.
local hooks = mapScripts.get(MAP)
check("SILPH_CO_11F still has the coordinate trigger",
hooks ~= nil and type(hooks.onStep) == "function")
for _, key in ipairs({ SPEECH, LOSS }) do
local body = game.data.text[key]
check(key .. " is extracted",
type(body) == "string" and body ~= "")
end
local speech = game.data.text[SPEECH]
if type(speech) == "string" then
U.log(" the speech reads:",
(speech:gsub("\n", " "):gsub("\f", " <page> ")))
end
local reward = victories["OPP_GIOVANNI#2"]
check("OPP_GIOVANNI#2 still sets EVENT_BEAT_SILPH_CO_GIOVANNI",
reward ~= nil and reward.flag == "EVENT_BEAT_SILPH_CO_GIOVANNI")
check("and now carries the loss line he has no trainer header for (#722)",
reward ~= nil and reward.dialogue ~= nil and reward.dialogue[1] == LOSS)
local vol = game.save.options and game.save.options.sfxVol
if (vol or 0) == 0 then
U.log("sfxVol is 0 -- the battle and its text will be silent, raise it in OPTION")
else
U.log("sfxVol", tostring(vol))
end
-- ---- put the fight back on the table -----------------------------------
local function armFight()
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 70),
Pokemon.new(game.data, "SNORLAX", 70),
Pokemon.new(game.data, "LAPRAS", 70),
}
game.save.player.name = "bryan"
game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI = nil
game.save.flags.EVENT_BEAT_SILPH_CO_11F_TRAINER_0 = nil
game.save.flags.EVENT_BEAT_SILPH_CO_11F_TRAINER_1 = nil
game.save.objectToggles = {}
game.save.defeatedTrainers = {}
end
armFight()
-- ---- walk onto the trigger pad -----------------------------------------
local function liveBattle()
for _, s in ipairs(game.stack.states or {}) do
if getmetatable(s) == BattleState then return s end
end
return nil
end
local battle, cell
for i, t in ipairs(TRIGGERS) do
U.teleport(game, MAP, t.stand[1], t.stand[2], "up")
U.wait(10)
if i == 1 then
for _, name in ipairs(ELEVENTH) do
check(name .. " is on the floor before the fight", spawned(name) ~= nil)
end
U.shot(game, DIR .. "/bug722_0_before.png")
end
U.hold(game, "up", 24)
for _ = 1, 300 do
U.wait(1)
battle = liveBattle()
if battle then break end
-- his pre-battle line holds the overworld while he walks down
if topBox() then U.tap(game, "a") end
end
if battle then cell = t.cell break end
U.log(("the step up from (%d,%d) missed the trigger; trying the other pad")
:format(t.stand[1], t.stand[2]))
end
cell = cell or TRIGGERS[1].cell
check(("stepping onto (%d,%d) started GIOVANNI"):format(cell[1], cell[2]),
battle ~= nil)
if not battle then
U.log("nothing below ran: the trigger pads moved, check")
U.log("SilphCo11FDefaultScript.PlayerCoordsArray against M.SILPH_CO_11F.onStep")
while true do coroutine.yield() end
end
U.shot(game, DIR .. "/bug722_1_battle.png")
-- ---- win it ------------------------------------------------------------
for _ = 1, 1200 do
if battle.result then break end
U.tap(game, "a")
U.wait(4)
end
check("the battle was won", battle.result == "win")
if battle.result ~= "win" then
U.log("the party lost, so the aftermath never runs; nothing below applies")
while true do coroutine.yield() end
end
-- The aftermath is queued, not run inline: the battle's own callbacks are
-- still unwinding when engageTrainer's onDone fires, so it has to wait for
-- an idle overworld frame (OverworldState:drainPendingScripts).
local queued
for _ = 1, 900 do
local ow = game.overworld
local pending = ow and ow.pendingScripts and ow.pendingScripts[1]
if pending then queued = pending.script break end
if game.stack:top() == ow and not (ow.runner and ow.runner:isRunning()) then
-- already drained: nothing left to inspect
break
end
U.tap(game, "a")
U.wait(4)
end
check("the win queues an aftermath script (#722)", type(queued) == "table")
if type(queued) == "table" then
local problems = ScriptRunner.validate(queued)
check("it validates: " .. (problems[1] or "no problems"), #problems == 0)
local first, second, last = queued[1], queued[2], queued[#queued]
check("row 1 is the Blast-it-all speech",
first and first[1] == "show_text" and first[2] == SPEECH)
check("row 2 fades out before anyone leaves",
second and second[1] == "fade" and second[2] == "out")
check("the last row fades back in",
last and last[1] == "fade" and last[2] == "in")
local hides, waits, sawWait = 0, 0, false
for _, row in ipairs(queued) do
if row[1] == "hide_object" then hides = hides + 1 end
if row[1] == "wait" then waits = waits + 1 sawWait = true end
end
check(("all %d rockets leave behind the fade (found %d)")
:format(HIDE_ROWS, hides), hides == HIDE_ROWS)
check("Delay3 is held between the hides and the fade in",
sawWait and waits >= 1)
end
-- ---- the loss line, then the speech ------------------------------------
local sawLoss, sawSpeech = false, false
for _ = 1, 600 do
local box = topBox()
if box then
local txt = boxText(box)
if txt:find("lost again", 1, true) then sawLoss = true end
if txt:find("Blast it all", 1, true) and not sawSpeech then
sawSpeech = true
U.wait(60) -- let the box finish typing before the capture
if not U.shot(game, DIR .. "/bug722_2_speech.png") then
U.log("the speech screenshot did not reach disk")
end
end
end
if sawSpeech and not topBox() then break end
U.tap(game, "a")
U.wait(6)
end
check("his \"Arrgh!! I lost again!?\" box came first", sawLoss)
check("then the \"Blast it all!\" speech played", sawSpeech)
-- ---- the fade ----------------------------------------------------------
local sawFade, shotFade = false, false
for _ = 1, 600 do
local ow = game.overworld
local overlay = ow and ow.fadeOverlay
if overlay then
sawFade = true
if (overlay.alpha or 0) > 0.85 and not shotFade then
shotFade = U.shot(game, DIR .. "/bug722_3_black.png")
end
end
if sawFade and shotFade and (not overlay or (overlay.alpha or 0) < 0.05) then
break
end
U.wait(1)
end
check("the screen faded to black over the departure", sawFade)
for _ = 1, 240 do
local ow = game.overworld
if game.stack:top() == ow and not (ow.runner and ow.runner:isRunning()) then
break
end
U.wait(1)
end
U.shot(game, DIR .. "/bug722_4_after.png")
for _, name in ipairs(ELEVENTH) do
check(name .. " left the floor", spawned(name) == nil)
end
check("EVENT_BEAT_SILPH_CO_GIOVANNI is set",
game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI == true)
local toggles = game.save.objectToggles[MAP] or {}
check("the 11F toggles were written to the save",
toggles.SILPHCO11F_GIOVANNI == false)
local twoF = game.save.objectToggles.SILPH_CO_2F or {}
check("the lower floors were cleared in the same pass",
twoF.SILPHCO2F_ROCKET1 == false)
U.log(("%d passed, %d failed"):format(pass, fail))
if fail > 0 then
U.log("something above failed, so what is on screen is not the fix")
end
-- ---- hand off ----------------------------------------------------------
-- re-arm and park the player one cell below the pad so the whole thing can
-- be watched again from a step and a mash
armFight()
U.teleport(game, MAP, TRIGGERS[1].stand[1], TRIGGERS[1].stand[2], "up")
U.wait(10)
U.log("fought giovanni on silph 11F and watched the aftermath once (#722).")
U.log("walk up one cell and win again: he should say \"Arrgh!!\", then the")
U.log("whole \"Blast it all!\" speech, and only then should the screen fade")
U.log("out, the rockets vanish behind the black, and fade back in. the")
U.log("near miss to watch for is the room going quiet and empty with no")
U.log("speech, or the rockets popping out in plain sight before the fade.")
while true do
coroutine.yield()
end
end
+90
View File
@@ -0,0 +1,90 @@
-- The Pokédex seen/own footer survives three-digit counts (#639).
-- engine/menus/pokedex.asm HandlePokedexListMenu prints both counts into a
-- fixed three-digit field (`lb bc, 1, 3` at hlcoord 16,3 and 16,6) under
-- PokedexSeenText / PokedexOwnText, so the line never changes width as the
-- dex fills. The port built "SEEN %d OWNED %d", which is 19 glyphs once
-- either count reaches 100: one column over the text box, so ListMenu's
-- bare footer paginated it into two lines and drew the first at y=120,
-- on top of the list's last row.
-- ROM-free: the real PokedexMenu over a synthetic 151-entry dex.
-- luajit tests/engine/pokedex_counts_bug639.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 PokedexMenu = require("src.ui.PokedexMenu")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local TextBox = require("src.render.TextBox")
-- A full Kanto numbering, which the fixture dataset deliberately does not
-- carry (tests/fixture_data/constants.lua caps dexSize at 3). Everything
-- the menu reads off a species is here: id, name, dex number.
local DEX_SIZE = 151
local dexData = setmetatable({
pokemon = {},
constants = { dexSize = DEX_SIZE, dexDigits = 3 },
}, { __index = Data })
for n = 1, DEX_SIZE do
local id = ("DEXMON_%03d"):format(n)
dexData.pokemon[id] = { id = id, name = ("MON%03d"):format(n), dex = n }
end
-- builds the dex list the way Screens does, with the first `ownedCount`
-- species owned and the next `seenOnly` species seen but not caught
local function footerFor(ownedCount, seenOnly)
local stack = setmetatable({}, { __index = StateStack })
stack:init()
local save = SaveData.newGame()
save.pokedex = { seen = {}, owned = {} }
for n = 1, ownedCount do
local id = ("DEXMON_%03d"):format(n)
save.pokedex.seen[id] = true
save.pokedex.owned[id] = true
end
for n = ownedCount + 1, ownedCount + seenOnly do
save.pokedex.seen[("DEXMON_%03d"):format(n)] = true
end
local game = { data = dexData, save = save, stack = stack }
return PokedexMenu.new(game, {}).footer
end
-- how many lines ListMenu's bare-footer branch would end up drawing: it
-- flattens every paginated line and, at two or more, starts them at y=120
-- instead of y=136, which is the row the last list entry sits on
local function lines(text)
local flat = {}
for _, page in ipairs(TextBox.paginate(text)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
return flat
end
-- --------------------------------------------------------- 100 or more
local hundred = footerFor(100, 5)
eq(hundred, "SEEN 105 OWN 100", "105 seen / 100 owned prints both counts")
eq(#lines(hundred), 1, "the three-digit footer still fits on one line")
local full = footerFor(DEX_SIZE, 0)
eq(full, "SEEN 151 OWN 151", "a completed dex prints 151 twice")
eq(#lines(full), 1, "the widest possible footer still fits on one line")
-- The witness for the bug: the string this replaced is one column over the
-- 18-column box, so it wrapped and the wrap is what collided with the list.
eq(#lines("SEEN 105 OWNED 100"), 2,
"the old OWNED wording is what pushed the footer onto a second line")
-- --------------------------------------------------------- under 100
-- The field is fixed width, so a small dex right-aligns into the same
-- columns rather than sliding left as it grows.
local small = footerFor(3, 6)
eq(small, "SEEN 9 OWN 3", "single digits keep the three-wide field")
eq(#lines(small), 1, "a small dex is one line too")
eq(#small, #hundred, "the footer is the same width empty or full")
check(small:find("OWN ") == hundred:find("OWN "),
"OWN starts in the same column at 3 caught and at 100")
T.finish("pokedex_counts_bug639")
@@ -0,0 +1,64 @@
-- The built-in GAME SPEED hotkeys on controller: the shoulder buttons
-- (L1/R1) and the analog triggers (L2/R2) all cycle the engine speed
-- ladder, R-side faster and L-side slower, exactly like keyboard hotkey
-- 1. Anything else must still fall through to the top-state pad
-- routing and never touch the speed ladder. LÖVE reports an analog
-- trigger as gamepadpressed once it crosses the press threshold, so
-- "lefttrigger"/"righttrigger" land here like any other pad button.
-- No pokered cite: port-only (gap C2).
-- luajit tests/engine/speed_shoulders_triggers_test.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 Game = require("src.core.Game")
local Input = require("src.core.Input")
Input:init()
local dirs = {}
local game = {
save = { options = { speed = 1 } },
_cycleSpeed = function(self, dir)
dirs[#dirs + 1] = dir
self.save.options.speed = self.save.options.speed + dir
end,
stack = { top = function() return nil end },
}
function game:writeOptions() end
local function last()
return dirs[#dirs]
end
-- ---- shoulders ----------------------------------------------------------
dirs = {}
Game.gamepadpressed(game, nil, "rightshoulder")
eq(last(), 1, "R1 speeds up")
Game.gamepadpressed(game, nil, "leftshoulder")
eq(last(), -1, "L1 slows down")
-- ---- analog triggers ----------------------------------------------------
dirs = {}
Game.gamepadpressed(game, nil, "righttrigger")
eq(last(), 1, "R2 speeds up")
Game.gamepadpressed(game, nil, "lefttrigger")
eq(last(), -1, "L2 slows down")
-- ---- everything else falls through to the top-state pad routing ---------
local routed = nil
game.stack.top = function()
return { onGamepadPressed = function(_, b) routed = b end }
end
Game.gamepadpressed(game, nil, "a")
eq(routed, "a", "a normal button still reaches the top-state pad routing")
routed = nil
Game.gamepadpressed(game, nil, "rightshoulder")
check(routed == nil, "the speed buttons are consumed, never routed to a menu")
Game.gamepadpressed(game, nil, "lefttrigger")
check(routed == nil, "a trigger pull is consumed the same way")
check(#dirs == 4, "only the speed buttons touched the speed ladder")
T.finish("speed_shoulders_triggers")
+16 -1
View File
@@ -50,6 +50,9 @@ TextBox.new = function(game, text, onDone, opts)
end
local sawChoice = false
-- what sat under the YES/NO box the first time it opened, and what sat at
-- the bottom of the stack for the whole conversation (#624)
local underChoice, coinBox = nil, nil
-- One conversation, start to finish. `answer` is which row of the YES/NO
-- box to take; the loop mashes A the way a player does and nudges the
@@ -58,16 +61,21 @@ local function talk(save, answer)
Game.save = save
StateStack:init()
shown, sawChoice = {}, false
underChoice, coinBox = nil, nil
local done = false
local ow = { map = { id = "GAME_CORNER", def = Data.maps.GAME_CORNER },
npcs = {}, entities = {} }
yellow(Game, ow, { def = {} }, function() done = true end)
coinBox = StateStack.states[1]
local moved = false
for _ = 1, 2000 do
local top = StateStack:top()
if not top then break end
if getmetatable(top) == ChoiceBox then
if not sawChoice then
underChoice = StateStack.states[#StateStack.states - 1]
end
sawChoice = true
if answer == "no" and not moved then
moved = true
@@ -108,7 +116,14 @@ do
check(said("¥1000 for 50"), "the offer quotes ¥1000 for 50 coins")
eq(save.money, 0, "¥1000 leaves the wallet")
eq(save.coins, 50, "50 coins land in the case")
check(said("COINS: 50"), "the receipt prints the new coin total")
check(getmetatable(underChoice) == TextBox,
"the offer stays on screen under the YES/NO box (#624)")
check(coinBox and coinBox.draw and not coinBox.update,
"a draw-only MONEY/COIN box sits under the whole conversation,"
.. " like GameCornerDrawCoinBox (#624)")
check(not said("COINS: 50"),
"the receipt is the plain thanks line: the new total shows in that"
.. " box, which the asm redraws after the sale (#624)")
end
-- ---------------------------------------------------------------- saying no
+12
View File
@@ -65,6 +65,18 @@ if b1 then
eq(b1.event, B1F_GUARD, "B1F waits on the fifth grunt")
end
-- Yellow ships no RocketHideoutB4FDoorCallbackScript (its
-- scripts/RocketHideoutB4F.asm calls EnableAutoTextBoxDrawing first thing)
-- and the same open .blk doorway, and Jessie & James hold the two guard
-- slots without setting either guard flag, so the B4F row must not be
-- stamped on a Yellow boot (#650). Read through fieldValue, which is how
-- a cache carrying closedDoors but no skipMaps still resolves it.
local skipMaps = FieldDefaults.fieldValue(Data, "cardKeyDoors", "skipMaps")
check(skipMaps and skipMaps.yellow and skipMaps.yellow[B4F],
"Yellow skips the B4F lift gate")
check(not (skipMaps and skipMaps.red), "Red keeps both gates")
check(not (skipMaps and skipMaps.blue), "Blue keeps both gates")
-- the events the rows name have to be the ones the guards actually set:
-- interact() writes trainerHeader().event on a win
eq(Data:trainerHeader("RocketHideoutB4F", 2).event, GUARD_0,
+152
View File
@@ -0,0 +1,152 @@
-- Parity (#650): Yellow's Rocket Hideout B4F lift gate is never barred.
--
-- Oracle: pokeyellow scripts/RocketHideoutB4F.asm has no
-- RocketHideoutB4FDoorCallbackScript at all -- the map script header goes
-- straight to EnableAutoTextBoxDrawing -- and the floor's two guard slots
-- are Jessie & James (data/maps/objects/RocketHideoutB4F.asm, object_event
-- 24, 10, SPRITE_JESSIE), who set EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES.
-- EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 / _1 do not exist in Yellow, so the
-- Red/Blue closedDoors row from #372 stamped a $2d block over a doorway
-- nothing in the game could ever re-open: Giovanni was unreachable.
-- pokeyellow scripts/RocketHideoutB1F.asm still calls its own
-- RocketHideoutB1FDoorCallbackScript, so only B4F is skipped.
--
-- The Red-imported cache is what this process has, and the two floors'
-- block layouts are the same in Yellow, so the version switch alone
-- reproduces the bug: what changes between the sections below is
-- GameVersion.get(), nothing else.
--
-- Self-contained: `luajit tests/parity_yellow_hideout_gate.lua`; also
-- globbed by tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.ROCKET_HIDEOUT_B4F) then Data:load() end
local S = require("tests.harness").suite("parity Yellow hideout gate (#650)")
local check, eq = S.check, S.eq
local FieldDefaults = require("src.world.FieldDefaults")
local GameVersion = require("src.core.GameVersion")
local B4F, B1F = "ROCKET_HIDEOUT_B4F", "ROCKET_HIDEOUT_B1F"
local GUARD_0 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0"
local GUARD_1 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1"
local B1F_GUARD = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4"
-- the flag Yellow's pair actually writes (SetEvent at scripts/
-- RocketHideoutB4F.asm:274); no closedDoors row names it, by design
local JESSIE_JAMES = "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES"
-- ---- the skip row --------------------------------------------------------
-- through fieldValue, so a cache that carries closedDoors from an older
-- manifest but no skipMaps still resolves the built-in table
local skipMaps = FieldDefaults.fieldValue(Data, "cardKeyDoors", "skipMaps")
check(skipMaps and skipMaps.yellow and skipMaps.yellow[B4F],
"Yellow skips the B4F lift gate")
check(not (skipMaps and skipMaps.yellow and skipMaps.yellow[B1F]),
"and keeps B1F, which Yellow still has a callback for")
-- ---- the live floor, on a Yellow boot ------------------------------------
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local Sound = require("src.core.Sound")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.save.party = { Pokemon.new(Data, "CHARMANDER", 20) }
local sfx = {}
local realPlay = Sound.play
Sound.play = function(_, id) sfx[#sfx + 1] = id end
local function goInsides()
local n = 0
for _, id in ipairs(sfx) do if id == "Go_Inside" then n = n + 1 end end
return n
end
-- Map:setBlock writes through to the shared Data record every later suite
-- in this process reads, so both doorways are put back at the bottom
local restore = {}
local function remember(mapId, bx, by)
local def = Data.maps[mapId]
restore[#restore + 1] = { mapId, bx, by, def.blocks[by * def.width + bx + 1] }
end
remember(B4F, 12, 5)
remember(B1F, 12, 8)
local function arrive(mapId, x, y)
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, mapId, x, y, "up")
return Game.stack:top()
end
local oldVersion = GameVersion.get()
GameVersion.set("yellow")
-- one cell south of the doorway, where the lift corridor starts
local ow = arrive(B4F, 24, 13)
eq(ow.map:blockAt(12, 5), 0x0e, "Yellow arrives on B4F with the gate open")
check(ow.map:isWalkableCell(24, 11) and ow.map:isWalkableCell(25, 11),
"both doorway cells are floor from the first visit")
eq(goInsides(), 0, "and nothing plays the door sound on arrival")
-- beating the pair: the flag Yellow sets is not one the Red/Blue row
-- names, and afterBattle re-runs the callback on the same map instance
-- (home/trainers.asm EndTrainerBattle), which is where the stamp used to
-- slam the doorway shut without a warp anywhere near it
Game.save.flags[JESSIE_JAMES] = true
ow:afterBattle("win", {})
check(not Game.save.flags[GUARD_0] and not Game.save.flags[GUARD_1],
"Jessie & James set neither Red/Blue guard flag")
eq(ow.map:blockAt(12, 5), 0x0e, "beating them leaves the gate open")
check(ow.map:isWalkableCell(24, 11) and ow.map:isWalkableCell(25, 11),
"and the way through to Giovanni stays walkable")
eq(goInsides(), 0, "a gate that was never barred needs no door sound")
-- no map reload in between: the same OverworldState the battle returned
-- to is the one being read, and a reload does not change the answer
ow:stampClosedDoors()
eq(ow.map:blockAt(12, 5), 0x0e, "a direct callback run on the live floor is a no-op")
ow = arrive(B4F, 24, 13)
eq(ow.map:blockAt(12, 5), 0x0e, "re-entering the floor still finds it open")
-- ---- B1F, which Yellow did keep -----------------------------------------
sfx = {}
ow = arrive(B1F, 24, 17)
eq(ow.map:blockAt(12, 8), 0x54, "Yellow's B1F is still barred until the grunt")
Game.save.flags[B1F_GUARD] = true
ow:afterBattle("win", {})
eq(ow.map:blockAt(12, 8), 0x0e, "beating him opens it on the spot")
eq(goInsides(), 1, "with SFX_GO_INSIDE")
-- ---- the control: Red still stamps B4F ----------------------------------
-- without this the section above would pass just as well against a
-- closedDoors table someone had emptied outright
GameVersion.set("red")
sfx = {}
Game.save.flags[JESSIE_JAMES] = nil
ow = arrive(B4F, 24, 13)
eq(ow.map:blockAt(12, 5), 0x2d, "Red arrives on B4F with the gate barred")
Game.save.flags[GUARD_0] = true
Game.save.flags[GUARD_1] = true
ow:afterBattle("win", {})
eq(ow.map:blockAt(12, 5), 0x0e, "and its two guards still open it")
eq(goInsides(), 1, "with the door sound Yellow has no script for")
for _, r in ipairs(restore) do
local def = Data.maps[r[1]]
def.blocks[r[3] * def.width + r[2] + 1] = r[4]
end
Sound.play = realPlay
GameVersion.set(oldVersion)
while Game.stack:top() do Game.stack:pop() end
return S:finish()
+2
View File
@@ -190,6 +190,8 @@ do
check(battle and battle.demo, "it is the old-man demo battle")
eq(battle and battle.enemy and battle.enemy.mon.species, "RATTATA",
"the demo is a RATTATA in Yellow (#617)")
check(battle and battle.demoFails,
"the initial training throw breaks out, never catches (#636)")
eq(save.flags[DONE_FLAG], nil, "the flag is still clear mid-demo")
battle.onFinish() -- the battle ends, the post-battle text prints
+6 -5
View File
@@ -145,10 +145,9 @@ for os, forwards in pairs(touchForwardsToImporter) do
os .. ": the synthesized mouse press is dropped only where touch already forwarded")
end
-- The FlexLove view polls love.touch itself and dedupes a tap's synthesized
-- mouse click in its action layer (LauncherView queueAction), so the
-- host-forwarded touch events are inert stubs: they must accept any id
-- without capturing state or throwing.
-- Before the FlexLove view attaches (_flex), touch handlers are no-ops: they
-- must accept any id without capturing importer state or throwing. Once the
-- view is up they forward into FlexLove.touch* for list drag-scroll.
local touch = importer("iOS")
touch:touchpressed(101, 20, 20)
touch:touchmoved(101, 22, 22)
@@ -156,7 +155,9 @@ touch:touchreleased(202, 20, 20)
touch:touchpressed(303, 20, 20)
touch:touchreleased(303, 20, 20)
check(touch._activeTouch == nil,
"touch events stay inert: the view's own polling owns touch input")
"touch events before the view attaches leave importer touch state alone")
check(not touch._flex,
"and do not attach the FlexLove view on their own")
love.system.getOS = saved.getOS
love.system.pickFile = saved.pickFile