Compare commits

..

3 Commits

Author SHA1 Message Date
bryanthaboi 1c4515eaaa squashy squash (#127) 2026-07-23 15:42:54 -04:00
bryanthaboi c04b93ce1c pokecenter fix (#126) 2026-07-23 15:17:20 -04:00
bryanthaboi 2c9970a643 Frame Cap (#124) 2026-07-23 14:35:26 -04:00
31 changed files with 1748 additions and 188 deletions
+78 -22
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+7 -3
View File
@@ -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`).
+75
View File
@@ -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
+34
View File
@@ -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)
+72
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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 = {},
+11
View File
@@ -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)
+13
View File
@@ -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)
+10
View File
@@ -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)
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
+14
View File
@@ -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",
+3 -2
View File
@@ -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
View File
@@ -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
+7 -3
View File
@@ -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
View File
@@ -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
+146 -48
View File
@@ -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
+18
View File
@@ -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")
+62
View File
@@ -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
View File
@@ -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
View File
@@ -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
-- ===================================================================
+228
View File
@@ -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()
+116
View File
@@ -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()
+184
View File
@@ -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()
+199
View File
@@ -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
View File
@@ -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)