mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c4515eaaa | |||
| c04b93ce1c | |||
| 2c9970a643 |
+78
-22
@@ -222,17 +222,55 @@ M.BILLS_HOUSE = {
|
||||
-- repair saves that already got the ticket under the old collapsed
|
||||
-- script (the monster never hidden, human Bill never shown)
|
||||
onEnter = function(game, ow)
|
||||
if game.save.flags.EVENT_GOT_SS_TICKET
|
||||
and not game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
|
||||
local flags = game.save.flags
|
||||
if flags.EVENT_GOT_SS_TICKET
|
||||
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||
game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
|
||||
game.save.flags.EVENT_MET_BILL = true
|
||||
game.save.flags.EVENT_MET_BILL_2 = true
|
||||
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||
flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
|
||||
flags.EVENT_MET_BILL = true
|
||||
flags.EVENT_MET_BILL_2 = true
|
||||
end
|
||||
-- After Route25ToggleBillsScript, BILL2 is the visible human Bill
|
||||
-- ("check out my rare POKéMON"). Re-apply on enter so a mid-house
|
||||
-- load still matches the toggles if the pool was rebuilt.
|
||||
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Route 25 outside Bill's house (scripts/Route25.asm
|
||||
-- Route25ToggleBillsScript): leaving after the SS Ticket arms the
|
||||
-- Eevee PC list and swaps human Bill to his post-help dialogue NPC.
|
||||
-- Leaving mid-quest (Bill still in the machine) puts the monster back.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
M.ROUTE_25 = {
|
||||
onEnter = function(game, ow)
|
||||
local flags = game.save.flags
|
||||
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = game, save = game.save, overworld = ow }
|
||||
if not flags.EVENT_MET_BILL_2 then
|
||||
flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil
|
||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
|
||||
return
|
||||
end
|
||||
if not flags.EVENT_GOT_SS_TICKET then return end
|
||||
flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true
|
||||
-- TOGGLE_NUGGET_BRIDGE_GUY (ROUTE24_COOLTRAINER_M1)
|
||||
Commands.hide_object(ctx, "ROUTE_24", "ROUTE24_COOLTRAINER_M1")
|
||||
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
|
||||
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -256,25 +294,40 @@ M.VERMILION_CITY = {
|
||||
end,
|
||||
-- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check:
|
||||
-- the unguarded cell (18,30) just west of the sailor leads straight
|
||||
-- onto the dock warp, so stepping onto it heading for the dock gets
|
||||
-- ticket-checked (and turned back once the ship has sailed) without
|
||||
-- the player ever pressing A. The sailor himself never disappears.
|
||||
-- onto the dock warp. Stepping onto it facing down always runs the
|
||||
-- sailor dialog (DisplayTextID TEXT_VERMILIONCITY_SAILOR1); only a
|
||||
-- ticket while the ship is still docked lets the player continue --
|
||||
-- otherwise they are walked back up. The sailor himself never hides.
|
||||
onStep = function(game, ow, x, y)
|
||||
if x ~= 18 or y ~= 30 then return false end
|
||||
if ow.player.facing ~= "down" then return false end
|
||||
local f = game.save.flags
|
||||
local Flags = require("src.script.Flags")
|
||||
local t = game.data.text
|
||||
local TextBox = require("src.render.TextBox")
|
||||
if f.EVENT_SS_ANNE_LEFT then
|
||||
local shipLeft = Flags.get(game.save, "EVENT_SS_ANNE_LEFT")
|
||||
local hasTicket = (game.save.inventory.S_S_TICKET or 0) > 0
|
||||
if shipLeft then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._VermilionCitySailor1ShipSetSailText or "The ship set sail.",
|
||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||
return true
|
||||
end
|
||||
if (game.save.inventory.S_S_TICKET or 0) > 0 then return false end
|
||||
-- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so
|
||||
-- VermilionCitySailor1Text always takes .greet_player_and_check_ticket:
|
||||
-- DoYouHaveATicket, then FlashedTicket (allow through) or YouNeedATicket
|
||||
-- (walk back). Returning true while the box is up also blocks the
|
||||
-- dock warp on this step.
|
||||
local ask = t._VermilionCitySailor1DoYouHaveATicketText
|
||||
or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?"
|
||||
if hasTicket then
|
||||
game.stack:push(TextBox.new(game,
|
||||
ask .. "\f"
|
||||
.. (t._VermilionCitySailor1FlashedTicketText
|
||||
or "{PLAYER} flashed\nthe S.S.TICKET!")))
|
||||
return true
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._VermilionCitySailor1WelcomeToSSAnneText or "Welcome to S.S.\nANNE!")
|
||||
.. "\f"
|
||||
ask .. "\f"
|
||||
.. (t._VermilionCitySailor1YouNeedATicketText
|
||||
or "You need a ticket\nto get aboard."),
|
||||
function() ow:scriptMove(ow.player, "up", 1) end))
|
||||
@@ -288,7 +341,7 @@ M.VERMILION_CITY = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2
|
||||
{ "jump_if_true", 11 }, -- 3
|
||||
{ "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 4
|
||||
{ "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4
|
||||
{ "check_item", "S_S_TICKET" }, -- 5
|
||||
{ "jump_if_false", 9 }, -- 6
|
||||
{ "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7
|
||||
@@ -318,18 +371,21 @@ M.SS_ANNE_2F = {
|
||||
|
||||
M.SS_ANNE_CAPTAINS_ROOM = {
|
||||
talk = {
|
||||
-- SSAnneCaptainsRoomCaptainText: after the rub line's text_asm tail,
|
||||
-- pokered plays MUSIC_PKMN_HEALED (scripts/SSAnneCaptainsRoom.asm).
|
||||
TEXT_SSANNECAPTAINSROOM_CAPTAIN = {
|
||||
{ "check_flag", "EVENT_GOT_HM01" }, -- 1
|
||||
{ "jump_if_true", 9 }, -- 2
|
||||
{ "jump_if_true", 10 }, -- 2
|
||||
{ "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3
|
||||
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 4
|
||||
{ "play_once", "Music_PkmnHealed" }, -- 4
|
||||
{ "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 5
|
||||
-- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem
|
||||
-- fills wStringBuffer; the received text reads it)
|
||||
{ "give_item", "HM_CUT", 1, false }, -- 5
|
||||
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 6
|
||||
{ "set_flag", "EVENT_GOT_HM01" }, -- 7
|
||||
{ "jump", 10 }, -- 8
|
||||
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 9
|
||||
{ "give_item", "HM_CUT", 1, false }, -- 6
|
||||
{ "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 7
|
||||
{ "set_flag", "EVENT_GOT_HM01" }, -- 8
|
||||
{ "jump", 11 }, -- 9
|
||||
{ "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 10
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+141
-44
@@ -719,73 +719,170 @@ M.CINNABAR_LAB_FOSSIL_ROOM = {
|
||||
-- -------------------------------------------------------------------
|
||||
-- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per
|
||||
-- step; the fee is ¥100 plus ¥100 per level gained.
|
||||
--
|
||||
-- #118: do not raise mon.level until a paid retrieve (pokered reverts
|
||||
-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into
|
||||
-- mon.exp once and clear them so a second talk cannot re-apply the same
|
||||
-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here —
|
||||
-- TextBox.TOKENS.RAM only knows wStringBuffer.
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local function fillDaycareText(s, subs)
|
||||
s = s:gsub("{PLAYER}", subs.player or "")
|
||||
s = s:gsub("{RAM:([^}]*)}", function(name) return subs[name] or "" end)
|
||||
-- extractor NUM spans may include flags after a comma; keep the name
|
||||
s = s:gsub("{NUM:([%w_]+)[^}]*}", function(name)
|
||||
return tostring(subs[name] or "0")
|
||||
end)
|
||||
return s
|
||||
end
|
||||
|
||||
M.DAYCARE = {
|
||||
talk = {
|
||||
TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local t = game.data.text
|
||||
local dc = game.save.daycare
|
||||
local playerName = game.save.player and game.save.player.name or "RED"
|
||||
|
||||
local function monName(mon)
|
||||
local def = game.data.pokemon[mon.species]
|
||||
return mon.nickname or (def and def.name) or mon.species
|
||||
end
|
||||
|
||||
if dc and dc.mon then
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local mon = dc.mon
|
||||
local def = game.data.pokemon[mon.species]
|
||||
mon.exp = mon.exp + (dc.steps or 0)
|
||||
local newLevel = math.min(100, Growth.levelForExp(def.growthRate, mon.exp))
|
||||
local fee = 100 + (newLevel - mon.level) * 100
|
||||
local grew = newLevel > mon.level
|
||||
mon.level = newLevel
|
||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
||||
mon.hp = mon.stats.hp
|
||||
local msg = grew and (t._DaycareGentlemanMonHasGrownText or "It's grown a lot!")
|
||||
or "Back already?"
|
||||
game.stack:push(TextBox.new(game,
|
||||
msg .. ("\fThe fee is ¥%d.\nGet it back?"):format(fee), function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if yes and game.save.money >= fee then
|
||||
-- Apply deferred step-exp once (OverworldController only bumps
|
||||
-- daycare.steps). Clearing prevents double-count on re-talk.
|
||||
mon.exp = (mon.exp or 0) + (dc.steps or 0)
|
||||
dc.steps = 0
|
||||
-- depositLevel mirrors wDayCareMonBoxLevel: fee baseline that
|
||||
-- must survive a declined retrieve. Fall back to mon.level for
|
||||
-- older saves that predate the field.
|
||||
if dc.depositLevel == nil then dc.depositLevel = mon.level end
|
||||
local startLevel = dc.depositLevel
|
||||
local newLevel = Growth.levelForExp(def and def.growthRate, mon.exp)
|
||||
if newLevel >= 100 then
|
||||
newLevel = 100
|
||||
if def then
|
||||
mon.exp = Growth.expForLevel(def.growthRate, 100)
|
||||
end
|
||||
end
|
||||
local levelsGrown = math.max(0, newLevel - startLevel)
|
||||
local fee = 100 + levelsGrown * 100
|
||||
local name = monName(mon)
|
||||
local subs = {
|
||||
player = playerName,
|
||||
wNameBuffer = name,
|
||||
wDayCareMonName = name,
|
||||
wDayCareNumLevelsGrown = levelsGrown,
|
||||
wDayCareTotalCost = fee,
|
||||
}
|
||||
local statusText = levelsGrown > 0
|
||||
and (t._DaycareGentlemanMonHasGrownText
|
||||
or "Your {RAM:wNameBuffer}\nhas grown a lot!\fBy level, it's\ngrown by {NUM:wDayCareNumLevelsGrown, 1, 3}!\fAren't I great?")
|
||||
or (t._DaycareGentlemanMonNeedsMoreTimeText
|
||||
or "Back already?\nYour {RAM:wNameBuffer}\nneeds some more\ntime with me.")
|
||||
|
||||
game.stack:push(TextBox.new(game, fillDaycareText(statusText, subs), function()
|
||||
if #game.save.party >= Party.MAX then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanNoRoomForMonText
|
||||
or "You have no room\nfor this POKéMON!", done))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
fillDaycareText(
|
||||
t._DaycareGentlemanOweMoneyText
|
||||
or "You owe me ¥{NUM:wDayCareTotalCost, 2 | LEADING_ZEROES | LEFT_ALIGN}\nfor the return\nof this POKéMON.",
|
||||
subs),
|
||||
nil, { choice = function(yes)
|
||||
if not yes then
|
||||
-- .leaveMonInDayCare: revert any transient level bump
|
||||
mon.level = startLevel
|
||||
game.stack:push(TextBox.new(game,
|
||||
(t._DaycareGentlemanAllRightThenText or "All right then,\n")
|
||||
.. (t._DaycareGentlemanComeAgainText or "come again."),
|
||||
done))
|
||||
return
|
||||
end
|
||||
if (game.save.money or 0) < fee then
|
||||
mon.level = startLevel
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanNotEnoughMoneyText
|
||||
or "Hey, you don't\nhave enough ¥!", done))
|
||||
return
|
||||
end
|
||||
game.save.money = game.save.money - fee
|
||||
mon.level = newLevel
|
||||
if def then
|
||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
||||
mon.hp = mon.stats.hp
|
||||
end
|
||||
table.insert(game.save.party, mon)
|
||||
game.save.daycare = nil
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanGotMonBackText or "Here you go!", done))
|
||||
else
|
||||
game.stack:push(TextBox.new(game,
|
||||
yes and (t._DaycareGentlemanOweMoneyText or "You owe me money!")
|
||||
or "Come again!", done))
|
||||
end
|
||||
end))
|
||||
t._DaycareGentlemanHeresYourMonText
|
||||
or "Thank you! Here's\nyour POKéMON!", function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
fillDaycareText(
|
||||
t._DaycareGentlemanGotMonBackText
|
||||
or "{PLAYER} got\n{RAM:wDayCareMonName} back!",
|
||||
subs), done))
|
||||
end))
|
||||
end }))
|
||||
end))
|
||||
return
|
||||
end
|
||||
|
||||
if #game.save.party < 2 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
"You only have one\nPOKéMON with you!", done))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanIntroText or "I can raise a\nPOKéMON for you.", function()
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then done() return end
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
game.stack:push(PartyMenu.new(game, {
|
||||
pickOnly = true,
|
||||
onSwitch = function(mon)
|
||||
for i, m in ipairs(game.save.party) do
|
||||
if m == mon then table.remove(game.save.party, i) break end
|
||||
end
|
||||
game.save.daycare = { mon = mon, steps = 0 }
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanWillLookAfterMonText or
|
||||
"Fine, I'll look\nafter it a while!", done))
|
||||
end,
|
||||
}))
|
||||
end))
|
||||
end))
|
||||
t._DaycareGentlemanIntroText
|
||||
or "I run a DAYCARE.\nWould you like me\nto raise one of\nyour POKéMON?",
|
||||
nil, { choice = function(yes)
|
||||
if not yes then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanComeAgainText or "come again.", done))
|
||||
return
|
||||
end
|
||||
if #game.save.party < 2 then
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanOnlyHaveOneMonText
|
||||
or "You only have one\nPOKéMON with you.", done))
|
||||
return
|
||||
end
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanWhichMonText or "Which POKéMON\nshould I raise?",
|
||||
function()
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
game.stack:push(PartyMenu.new(game, {
|
||||
pickOnly = true,
|
||||
onSwitch = function(mon)
|
||||
for i, m in ipairs(game.save.party) do
|
||||
if m == mon then table.remove(game.save.party, i) break end
|
||||
end
|
||||
local name = monName(mon)
|
||||
-- depositLevel = wDayCareMonBoxLevel at deposit time
|
||||
game.save.daycare = {
|
||||
mon = mon, steps = 0, depositLevel = mon.level,
|
||||
}
|
||||
game.stack:push(TextBox.new(game,
|
||||
fillDaycareText(
|
||||
t._DaycareGentlemanWillLookAfterMonText
|
||||
or "Fine, I'll look\nafter {RAM:wNameBuffer}\nfor a while.",
|
||||
{ player = playerName, wNameBuffer = name }),
|
||||
function()
|
||||
game.stack:push(TextBox.new(game,
|
||||
t._DaycareGentlemanComeSeeMeInAWhileText
|
||||
or "Come see me in\na while.", done))
|
||||
end))
|
||||
end,
|
||||
}))
|
||||
end))
|
||||
end }))
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
+43
-18
@@ -150,18 +150,20 @@ local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right =
|
||||
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local WALK_ORDER = { "up", "down", "left", "right" }
|
||||
|
||||
local function elevatorWalkOut(ow, floor)
|
||||
local m, p = ow.map, ow.player
|
||||
-- .UpdateWarp is run twice, so BOTH car warp entries get the same
|
||||
-- (warp id, map id): point every exit warp at the picked floor's
|
||||
-- elevator-door warp (the reciprocal warp found while building the
|
||||
-- menu). The car map's def is shared generated data, but its own
|
||||
-- warps are only ever read from inside the car, and this rewrite runs
|
||||
-- on every ride before the walk-out fires, so it is self-correcting.
|
||||
for _, w in ipairs(m.def.warps) do
|
||||
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
|
||||
-- door (warp id, map id). Shared generated map data, but the car's
|
||||
-- warps are only read from inside the car; rides rewrite them again.
|
||||
local function elevatorSetExit(ow, floor)
|
||||
if not floor then return end
|
||||
for _, w in ipairs(ow.map.def.warps) do
|
||||
w.destMap = floor.map
|
||||
w.destWarp = floor.warpIdx
|
||||
end
|
||||
end
|
||||
|
||||
local function elevatorWalkOut(ow, floor)
|
||||
local m, p = ow.map, ow.player
|
||||
elevatorSetExit(ow, floor)
|
||||
-- leave by the exit tile under the player (they warped in onto one),
|
||||
-- else the nearest
|
||||
local door
|
||||
@@ -201,7 +203,11 @@ end
|
||||
|
||||
local function elevator(elevatorMapId, keyGate, preFrames)
|
||||
return {
|
||||
onEnter = function(game, ow)
|
||||
-- fromMapId: the floor the player just left (setMap passes it), so a
|
||||
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
|
||||
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
|
||||
-- asserted and hard-crashed (#123).
|
||||
onEnter = function(game, ow, fromMapId)
|
||||
if keyGate and not game.save.inventory[keyGate.item] then
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game,
|
||||
@@ -230,6 +236,17 @@ local function elevator(elevatorMapId, keyGate, preFrames)
|
||||
return (tonumber(a.token:match("%d+")) or 0) <
|
||||
(tonumber(b.token:match("%d+")) or 0)
|
||||
end)
|
||||
-- Seed a walk-out destination before the menu: entry floor when
|
||||
-- known, else the first listed floor (1F). Choosing a floor still
|
||||
-- rewrites via elevatorWalkOut; B-cancel keeps this seed so leaving
|
||||
-- the car cannot hit a missing ROM placeholder map.
|
||||
local exitFloor = floors[1]
|
||||
if fromMapId then
|
||||
for _, f in ipairs(floors) do
|
||||
if f.map == fromMapId then exitFloor = f break end
|
||||
end
|
||||
end
|
||||
elevatorSetExit(ow, exitFloor)
|
||||
local items = {}
|
||||
for _, f in ipairs(floors) do
|
||||
table.insert(items, { label = f.token, value = f })
|
||||
@@ -262,7 +279,8 @@ local function elevator(elevatorMapId, keyGate, preFrames)
|
||||
end,
|
||||
onCancel = function()
|
||||
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
|
||||
-- happens, the player just stays in the car
|
||||
-- happens, the player just stays in the car (exit warps were
|
||||
-- already seeded to the entry floor above)
|
||||
end,
|
||||
}))
|
||||
end,
|
||||
@@ -481,8 +499,9 @@ local DOCK_SHIP_BLOCKS = {
|
||||
|
||||
M.VERMILION_DOCK = {
|
||||
onEnter = function(game, ow)
|
||||
local Flags = require("src.script.Flags")
|
||||
local f = game.save.flags
|
||||
if f.EVENT_SS_ANNE_LEFT then
|
||||
if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then
|
||||
-- the ship is long gone: erase her right away, and anyone who
|
||||
-- still lands here is sent back out past the guard
|
||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||
@@ -498,13 +517,15 @@ M.VERMILION_DOCK = {
|
||||
elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then
|
||||
-- VermilionDockSSAnneLeavesScript: only stepping OFF the ship
|
||||
-- triggers the departure (wDestinationWarpID == 1 in pokered) --
|
||||
-- the horn blows, smoke puffs drift off the funnel, the ship is
|
||||
-- erased to open water, and the player is walked off the dock
|
||||
-- into the city past the guard (VermilionCity's
|
||||
-- Music_Surfing plays for the sail-away cutscene, smoke puffs
|
||||
-- drift off the funnel, the horn blows, the ship is erased to
|
||||
-- open water, and the player is walked off the dock into the
|
||||
-- city past the guard (VermilionCity's
|
||||
-- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk)
|
||||
f.EVENT_SS_ANNE_LEFT = true
|
||||
require("src.core.Music").stop()
|
||||
require("src.core.Sound").play(game.data, "SS_Anne_Horn")
|
||||
Flags.set(game.save, "EVENT_SS_ANNE_LEFT")
|
||||
local Music = require("src.core.Music")
|
||||
Music.stop()
|
||||
Music.play(game.data, "Music_Surfing")
|
||||
local function puff(n, cx)
|
||||
if n <= 0 then return end
|
||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||
@@ -512,11 +533,15 @@ M.VERMILION_DOCK = {
|
||||
puff(3, 15)
|
||||
local rows = {}
|
||||
rows[#rows + 1] = { "wait", 100 }
|
||||
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
|
||||
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
|
||||
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 30 }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
-- keep Music_Surfing across the city warp (pokered stays on it
|
||||
-- through the exit-ship walk)
|
||||
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
|
||||
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
|
||||
rows[#rows + 1] = { "move_player", "up", 2 }
|
||||
ow:queueScript(rows)
|
||||
|
||||
@@ -42,8 +42,10 @@ Game Boy equivalent:
|
||||
rows above the player recede and rows below come toward the viewer. Only
|
||||
things that actually *stand* on the ground draw as upright billboards,
|
||||
unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
|
||||
and the screen-anchored FX attached to them (heal machine glow, emote
|
||||
bubbles, the fishing rod, the FLY bird). An earlier revision tried
|
||||
and the standing FX attached to them (emote bubbles, the fishing rod,
|
||||
the FLY bird). The Poké Center heal-machine overlay stays on the ground
|
||||
plane with the machine tiles (it is OAM glued to a BG graphic, not a
|
||||
standing sprite). An earlier revision tried
|
||||
billboarding buildings/trees/signs too (cutting them out of the ground
|
||||
per hand-curated per-tileset tables); that chased an endless tail of
|
||||
special cases, dense tree canopy, fences fused into grass, building
|
||||
@@ -148,4 +150,6 @@ migrated once into `options.lua` on load.
|
||||
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
|
||||
SuperPalettes + per-species mon colors)
|
||||
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
||||
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
||||
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
|
||||
a hard render frame-rate cap (`save.options.fpsCap`).
|
||||
@@ -299,3 +299,78 @@ function love.filedropped(file)
|
||||
end
|
||||
if Importer then Importer:filedropped(file) end
|
||||
end
|
||||
|
||||
local function pacingEnabled()
|
||||
if os.getenv("POKEPORT_AUTOPILOT") then return false end
|
||||
if os.getenv("POKEPORT_DRIVER") then return false end
|
||||
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
function love.run()
|
||||
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
|
||||
|
||||
-- don't let love.load's cost land in the first frame's dt
|
||||
if love.timer then love.timer.step() end
|
||||
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local paced = pacingEnabled()
|
||||
-- The deadline the next present() should not beat. Carried forward one
|
||||
-- budget per frame so pacing stays even instead of drifting with the
|
||||
-- per-frame sleep-granularity jitter.
|
||||
local nextFrame = love.timer and love.timer.getTime() or 0
|
||||
local dt = 0
|
||||
|
||||
return function()
|
||||
-- process events
|
||||
if love.event then
|
||||
love.event.pump()
|
||||
for name, a, b, c, d, e, f in love.event.poll() do
|
||||
if name == "quit" then
|
||||
if not love.quit or not love.quit() then
|
||||
return a or 0
|
||||
end
|
||||
end
|
||||
love.handlers[name](a, b, c, d, e, f)
|
||||
end
|
||||
end
|
||||
|
||||
-- update dt
|
||||
if love.timer then dt = love.timer.step() end
|
||||
|
||||
-- call update and draw
|
||||
if love.update then love.update(dt) end
|
||||
|
||||
if love.graphics and love.graphics.isActive() then
|
||||
love.graphics.origin()
|
||||
love.graphics.clear(love.graphics.getBackgroundColor())
|
||||
if love.draw then love.draw() end
|
||||
love.graphics.present()
|
||||
end
|
||||
|
||||
if love.timer then
|
||||
if paced then
|
||||
-- Sleep out the remainder of the frame budget, measured from the
|
||||
-- carried deadline, in small chunks so the OS timer stays
|
||||
-- responsive. vsync is untouched: when it already paces slower
|
||||
-- than the cap the remainder is <= 0 and this rounds to a no-op.
|
||||
local budget = 1 / FrameCap.current
|
||||
nextFrame = nextFrame + budget
|
||||
local now = love.timer.getTime()
|
||||
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
|
||||
-- deadline more than a full budget in the past; re-anchor to now so
|
||||
-- we pace the next frame rather than burst uncapped to catch up.
|
||||
if now - nextFrame > budget then
|
||||
nextFrame = now
|
||||
end
|
||||
while true do
|
||||
local remaining = nextFrame - love.timer.getTime()
|
||||
if remaining <= 0 then break end
|
||||
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
|
||||
end
|
||||
else
|
||||
love.timer.sleep(0.001)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -230,6 +230,25 @@ function ChipAudio.ensureMusicPlaying()
|
||||
end
|
||||
end
|
||||
|
||||
-- Threaded playMusic returns an empty QueueableSource and only calls
|
||||
-- Source:play once the first worker buffer lands (~1 frame later). Until
|
||||
-- then Source:isPlaying is false -- callers that treat that as "song over"
|
||||
-- (Music.oneShotPlaying / pendingRestore) must wait here instead, or a
|
||||
-- playOnce jingle like Music_PkmnHealed is cut off before it starts.
|
||||
local forceAwaitingFirstBuffer -- test-only override (see _simulate*)
|
||||
|
||||
function ChipAudio.awaitingFirstBuffer()
|
||||
if forceAwaitingFirstBuffer then return true end
|
||||
local m = currentMusic
|
||||
if not (m and m.threaded and not m.started and not m.finished) then
|
||||
return false
|
||||
end
|
||||
-- a dead worker will never deliver the first buffer
|
||||
if workerReady == false then return false end
|
||||
if worker and worker.getError and worker:getError() then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
function ChipAudio.stopMusic()
|
||||
if currentMusic and currentMusic.source then
|
||||
pcall(currentMusic.source.stop, currentMusic.source)
|
||||
@@ -240,6 +259,7 @@ function ChipAudio.stopMusic()
|
||||
end
|
||||
pendingBuf = nil
|
||||
currentMusic = nil
|
||||
forceAwaitingFirstBuffer = nil
|
||||
end
|
||||
|
||||
-- hot reload: the next play re-reads programs.bin (a mod may have swapped the
|
||||
@@ -302,6 +322,20 @@ end
|
||||
-- test hooks (headless): synchronous synthesis straight through ChipSynth
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Force the "threaded, first buffer not yet queued" window so Music's
|
||||
-- playOnce / pendingRestore race can be asserted without love.thread.
|
||||
-- Returns a clear() that drops the override (call after the assertion).
|
||||
function ChipAudio._simulateAwaitingFirstBufferForTest()
|
||||
local m = currentMusic
|
||||
if not m or not m.source then return nil end
|
||||
m.threaded = true
|
||||
m.started = false
|
||||
m.finished = false
|
||||
pcall(function() m.source.playing = false end)
|
||||
forceAwaitingFirstBuffer = true
|
||||
return function() forceAwaitingFirstBuffer = nil end
|
||||
end
|
||||
|
||||
function ChipAudio._renderMusicForTest(data, header, seconds)
|
||||
local engine = ChipSynth.newEngine(data, header, { allowLoops = true })
|
||||
return ChipSynth.soundData(engine, math.floor(seconds * SAMPLE_RATE), 2)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Render frame-rate cap. With a driver control panel forcing
|
||||
-- vsync off, the 160x144 game is trivially cheap and love.run will present
|
||||
-- thousands of frames a second; over hours that cooks the graphics driver
|
||||
-- until a restart, and it wastes power whenever the window is left open in
|
||||
-- the background. A hard cap bounds the present rate. Render-only: game
|
||||
-- logic is fixed-step off dt (src/core/FixedStep.lua), so pacing present()
|
||||
-- changes nothing about timing, audio, or determinism.
|
||||
--
|
||||
-- Persisted as save.options.fpsCap; applied from OptionsMenu and on boot
|
||||
-- via Game:applyOptions. main.lua's love.run reads FrameCap.current each
|
||||
-- frame for its sleep budget. The module never touches love.timer itself,
|
||||
-- so it stays safe under the headless test stub.
|
||||
|
||||
local FrameCap = {}
|
||||
|
||||
-- Selectable steps: the normal framerate stops between the floor and the
|
||||
-- ceiling. STEPS[1] == MIN and STEPS[#STEPS] == MAX, so the nearest-step
|
||||
-- snap in normalize doubles as the clamp. Cycling past the last wraps.
|
||||
FrameCap.STEPS = { 30, 40, 50, 60, 75, 90, 100, 120, 144, 160 }
|
||||
FrameCap.MIN = 30
|
||||
FrameCap.MAX = 160
|
||||
FrameCap.DEFAULT = 60
|
||||
|
||||
-- The live cap the run loop paces to. Defaults so the launcher and the
|
||||
-- save editor are paced before any save applies its stored option.
|
||||
FrameCap.current = FrameCap.DEFAULT
|
||||
|
||||
-- Nearest valid step for an arbitrary value (a hand-edited options.lua or
|
||||
-- an old save with no fpsCap key), so a bad number degrades to something
|
||||
-- sane; nil / non-numbers fall back to the default. A value below MIN or
|
||||
-- above MAX snaps to that end, since MIN/MAX are the first/last steps.
|
||||
function FrameCap.normalize(value)
|
||||
value = tonumber(value)
|
||||
if not value then return FrameCap.DEFAULT end
|
||||
local best, bestDiff = FrameCap.DEFAULT, math.huge
|
||||
for _, step in ipairs(FrameCap.STEPS) do
|
||||
local diff = math.abs(step - value)
|
||||
if diff < bestDiff then best, bestDiff = step, diff end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
-- plain numeric text for the options row (e.g. "60")
|
||||
function FrameCap.label(value)
|
||||
return tostring(FrameCap.normalize(value))
|
||||
end
|
||||
|
||||
-- cycle to the next/previous step, wrapping (the options row idiom)
|
||||
function FrameCap.cycle(value, dir)
|
||||
local steps = FrameCap.STEPS
|
||||
local snapped = FrameCap.normalize(value)
|
||||
local cur = 1
|
||||
for i, step in ipairs(steps) do
|
||||
if step == snapped then cur = i break end
|
||||
end
|
||||
local nextIdx = (cur - 1 + (dir or 1)) % #steps + 1
|
||||
return steps[nextIdx]
|
||||
end
|
||||
|
||||
-- Store the chosen cap as the live value the run loop paces to. Never
|
||||
-- touches love.timer, so it is safe headless -- the loop just reads the
|
||||
-- number back. Returns the normalized value it stored.
|
||||
function FrameCap.apply(value)
|
||||
FrameCap.current = FrameCap.normalize(value)
|
||||
return FrameCap.current
|
||||
end
|
||||
|
||||
function FrameCap.applyOptions(opts)
|
||||
FrameCap.apply(opts and opts.fpsCap)
|
||||
end
|
||||
|
||||
return FrameCap
|
||||
@@ -445,6 +445,9 @@ function Game:applyOptions(opts)
|
||||
require("src.render.Tilt").applyOptions(opts)
|
||||
require("src.render.GBCFX").applyOptions(opts)
|
||||
require("src.core.VideoMode").applyOptions(opts)
|
||||
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||
-- fpsCap key pace at the standard rate (issue #88)
|
||||
require("src.core.FrameCap").applyOptions(opts)
|
||||
Input:applyBindings(opts.bindings)
|
||||
end
|
||||
|
||||
|
||||
+14
-1
@@ -254,6 +254,7 @@ function Music.stop()
|
||||
require("src.core.ChipAudio").stopMusic()
|
||||
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
|
||||
state.chip = false
|
||||
state.pendingRestore = nil
|
||||
if previous and Runtime.wants("music.stopped") then
|
||||
Runtime.emit("music.stopped", { song = previous })
|
||||
end
|
||||
@@ -347,14 +348,24 @@ end
|
||||
function Music.playOnce(data, song)
|
||||
if not songDef(data, song) then return false end
|
||||
Music.play(data, song, false, { reason = "once" })
|
||||
-- play() can no-op (hook silence, failed def); only arm restore when
|
||||
-- the jingle actually became current
|
||||
if state.current ~= song then return false end
|
||||
state.pendingRestore = true
|
||||
return true
|
||||
end
|
||||
|
||||
local function chipAwaitingFirstBuffer()
|
||||
return state.chip
|
||||
and require("src.core.ChipAudio").awaitingFirstBuffer()
|
||||
end
|
||||
|
||||
-- is a playOnce jingle still sounding? (AnimateHealingMachine's
|
||||
-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends)
|
||||
function Music.oneShotPlaying()
|
||||
if not state.pendingRestore then return false end
|
||||
-- threaded chip songs start silent for ~1 frame; that gap is not "over"
|
||||
if chipAwaitingFirstBuffer() then return true end
|
||||
local src = state.source
|
||||
if not src then return false end
|
||||
local ok, playing = pcall(src.isPlaying, src)
|
||||
@@ -441,8 +452,10 @@ function Music.update(data)
|
||||
state.source = loopSrc
|
||||
pcall(loopSrc.play, loopSrc)
|
||||
end
|
||||
-- do not treat "threaded source still waiting on its first buffer" as
|
||||
-- ended, or playOnce jingles get restored over before they can sound
|
||||
if state.pendingRestore and sourceStopped(state.source)
|
||||
and not state.loopSource then
|
||||
and not state.loopSource and not chipAwaitingFirstBuffer() then
|
||||
Music.restoreMap(data)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -192,6 +192,8 @@ function SaveData.defaultOptions()
|
||||
gbcfx = 0,
|
||||
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
||||
videoMode = "windowed",
|
||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||
fpsCap = 60,
|
||||
-- Native mod enablement is an installation option, not save-slot data.
|
||||
-- Missing entries mean enabled so newly installed mods work by default.
|
||||
mods = {},
|
||||
|
||||
@@ -148,6 +148,17 @@ function Font.advanceOf(code)
|
||||
return page and page.advance or GLYPH
|
||||
end
|
||||
|
||||
-- Pixel width of a string (glyph advances, not UTF-8 byte length).
|
||||
-- Multi-byte charmap entries like "¥" are one glyph; callers that
|
||||
-- right-align with `#text * 8` mis-place them.
|
||||
function Font.width(text)
|
||||
local w = 0
|
||||
for _, code in ipairs(Font.encode(text)) do
|
||||
w = w + Font.advanceOf(code)
|
||||
end
|
||||
return w
|
||||
end
|
||||
|
||||
-- Draw a plain single-line string at pixel (x, y). Returns the width
|
||||
-- drawn, which is #codes * 8 for every fixed-width page.
|
||||
function Font.draw(text, x, y)
|
||||
|
||||
@@ -62,6 +62,9 @@ end
|
||||
function Renderer:beginFrame(transparent)
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
||||
-- a popped transition cannot leave a sticky black veil
|
||||
self.worldFadeAlpha = nil
|
||||
-- last frame's trueColor rects and sprite redraws go before anything
|
||||
-- draws this one
|
||||
PaletteFX.clearTrueColor()
|
||||
@@ -414,6 +417,16 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
|
||||
love.graphics.setScissor()
|
||||
end
|
||||
-- Screen-space warp fade (Transition) over the full world composite so
|
||||
-- survey zoom / tilt edges darken with the center, not only the 160x144
|
||||
-- UI letterbox. Drawn before the UI blit so menus above a fade still
|
||||
-- composite normally if one is ever stacked that way.
|
||||
local fade = self.worldFadeAlpha
|
||||
if fade and fade > 0 then
|
||||
love.graphics.setColor(0, 0, 0, fade)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
-- UI stays in the classic centered GB letterbox
|
||||
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
|
||||
|
||||
@@ -57,6 +57,16 @@ end
|
||||
function Transition:draw()
|
||||
local alpha = self.t / self.frames
|
||||
if self.phase == "in" then alpha = 1 - alpha end
|
||||
-- Survey zoom draws the overworld into a window-filling world canvas
|
||||
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
|
||||
-- UI canvas only darkens that center box (issue #121); when the world
|
||||
-- pass ran this frame, hand the alpha to Renderer:endFrame so it paints
|
||||
-- a screen-space overlay over the full composite instead.
|
||||
local r = self.game and self.game.renderer
|
||||
if r and r.worldActive then
|
||||
r.worldFadeAlpha = alpha
|
||||
return
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, alpha)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
@@ -438,6 +438,12 @@ function Commands.play_sound(ctx, soundId)
|
||||
require("src.core.Sound").play(ctx.game.data, soundId)
|
||||
end
|
||||
|
||||
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); the map
|
||||
-- theme resumes when it ends (Music.playOnce / pendingRestore)
|
||||
function Commands.play_once(ctx, songId)
|
||||
require("src.core.Music").playOnce(ctx.game.data, songId)
|
||||
end
|
||||
|
||||
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
|
||||
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
|
||||
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
|
||||
|
||||
+5
-1
@@ -145,6 +145,10 @@ end
|
||||
|
||||
function BoxMenu.new(game)
|
||||
Boxes.ensure(game.save)
|
||||
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
|
||||
-- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the
|
||||
-- full interior (cursor col + label); the old tw=12 right-side box
|
||||
-- drew the final glyph on the border.
|
||||
return Menu.new(game, {
|
||||
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
|
||||
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
|
||||
@@ -153,7 +157,7 @@ function BoxMenu.new(game)
|
||||
{ label = "SEE YA!" },
|
||||
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
|
||||
-- engine/menus/pokemon_pc.asm)
|
||||
}, { tx = 8, ty = 0, tw = 12, th = 12, noSound = true })
|
||||
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
|
||||
end
|
||||
|
||||
return BoxMenu
|
||||
|
||||
+13
-3
@@ -117,7 +117,7 @@ function ListMenu:draw()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
end
|
||||
if item.right then
|
||||
Font.draw(item.right, 160 - 8 - #item.right * 8, y)
|
||||
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
|
||||
end
|
||||
if i == self.index then
|
||||
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
|
||||
@@ -136,7 +136,7 @@ function ListMenu:draw()
|
||||
Font.drawBox(11, 0, 9, 3)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local money = ("¥%d"):format(self.money and self.money() or 0)
|
||||
Font.draw(money, 152 - #money * 8, 8)
|
||||
Font.draw(money, 152 - Font.width(money), 8)
|
||||
-- the clerk's line in the standard bottom text box; long prompts
|
||||
-- wrap and keep their last two lines, like the GB's scrolled box
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
@@ -153,7 +153,17 @@ function ListMenu:draw()
|
||||
end
|
||||
end
|
||||
elseif self.footer then
|
||||
Font.draw(self.footer, 8, 136)
|
||||
-- PC deposit/withdraw footers use "\n"; draw the last two lines so
|
||||
-- long item names are not clipped at the screen edge (#115).
|
||||
local flat = {}
|
||||
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
|
||||
for _, line in ipairs(page) do flat[#flat + 1] = line end
|
||||
end
|
||||
local y = (#flat >= 2) and 120 or 136
|
||||
for i = math.max(1, #flat - 1), #flat do
|
||||
Font.draw(flat[i], 8, y)
|
||||
y = y + 16
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -13,6 +13,7 @@ local Tilt = require("src.render.Tilt")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local VideoMode = require("src.core.VideoMode")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
@@ -202,6 +203,19 @@ local function buildRows(game)
|
||||
VideoMode.apply(o.videoMode)
|
||||
return true
|
||||
end },
|
||||
-- hard render cap (issue #88): bounds the present rate so a
|
||||
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
|
||||
-- is fixed-step off dt, so this touches presentation only.
|
||||
{ id = "fpsCap", label = "MAX FPS",
|
||||
value = function(g)
|
||||
return FrameCap.label(g.save.options.fpsCap)
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
o.fpsCap = FrameCap.cycle(o.fpsCap, dir)
|
||||
FrameCap.apply(o.fpsCap)
|
||||
return true
|
||||
end },
|
||||
-- fast-forward the logic clock only; music and sfx keep their tempo
|
||||
-- (src/core/GameSpeed.lua), so this is safe to leave on
|
||||
{ id = "speed", label = "GAME SPEED",
|
||||
|
||||
@@ -182,12 +182,13 @@ function PartyMenu:update(dt)
|
||||
-- and music return first (PlayDefaultMusic +
|
||||
-- LoadWalkingPlayerSpriteGraphics), the menu closes with the
|
||||
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
|
||||
-- steps the player forward onto land
|
||||
-- steps the player forward onto land (or across a connection
|
||||
-- strip when the shore is the next map's edge)
|
||||
self.game.stack:pop()
|
||||
ow.player.surfing = false
|
||||
require("src.core.Music").setSurfing(self.game.data, false)
|
||||
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
|
||||
ow:scriptMove(ow.player, ow.player.facing, 1)
|
||||
ow:stepForwardOrCrossEdge(ow.player.facing)
|
||||
end))
|
||||
return
|
||||
end
|
||||
|
||||
+7
-3
@@ -84,13 +84,17 @@ local function buy(game, stock)
|
||||
end
|
||||
|
||||
local function sell(game)
|
||||
-- Sell list is ITEMLISTMENU with wPrintItemPrices cleared
|
||||
-- (pokemart.asm .sellMenuLoop): name + quantity only. Price shows
|
||||
-- in the quantity chooser. Stuffing "xN" into the label next to a
|
||||
-- right-aligned ¥ price made long names overlap (issue #116).
|
||||
local items = {}
|
||||
for _, id in ipairs(Bag.order(game.save)) do
|
||||
local def = game.data.items[id]
|
||||
table.insert(items, {
|
||||
value = id,
|
||||
label = (def and def.name or id) .. " x" .. game.save.inventory[id],
|
||||
right = ("¥%d"):format(def and math.floor(def.price / 2) or 0),
|
||||
label = def and def.name or id,
|
||||
right = "x" .. game.save.inventory[id],
|
||||
})
|
||||
end
|
||||
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
|
||||
@@ -128,7 +132,7 @@ local function sell(game)
|
||||
Bag.remove(game.save, item.value, qty)
|
||||
local left = game.save.inventory[item.value]
|
||||
if left then
|
||||
item.label = def.name .. " x" .. left
|
||||
item.right = "x" .. left
|
||||
else
|
||||
list:removeCurrent()
|
||||
end
|
||||
|
||||
@@ -115,12 +115,16 @@ function SummaryMenu:draw()
|
||||
drawLineBox(19, 1, 6, 10)
|
||||
Font.draw("EXP POINTS", 72, 24)
|
||||
Font.draw(("%d"):format(mon.exp), 96, 32)
|
||||
Font.draw("LEVEL UP", 72, 44)
|
||||
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols
|
||||
-- at (7,6); space at (14,6); PrintLevel at (16,6). The old
|
||||
-- "%d to L%d" string at x=88 overflowed the DrawLineBox edge.
|
||||
Font.draw("LEVEL UP", 72, 40)
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local nextExp = mon.level < 100
|
||||
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
|
||||
Font.draw(("%d to L%d"):format(math.max(0, nextExp),
|
||||
math.min(100, mon.level + 1)), 88, 52)
|
||||
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
|
||||
HudTiles.tile(0x6E, 128, 48) -- <LV>
|
||||
Font.draw(tostring(math.min(100, mon.level + 1)), 136, 48)
|
||||
Font.drawBox(0, 8, 20, 10)
|
||||
for i = 1, 4 do
|
||||
local mv = mon.moves[i]
|
||||
|
||||
+47
-26
@@ -38,6 +38,51 @@ local function hashSet(list, into)
|
||||
return into
|
||||
end
|
||||
|
||||
-- Collision tile (bottom-left 8x8) of a cell on an UNLOADED map def --
|
||||
-- the connected neighbor during an edge crossing. pokered's
|
||||
-- GetTileAndCoordsInFrontOfPlayer / collision checks read the neighbor
|
||||
-- strip's tile bytes the same way.
|
||||
function Map.defCellTile(def, tilesetDef, cx, cy)
|
||||
if not (def and tilesetDef and tilesetDef.blocks) then return nil end
|
||||
local tx, ty = cx * 2, cy * 2 + 1
|
||||
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
|
||||
local id
|
||||
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
|
||||
id = def.borderBlock
|
||||
else
|
||||
id = def.blocks[by * def.width + bx + 1]
|
||||
end
|
||||
local block = tilesetDef.blocks[(id or 0) + 1]
|
||||
if not block then return nil end
|
||||
return block[(ty % 4) * 4 + (tx % 4) + 1]
|
||||
end
|
||||
|
||||
local function defWaterTileSet(def, tilesetDef)
|
||||
local water = {}
|
||||
hashSet(tilesetDef.waterTiles or WATER_TILES, water)
|
||||
local shore = tilesetDef.shoreTiles
|
||||
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then shore = SHORE_TILES end
|
||||
hashSet(shore or {}, water)
|
||||
return water
|
||||
end
|
||||
|
||||
-- Water/shore on an unloaded map def (same tile ids as Map:isWaterCell).
|
||||
function Map.defIsWaterCell(def, tilesetDef, cx, cy)
|
||||
local tile = Map.defCellTile(def, tilesetDef, cx, cy)
|
||||
if tile == nil then return false end
|
||||
return defWaterTileSet(def, tilesetDef)[tile] or false
|
||||
end
|
||||
|
||||
function Map.defIsWalkableCell(def, tilesetDef, cx, cy)
|
||||
if not (tilesetDef and tilesetDef.walkable) then return false end
|
||||
local tile = Map.defCellTile(def, tilesetDef, cx, cy)
|
||||
if tile == nil then return false end
|
||||
for _, t in ipairs(tilesetDef.walkable) do
|
||||
if t == tile then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Passability of a cell of an UNLOADED map def -- the connected neighbor
|
||||
-- during an edge crossing. pokered's collision check reads the neighbor
|
||||
-- strip's tile bytes, so a step off the map edge onto a solid tile of
|
||||
@@ -52,32 +97,8 @@ function Map.defPassable(def, tilesetDef, cx, cy, surfing)
|
||||
if not (def and tilesetDef and tilesetDef.blocks and tilesetDef.walkable) then
|
||||
return false
|
||||
end
|
||||
local tx, ty = cx * 2, cy * 2 + 1
|
||||
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
|
||||
local id
|
||||
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
|
||||
id = def.borderBlock
|
||||
else
|
||||
id = def.blocks[by * def.width + bx + 1]
|
||||
end
|
||||
local block = tilesetDef.blocks[(id or 0) + 1]
|
||||
if not block then return false end
|
||||
local tile = block[(ty % 4) * 4 + (tx % 4) + 1]
|
||||
for _, t in ipairs(tilesetDef.walkable) do
|
||||
if t == tile then return true end
|
||||
end
|
||||
if surfing then
|
||||
for _, t in ipairs(tilesetDef.waterTiles or WATER_TILES) do
|
||||
if t == tile then return true end
|
||||
end
|
||||
local shore = tilesetDef.shoreTiles
|
||||
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then
|
||||
shore = SHORE_TILES
|
||||
end
|
||||
for _, t in ipairs(shore or {}) do
|
||||
if t == tile then return true end
|
||||
end
|
||||
end
|
||||
if Map.defIsWalkableCell(def, tilesetDef, cx, cy) then return true end
|
||||
if surfing and Map.defIsWaterCell(def, tilesetDef, cx, cy) then return true end
|
||||
return false
|
||||
end
|
||||
|
||||
|
||||
@@ -338,10 +338,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
or (fromMapId and "warp" or "boot"),
|
||||
})
|
||||
|
||||
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers)
|
||||
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers).
|
||||
-- fromMapId lets elevators seed a valid walk-out floor when the ROM
|
||||
-- car warps still point at a missing map (Silph's UNUSED_MAP_ED) and
|
||||
-- the player B-cancels the floor menu without .UpdateWarp.
|
||||
local hooks = mapScripts.get(mapId)
|
||||
if hooks and hooks.onEnter then
|
||||
hooks.onEnter(Game, self)
|
||||
hooks.onEnter(Game, self, fromMapId)
|
||||
end
|
||||
|
||||
self:rebuildNeighbors()
|
||||
@@ -1000,20 +1003,19 @@ function OverworldState:checkEdgeExit(dir)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Map connections: the connected map's strip offset is in blocks; arriving
|
||||
-- coordinates follow destX = curX - offset*2 (see docs/extraction-notes.md).
|
||||
-- The crossing scrolls continuously: the map data swaps while the player
|
||||
-- is placed one cell before the entry point (their old world position,
|
||||
-- which the neighbor strips render identically) and walks the seam step.
|
||||
function OverworldState:crossConnection(dir, conn)
|
||||
-- Landing cell on the connected map for a step off this map's edge in
|
||||
-- `dir` (same math as crossConnection). Returns destDef, tilesetDef, x, y
|
||||
-- or nil when there is no usable connection.
|
||||
function OverworldState:connectionLanding(dir)
|
||||
local conn = self.map:connection(COMPASS[dir])
|
||||
if not conn then return nil end
|
||||
local dest = Game.data.maps[conn.map]
|
||||
if not dest then
|
||||
Logger.warn("connection to unknown map %s", tostring(conn.map))
|
||||
return false
|
||||
end
|
||||
if not dest then return nil end
|
||||
local ts = Game.data.tilesets[dest.tileset]
|
||||
if not ts then return nil end
|
||||
local p = self.player
|
||||
local x, y = p.cellX, p.cellY
|
||||
local destW, destH = dest.width * 2, dest.height * 2
|
||||
local x, y
|
||||
if dir == "up" then
|
||||
x, y = p.cellX - conn.offset * 2, destH - 1
|
||||
elseif dir == "down" then
|
||||
@@ -1025,13 +1027,27 @@ function OverworldState:crossConnection(dir, conn)
|
||||
end
|
||||
x = math.max(0, math.min(destW - 1, x))
|
||||
y = math.max(0, math.min(destH - 1, y))
|
||||
return dest, ts, x, y, conn
|
||||
end
|
||||
|
||||
-- Map connections: the connected map's strip offset is in blocks; arriving
|
||||
-- coordinates follow destX = curX - offset*2 (see docs/extraction-notes.md).
|
||||
-- The crossing scrolls continuously: the map data swaps while the player
|
||||
-- is placed one cell before the entry point (their old world position,
|
||||
-- which the neighbor strips render identically) and walks the seam step.
|
||||
function OverworldState:crossConnection(dir, conn)
|
||||
local dest, ts, x, y = self:connectionLanding(dir)
|
||||
if not dest then
|
||||
Logger.warn("connection to unknown map %s", tostring(conn and conn.map))
|
||||
return false
|
||||
end
|
||||
local p = self.player
|
||||
-- pokered's collision check reads the NEIGHBOR strip's tile bytes, so
|
||||
-- stepping off the edge onto a solid tile of the connected map bumps
|
||||
-- exactly like an in-map wall. Without this read, Pallet's south
|
||||
-- shore (land at x2-3) walked straight onto ROUTE_21 (3,0) -- a
|
||||
-- collision tile -- stranding the player on a cell no walk can leave.
|
||||
if not Map.defPassable(dest, Game.data.tilesets[dest.tileset], x, y,
|
||||
p.surfing) then
|
||||
if not Map.defPassable(dest, ts, x, y, p.surfing) then
|
||||
return false
|
||||
end
|
||||
self:setMap(conn.map, x, y, p.facing, { seamless = true })
|
||||
@@ -1054,6 +1070,53 @@ function OverworldState:crossConnection(dir, conn)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ItemUseSurfboard's simulated pad press: step onto the facing cell, or
|
||||
-- cross a map connection when that cell is off this map's edge (Cinnabar
|
||||
-- east coast -> Route 20 water, and the reverse dismount ashore).
|
||||
function OverworldState:stepForwardOrCrossEdge(dir)
|
||||
dir = dir or self.player.facing
|
||||
local fx, fy = Collision.target(self.player.cellX, self.player.cellY, dir)
|
||||
if not self.map:inBounds(fx, fy) then
|
||||
return self:checkEdgeExit(dir)
|
||||
end
|
||||
self:scriptMove(self.player, dir, 1)
|
||||
return true
|
||||
end
|
||||
|
||||
-- IsNextTileShoreOrWater across a connection strip: pokered loads the
|
||||
-- neighbor's tiles into the border, so wTileInFrontOfPlayer is the
|
||||
-- connected map's tile even when the facing cell is off this map.
|
||||
-- Shore/water classification still uses THIS map's tileset rules
|
||||
-- (SHIP_PORT's $32 dock exception), matching the asm.
|
||||
function OverworldState:facingIsShoreOrWater()
|
||||
if not self:tilesetHasWater() then return false end
|
||||
local fx, fy = self.player:facingCell()
|
||||
if self.map:inBounds(fx, fy) then
|
||||
return self.map:isWaterCell(fx, fy)
|
||||
end
|
||||
local dest, ts, x, y = self:connectionLanding(self.player.facing)
|
||||
if not dest then return false end
|
||||
local tile = Map.defCellTile(dest, ts, x, y)
|
||||
if tile == nil then return false end
|
||||
return self.map.waterTiles[tile] or false
|
||||
end
|
||||
|
||||
-- tryToStopSurfing land check, including a land landing across a map
|
||||
-- connection (surf off Cinnabar's east coast water back onto the coast).
|
||||
function OverworldState:facingIsLandDismount()
|
||||
local p = self.player
|
||||
local fx, fy = p:facingCell()
|
||||
if self.map:inBounds(fx, fy) then
|
||||
return self.map:isWalkableCell(fx, fy)
|
||||
and Collision.canMove(self.map, self.entities, p, p.facing)
|
||||
end
|
||||
local dest, ts, x, y = self:connectionLanding(p.facing)
|
||||
if not dest then return false end
|
||||
if not Map.defIsWalkableCell(dest, ts, x, y) then return false end
|
||||
-- IsSpriteInFrontOfPlayer2: no current-map sprite can sit past the edge
|
||||
return not Collision.occupied(self.entities, fx, fy, p)
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------------
|
||||
-- interactions
|
||||
-- -------------------------------------------------------------------------
|
||||
@@ -1571,16 +1634,21 @@ function OverworldState:trashCanSwitch(canIndex)
|
||||
end
|
||||
|
||||
-- Bill's House PC (engine/events/hidden_events/bills_house_pc.asm
|
||||
-- BillsHousePC): once Bill-the-Pokémon has climbed into the machine
|
||||
-- (EVENT_BILL_SAID_USE_CELL_SEPARATOR), running the PC plays the cell
|
||||
-- separator's SFX sequence, sets EVENT_USED_CELL_SEPARATOR_ON_BILL and
|
||||
-- Bill steps back out of the machine human again
|
||||
-- (BillsHouseBillExitsMachineScript / CleanupScript set EVENT_MET_BILL).
|
||||
-- BillsHousePC). Check order matches pokered:
|
||||
-- 1) EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING -> Eevee collection list
|
||||
-- 2) EVENT_USED_CELL_SEPARATOR_ON_BILL -> teleporter monitor text
|
||||
-- 3) EVENT_BILL_SAID_USE_CELL_SEPARATOR -> cell-separator cutscene
|
||||
-- 4) else -> teleporter monitor text
|
||||
-- Leaving after the SS Ticket (Route25ToggleBillsScript) arms (1).
|
||||
function OverworldState:billsHousePC()
|
||||
local t = Game.data.text
|
||||
local flags = Game.save.flags
|
||||
if not (flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR
|
||||
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL) then
|
||||
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
|
||||
self:billsHousePokemonList()
|
||||
return
|
||||
end
|
||||
if flags.EVENT_USED_CELL_SEPARATOR_ON_BILL
|
||||
or not flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then
|
||||
Game.stack:push(TextBox.new(Game, t._BillsHouseMonitorText
|
||||
or "TELEPORTER is\ndisplayed on the\nPC monitor."))
|
||||
return
|
||||
@@ -1604,9 +1672,40 @@ function OverworldState:billsHousePC()
|
||||
end))
|
||||
end
|
||||
|
||||
-- BillsHousePokemonList: EEVEE / FLAREON / JOLTEON / VAPOREON + CANCEL;
|
||||
-- picking one runs DisplayPokedex (DexEntryMenu) and returns to the list.
|
||||
function OverworldState:billsHousePokemonList()
|
||||
local t = Game.data.text
|
||||
local Menu = require("src.ui.Menu")
|
||||
local function openList()
|
||||
local species = { "EEVEE", "FLAREON", "JOLTEON", "VAPOREON" }
|
||||
local items = {}
|
||||
for _, id in ipairs(species) do
|
||||
local def = Game.data.pokemon[id]
|
||||
table.insert(items, {
|
||||
label = (def and def.name) or id,
|
||||
keepOpen = true,
|
||||
onSelect = function()
|
||||
local dex = Game.save.pokedex
|
||||
if dex then dex.seen[id] = true end
|
||||
Screens.push(Game, "DexEntryMenu", id)
|
||||
end,
|
||||
})
|
||||
end
|
||||
table.insert(items, { label = "CANCEL" })
|
||||
-- TextBoxBorder b=10,c=9 at (0,0) -> total tw=11, th=12
|
||||
Game.stack:push(Menu.new(Game, items,
|
||||
{ tx = 0, ty = 0, tw = 11, th = 12 }))
|
||||
end
|
||||
Game.stack:push(TextBox.new(Game, t._BillsHousePokemonListText1
|
||||
or "BILL's favorite\nPOKéMON list!", openList))
|
||||
end
|
||||
|
||||
-- BillsHouseBillExitsMachineScript: human Bill appears inside the machine
|
||||
-- at (1,2) and walks out to his spot at (4,4); the map music resumes and
|
||||
-- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue.
|
||||
-- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue. The Eevee
|
||||
-- PC list arms later, on the first Route 25 load after the ticket
|
||||
-- (EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING).
|
||||
function OverworldState:billsHouseBillExits()
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { game = Game, save = Game.save, overworld = self }
|
||||
@@ -1698,14 +1797,15 @@ function OverworldState:trySurf(fx, fy)
|
||||
Game.stack:push(TextBox.new(Game, text, function()
|
||||
-- start_sub_menus.asm .surf: UseItem returns (mount + text done),
|
||||
-- then GBPalWhiteOutWithDelay3 blinks before the simulated forward
|
||||
-- press steps onto the water
|
||||
-- press steps onto the water (or across a connection strip, like
|
||||
-- Cinnabar's east coast onto Route 20)
|
||||
local Transition = require("src.render.Transition")
|
||||
if Transition.whiteFlash then
|
||||
Game.stack:push(Transition.whiteFlash(Game, nil, function()
|
||||
self:scriptMove(p, p.facing, 1)
|
||||
self:stepForwardOrCrossEdge(p.facing)
|
||||
end))
|
||||
else
|
||||
self:scriptMove(p, p.facing, 1)
|
||||
self:stepForwardOrCrossEdge(p.facing)
|
||||
end
|
||||
end))
|
||||
end
|
||||
@@ -1805,20 +1905,20 @@ function OverworldState:useSurfFieldMove()
|
||||
-- until both EVENT_SEAFOAM4_BOULDER*_DOWN_HOLE events are set.
|
||||
if Game.save.forcedBike then return "forced_bike" end
|
||||
if self:surfBlockedHere() then return "current" end
|
||||
local fx, fy = p:facingCell()
|
||||
if p.surfing then
|
||||
-- ItemUseSurfboard .tryToStopSurfing: blocked by a sprite in front
|
||||
-- (IsSpriteInFrontOfPlayer2), a water tile-pair collision, or a
|
||||
-- facing tile that isn't in the tileset's land-passable list;
|
||||
-- otherwise the player walks forward off the water.
|
||||
if self.map:inBounds(fx, fy) and self.map:isWalkableCell(fx, fy)
|
||||
and Collision.canMove(self.map, self.entities, p, p.facing) then
|
||||
-- otherwise the player walks forward off the water. Facing a land
|
||||
-- cell across a map connection (Cinnabar east coast) counts too --
|
||||
-- pokered reads that landing from the connection strip.
|
||||
if self:facingIsLandDismount() then
|
||||
return "dismount"
|
||||
end
|
||||
return "no_place"
|
||||
end
|
||||
if not self.map:inBounds(fx, fy)
|
||||
or not self.map:isWaterCell(fx, fy) or not self:tilesetHasWater() then
|
||||
-- IsNextTileShoreOrWater, including connection-strip water (issue #125)
|
||||
if not self:facingIsShoreOrWater() then
|
||||
return "no_water"
|
||||
end
|
||||
return "ok"
|
||||
@@ -3415,8 +3515,11 @@ function OverworldState:drawWorld()
|
||||
love.graphics.setShader(shader)
|
||||
end
|
||||
end
|
||||
local ox = ha.px - 64 - cam.x
|
||||
local oy = ha.py - 64 - cam.y
|
||||
-- TileRenderer windows with -floor(cam), so the overlay must use the
|
||||
-- same snap or a fractional camera (odd fill/tilt view sizes) parks
|
||||
-- the balls a pixel off the machine tiles
|
||||
local ox = ha.px - 64 - math.floor(cam.x)
|
||||
local oy = ha.py - 64 - math.floor(cam.y)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20)
|
||||
for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do
|
||||
@@ -3632,11 +3735,13 @@ function OverworldState:drawWorld()
|
||||
else
|
||||
-- === TILT PATH: ground-hugging FX stay on the projected ground, all
|
||||
-- standing things billboard upright over it in a separate pass. ======
|
||||
-- Dust is ground-hugging smoke -> ground canvas (puts it
|
||||
-- with the flat layer, so it projects with the ground). Flat mode
|
||||
-- draws it last, over the sprites, in the same canvas; here the two
|
||||
-- Dust / cut / the Poké Center heal overlay hug the BG (the heal
|
||||
-- machine is a tileset graphic; its OAM balls must ride that plane or
|
||||
-- they float off the machine once the ground foreshortens). Flat mode
|
||||
-- draws them last, over the sprites, in the same canvas; here the two
|
||||
-- layers are separate and composited ground-under-upright, so drawing
|
||||
-- it now into the still-active ground canvas is order-equivalent.
|
||||
-- them now into the still-active ground canvas is order-equivalent.
|
||||
fxHeal()
|
||||
fxDust()
|
||||
fxCutTree()
|
||||
|
||||
@@ -3692,18 +3797,11 @@ function OverworldState:drawWorld()
|
||||
end
|
||||
end
|
||||
|
||||
-- Screen-anchored world FX : each billboards at the
|
||||
-- ground foot of the character it belongs to, so it stands upright and
|
||||
-- scales with that character's depth.
|
||||
-- heal machine -> the healed player's foot (the machine stands on
|
||||
-- the ground in front of where the player was)
|
||||
-- Standing world FX: each billboards at the ground foot of the
|
||||
-- character it belongs to, so it stays upright over the tilted ground.
|
||||
-- emote bubble -> the spotting NPC's foot (rides above its head)
|
||||
-- fly bird, rod -> the player's foot
|
||||
if self.healAnim then
|
||||
local fx = self.healAnim.px - cam.x + 8
|
||||
local fy = self.healAnim.py - cam.y + 16
|
||||
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxHeal)
|
||||
end
|
||||
-- (heal machine is ground-hugging -- drawn above with dust/cut)
|
||||
if self.emote and self.emote.npc then
|
||||
local fx = self.emote.npc.px - cam.x + 8
|
||||
local fy = self.emote.npc.py - cam.y + 16
|
||||
|
||||
@@ -344,6 +344,24 @@ check(lastSource().queueable and lastSource().playing,
|
||||
"a chip song still plays after a file song")
|
||||
check(not body.playing, "the outgoing file song was stopped")
|
||||
|
||||
-- playOnce must survive the threaded "empty QueueableSource" window:
|
||||
-- Source:isPlaying is false until the first worker buffer lands, and that
|
||||
-- gap must not look like the jingle already ended (Poké Center heal).
|
||||
data = reset(fixtureData())
|
||||
Music.playMap(data, "PALLET_TOWN", false, false)
|
||||
check(Music.playOnce(data, "Music_Chip"), "playOnce starts a chip jingle")
|
||||
local jingle = lastSource()
|
||||
local clearAwait = ChipAudio._simulateAwaitingFirstBufferForTest()
|
||||
check(clearAwait ~= nil, "test can force the awaiting-first-buffer window")
|
||||
check(Music.oneShotPlaying(),
|
||||
"oneShotPlaying stays true while the first buffer is still in flight")
|
||||
Music.update(data)
|
||||
check(jingle == lastSource() and jingle.queueable,
|
||||
"pendingRestore does not swap the map theme over a pending chip jingle")
|
||||
check(ChipAudio.awaitingFirstBuffer(),
|
||||
"awaitingFirstBuffer reports the forced window")
|
||||
clearAwait()
|
||||
|
||||
-- sfx shape dispatch
|
||||
check(Sound.play(data, "Beep") == nil, "Sound.play returns nothing")
|
||||
check(lastSource().file == "assets/beep.wav", "a bare string sfx is a static source")
|
||||
|
||||
@@ -647,6 +647,68 @@ check(Transition.new({ data = retimed }).frames == 30,
|
||||
check(Transition.new({ data = { transitions = {} } }).frames == 12,
|
||||
"an unregistered id falls back to the built-in 12 frames")
|
||||
|
||||
-- issue #121: with the survey-zoom world pass active, the warp fade must
|
||||
-- darken the full window composite (via Renderer.worldFadeAlpha), not just
|
||||
-- paint a 160x144 rect on the UI letterbox.
|
||||
do
|
||||
local rects = {}
|
||||
local color = { 1, 1, 1, 1 }
|
||||
local savedRect, savedColor = love.graphics.rectangle, love.graphics.setColor
|
||||
love.graphics.rectangle = function(mode, x, y, w, h)
|
||||
rects[#rects + 1] = {
|
||||
mode = mode, x = x, y = y, w = w, h = h,
|
||||
a = color[4], r = color[1],
|
||||
}
|
||||
end
|
||||
love.graphics.setColor = function(r, g, b, a)
|
||||
color[1], color[2], color[3], color[4] = r, g, b, a or 1
|
||||
end
|
||||
|
||||
Renderer:init()
|
||||
local fade = Transition.new({ renderer = Renderer, stack = { pop = noop } })
|
||||
fade.t = 6 -- mid fade-out (12 frames)
|
||||
fade.phase = "out"
|
||||
|
||||
Renderer:beginFrame(true)
|
||||
Renderer:beginWorldPass()
|
||||
Renderer:endWorldPass()
|
||||
check(Renderer.worldActive == true, "world pass stays marked active until endFrame")
|
||||
fade:draw()
|
||||
check(Renderer.worldFadeAlpha == 0.5,
|
||||
"warp fade hands mid-out alpha to the world composite overlay")
|
||||
check(#rects == 0,
|
||||
"warp fade does not paint the 160x144 UI letterbox while the world pass is up")
|
||||
|
||||
resetLog()
|
||||
rects = {}
|
||||
Renderer:endFrame(nil, fullWorldZones())
|
||||
local fadeRect
|
||||
for _, r in ipairs(rects) do
|
||||
-- endFrame's letterbox clear is also a full-window black fill (a == 1);
|
||||
-- the warp overlay is the half-alpha one Transition requested
|
||||
if r.mode == "fill" and r.x == 0 and r.y == 0
|
||||
and r.w == 640 and r.h == 576 and r.r == 0 and r.a == 0.5 then
|
||||
fadeRect = r
|
||||
end
|
||||
end
|
||||
check(fadeRect ~= nil,
|
||||
"endFrame paints the warp fade over the full window at the fade alpha")
|
||||
check(Renderer.worldActive == false, "endFrame clears worldActive")
|
||||
|
||||
-- without a world pass (opaque UI states), keep the classic UI rect
|
||||
Renderer:beginFrame(false)
|
||||
fade.t = 6
|
||||
fade.phase = "out"
|
||||
rects = {}
|
||||
fade:draw()
|
||||
check(Renderer.worldFadeAlpha == nil,
|
||||
"no world pass: warp fade does not set a world overlay")
|
||||
check(#rects == 1 and rects[1].w == 160 and rects[1].h == 144,
|
||||
"no world pass: warp fade still fills the 160x144 UI canvas")
|
||||
|
||||
love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor
|
||||
end
|
||||
|
||||
-- ------- the transition.style hook
|
||||
|
||||
local stack = { pop = function() end }
|
||||
|
||||
+41
-3
@@ -22,6 +22,7 @@ local StateStack = require("src.core.StateStack")
|
||||
local ManagerState = require("src.mods.ManagerState")
|
||||
local ModUI = require("src.ui.ModUI")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
|
||||
local savedEvents, savedHooks, savedErrors =
|
||||
Runtime.events, Runtime.hooks, Runtime.errors
|
||||
@@ -205,7 +206,7 @@ end
|
||||
local om = OptionsMenu.new(optGame())
|
||||
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
|
||||
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
|
||||
"gbcfx", "videoMode", "speed", "mods", "controls" }
|
||||
"gbcfx", "videoMode", "fpsCap", "speed", "mods", "controls" }
|
||||
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
|
||||
for i, id in ipairs(WANT_IDS) do
|
||||
check(om.rows[i].id == id, "options row order: " .. id)
|
||||
@@ -235,10 +236,47 @@ check(om.game.save.options.musicVol == 6, "music volume steps down")
|
||||
for _ = 1, 10 do om.rows[5].step(om.game, -1) end
|
||||
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
|
||||
|
||||
-- the MAX FPS row cycles the render-cap steps and shows the value plain
|
||||
om.game.save.options.fpsCap = nil
|
||||
check(om.rows[12].value(om.game) == "60",
|
||||
"MAX FPS row defaults to 60 with no saved cap")
|
||||
om.rows[12].step(om.game, 1)
|
||||
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
|
||||
check(om.rows[12].value(om.game) == "75", "the MAX FPS row renders the cap")
|
||||
om.game.save.options.fpsCap = 160
|
||||
om.rows[12].step(om.game, 1)
|
||||
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
|
||||
om.rows[12].step(om.game, -1)
|
||||
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
|
||||
|
||||
-- ------- FrameCap normalize / cycle (issue #88)
|
||||
check(FrameCap.normalize(nil) == 60, "FrameCap defaults nil to 60")
|
||||
check(FrameCap.normalize("junk") == 60, "FrameCap defaults garbage to 60")
|
||||
check(FrameCap.normalize(60) == 60, "FrameCap keeps an exact step")
|
||||
check(FrameCap.normalize(58) == 60, "FrameCap snaps 58 to the nearest step 60")
|
||||
check(FrameCap.normalize(72) == 75, "FrameCap snaps 72 to the nearest step 75")
|
||||
check(FrameCap.normalize(0) == 30, "FrameCap clamps below the floor to 30")
|
||||
check(FrameCap.normalize(9999) == 160, "FrameCap clamps above the ceiling to 160")
|
||||
check(FrameCap.normalize(30) == 30 and FrameCap.normalize(160) == 160,
|
||||
"FrameCap keeps the exact floor and ceiling")
|
||||
check(FrameCap.label(nil) == "60" and FrameCap.label(144) == "144",
|
||||
"FrameCap.label renders the normalized cap as plain text")
|
||||
check(FrameCap.cycle(60, 1) == 75, "FrameCap cycles 60 up to 75")
|
||||
check(FrameCap.cycle(60, -1) == 50, "FrameCap cycles 60 down to 50")
|
||||
check(FrameCap.cycle(160, 1) == 30, "FrameCap cycle wraps the ceiling to the floor")
|
||||
check(FrameCap.cycle(30, -1) == 160, "FrameCap cycle wraps the floor to the ceiling")
|
||||
check(FrameCap.cycle(nil, 1) == 75,
|
||||
"FrameCap cycle normalizes a nil cap (60) before stepping")
|
||||
-- apply drives the live value the run loop paces to; never touches love.timer
|
||||
FrameCap.apply(144)
|
||||
check(FrameCap.current == 144, "FrameCap.apply stores the live cap")
|
||||
FrameCap.applyOptions({})
|
||||
check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 60")
|
||||
|
||||
-- the MODS row is the manager's discoverable home
|
||||
local mgGame = optGame()
|
||||
om = OptionsMenu.new(mgGame)
|
||||
om.rows[13].activate(mgGame)
|
||||
om.rows[14].activate(mgGame)
|
||||
check(getmetatable(mgGame.stack:top()) == ManagerState,
|
||||
"the MODS row opens the manager")
|
||||
check(mgGame.stack:top().screenId == "ManagerState",
|
||||
@@ -248,7 +286,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
|
||||
local BindingsMenu = require("src.ui.BindingsMenu")
|
||||
local cbGame = optGame()
|
||||
om = OptionsMenu.new(cbGame)
|
||||
om.rows[14].activate(cbGame)
|
||||
om.rows[15].activate(cbGame)
|
||||
local bm = cbGame.stack:top()
|
||||
check(getmetatable(bm) == BindingsMenu,
|
||||
"the CONTROLS row opens the rebind list")
|
||||
|
||||
+51
-4
@@ -55,9 +55,27 @@ local function newStack()
|
||||
return stack, items
|
||||
end
|
||||
|
||||
-- snapshot / restore car warps so tests that seed or ride do not leak
|
||||
-- across cases (Silph's ROM default is UNUSED_MAP_ED)
|
||||
local function snapshotWarps(mapId)
|
||||
local snap = {}
|
||||
for i, w in ipairs(Data.maps[mapId].warps) do
|
||||
snap[i] = { destMap = w.destMap, destWarp = w.destWarp }
|
||||
end
|
||||
return snap
|
||||
end
|
||||
|
||||
local function restoreWarps(mapId, snap)
|
||||
for i, w in ipairs(Data.maps[mapId].warps) do
|
||||
w.destMap, w.destWarp = snap[i].destMap, snap[i].destWarp
|
||||
end
|
||||
end
|
||||
|
||||
-- drives one elevator map's onEnter and returns the pushed state (either a
|
||||
-- ListMenu or, for the keyGated Rocket Hideout without the key, a TextBox)
|
||||
local function openElevator(mapId, inventory)
|
||||
-- ListMenu or, for the keyGated Rocket Hideout without the key, a TextBox).
|
||||
-- fromMapId is the floor the player entered from (OverworldState:setMap
|
||||
-- passes it), used to seed a cancel-safe walk-out destination.
|
||||
local function openElevator(mapId, inventory, fromMapId)
|
||||
local script = mapScripts.get(mapId)
|
||||
check(script ~= nil, mapId .. " script registered")
|
||||
check(script and script.onEnter ~= nil, mapId .. " has onEnter")
|
||||
@@ -98,7 +116,7 @@ local function openElevator(mapId, inventory)
|
||||
stack = stack,
|
||||
}
|
||||
sfxCalls = {}
|
||||
script.onEnter(game, ow)
|
||||
script.onEnter(game, ow, fromMapId)
|
||||
return items[#items], warpCalls, stack, ow
|
||||
end
|
||||
|
||||
@@ -130,7 +148,14 @@ end
|
||||
-- Silph Co elevator: 11 floors, the double-digit sort/label regression
|
||||
-- ===================================================================
|
||||
do
|
||||
local menu, warpCalls, stack, ow = openElevator("SILPH_CO_ELEVATOR")
|
||||
local silphSnap = snapshotWarps("SILPH_CO_ELEVATOR")
|
||||
-- ROM default (UNUSED_MAP_ED) must still be what we start from so the
|
||||
-- cancel-then-exit regression below is real
|
||||
eq(silphSnap[1].destMap, "UNUSED_MAP_ED",
|
||||
"Silph Co ROM car warps still default to UNUSED_MAP_ED")
|
||||
|
||||
local menu, warpCalls, stack, ow =
|
||||
openElevator("SILPH_CO_ELEVATOR", nil, "SILPH_CO_5F")
|
||||
check(menu ~= nil and getmetatable(menu) == ListMenu, "SILPH_CO_ELEVATOR opens a ListMenu")
|
||||
if menu then
|
||||
eq(#menu.items, 11, "Silph Co elevator lists all 11 floors")
|
||||
@@ -143,12 +168,32 @@ do
|
||||
check(menu.items[1].label ~= "SILPH CO 1F" and not menu.items[1].label:find("SILPH"),
|
||||
"Silph Co floor label is the short token, not the full map id")
|
||||
|
||||
-- onEnter seeds the car's exit to the floor we came from so a B-cancel
|
||||
-- cannot leave UNUSED_MAP_ED in place (#123 hard crash on walk-out)
|
||||
eq(ow.map.def.warps[1].destMap, "SILPH_CO_5F",
|
||||
"Silph onEnter seeds exit warps to the entry floor")
|
||||
check(Data.maps[ow.map.def.warps[1].destMap] ~= nil,
|
||||
"seeded Silph exit map exists in Data.maps")
|
||||
|
||||
-- Cancel: pokered's DisplayElevatorFloorMenu does `ret c` on B --
|
||||
-- no warp at all.
|
||||
clear(warpCalls)
|
||||
menu.onCancel()
|
||||
eq(#warpCalls, 0, "Cancel does not warp (bare ret c, no floors[1] fallback)")
|
||||
|
||||
-- #123: after B-cancel, walking out of the car must resolve without
|
||||
-- asserting on the ROM placeholder UNUSED_MAP_ED
|
||||
clear(warpCalls)
|
||||
local okExit, errExit = pcall(function()
|
||||
ow:takeWarp(ow.map.def.warps[1])
|
||||
end)
|
||||
check(okExit, "cancel then exit does not crash: " .. tostring(errExit))
|
||||
eq(#warpCalls, 1, "cancel then exit takes the seeded entry-floor warp")
|
||||
if warpCalls[1] then
|
||||
eq(warpCalls[1].map, "SILPH_CO_5F",
|
||||
"cancel then exit returns to the floor the player entered from")
|
||||
end
|
||||
|
||||
-- Choose a mid-list floor (5F): pokered never warps on the spot --
|
||||
-- DisplayElevatorFloorMenu sets BIT_CUR_MAP_USED_ELEVATOR and the
|
||||
-- map script runs ShakeElevator: a 12-frame lead-in (the script's
|
||||
@@ -157,6 +202,7 @@ do
|
||||
-- cycle, then SFX_SAFARI_ZONE_PA, and only then the floor warp.
|
||||
clear(warpCalls)
|
||||
sfxCalls = {}
|
||||
ow.walkSteps = {}
|
||||
local chosen = menu.items[5] -- "5F"
|
||||
menu.onChoose(chosen, menu)
|
||||
eq(#warpCalls, 0, "choosing a floor does not warp on the spot (the shake runs first)")
|
||||
@@ -184,6 +230,7 @@ do
|
||||
eq(warpCalls[1].y, chosen.value.y, "walk-out lands on the chosen floor's y")
|
||||
end
|
||||
end
|
||||
restoreWarps("SILPH_CO_ELEVATOR", silphSnap)
|
||||
end
|
||||
|
||||
-- ===================================================================
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
-- Parity test, Bill's House PC + Route25ToggleBillsScript (#120).
|
||||
--
|
||||
-- asm sources:
|
||||
-- engine/events/hidden_events/bills_house_pc.asm (BillsHousePC /
|
||||
-- BillsHousePokemonList: after EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING
|
||||
-- the PC opens EEVEE/FLAREON/JOLTEON/VAPOREON; before that the
|
||||
-- teleporter monitor text or cell-separator cutscene)
|
||||
-- scripts/Route25.asm (Route25ToggleBillsScript: first Route 25 load
|
||||
-- after EVENT_MET_BILL_2 + EVENT_GOT_SS_TICKET sets
|
||||
-- EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING, hides BILL1 / nugget-bridge
|
||||
-- guy, shows BILL2; mid-quest leave resets the separator flag)
|
||||
--
|
||||
-- Self-contained: run via `luajit tests/parity_bills_pc.lua`; also
|
||||
-- dofile'd by tests/run_tests.lua's aggregator.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
local S = require("tests.harness").suite("parity bills pc")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local OW = require("src.world.OverworldController")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local story = require("data.scripts.story")
|
||||
|
||||
local function getUpvalue(fn, name)
|
||||
local i = 1
|
||||
while true do
|
||||
local n, v = debug.getupvalue(fn, i)
|
||||
if not n then return nil end
|
||||
if n == name then return v end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
local function setUpvalue(fn, name, val)
|
||||
local i = 1
|
||||
while true do
|
||||
local n = debug.getupvalue(fn, i)
|
||||
if not n then return false end
|
||||
if n == name then debug.setupvalue(fn, i, val); return true end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
|
||||
local pushed = {}
|
||||
local stackStub = {
|
||||
push = function(_, item)
|
||||
pushed[#pushed + 1] = item
|
||||
end,
|
||||
}
|
||||
local textBoxStub = {
|
||||
new = function(_, text, onDone)
|
||||
local box = { kind = "text", text = text, onDone = onDone }
|
||||
if onDone then onDone() end
|
||||
return box
|
||||
end,
|
||||
}
|
||||
local menuStub = {
|
||||
new = function(_, items, opts)
|
||||
return { kind = "menu", items = items, opts = opts or {} }
|
||||
end,
|
||||
}
|
||||
local screensStub = {
|
||||
push = function(_, id, species)
|
||||
pushed[#pushed + 1] = { kind = "screen", id = id, species = species }
|
||||
end,
|
||||
}
|
||||
|
||||
local fakeGame = {
|
||||
data = Data,
|
||||
save = SaveData.newGame(),
|
||||
stack = stackStub,
|
||||
}
|
||||
check(setUpvalue(OW.billsHousePC, "TextBox", textBoxStub), "TextBox upvalue")
|
||||
check(setUpvalue(OW.billsHousePC, "Game", fakeGame), "Game upvalue for PC")
|
||||
-- Screens is only referenced from billsHousePokemonList
|
||||
check(setUpvalue(OW.billsHousePokemonList, "Screens", screensStub),
|
||||
"Screens upvalue on pokemon list")
|
||||
check(setUpvalue(OW.billsHousePokemonList, "Game", fakeGame),
|
||||
"Game upvalue on pokemon list")
|
||||
check(setUpvalue(OW.billsHousePokemonList, "TextBox", textBoxStub),
|
||||
"TextBox upvalue on pokemon list")
|
||||
|
||||
-- Menu is required inside billsHousePokemonList; stub via package.loaded
|
||||
local realMenu = package.loaded["src.ui.Menu"]
|
||||
package.loaded["src.ui.Menu"] = menuStub
|
||||
|
||||
local fakeSelf = setmetatable({
|
||||
queueScript = function() end,
|
||||
billsHouseBillExits = function() end,
|
||||
}, { __index = OW })
|
||||
|
||||
local function resetFlags()
|
||||
fakeGame.save = SaveData.newGame()
|
||||
setUpvalue(OW.billsHousePC, "Game", fakeGame)
|
||||
pushed = {}
|
||||
end
|
||||
|
||||
local function lastPush()
|
||||
return pushed[#pushed]
|
||||
end
|
||||
|
||||
local function runPC()
|
||||
pushed = {}
|
||||
fakeSelf:billsHousePC()
|
||||
end
|
||||
|
||||
-- === 1) default: teleporter monitor text ===
|
||||
resetFlags()
|
||||
runPC()
|
||||
eq(lastPush() and lastPush().kind, "text", "default PC is a text box")
|
||||
check(tostring(lastPush().text):find("TELEPORTER", 1, true)
|
||||
or tostring(lastPush().text):find("displayed on the", 1, true),
|
||||
"default PC shows monitor text")
|
||||
|
||||
-- === 2) Bill in machine, separator not used yet: initiated text ===
|
||||
resetFlags()
|
||||
fakeGame.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||
local musicStopped = false
|
||||
local realMusic = package.loaded["src.core.Music"]
|
||||
package.loaded["src.core.Music"] = {
|
||||
stop = function() musicStopped = true end,
|
||||
playMap = function() end,
|
||||
}
|
||||
local realSound = package.loaded["src.core.Sound"]
|
||||
package.loaded["src.core.Sound"] = {
|
||||
play = function() end,
|
||||
playCry = function() end,
|
||||
}
|
||||
runPC()
|
||||
check(musicStopped, "cell separator stops map music")
|
||||
check(tostring(lastPush().text):find("Cell", 1, true)
|
||||
or tostring(lastPush().text):find("TELEPORTER", 1, true),
|
||||
"separator path prints initiated text")
|
||||
check(fakeGame.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL,
|
||||
"separator path sets EVENT_USED_CELL_SEPARATOR_ON_BILL")
|
||||
|
||||
-- === 3) after separator, before leaving: monitor text again ===
|
||||
resetFlags()
|
||||
fakeGame.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
|
||||
fakeGame.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true
|
||||
fakeGame.save.flags.EVENT_MET_BILL = true
|
||||
runPC()
|
||||
check(tostring(lastPush().text):find("TELEPORTER", 1, true)
|
||||
or tostring(lastPush().text):find("displayed on the", 1, true),
|
||||
"post-separator pre-leave PC shows monitor text")
|
||||
|
||||
-- === 4) after leaving: Eevee collection menu ===
|
||||
resetFlags()
|
||||
fakeGame.save.flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true
|
||||
runPC()
|
||||
local menu
|
||||
for _, p in ipairs(pushed) do
|
||||
if p.kind == "menu" then menu = p break end
|
||||
end
|
||||
check(menu ~= nil, "post-leave PC opens a menu")
|
||||
local labels = {}
|
||||
for _, item in ipairs(menu.items) do labels[#labels + 1] = item.label end
|
||||
eq(table.concat(labels, ","), "EEVEE,FLAREON,JOLTEON,VAPOREON,CANCEL",
|
||||
"Eevee collection lists the four mons + CANCEL")
|
||||
|
||||
-- selecting EEVEE marks seen and opens DexEntryMenu without closing list
|
||||
fakeGame.save.pokedex = { seen = {}, owned = {} }
|
||||
menu.items[1].onSelect()
|
||||
check(fakeGame.save.pokedex.seen.EEVEE, "viewing marks EEVEE seen")
|
||||
local dexPush
|
||||
for i = #pushed, 1, -1 do
|
||||
if pushed[i].kind == "screen" then dexPush = pushed[i] break end
|
||||
end
|
||||
eq(dexPush and dexPush.id, "DexEntryMenu", "selection opens DexEntryMenu")
|
||||
eq(dexPush and dexPush.species, "EEVEE", "DexEntryMenu gets EEVEE")
|
||||
check(menu.items[1].keepOpen, "dex pick keeps the list open")
|
||||
|
||||
-- === 5) Route25ToggleBillsScript ===
|
||||
local toggles = {}
|
||||
local Commands = require("src.script.Commands")
|
||||
local realHide, realShow = Commands.hide_object, Commands.show_object
|
||||
Commands.hide_object = function(_, mapId, name)
|
||||
toggles[mapId .. ":" .. name] = false
|
||||
end
|
||||
Commands.show_object = function(_, mapId, name)
|
||||
toggles[mapId .. ":" .. name] = true
|
||||
end
|
||||
|
||||
local function runRoute25(flags)
|
||||
toggles = {}
|
||||
local save = SaveData.newGame()
|
||||
for k, v in pairs(flags) do save.flags[k] = v end
|
||||
story.ROUTE_25.onEnter({ save = save }, {})
|
||||
return save.flags, toggles
|
||||
end
|
||||
|
||||
local f, t = runRoute25({})
|
||||
check(not f.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING,
|
||||
"no leave flag before meeting Bill")
|
||||
check(t["BILLS_HOUSE:BILLSHOUSE_BILL_POKEMON"] == true,
|
||||
"mid-quest leave restores monster Bill")
|
||||
check(f.EVENT_BILL_SAID_USE_CELL_SEPARATOR == nil,
|
||||
"mid-quest leave clears separator arming flag")
|
||||
|
||||
f, t = runRoute25({ EVENT_MET_BILL_2 = true })
|
||||
check(not f.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING,
|
||||
"MET_BILL_2 without ticket does not arm leave flag")
|
||||
|
||||
f, t = runRoute25({
|
||||
EVENT_MET_BILL_2 = true,
|
||||
EVENT_GOT_SS_TICKET = true,
|
||||
})
|
||||
check(f.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING,
|
||||
"ticket + MET_BILL_2 arms EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING")
|
||||
eq(t["BILLS_HOUSE:BILLSHOUSE_BILL1"], false, "hides SS-Ticket Bill")
|
||||
eq(t["BILLS_HOUSE:BILLSHOUSE_BILL2"], true, "shows rare-POKéMON Bill")
|
||||
eq(t["ROUTE_24:ROUTE24_COOLTRAINER_M1"], false, "hides nugget-bridge guy")
|
||||
|
||||
f, t = runRoute25({
|
||||
EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true,
|
||||
EVENT_MET_BILL_2 = true,
|
||||
EVENT_GOT_SS_TICKET = true,
|
||||
})
|
||||
eq(next(t), nil, "already-left Route 25 enter is a no-op")
|
||||
|
||||
Commands.hide_object = realHide
|
||||
Commands.show_object = realShow
|
||||
package.loaded["src.ui.Menu"] = realMenu
|
||||
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic end
|
||||
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,116 @@
|
||||
-- Regression (#125): SURF from Cinnabar's east coast into Route 20 water.
|
||||
--
|
||||
-- Cinnabar's easternmost cells are walkable land (tile $39); the water is
|
||||
-- on ROUTE_20 across the east connection. pokered loads that strip into
|
||||
-- the border, so IsNextTileShoreOrWater sees shore tile $32. The port
|
||||
-- must read the connection landing the same way -- an inBounds-only
|
||||
-- water check returns "no_water" and blocks the mount (and the reverse
|
||||
-- party-menu dismount back onto the coast).
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_cinnabar_east_surf.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.CINNABAR_ISLAND) then Data:load() end
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local Map = require("src.world.Map")
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local OW = require("src.world.OverworldController")
|
||||
local S = require("tests.harness").suite("parity cinnabar east surf")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local function mkMon(species, ...)
|
||||
local moves = {}
|
||||
for _, id in ipairs({ ... }) do
|
||||
table.insert(moves, { id = id, pp = 10, ppUp = 0 })
|
||||
end
|
||||
return {
|
||||
species = species, level = 30, hp = 50, maxHp = 50,
|
||||
status = 0, moves = moves, nickname = nil,
|
||||
}
|
||||
end
|
||||
|
||||
local cin = MapLoader.load(Data, "CINNABAR_ISLAND")
|
||||
local r20 = MapLoader.load(Data, "ROUTE_20")
|
||||
local r20ts = Data.tilesets[r20.def.tileset]
|
||||
|
||||
-- ground truth: east edge is land, Route 20 west landing is shore/water
|
||||
check(cin:isWalkableCell(19, 8), "Cinnabar (19,8) is walkable coast")
|
||||
check(not cin:isWaterCell(19, 8), "Cinnabar (19,8) is not water")
|
||||
check(not cin:inBounds(20, 8), "facing east from (19,8) is off-map")
|
||||
check(r20:isWaterCell(0, 8), "ROUTE_20 (0,8) is water/shore")
|
||||
check(Map.defIsWaterCell(r20.def, r20ts, 0, 8),
|
||||
"defIsWaterCell agrees on ROUTE_20 (0,8)")
|
||||
check(Map.defIsWalkableCell(cin.def, Data.tilesets[cin.def.tileset], 19, 8),
|
||||
"defIsWalkableCell agrees on Cinnabar coast")
|
||||
|
||||
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 = { mkMon("SQUIRTLE", "SURF") }
|
||||
Game.save.inventory = { SOULBADGE = true }
|
||||
Game.overworld = OW
|
||||
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "CINNABAR_ISLAND", 19, 8, "right")
|
||||
local ow = Game.stack:top()
|
||||
eq(ow.map.id, "CINNABAR_ISLAND", "start on Cinnabar east coast")
|
||||
eq(ow.player.cellX, 19, "coast x")
|
||||
eq(ow.player.cellY, 8, "coast y")
|
||||
eq(ow.player.facing, "right", "facing east toward Route 20")
|
||||
|
||||
check(ow:facingIsShoreOrWater(),
|
||||
"facingIsShoreOrWater reads Route 20 shore across the seam")
|
||||
eq(ow:useSurfFieldMove(), "ok",
|
||||
"useSurfFieldMove ok facing connection water (#125)")
|
||||
|
||||
-- reverse: surfing on Route 20 west edge, facing Cinnabar land
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "ROUTE_20", 0, 8, "left")
|
||||
ow = Game.stack:top()
|
||||
ow.player.surfing = true
|
||||
eq(ow.map.id, "ROUTE_20", "on Route 20 west water")
|
||||
check(ow.map:isWaterCell(0, 8), "standing cell is water")
|
||||
check(ow:facingIsLandDismount(),
|
||||
"facingIsLandDismount sees Cinnabar coast across the seam")
|
||||
eq(ow:useSurfFieldMove(), "dismount",
|
||||
"party-menu SURF dismounts onto Cinnabar east coast")
|
||||
|
||||
-- facing open water while surfing still refuses
|
||||
ow.player.facing = "right"
|
||||
eq(ow:useSurfFieldMove(), "no_place",
|
||||
"surfing + facing open water: no place (unchanged)")
|
||||
|
||||
-- mount step crosses the seam onto Route 20 (surfing already armed)
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "CINNABAR_ISLAND", 19, 8, "right")
|
||||
ow = Game.stack:top()
|
||||
ow.player.surfing = true
|
||||
check(ow:stepForwardOrCrossEdge("right"),
|
||||
"stepForwardOrCrossEdge crosses onto Route 20 while surfing")
|
||||
eq(ow.map.id, "ROUTE_20", "map swapped to Route 20 after edge surf step")
|
||||
-- crossConnection parks one cell before the seam and walks in (same as
|
||||
-- a live edge press); the landing target is Route 20 (0,8)
|
||||
eq(ow.player.targetX, 0, "step target is Route 20 west column")
|
||||
eq(ow.player.targetY, 8, "same Y across offset-0 east connection")
|
||||
check(ow.player.moving, "seam step is in progress")
|
||||
|
||||
-- in-map Pallet south shore still works (no false connection match)
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.stack:push(OW, "PALLET_TOWN", 4, 13, "down")
|
||||
ow = Game.stack:top()
|
||||
ow.player.surfing = false
|
||||
eq(ow:useSurfFieldMove(), "ok", "in-map water mount still ok")
|
||||
ow.player.facing = "up"
|
||||
eq(ow:useSurfFieldMove(), "no_water", "in-map land still no_water")
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,184 @@
|
||||
-- Parity test for Route 5 Day Care (issue #118).
|
||||
-- Self-contained: `luajit tests/parity_daycare.lua`; also dofile'd by
|
||||
-- tests/run_tests.lua's parity_* aggregator.
|
||||
--
|
||||
-- Covers scripts/Daycare.asm retrieve flow ported in
|
||||
-- data/scripts/story2.lua M.DAYCARE:
|
||||
-- * name substitution in grown / got-back text
|
||||
-- * fee = ¥100 + ¥100 per level gained
|
||||
-- * declining retrieve must not collapse the fee to ¥100 on re-talk
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
local S = require("tests.harness").suite("parity daycare")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local realTextBox = package.loaded["src.render.TextBox"]
|
||||
local shownTexts = {}
|
||||
local choiceAnswer = true
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(game, text, onDone, opts)
|
||||
table.insert(shownTexts, text)
|
||||
if opts and opts.choice then
|
||||
opts.choice(choiceAnswer)
|
||||
elseif onDone then
|
||||
onDone()
|
||||
end
|
||||
return { text = text }
|
||||
end,
|
||||
}
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local story2 = require("data.scripts.story2")
|
||||
|
||||
check(story2.DAYCARE ~= nil, "DAYCARE registered")
|
||||
check(story2.DAYCARE.talk.TEXT_DAYCARE_GENTLEMAN ~= nil,
|
||||
"DAYCARE gentleman talk handler registered")
|
||||
|
||||
local talkGentleman = story2.DAYCARE.talk.TEXT_DAYCARE_GENTLEMAN
|
||||
|
||||
local function newGame()
|
||||
local save = SaveData.newGame()
|
||||
save.money = 10000
|
||||
local game = { data = Data, save = save, stack = { push = function() end } }
|
||||
return game
|
||||
end
|
||||
|
||||
local function talk(game)
|
||||
shownTexts = {}
|
||||
local doneCalled = false
|
||||
talkGentleman(game, {}, nil, function() doneCalled = true end)
|
||||
return doneCalled
|
||||
end
|
||||
|
||||
local function boardAtLevel(game, species, level, nickname)
|
||||
local mon = Pokemon.new(Data, species, level)
|
||||
if nickname then mon.nickname = nickname end
|
||||
game.save.daycare = { mon = mon, steps = 0 }
|
||||
return mon
|
||||
end
|
||||
|
||||
-- Exp needed to gain `gained` levels from `level` (exclusive of current).
|
||||
local function stepsForLevels(species, level, gained)
|
||||
local def = Data.pokemon[species]
|
||||
local target = Growth.expForLevel(def.growthRate, level + gained)
|
||||
local current = Growth.expForLevel(def.growthRate, level)
|
||||
return target - current
|
||||
end
|
||||
|
||||
-- === 1) grown text names the mon; fee matches levels grown ===
|
||||
do
|
||||
local game = newGame()
|
||||
local mon = boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
|
||||
local gained = 3
|
||||
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
|
||||
choiceAnswer = false -- decline retrieve
|
||||
check(talk(game), "grown+decline talk completes")
|
||||
local grown
|
||||
for _, s in ipairs(shownTexts) do
|
||||
if s:find("grown a lot", 1, true) then grown = s break end
|
||||
end
|
||||
check(grown ~= nil, "shows MonHasGrownText")
|
||||
check(grown:find("SCRAPPY", 1, true), "grown text substitutes wNameBuffer")
|
||||
check(grown:find(tostring(gained), 1, true), "grown text shows levels grown")
|
||||
check(not grown:find("{RAM:", 1, true), "grown text has no leftover RAM tokens")
|
||||
local owe
|
||||
for _, s in ipairs(shownTexts) do
|
||||
if s:find("owe me", 1, true) then owe = s break end
|
||||
end
|
||||
check(owe ~= nil, "shows OweMoneyText")
|
||||
eq(owe:match("¥(%d+)"), tostring(100 + gained * 100),
|
||||
"fee is ¥100 + ¥100 per level gained")
|
||||
eq(game.save.daycare.mon.level, 5,
|
||||
"decline leaves deposit level untouched (BoxLevel baseline)")
|
||||
eq(game.save.daycare.steps, 0, "pending steps folded into exp on talk")
|
||||
end
|
||||
|
||||
-- === 2) re-talk after decline keeps the correct fee (issue #118) ===
|
||||
do
|
||||
local game = newGame()
|
||||
boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
|
||||
local gained = 3
|
||||
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
|
||||
choiceAnswer = false
|
||||
talk(game)
|
||||
choiceAnswer = false
|
||||
check(talk(game), "second decline talk completes")
|
||||
local owe
|
||||
for _, s in ipairs(shownTexts) do
|
||||
if s:find("owe me", 1, true) then owe = s break end
|
||||
end
|
||||
check(owe ~= nil, "re-talk still shows OweMoneyText")
|
||||
eq(owe:match("¥(%d+)"), tostring(100 + gained * 100),
|
||||
"re-talk after decline keeps fee (not ¥100)")
|
||||
eq(game.save.daycare.mon.level, 5, "re-talk still leaves deposit level")
|
||||
end
|
||||
|
||||
-- === 3) paid retrieve raises level, names the mon, clears daycare ===
|
||||
do
|
||||
local game = newGame()
|
||||
boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
|
||||
local gained = 2
|
||||
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
|
||||
local fee = 100 + gained * 100
|
||||
local moneyBefore = game.save.money
|
||||
choiceAnswer = true
|
||||
check(talk(game), "retrieve talk completes")
|
||||
eq(game.save.daycare, nil, "daycare cleared after paid retrieve")
|
||||
eq(#game.save.party, 1, "mon returned to party")
|
||||
eq(game.save.party[1].nickname, "SCRAPPY", "retrieved nickname preserved")
|
||||
eq(game.save.party[1].level, 5 + gained, "level applied only on retrieve")
|
||||
eq(game.save.money, moneyBefore - fee, "money deducted by correct fee")
|
||||
local got
|
||||
for _, s in ipairs(shownTexts) do
|
||||
if s:find("got", 1, true) and s:find("back", 1, true) then got = s break end
|
||||
end
|
||||
check(got ~= nil, "shows GotMonBackText")
|
||||
check(got:find("SCRAPPY", 1, true), "got-back text substitutes wDayCareMonName")
|
||||
check(not got:find("{RAM:", 1, true), "got-back text has no leftover RAM tokens")
|
||||
end
|
||||
|
||||
-- === 4) no levels gained: NeedsMoreTime still names the mon; fee ¥100 ===
|
||||
do
|
||||
local game = newGame()
|
||||
boardAtLevel(game, "RATTATA", 10, "PIP")
|
||||
game.save.daycare.steps = 0
|
||||
choiceAnswer = false
|
||||
check(talk(game), "no-growth talk completes")
|
||||
local needs
|
||||
for _, s in ipairs(shownTexts) do
|
||||
if s:find("Back already", 1, true) then needs = s break end
|
||||
end
|
||||
check(needs ~= nil, "shows MonNeedsMoreTimeText")
|
||||
check(needs and needs:find("PIP", 1, true), "needs-more-time names the mon")
|
||||
local owe
|
||||
for _, s in ipairs(shownTexts) do
|
||||
if s:find("owe me", 1, true) then owe = s break end
|
||||
end
|
||||
eq(owe and owe:match("¥(%d+)"), "100", "no growth still costs base ¥100")
|
||||
end
|
||||
|
||||
-- === 5) depositLevel survives a corrupted mon.level from older buggy talks ===
|
||||
do
|
||||
local game = newGame()
|
||||
local mon = boardAtLevel(game, "RATTATA", 5, "SCRAPPY")
|
||||
local gained = 4
|
||||
game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained)
|
||||
game.save.daycare.depositLevel = 5
|
||||
mon.level = 5 + gained -- simulate pre-#118 raise-on-talk corruption
|
||||
choiceAnswer = false
|
||||
check(talk(game), "depositLevel baseline talk completes")
|
||||
local owe
|
||||
for _, s in ipairs(shownTexts) do
|
||||
if s:find("owe me", 1, true) then owe = s break end
|
||||
end
|
||||
eq(owe and owe:match("¥(%d+)"), tostring(100 + gained * 100),
|
||||
"depositLevel keeps fee correct even if mon.level was raised early")
|
||||
eq(game.save.daycare.mon.level, 5, "decline restores mon.level to depositLevel")
|
||||
end
|
||||
|
||||
package.loaded["src.render.TextBox"] = realTextBox
|
||||
S.finish()
|
||||
@@ -0,0 +1,199 @@
|
||||
-- Parity test: Vermilion City S.S. Anne sailor (#122).
|
||||
--
|
||||
-- pokered VermilionCityDefaultScript (scripts/VermilionCity.asm):
|
||||
-- stepping onto SSAnneTicketCheckCoords (18,30) facing down always
|
||||
-- DisplayTextID's the sailor. With a ticket and the ship still docked,
|
||||
-- FlashedTicket plays and the player may continue; without a ticket, or
|
||||
-- after EVENT_SS_ANNE_LEFT, they are walked back up.
|
||||
--
|
||||
-- The port used to early-return when the ticket was in the bag, skipping
|
||||
-- the flash dialog entirely.
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_ss_anne_guard.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local S = require("tests.harness").suite("parity ss anne guard")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { text = text, done = done } end,
|
||||
}
|
||||
|
||||
local musicCalls = {}
|
||||
package.loaded["src.core.Music"] = {
|
||||
stop = function() musicCalls[#musicCalls + 1] = { "stop" } end,
|
||||
play = function(_, song)
|
||||
musicCalls[#musicCalls + 1] = { "play", song }
|
||||
end,
|
||||
playOnce = function(_, song)
|
||||
musicCalls[#musicCalls + 1] = { "playOnce", song }
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
local soundCalls = {}
|
||||
package.loaded["src.core.Sound"] = {
|
||||
play = function(_, id) soundCalls[#soundCalls + 1] = id end,
|
||||
}
|
||||
|
||||
local story = dofile("data/scripts/story.lua")
|
||||
local story3 = dofile("data/scripts/story3.lua")
|
||||
local Flags = require("src.script.Flags")
|
||||
|
||||
local function gameWith(opts)
|
||||
local pushed, moved = {}, {}
|
||||
local game = {
|
||||
save = {
|
||||
inventory = opts.inventory or {},
|
||||
flags = opts.flags or {},
|
||||
},
|
||||
data = {
|
||||
text = {
|
||||
_VermilionCitySailor1DoYouHaveATicketText =
|
||||
"Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?",
|
||||
_VermilionCitySailor1FlashedTicketText =
|
||||
"PLAYER flashed\nthe S.S.TICKET!",
|
||||
_VermilionCitySailor1YouNeedATicketText =
|
||||
"You need a ticket\nto get aboard.",
|
||||
_VermilionCitySailor1ShipSetSailText = "The ship set sail.",
|
||||
_SSAnneCaptainsRoomRubCaptainsBackText = "Rub-rub...",
|
||||
_SSAnneCaptainsRoomCaptainIFeelMuchBetterText = "I feel better!",
|
||||
_SSAnneCaptainsRoomCaptainReceivedHM01Text = "Got HM01!",
|
||||
_SSAnneCaptainsRoomCaptainNotSickAnymoreText = "Not sick.",
|
||||
},
|
||||
},
|
||||
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
|
||||
_pushed = pushed,
|
||||
_moved = moved,
|
||||
}
|
||||
return game, pushed, moved
|
||||
end
|
||||
|
||||
local function owWith(moved)
|
||||
return {
|
||||
player = { facing = "down", cellY = 2 },
|
||||
scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end,
|
||||
queueScript = function(self, rows) self._queued = rows end,
|
||||
startDustAnim = function() end,
|
||||
map = {
|
||||
setBlock = function() end,
|
||||
renderer = { rebuild = function() end },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local city = story.VERMILION_CITY
|
||||
check(city and city.onStep, "VERMILION_CITY has the sailor coord trigger")
|
||||
|
||||
-- With ticket, ship still docked: flash dialog, do NOT walk back.
|
||||
do
|
||||
local game, pushed, moved = gameWith({ inventory = { S_S_TICKET = 1 } })
|
||||
local ow = owWith(moved)
|
||||
check(city.onStep(game, ow, 18, 30), "ticket: walk-past trigger consumes the step")
|
||||
check(#pushed == 1, "ticket: auto dialog is shown")
|
||||
check(pushed[1].text:find("flashed", 1, true)
|
||||
or pushed[1].text:find("ticket", 1, true),
|
||||
"ticket: flash / ticket-check dialog text")
|
||||
check(#moved == 0, "ticket: player is not walked back")
|
||||
end
|
||||
|
||||
-- Without ticket: dialog + walk back.
|
||||
do
|
||||
local game, pushed, moved = gameWith({})
|
||||
local ow = owWith(moved)
|
||||
check(city.onStep(game, ow, 18, 30), "no ticket: trigger fires")
|
||||
check(#pushed == 1, "no ticket: dialog shown")
|
||||
if pushed[1] and pushed[1].done then pushed[1].done() end
|
||||
check(#moved == 1 and moved[1][1] == "up",
|
||||
"no ticket: walked back up after dialog")
|
||||
end
|
||||
|
||||
-- After the ship leaves: ShipSetSail + walk back, even with a ticket.
|
||||
do
|
||||
local game, pushed, moved = gameWith({
|
||||
inventory = { S_S_TICKET = 1 },
|
||||
flags = { EVENT_SS_ANNE_LEFT = true },
|
||||
})
|
||||
local ow = owWith(moved)
|
||||
check(city.onStep(game, ow, 18, 30), "ship left: trigger still fires")
|
||||
eq(pushed[1] and pushed[1].text, "The ship set sail.",
|
||||
"ship left: dialog updates to ShipSetSail")
|
||||
if pushed[1] and pushed[1].done then pushed[1].done() end
|
||||
check(#moved == 1 and moved[1][1] == "up",
|
||||
"ship left: walked back up")
|
||||
end
|
||||
|
||||
-- Off-tile / wrong facing: no trigger.
|
||||
do
|
||||
local game, pushed = gameWith({ inventory = { S_S_TICKET = 1 } })
|
||||
local ow = owWith({})
|
||||
eq(city.onStep(game, ow, 18, 29), false, "wrong Y: no trigger")
|
||||
ow.player.facing = "up"
|
||||
eq(city.onStep(game, ow, 18, 30), false, "facing up: no trigger")
|
||||
check(#pushed == 0, "no spurious dialog off the check")
|
||||
end
|
||||
|
||||
-- Sailor talk after ship left: ShipSetSail branch (row script).
|
||||
do
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local game = gameWith({ flags = { EVENT_SS_ANNE_LEFT = true } })
|
||||
local rows = city.talk.TEXT_VERMILIONCITY_SAILOR1
|
||||
local runner = ScriptRunner.new(game, nil)
|
||||
runner:run(rows, {})
|
||||
local guard = 0
|
||||
while runner:isRunning() and guard < 200 do
|
||||
guard = guard + 1
|
||||
if game.stack and game._pushed then
|
||||
local box = game._pushed[#game._pushed]
|
||||
if box and box.done then box.done() end
|
||||
end
|
||||
runner:update()
|
||||
end
|
||||
local last = game._pushed[#game._pushed]
|
||||
eq(last and last.text, "The ship set sail.",
|
||||
"talk after ship left shows ShipSetSail")
|
||||
end
|
||||
|
||||
-- Departure cutscene: Music_Surfing + EVENT_SS_ANNE_LEFT via Flags.set.
|
||||
do
|
||||
for i = #musicCalls, 1, -1 do musicCalls[i] = nil end
|
||||
local game, _, moved = gameWith({ flags = { EVENT_GOT_HM01 = true } })
|
||||
local ow = owWith(moved)
|
||||
story3.VERMILION_DOCK.onEnter(game, ow)
|
||||
check(Flags.get(game.save, "EVENT_SS_ANNE_LEFT"),
|
||||
"departure sets EVENT_SS_ANNE_LEFT")
|
||||
local sawSurf = false
|
||||
for _, c in ipairs(musicCalls) do
|
||||
if c[1] == "play" and c[2] == "Music_Surfing" then sawSurf = true end
|
||||
end
|
||||
check(sawSurf, "departure plays Music_Surfing")
|
||||
check(ow._queued ~= nil, "departure queues the sail-away script")
|
||||
local kept, horn = false, false
|
||||
for _, row in ipairs(ow._queued or {}) do
|
||||
if row[1] == "play_music" and row[2] == "Music_Surfing"
|
||||
and row[3] and row[3].keep then
|
||||
kept = true
|
||||
end
|
||||
if row[1] == "play_sound" and row[2] == "SS_Anne_Horn" then
|
||||
horn = true
|
||||
end
|
||||
end
|
||||
check(kept, "departure keeps Music_Surfing across the city warp")
|
||||
check(horn, "departure queues SS_Anne_Horn")
|
||||
end
|
||||
|
||||
-- Captain rub jingle: play_once Music_PkmnHealed sits after the rub text.
|
||||
do
|
||||
local rows = story.SS_ANNE_CAPTAINS_ROOM.talk.TEXT_SSANNECAPTAINSROOM_CAPTAIN
|
||||
local found = false
|
||||
for i, row in ipairs(rows) do
|
||||
if row[1] == "play_once" and row[2] == "Music_PkmnHealed" then
|
||||
found = true
|
||||
check(rows[i - 1] and rows[i - 1][2] == "_SSAnneCaptainsRoomRubCaptainsBackText",
|
||||
"play_once follows the rub-back text")
|
||||
end
|
||||
end
|
||||
check(found, "captain script plays Music_PkmnHealed after the rub")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
+98
-7
@@ -1895,6 +1895,87 @@ do
|
||||
eq(#StateStack.states, depth0, "mart menu unwound cleanly")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- UI text layout (#115/#116/#119)
|
||||
-- Nested function so LuaJIT's 200-local main-chunk limit is not hit.
|
||||
;(function()
|
||||
local BoxMenu = require("src.ui.BoxMenu")
|
||||
local box = BoxMenu.new(Game)
|
||||
check(box.tw >= 14, "Bill's PC menu is wide enough for CHANGE BOX")
|
||||
local labelTiles = 2 + #"CHANGE BOX" -- cursor gap + label
|
||||
check(box.tx + labelTiles <= box.tx + box.tw - 1,
|
||||
"CHANGE BOX fits inside the Bill's PC border")
|
||||
|
||||
-- ¥ is one glyph (charmap 0xF0) but two UTF-8 bytes; right-align must
|
||||
-- use Font.width, not #string.
|
||||
eq(Font.width("¥300"), 4 * 8, "Font.width counts yen as one glyph")
|
||||
check(Font.width("¥300") < #"¥300" * 8,
|
||||
"byte-length right-align would shift yen prices left")
|
||||
|
||||
-- Sell list: name + quantity column only (no ¥ price on the row).
|
||||
Game.save.inventory = { GREAT_BALL = 5, HYPER_POTION = 99 }
|
||||
local sellShop = require("src.ui.ShopMenu").new(Game, { "POTION" }, function() end)
|
||||
StateStack:push(sellShop)
|
||||
Input.pressed = { down = true }; StateStack:update(1 / 60); Input.pressed = {}
|
||||
Input.pressed = { a = true }; StateStack:update(1 / 60); Input.pressed = {}
|
||||
local sellList = StateStack:top()
|
||||
local foundHyper
|
||||
for _, it in ipairs(sellList.items or {}) do
|
||||
if it.value == "HYPER_POTION" then
|
||||
foundHyper = it
|
||||
local nameEnd = 16 + Font.width(it.label)
|
||||
local rightX = 160 - 8 - Font.width(it.right)
|
||||
check(nameEnd <= rightX,
|
||||
"sell HYPER POTION name does not overlap quantity")
|
||||
check(not tostring(it.right):find("¥", 1, true),
|
||||
"sell list keeps prices out of the right column")
|
||||
check(tostring(it.label):find("x", 1, true) == nil,
|
||||
"sell list does not glue quantity into the name")
|
||||
end
|
||||
end
|
||||
check(foundHyper, "sell list includes HYPER POTION")
|
||||
StateStack:pop() -- sell list
|
||||
StateStack:pop() -- shop
|
||||
Game.save.inventory = {}
|
||||
|
||||
-- Status screen page 2: next-level line stays left of the line-box edge.
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local mon = Pokemon.new(Data, "RAICHU", 27)
|
||||
local SummaryMenu = require("src.ui.SummaryMenu")
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
local drawn = {}
|
||||
local savedDraw, savedTile = Font.draw, HudTiles.tile
|
||||
Font.draw = function(text, x, y)
|
||||
drawn[#drawn + 1] = { text = tostring(text), x = x, y = y }
|
||||
return Font.width(text)
|
||||
end
|
||||
HudTiles.tile = function(code, x, y)
|
||||
drawn[#drawn + 1] = { tile = code, x = x, y = y }
|
||||
end
|
||||
local summary = SummaryMenu.new(Game, mon)
|
||||
summary.page = 2
|
||||
summary:draw()
|
||||
Font.draw = savedDraw
|
||||
HudTiles.tile = savedTile
|
||||
local nextExp, lvTile, lvNum
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.text == "LEVEL UP" then
|
||||
eq(d.y, 40, "LEVEL UP sits on tile row 5")
|
||||
elseif type(d.text) == "string" and d.text:match("^%s*%d+$") and d.y == 48 and d.x == 56 then
|
||||
nextExp = d
|
||||
elseif d.tile == 0x6E and d.y == 48 then
|
||||
lvTile = d
|
||||
elseif d.text == "28" and d.y == 48 then
|
||||
lvNum = d
|
||||
end
|
||||
end
|
||||
check(nextExp, "next-exp prints at (7,6)")
|
||||
check(lvTile and lvTile.x == 128, "next level uses the <LV> tile at col 16")
|
||||
check(lvNum and lvNum.x == 136, "next level digits follow <LV>")
|
||||
local edge = 19 * 8 -- DrawLineBox vertical at col 19
|
||||
check(lvNum.x + Font.width(lvNum.text) <= edge,
|
||||
"next level digits stay left of the status line-box")
|
||||
end)()
|
||||
|
||||
|
||||
|
||||
-- ================= BUGS.md batch: battle-victory-music =================
|
||||
@@ -2091,6 +2172,7 @@ do
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local VideoMode = require("src.core.VideoMode")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local SD = require("src.core.SaveData")
|
||||
-- Isolate from earlier save/options writes in this suite
|
||||
SD.saveOptions(SD.defaultOptions())
|
||||
@@ -2155,7 +2237,16 @@ do
|
||||
eq(og.save.options.videoMode, "windowed",
|
||||
"VIDEO MODE wraps back to WINDOWED")
|
||||
press("down")
|
||||
eq(om.index, 12, "cursor reaches GAME SPEED")
|
||||
eq(om.index, 12, "cursor reaches MAX FPS")
|
||||
press("a")
|
||||
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75")
|
||||
eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option")
|
||||
-- Driven by the step list rather than a literal press count, like GAME
|
||||
-- SPEED below: a full loop of #STEPS presses returns to the 60 default.
|
||||
for _ = 1, #FrameCap.STEPS - 1 do press("a") end
|
||||
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
|
||||
press("down")
|
||||
eq(om.index, 13, "cursor reaches GAME SPEED")
|
||||
press("a")
|
||||
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
|
||||
-- Driven by the level list rather than a literal press count: adding a
|
||||
@@ -2164,19 +2255,19 @@ do
|
||||
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
||||
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
|
||||
press("down")
|
||||
eq(om.index, 13, "cursor reaches MODS")
|
||||
eq(om.index, 14, "cursor reaches MODS")
|
||||
press("down")
|
||||
eq(om.index, 14, "cursor reaches CONTROLS")
|
||||
eq(om.index, 15, "cursor reaches CONTROLS")
|
||||
press("down")
|
||||
eq(om.index, 15, "CANCEL stays the fixed final row")
|
||||
eq(om.scroll, 10, "CANCEL keeps the last option boxes on screen")
|
||||
eq(om.index, 16, "CANCEL stays the fixed final row")
|
||||
eq(om.scroll, 11, "CANCEL keeps the last option boxes on screen")
|
||||
om:draw() -- smoke: scrolled layout draws under the headless stub
|
||||
press("a")
|
||||
check(popped, "A on CANCEL closes the options menu")
|
||||
local om2 = OptionsMenu.new(og)
|
||||
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
|
||||
eq(om2.index, 15, "up from the top wraps to CANCEL")
|
||||
eq(om2.scroll, 10, "wrapping to CANCEL scrolls to the tail")
|
||||
eq(om2.index, 16, "up from the top wraps to CANCEL")
|
||||
eq(om2.scroll, 11, "wrapping to CANCEL scrolls to the tail")
|
||||
-- headless-safe: no love.audio, setters only update internal state
|
||||
require("src.core.Music").applyOptions(og.save.options)
|
||||
require("src.core.Sound").applyOptions(og.save.options)
|
||||
|
||||
Reference in New Issue
Block a user