mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aec2ca89b | |||
| 0f7261dd92 | |||
| 5c041ea857 | |||
| a528868656 | |||
| c82af28fd4 | |||
| d72e5db0a5 | |||
| 9907cfb0a0 | |||
| 0971b47bec |
+67
-24
@@ -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
@@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,6 +12,20 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"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",
|
||||
"size": 9052744,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.61/gen1recomp-0.1.61-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #325 Launcher crashes importing a mod .zip with non-ASCII characters in the filename (Windows)\n- #646 Fuchsia City Pokemon not being added to Pokedex.\n- #651 Parasect trading NPC mute and deaf\n- #665 fix: file path sanitizing is needed\n- #706 Champion theme playing too late\n- #709 Fainted Pokemon plays its normal cry instead of the pitched-down, slower faint cry\n- #719 Silph C.O workers dialog bug.\n\n## Contributors\n\n- @bryanthaboi\n- @castdrian\n- @jherediagu\n- @johnjohto\n- @ShaneMcGovernIE\n- hernan"
|
||||
},
|
||||
{
|
||||
"version": "0.1.60",
|
||||
"date": "2026-08-02",
|
||||
|
||||
@@ -83,6 +83,7 @@ fi
|
||||
# way once).
|
||||
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \
|
||||
tools/save-editor/panels/Party.lua \
|
||||
libs/flexlove/FlexLove.lua \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json; do
|
||||
unzip -Z1 "$LOVE_FILE" | grep -qx "$required" \
|
||||
|
||||
@@ -202,8 +202,12 @@ pack_game_love() {
|
||||
# APK, so the mod manager's Delete can't remove it and it reappears every
|
||||
# launch. Pokewalker ships as an importable .zip instead, which gives it
|
||||
# a real install/upgrade/delete lifecycle.
|
||||
# libs/ carries the vendored FlexLove toolkit the launcher UI is built on
|
||||
# (src/import/LauncherView.lua requires it at the top level, and RomImporter
|
||||
# calls into that view from both update and draw), so an archive without it
|
||||
# dies on the first frame with nothing left to fall back to.
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
@@ -220,6 +224,12 @@ pack_game_love() {
|
||||
|| fail "game.love is missing the save editor (Edit on a save row would crash)"
|
||||
grep -qx "$YELLOW_MANIFEST_RELATIVE" <<< "$archive_entries" \
|
||||
|| fail "game.love is missing the Yellow ROM import manifest"
|
||||
# This gate exists because libs/ was added to scripts/build.sh's payload and
|
||||
# to no other packager, so Android and iOS built an APK/IPA whose launcher
|
||||
# threw on require("libs.flexlove.FlexLove") before drawing anything. Source
|
||||
# runs read libs/ off the working tree, so only a build can catch it.
|
||||
grep -qx 'libs/flexlove/FlexLove.lua' <<< "$archive_entries" \
|
||||
|| fail "game.love is missing the FlexLove UI toolkit (launcher dies on frame 1)"
|
||||
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
|
||||
|
||||
# This script packs its own game.love (it does not reuse build.sh's), so it
|
||||
|
||||
@@ -315,9 +315,13 @@ pack_game_love() {
|
||||
# it reappears every launch. Mods install as .zips at runtime instead
|
||||
# (launcher -> MODS -> Import mod .zip), the same lifecycle as every
|
||||
# other platform.
|
||||
# libs/ carries the vendored FlexLove toolkit the launcher UI is built on
|
||||
# (src/import/LauncherView.lua requires it at the top level, and RomImporter
|
||||
# calls into that view from both update and draw), so an archive without it
|
||||
# dies on the first frame with nothing left to fall back to.
|
||||
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
|
||||
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
|
||||
main.lua conf.lua src data assets tools/save-editor \
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
$MANIFESTS \
|
||||
-x '*.DS_Store' -x '*/.git/*' -x '*/.DS_Store' \
|
||||
-x 'data/generated/*' -x 'assets/generated/*')
|
||||
@@ -333,10 +337,14 @@ pack_game_love() {
|
||||
# in 0.1.45 through 0.1.47: decodeManifest (src/import/RomImporter.lua) errors
|
||||
# outright when a version's manifest is absent, so Import ROM on Yellow died
|
||||
# in the built app while dev, which reads the source tree, stayed green.
|
||||
# libs/flexlove/FlexLove.lua is on the list for the same reason: it was added
|
||||
# to build.sh's payload and to no other packager, so the mobile builds shipped
|
||||
# a launcher that threw before drawing its first frame.
|
||||
archive_entries="$(unzip -Z1 "$LOVE_FILE")"
|
||||
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
|
||||
for required in src/update/Boot.lua tools/save-editor/App.lua \
|
||||
tools/save-editor/Kit.lua tools/save-editor/panels/Party.lua \
|
||||
libs/flexlove/FlexLove.lua \
|
||||
$MANIFESTS; do
|
||||
printf '%s\n' "$archive_entries" | grep -qx "$required" \
|
||||
|| fail "game.love is missing $required"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
+77
-10
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user