Closes #919, closes #982, closes #1003, closes #1012, closes #1022, closes #1028, closes #1033

This commit is contained in:
bryanthaboi
2026-08-14 10:05:51 -04:00
parent 2010ba71a6
commit a94fecfec8
48 changed files with 1263 additions and 541 deletions
+1 -1
View File
@@ -92,7 +92,7 @@ return {
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7 { "set_flag", "EVENT_GOT_EEVEE" }, -- 7
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE", { "hide_object", "CELADON_MANSION_ROOF_HOUSE",
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8 "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
{ "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle) { "text_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10 { "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
{ "jump", 13 }, -- 11 { "jump", 13 }, -- 11
{ "show_text", "_BoxIsFullText" }, -- 12 { "show_text", "_BoxIsFullText" }, -- 12
+6 -5
View File
@@ -10,10 +10,10 @@
local function coinGiver(opts) local function coinGiver(opts)
return function(game, ow, npc, done) return function(game, ow, npc, done)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Sound = require("src.core.Sound")
local t = game.data.text local t = game.data.text
local function push(label, fallback, onDone) local function push(label, fallback, onDone, popts)
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done)) game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done,
popts))
end end
if game.save.flags[opts.event] then if game.save.flags[opts.event] then
push(opts.alreadyGotLabel, opts.alreadyGotFallback) push(opts.alreadyGotLabel, opts.alreadyGotFallback)
@@ -30,9 +30,10 @@ local function coinGiver(opts)
end end
game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount) game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount)
game.save.flags[opts.event] = true game.save.flags[opts.event] = true
Sound.play(game.data, "Get_Item1") -- the ReceivedNCoinsText strings carry sound_get_item_1
push(opts.receivedLabel, push(opts.receivedLabel,
("{PLAYER} received\n%d coins!"):format(opts.amount)) ("{PLAYER} received\n%d coins!"):format(opts.amount), nil,
TextBox.soundOpts(game, "Get_Item1"))
end) end)
end end
end end
+5 -4
View File
@@ -22,8 +22,8 @@ return {
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
local t = game.data.text local t = game.data.text
local function say(label, cb) local function say(label, cb, sopts)
game.stack:push(TextBox.new(game, t[label] or label, cb)) game.stack:push(TextBox.new(game, t[label] or label, cb, sopts))
end end
if game.save.flags.EVENT_GOT_OLD_AMBER then if game.save.flags.EVENT_GOT_OLD_AMBER then
@@ -39,8 +39,9 @@ return {
game.save.flags.EVENT_GOT_OLD_AMBER = true game.save.flags.EVENT_GOT_OLD_AMBER = true
Commands.hide_object({ save = game.save, overworld = ow, game = game }, Commands.hide_object({ save = game.save, overworld = ow, game = game },
"MUSEUM_1F", "MUSEUM1F_OLD_AMBER") "MUSEUM_1F", "MUSEUM1F_OLD_AMBER")
require("src.core.Sound").play(game.data, "Get_Item1") -- .ReceivedOldAmberText carries sound_get_item_1
say("_Museum1FScientist2ReceivedOldAmberText", done) say("_Museum1FScientist2ReceivedOldAmberText", done,
TextBox.soundOpts(game, "Get_Item1"))
end) end)
end, end,
+6 -6
View File
@@ -38,9 +38,9 @@ local function starterBall(askText, species, choseFlag, ownBall,
-- inside give_pokemon). Show the received text first so the -- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1. -- nickname prompt follows "you got X", matching Gen1.
-- The received text carries sound_get_key_item (OaksLab.asm -- The received text carries sound_get_key_item (OaksLab.asm
-- OaksLabReceivedMonText); the jingle plays as the box opens -- OaksLabReceivedMonText), so the jingle fires once the box has
-- (same beat as the Yellow port's starter, #668). -- typed out and holds it (same beat as the Yellow port's starter, #668).
{ "play_sound", "Get_Key_Item" }, -- 8 { "text_sound", "Get_Key_Item" }, -- 8
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9 { "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
{ "give_pokemon", species, 5 }, -- 10 { "give_pokemon", species, 5 }, -- 10
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11 { "set_flag", "EVENT_GOT_STARTER" }, -- 11
@@ -54,7 +54,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "face_object", 1, "up" }, -- 15 { "face_object", 1, "up" }, -- 15
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16 { "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17 { "hide_object", "OAKS_LAB", rivalBall }, -- 17
{ "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item) { "text_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
{ "show_text", "_OaksLabRivalReceivedMonText", { "show_text", "_OaksLabRivalReceivedMonText",
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER" { RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE" or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
@@ -106,8 +106,8 @@ return {
{ "check_item", "OAKS_PARCEL" }, { "check_item", "OAKS_PARCEL" },
{ "jump_if_false", "raise_young" }, { "jump_if_false", "raise_young" },
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex -- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1DeliverParcelText" }, { "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" }, { "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 }, { "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" }, { "stop_music" },
@@ -128,8 +128,8 @@ return {
{ "face_object", 1, "up" }, { "face_object", 1, "up" },
{ "face_object", 5, "down" }, { "face_object", 5, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" }, { "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOakGotPokedexText" }, { "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", 1, "up" }, { "face_object", 1, "up" },
+5 -5
View File
@@ -38,8 +38,8 @@ return {
{ "jump_if_false", "raise_young" }, { "jump_if_false", "raise_young" },
-- .DeliverParcelText: parcel handover, then the Pokédex scene -- .DeliverParcelText: parcel handover, then the Pokédex scene
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript) -- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1DeliverParcelText" }, { "show_text", "_OaksLabOak1DeliverParcelText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ParcelThanksText" }, { "show_text", "_OaksLabOak1ParcelThanksText" },
{ "take_item", "OAKS_PARCEL", 1 }, { "take_item", "OAKS_PARCEL", 1 },
{ "stop_music" }, { "stop_music" },
@@ -62,8 +62,8 @@ return {
{ "face_object", RIVAL, "up" }, { "face_object", RIVAL, "up" },
{ "face_object", OAK1, "down" }, { "face_object", OAK1, "down" },
{ "show_text", "_OaksLabOakMyInventionPokedexText" }, { "show_text", "_OaksLabOakMyInventionPokedexText" },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOakGotPokedexText" }, { "show_text", "_OaksLabOakGotPokedexText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" }, { "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
{ "face_object", RIVAL, "up" }, { "face_object", RIVAL, "up" },
@@ -113,8 +113,8 @@ return {
{ "jump_if_true", "come_see" }, { "jump_if_true", "come_see" },
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" }, { "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
{ "give_item", "POKE_BALL", 5, false }, { "give_item", "POKE_BALL", 5, false },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" }, { "show_text", "_OaksLabOak1ReceivedPokeballsText" },
{ "play_sound", "Get_Key_Item" },
{ "show_text", "_OaksLabGivePokeballsExplanationText" }, { "show_text", "_OaksLabGivePokeballsExplanationText" },
{ "jump", "end" }, { "jump", "end" },
@@ -176,7 +176,7 @@ return {
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time -- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
rows[#rows + 1] = { "set_field", "rivalStarter", 1 } rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText1" }
rows[#rows + 1] = { "play_sound", "Get_Key_Item" } rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText2" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText3" }
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" } rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
@@ -196,7 +196,7 @@ return {
rows[#rows + 1] = { "face_object", OAK1, "down" } rows[#rows + 1] = { "face_object", OAK1, "down" }
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013) -- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" } rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" }
rows[#rows + 1] = { "play_sound", "Get_Key_Item" } rows[#rows + 1] = { "text_sound", "Get_Key_Item" }
rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } } rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } }
rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 } rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 }
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009) -- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
+2
View File
@@ -297,6 +297,7 @@ M.BILLS_HOUSE = {
M.ROUTE_25 = { M.ROUTE_25 = {
onEnter = function(game, ow) onEnter = function(game, ow)
game.save.pikachuMapScriptActive = nil
local flags = game.save.flags local flags = game.save.flags
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
local Commands = require("src.script.Commands") local Commands = require("src.script.Commands")
@@ -329,6 +330,7 @@ M.VERMILION_CITY = {
-- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only -- only read while EVENT_1ST_LOCK_OPENED is unset (the gym is only
-- reachable through this map, so a fresh visit always re-rolls). -- reachable through this map, so a fresh visit always re-rolls).
onEnter = function(game, ow) onEnter = function(game, ow)
game.save.pikachuMapScriptActive = nil
local puz = game.save.trashPuzzle or {} local puz = game.save.trashPuzzle or {}
game.save.trashPuzzle = puz game.save.trashPuzzle = puz
puz.first = love.math.random(0, 7) * 2 puz.first = love.math.random(0, 7) * 2
+5 -3
View File
@@ -648,8 +648,10 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
end end
local idef = game.data.items[itemId] local idef = game.data.items[itemId]
game.stringBuffer = idef and idef.name or itemId game.stringBuffer = idef and idef.name or itemId
require("src.core.Sound").play(game.data, "Get_Key_Item")
local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId) local dirs = mtMoonNerdWalk(ow.player.cellX, ow.player.cellY, itemId)
-- MtMoonB2FReceivedFossilText: text_far, sound_get_key_item,
-- text_waitbutton -- the jingle plays after the box has typed and
-- the button wait comes after it
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._MtMoonB2FReceivedFossilText t._MtMoonB2FReceivedFossilText
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"), or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
@@ -662,11 +664,11 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
ow.runner:run({ ow.runner:run({
{ "walk_npc", 1, dirs }, { "walk_npc", 1, dirs },
{ "text_opts", { auto = true } }, { "text_opts", { auto = true } },
{ "text_sound", "Get_Key_Item" },
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" }, { "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
{ "play_sound", "Get_Key_Item" },
{ "hide_object", "MT_MOON_B2F", otherName }, { "hide_object", "MT_MOON_B2F", otherName },
}, { onDone = done }) }, { onDone = done })
end)) end, TextBox.soundOpts(game, "Get_Key_Item")))
end })) end }))
end end
end end
+7 -11
View File
@@ -8,9 +8,9 @@ local M = {}
local function text(game) return game.data.text end local function text(game) return game.data.text end
local function push(game, s, done) local function push(game, s, done, opts)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, done)) game.stack:push(TextBox.new(game, s, done, opts))
end end
-- The question stays on screen under the YES/NO menu. The dojo prize -- The question stays on screen under the YES/NO menu. The dojo prize
@@ -263,7 +263,7 @@ M.SILPH_CO_7F = {
{ "jump_if_false", "box_full" }, { "jump_if_false", "box_full" },
-- flag ahead of the jingle, like the Celadon EEVEE (#426) -- flag ahead of the jingle, like the Celadon EEVEE (#426)
{ "set_flag", "EVENT_GOT_LAPRAS" }, { "set_flag", "EVENT_GOT_LAPRAS" },
{ "play_sound", "Get_Item1" }, { "text_sound", "Get_Item1" },
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } }, { "show_text", "_GotMonText", { RAM = "LAPRAS" } },
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" }, { "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
{ "jump", "end" }, { "jump", "end" },
@@ -307,12 +307,11 @@ M.COPYCATS_HOUSE_2F = {
return return
end end
game.stringBuffer = game.data.items.TM_MIMIC.name game.stringBuffer = game.data.items.TM_MIMIC.name
require("src.core.Sound").play(game.data, "Get_Item1")
Bag.remove(game.save, "POKE_DOLL", 1) Bag.remove(game.save, "POKE_DOLL", 1)
game.save.flags.EVENT_GOT_TM31 = true game.save.flags.EVENT_GOT_TM31 = true
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function() push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done) push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
end) end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end) end)
end) end)
end, end,
@@ -478,7 +477,6 @@ M.CELADON_MART_ROOF = {
return return
end end
game.save.flags[g.flag] = true game.save.flags[g.flag] = true
require("src.core.Sound").play(game.data, "Get_Item1")
local subs = { player = game.save.player.name, local subs = { player = game.save.player.name,
ram = game.data.items[g.tm].name } ram = game.data.items[g.tm].name }
local explain = fill(t[g.explain] or "", subs) local explain = fill(t[g.explain] or "", subs)
@@ -490,7 +488,7 @@ M.CELADON_MART_ROOF = {
else else
done() done()
end end
end) end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end) end)
end, end,
onCancel = done, onCancel = done,
@@ -522,7 +520,6 @@ M.ROUTE_24 = {
local t = text(game) local t = text(game)
push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f" push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f"
.. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function() .. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function()
require("src.core.Sound").play(game.data, "Get_Item1")
if not require("src.inventory.Bag").add(game.save, "NUGGET", 1, if not require("src.inventory.Bag").add(game.save, "NUGGET", 1,
game.data) then game.data) then
push(game, t._Route24CooltrainerM1NoRoomText, done) push(game, t._Route24CooltrainerM1NoRoomText, done)
@@ -531,11 +528,10 @@ M.ROUTE_24 = {
flags.EVENT_GOT_NUGGET = true flags.EVENT_GOT_NUGGET = true
game.stringBuffer = game.data.items.NUGGET.name game.stringBuffer = game.data.items.NUGGET.name
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function() push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
require("src.core.Sound").play(game.data, "Get_Item1")
push(game, t._Route24CooltrainerM1JoinTeamRocketText, push(game, t._Route24CooltrainerM1JoinTeamRocketText,
battleOrDone) battleOrDone)
end) end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
end) end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
return return
end end
battleOrDone() battleOrDone()
+8 -7
View File
@@ -4,9 +4,9 @@ local M = {}
local function text(game) return game.data.text end local function text(game) return game.data.text end
local function push(game, s, done) local function push(game, s, done, opts)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, s, done)) game.stack:push(TextBox.new(game, s, done, opts))
end end
-- fill the extracted text placeholders ({RAM:...}, {PLAYER}) -- fill the extracted text placeholders ({RAM:...}, {PLAYER})
@@ -25,8 +25,8 @@ local function gift(opts)
local t = text(game) local t = text(game)
local itemName = game.data.items[opts.item].name local itemName = game.data.items[opts.item].name
local subs = { ram = itemName, player = game.save.player.name } local subs = { ram = itemName, player = game.save.player.name }
local function say(label, fallback, cb) local function say(label, fallback, cb, sopts)
push(game, fill(t[label] or fallback, subs), cb) push(game, fill(t[label] or fallback, subs), cb, sopts)
end end
if game.save.flags[opts.flag] then if game.save.flags[opts.flag] then
say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done) say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done)
@@ -39,15 +39,16 @@ local function gift(opts)
end end
game.save.flags[opts.flag] = true game.save.flags[opts.flag] = true
local idef = game.data.items[opts.item] local idef = game.data.items[opts.item]
require("src.core.Sound").play(game.data, -- the received texts carry sound_get_item_1 / sound_get_key_item, so
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1") -- the jingle only fires once that box has typed out
say(opts.received, "{PLAYER} received\n{RAM:}!", function() say(opts.received, "{PLAYER} received\n{RAM:}!", function()
if opts.explain then if opts.explain then
say(opts.explain, "", done) say(opts.explain, "", done)
else else
done() done()
end end
end) end, require("src.render.TextBox").soundOpts(game,
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1"))
end end
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
end end
+21
View File
@@ -28,6 +28,14 @@
-- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a -- is full; `gotFlag` (pokered's EVENT_GOT_TM*) is set only on a
-- successful give, which is what makes the leader's talk script retry -- successful give, which is what makes the leader's talk script retry
-- later (gyms.lua). -- later (gyms.lua).
--
-- `badgeSound` / `tmSound` are the text sound command each gym's reward
-- text carries right after its FIRST label -- home/text.asm TextCommand_SOUND
-- plays it once that page has typed out and then blocks on
-- WaitForSoundToFinish, so the jingle sits between the pages rather than
-- under them. macros/scripts/text.asm defines sound_level_up as
-- sound_get_item_1, so Pewter's and Viridian's badge lines are Get_Item1
-- too. Vermilion, Celadon and Fuchsia carry no sound on the badge text.
local function range(prefix, first, last) local function range(prefix, first, last)
local t = {} local t = {}
@@ -50,6 +58,8 @@ return {
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" }, { "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
{ "ROUTE_22", "ROUTE22_RIVAL1" }, { "ROUTE_22", "ROUTE22_RIVAL1" },
}, },
badgeSound = "Get_Item1", -- sound_level_up
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_PewterGymBrockReceivedBoulderBadgeText", "_PewterGymBrockReceivedBoulderBadgeText",
"_PewterGymBrockBoulderBadgeInfoText", "_PewterGymBrockBoulderBadgeInfoText",
@@ -64,6 +74,8 @@ return {
gotFlag = "EVENT_GOT_TM11", gotFlag = "EVENT_GOT_TM11",
noRoom = "_CeruleanGymMistyTM11NoRoomText", noRoom = "_CeruleanGymMistyTM11NoRoomText",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1), deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
badgeSound = "Get_Key_Item",
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_CeruleanGymMistyReceivedCascadeBadgeText", "_CeruleanGymMistyReceivedCascadeBadgeText",
}, },
@@ -76,6 +88,7 @@ return {
gotFlag = "EVENT_GOT_TM24", gotFlag = "EVENT_GOT_TM24",
noRoom = "_VermilionGymLTSurgeTM24NoRoomText", noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2), deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
tmSound = "Get_Key_Item",
dialogue = { dialogue = {
"_VermilionGymLTSurgeReceivedThunderBadgeText", "_VermilionGymLTSurgeReceivedThunderBadgeText",
}, },
@@ -89,6 +102,7 @@ return {
gotFlag = "EVENT_GOT_TM21", gotFlag = "EVENT_GOT_TM21",
noRoom = "_CeladonGymTM21NoRoomText", noRoom = "_CeladonGymTM21NoRoomText",
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6), deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_CeladonGymErikaReceivedRainbowBadgeText", "_CeladonGymErikaReceivedRainbowBadgeText",
}, },
@@ -102,6 +116,7 @@ return {
gotFlag = "EVENT_GOT_TM06", gotFlag = "EVENT_GOT_TM06",
noRoom = "_FuchsiaGymKogaTM06NoRoomText", noRoom = "_FuchsiaGymKogaTM06NoRoomText",
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5), deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
tmSound = "Get_Key_Item",
dialogue = { dialogue = {
"_FuchsiaGymKogaReceivedSoulBadgeText", "_FuchsiaGymKogaReceivedSoulBadgeText",
}, },
@@ -115,6 +130,8 @@ return {
gotFlag = "EVENT_GOT_TM46", gotFlag = "EVENT_GOT_TM46",
noRoom = "_SaffronGymSabrinaTM46NoRoomText", noRoom = "_SaffronGymSabrinaTM46NoRoomText",
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6), deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
badgeSound = "Get_Key_Item",
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_SaffronGymSabrinaReceivedMarshBadgeText", "_SaffronGymSabrinaReceivedMarshBadgeText",
}, },
@@ -128,6 +145,8 @@ return {
gotFlag = "EVENT_GOT_TM38", gotFlag = "EVENT_GOT_TM38",
noRoom = "_CinnabarGymBlaineTM38NoRoomText", noRoom = "_CinnabarGymBlaineTM38NoRoomText",
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6), deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
badgeSound = "Get_Key_Item",
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_CinnabarGymBlaineReceivedVolcanoBadgeText", "_CinnabarGymBlaineReceivedVolcanoBadgeText",
}, },
@@ -141,6 +160,8 @@ return {
gotFlag = "EVENT_GOT_TM27", gotFlag = "EVENT_GOT_TM27",
noRoom = "_ViridianGymGiovanniTM27NoRoomText", noRoom = "_ViridianGymGiovanniTM27NoRoomText",
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7), deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
badgeSound = "Get_Item1", -- sound_level_up
tmSound = "Get_Item1",
dialogue = { dialogue = {
"_ViridianGymGiovanniReceivedEarthBadgeText", "_ViridianGymGiovanniReceivedEarthBadgeText",
}, },
+1 -1
View File
@@ -26,7 +26,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
## Pokémon Gold (Gen 2) ## Pokémon Gold (Gen 2)
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered. Port extras beyond the cartridge: A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered (and pokeyellow). Port extras beyond the cartridge:
* **COLOR, zoom, tilt, GBC FX, and quick save/load** * **COLOR, zoom, tilt, GBC FX, and quick save/load**
* **UI that stays fixed while the overworld zooms** * **UI that stays fixed while the overworld zooms**
+119 -69
View File
@@ -501,6 +501,9 @@ local function makeBattler(data, mon, isPlayer, save)
badgeBoosts = badgeBoosts, badgeBoosts = badgeBoosts,
statuses = data.statuses, statuses = data.statuses,
shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain) shownHP = mon.hp, -- the HP the bar displays (UpdateHPBar drain)
-- the bar's own length in GetHPBarLength pixels; it trails shownHP
-- because UpdateHPBar_AnimateHPBar slides it one pixel at a time
shownPx = Timing.hpBarPixels(mon.hp, math.max(1, mon.stats.hp)),
-- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move -- HUD status label (DrawHUDsAndHPBars); mon.status can land mid-move
-- while the tilemap still shows the prior condition until the next -- while the tilemap still shows the prior condition until the next
-- post-action HUD refresh (core.asm after Execute*Move) -- post-action HUD refresh (core.asm after Execute*Move)
@@ -563,10 +566,6 @@ local function markOwned(game, species)
local dex = game.save.pokedex local dex = game.save.pokedex
if dex then if dex then
dex.seen[species] = true dex.seen[species] = true
if not dex.owned[species] then
-- new dex page registered (SFX_DEX_PAGE_ADDED)
require("src.core.Sound").play(game.data, "Dex_Page_Added")
end
dex.owned[species] = true dex.owned[species] = true
end end
end end
@@ -935,6 +934,17 @@ function BattleState:sayNextWaitSfx(text, sfx)
table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx }) table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx })
end end
-- WaitForSoundToFinish for a sound an act() has already started: PlayCry
-- ends in `jp WaitForSoundToFinish` (home/pokemon.asm), so every cry in
-- Gen 1 holds whatever the ROM does next. `src` is the audio source, or a
-- getter the row calls at execution time when the source is only known then.
function BattleState:waitSfxNext(src)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert,
{ waitSound = type(src) == "function" and src
or function() return src end })
end
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765) -- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
function BattleState:sayNextAuto(text, delay) function BattleState:sayNextAuto(text, delay)
self.nextInsert = (self.nextInsert or 0) + 1 self.nextInsert = (self.nextInsert or 0) + 1
@@ -1022,22 +1032,39 @@ function BattleState:stepHPDrain()
and b.shownHP >= b.drainFloor then and b.shownHP >= b.drainFloor then
goal = b.drainFloor goal = b.drainFloor
end end
local maxHP = math.max(1, b.mon.stats.hp)
local playerSide = (b == self.player)
local targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
if not b.shownPx then b.shownPx = targetPx end
if (b.drainHold or 0) > 0 then if (b.drainHold or 0) > 0 then
b.drainHold = b.drainHold - 1 b.drainHold = b.drainHold - 1
busy = true busy = true
elseif b.shownPx ~= targetPx then
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
-- call DelayFrames` apiece (:141-148), so a single HP point that
-- spans several pixels still slides instead of jumping
b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1)
b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1
busy = true
elseif b.shownHP ~= goal then elseif b.shownHP ~= goal then
local maxHP = math.max(1, b.mon.stats.hp) local spent = 0
local playerSide = (b == self.player)
local cost = 0
-- consume whole HP steps until this frame's budget is spent; on the -- consume whole HP steps until this frame's budget is spent; on the
-- enemy HUD several free steps can land in the same frame -- enemy HUD several free steps can land in the same frame
while b.shownHP ~= goal and cost < 1 do repeat
local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1) b.shownHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP, spent = spent + (playerSide and Timing.HP_BAR_HP_STEP or 0)
maxHP, playerSide) targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
b.shownHP = nextHP until b.shownHP == goal or targetPx ~= b.shownPx or spent >= 1
if spent > 0 then
b.drainHold = spent - 1
elseif targetPx ~= b.shownPx then
-- the enemy HUD printed no number, so this frame is already the
-- first of the pixel step the crossing just asked for
b.shownPx = b.shownPx + ((b.shownPx > targetPx) and -1 or 1)
b.drainHold = Timing.HP_BAR_PIXEL_STEP - 1
else
b.drainHold = 0
end end
b.drainHold = math.max(0, cost - 1)
b.draining = true b.draining = true
busy = true busy = true
elseif b.draining then elseif b.draining then
@@ -1431,7 +1458,7 @@ end
function BattleState:playEntranceCry(battler) function BattleState:playEntranceCry(battler)
local mon = battler and battler.mon local mon = battler and battler.mon
if not mon then return end if not mon then return end
require("src.core.Sound").playCry(self.data, mon.species, return require("src.core.Sound").playCry(self.data, mon.species,
mon.status == "SLP" and 37 or 11) mon.status == "SLP" and 37 or 11)
end end
@@ -1555,7 +1582,23 @@ function BattleState:enter()
-- default stays opaque for every other battle and for older saves. -- default stays opaque for every other battle and for older saves.
self.isOpaque = self:bgMode() ~= "world" self.isOpaque = self:bgMode() ~= "world"
self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil -- GetTrainerInformation .linkBattle (home/trainers2.asm:26-31): the link
-- foe's pic is RedPicFront whatever either save looks like, and
-- InitBattleCommon loads and tilemaps it at hlcoord 12,0 for every
-- wIsInBattle == 2 battle (core.asm:6681-6688), the link one included.
-- wEnemyMonSpecies2 is zeroed under it, so the pic palette is PAL_MEWMON
-- exactly as it is for a trainer.
if self.kind == "link" and not self.trainerPic then
local frontPath, frontTrueColor =
require("src.pokemon.Sprites").playerPath(self.data, "front",
{ kind = "battle", battle = self })
if frontPath and require("src.render.Assets").exists(frontPath) then
self.trainerPic = getImage(frontPath, namedPalette(self.data, "MEWMON"),
frontTrueColor)
end
end
self.showEnemyTrainer = (self.kind == "trainer" or self.kind == "link")
and self.trainerPic ~= nil
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the -- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles / -- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
-- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with -- PlaceEnemyHUDTiles, draw_hud_pokeball_gfx.asm:119-165) on screen with
@@ -1587,13 +1630,13 @@ function BattleState:enter()
-- a different point in each battle kind, so queue it per branch -- a different point in each battle kind, so queue it per branch
local function queueEnemyCry() local function queueEnemyCry()
self:act(function() self:act(function()
self:playEntranceCry(self.enemy) self:waitSfxNext(self:playEntranceCry(self.enemy))
end) end)
end end
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild -- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry -- battle calls PlayCry BEFORE PrintText WildMonAppearedText, and PlayCry
-- sounds with the "Wild X appeared!" box instead of waiting on the A -- ends in WaitForSoundToFinish, so the cry runs to its end and only then
-- press that clears its `prompt` (#303). The Silph-Scope-less tower -- does the "Wild X appeared!" box open (#303). The Silph-Scope-less tower
-- ghost gets no cry at all (common_text.asm:43-48), and neither does the -- ghost gets no cry at all (common_text.asm:43-48), and neither does the
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492). -- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
if self.kind ~= "trainer" and self.kind ~= "link" if self.kind ~= "trainer" and self.kind ~= "link"
@@ -1611,7 +1654,10 @@ function BattleState:enter()
-- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json -- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names -- sfxHeaders, bank 8 / $42bb -- the same header pokered names
-- SFX_Silph_Scope); nothing had ever played it. -- SFX_Silph_Scope); nothing had ever played it.
if self.kind == "trainer" then --
-- A link battle is wIsInBattle == 2, so PrintBeginningBattleText takes the
-- same .trainerBattle arm and owes the sfx too (common_text.asm:20-23).
if self.kind == "trainer" or self.kind == "link" then
self:act(function() self:act(function()
self.introSfx = require("src.core.Sound").play(self.data, self.introSfx = require("src.core.Sound").play(self.data,
"Trainer_Appeared") "Trainer_Appeared")
@@ -1629,13 +1675,17 @@ function BattleState:enter()
-- battle -- not on a switch, and not when the beaten trainer's pic -- battle -- not on a switch, and not when the beaten trainer's pic
-- scrolls back in (#317, #282) -- scrolls back in (#317, #282)
self:act(function() self.introBalls = nil end) self:act(function() self.introBalls = nil end)
if self.kind == "trainer" then if self.kind == "trainer" or self.kind == "link" then
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen local foeName = self.trainer and self.trainer.name
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles, or self.opponentName or Strings("FOE")
-- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does if self.showEnemyTrainer then
-- not blink out under the text (#317) -- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
self:act(function() self:slidePic("foe", 0, 64, 4) end) -- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
table.insert(self.queue, { wait = 16 }) -- one tile every 2 frames) BEFORE TrainerSentOutText -- the pic does
-- not blink out under the text (#317)
self:act(function() self:slidePic("foe", 0, 64, 4) end)
table.insert(self.queue, { wait = 16 })
end
self:act(function() self:act(function()
self.showEnemyTrainer = false self.showEnemyTrainer = false
-- the slot is EMPTY from here until AnimateSendingOutMon runs below: -- the slot is EMPTY from here until AnimateSendingOutMon runs below:
@@ -1648,7 +1698,7 @@ function BattleState:enter()
self.enemySendingOut = true self.enemySendingOut = true
self:slidePic("foe") self:slidePic("foe")
end) end)
self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name))
self:act(function() self:act(function()
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the -- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
-- pic grows out of the ball (AnimateSendingOutMon), then the cry -- pic grows out of the ball (AnimateSendingOutMon), then the cry
@@ -1656,18 +1706,6 @@ function BattleState:enter()
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
end) end)
queueEnemyCry() queueEnemyCry()
elseif self.kind == "link" then
-- Colosseum has no foe trainer pic, but the enemy mon still grows
-- out of the ball after "X sent out Y!" (not the wild "already there"
-- intro that LinkBattle previously inherited from newWild).
self.enemySendingOut = true
self:say(Strings("%s sent\nout %s!", self.opponentName or Strings("FOE"),
self.enemy.name))
self:act(function()
self.enemySendingOut = false
self:startGrowIn(self.enemy)
end)
queueEnemyCry()
end end
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz` -- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
-- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames` -- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames`
@@ -1698,7 +1736,7 @@ function BattleState:enter()
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows -- SendOutMon (core.asm:1757-1762): after the poof the mon grows
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11) -- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
self:startGrowIn(self.player) self:startGrowIn(self.player)
self:playEntranceCry(self.player) self:waitSfxNext(self:playEntranceCry(self.player))
end) end)
self:markParticipant() self:markParticipant()
end end
@@ -1876,7 +1914,10 @@ function BattleState:update(dt)
if self.phase == "menu" then if self.phase == "menu" then
for _, b in ipairs({ self.player, self.enemy }) do for _, b in ipairs({ self.player, self.enemy }) do
if b then if b then
if b.shownHP then b.shownHP = b.mon.hp end if b.shownHP then
b.shownHP = b.mon.hp
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
end
b.drainFloor = nil b.drainFloor = nil
b.shownStatus = b.mon.status b.shownStatus = b.mon.status
end end
@@ -2294,10 +2335,10 @@ function BattleState:oldManThrow()
return return
end end
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL") self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
self:actNext(function() -- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
require("src.core.Sound").play(self.data, "Caught_Mon") -- the fanfare follows the caught text and holds the prompt
end) self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name)) function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
end) end)
end end
@@ -2481,7 +2522,7 @@ function BattleState:resolveSwitch(newMon)
self.sendingOut = false self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in -- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player) self:startGrowIn(self.player)
self:playEntranceCry(self.player) self:waitSfxNext(self:playEntranceCry(self.player))
end) end)
end) end)
self:act(function() self:act(function()
@@ -3793,7 +3834,7 @@ function BattleState:onFaint(battler)
if battler.isPlayer then if battler.isPlayer then
-- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's -- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's
-- faint plays its ordinary species cry -- no Faint_Fall -- faint plays its ordinary species cry -- no Faint_Fall
Sound.playCry(self.data, battler.mon.species) self.faintCry = Sound.playCry(self.data, battler.mon.species)
elseif self.kind ~= "wild" then elseif self.kind ~= "wild" then
-- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no -- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no
-- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD -- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD
@@ -3808,6 +3849,11 @@ function BattleState:onFaint(battler)
end) end)
self.nextInsert = (self.nextInsert or 0) + 1 self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE }) table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE })
if battler.isPlayer then
-- RemoveFaintedPlayerMon ends `call PlayCry / jp PrintText`, and PlayCry
-- is `jp WaitForSoundToFinish`, so "X fainted!" waits out the cry
self:waitSfxNext(function() return self.faintCry end)
end
if not battler.isPlayer and self.kind ~= "wild" then if not battler.isPlayer and self.kind ~= "wild" then
-- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after -- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after
-- Faint_Fall, before EnemyMonFaintedText) -- Faint_Fall, before EnemyMonFaintedText)
@@ -3895,9 +3941,11 @@ function BattleState:awardExp()
-- experience.asm:248 fires per grew-level text -- experience.asm:248 fires per grew-level text
require("src.world.PikachuFollower") require("src.world.PikachuFollower")
.modifyHappiness(game.save, "LEVELUP", mon) .modifyHappiness(game.save, "LEVELUP", mon)
self:sayNext(Strings("%s grew\nto level %d!", name, lv)) -- GrewLevelText: text_far, sound_level_up, text_end (experience.asm:
-- 369-372); PrintStatsBox only runs once PrintText has returned
self:sayNextWaitSfx(Strings("%s grew\nto level %d!", name, lv),
function() return require("src.core.Sound").play(game.data, "Level_Up") end)
self:uiNext(function() self:uiNext(function()
require("src.core.Sound").play(game.data, "Level_Up")
return StatBox.new(game, mon) return StatBox.new(game, mon)
end) end)
-- After PrintStatsBox, experience.asm reloads the active battler's -- After PrintStatsBox, experience.asm reloads the active battler's
@@ -4042,7 +4090,7 @@ function BattleState:enemyMonFainted()
self.enemySendingOut = false self.enemySendingOut = false
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
self:actNext(function() self:actNext(function()
self:playEntranceCry(self.enemy) self:waitSfxNext(self:playEntranceCry(self.enemy))
end) end)
end) end)
end) end)
@@ -4081,7 +4129,7 @@ function BattleState:enemyMonFainted()
self:actNext(function() self:actNext(function()
self.sendingOut = false self.sendingOut = false
self:startGrowIn(self.player) self:startGrowIn(self.player)
self:playEntranceCry(self.player) self:waitSfxNext(self:playEntranceCry(self.player))
end) end)
end) end)
return return
@@ -4277,7 +4325,7 @@ function BattleState:openReplacementMenu()
self.sendingOut = false self.sendingOut = false
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in -- SendOutMon (core.asm:1757-1762): poof, then the grow-in
self:startGrowIn(self.player) self:startGrowIn(self.player)
self:playEntranceCry(self.player) self:waitSfxNext(self:playEntranceCry(self.player))
end) end)
end, end,
}) })
@@ -4321,11 +4369,10 @@ function BattleState:safariAction(choice)
-- above DoBallTossSpecialEffects's <= ULTRA_BALL check) -- above DoBallTossSpecialEffects's <= ULTRA_BALL check)
self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL") self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL")
if caught then if caught then
-- ItemUseBallText05's sound_caught_mon: fanfare with the text -- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
self:actNext(function() -- the fanfare follows the caught text and holds the prompt
require("src.core.Sound").play(self.data, "Caught_Mon") self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
end) function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
-- same ItemUseBall .captured flow as a regular ball -- same ItemUseBall .captured flow as a regular ball
self:act(function() self:storeCaughtMon() end) self:act(function() self:storeCaughtMon() end)
else else
@@ -4553,8 +4600,12 @@ function BattleState:storeCaughtMon()
markOwned(game, species) markOwned(game, species)
stampOT(game.save, self.enemy.mon) stampOT(game.save, self.enemy.mon)
if isNew then if isNew then
-- _ItemUseBallText06 + ShowPokedexData -- _ItemUseBallText06 + ShowPokedexData: text_far, sound_dex_page_added,
self:sayNext(Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name)) -- text_promptbutton (item_effects.asm:624-629), so the fanfare follows
-- the box rather than firing when the dex bit is set
self:sayNextWaitSfx(
Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name),
function() return require("src.core.Sound").play(self.data, "Dex_Page_Added") end)
self:uiNext(function() self:uiNext(function()
return self:buildScreen("DexEntryMenu", species) return self:buildScreen("DexEntryMenu", species)
end) end)
@@ -4704,12 +4755,10 @@ function BattleState:throwBall(ball)
self:ballChain(self:tossAnimFor(ball), caught, shakes, ball) self:ballChain(self:tossAnimFor(ball), caught, shakes, ball)
if caught then if caught then
-- ItemUseBallText05 carries sound_caught_mon (item_effects.asm: -- ItemUseBallText05 carries sound_caught_mon (item_effects.asm:
-- 608-614): the fanfare sounds with the caught message, before -- 608-614): text_far, sound_caught_mon, text_promptbutton -- the
-- the prompt, not after the text is dismissed -- fanfare follows the caught message and holds the prompt
self:actNext(function() self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
require("src.core.Sound").play(self.data, "Caught_Mon") function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
end)
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
self:act(function() self:storeCaughtMon() end) self:act(function() self:storeCaughtMon() end)
else else
self:sayNext(self:ballMissMessage(shakes)) self:sayNext(self:ballMissMessage(shakes))
@@ -5196,7 +5245,8 @@ function BattleState:sgbBattlePals()
local function bar(b) local function bar(b)
if not b then return pals.GREENBAR end if not b then return pals.GREENBAR end
local hp = b.shownHP or b.mon.hp local hp = b.shownHP or b.mon.hp
return pals[PaletteFX.barPalName(hp, b.mon.stats.hp)] or pals.GREENBAR return pals[PaletteFX.barPalName(hp, b.mon.stats.hp, b.shownPx)]
or pals.GREENBAR
end end
local function mon(b, placeholder) local function mon(b, placeholder)
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
@@ -5594,7 +5644,7 @@ function BattleState:drawHUDs(slide)
hudTile(0x73, 8, 16) hudTile(0x73, 8, 16)
drawHPBar(barData, 2, 2, drawHPBar(barData, 2, 2,
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats }, { hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
nil, grayFill) nil, grayFill, nil, self.enemy.shownPx)
hudTile(0x74, 8, 24) hudTile(0x74, 8, 24)
for i = 2, 9 do hudTile(0x76, i * 8, 24) end for i = 2, 9 do hudTile(0x76, i * 8, 24) end
hudTile(0x78, 80, 24) hudTile(0x78, 80, 24)
@@ -5675,7 +5725,7 @@ function BattleState:drawHUDs(slide)
end end
drawHPBar(barData, 10, 9, drawHPBar(barData, 10, 9,
{ hp = shownHP(self.player), stats = self.player.mon.stats }, { hp = shownHP(self.player), stats = self.player.mon.stats },
1, grayFill) -- wHPBarType 1: the $6D cap 1, grayFill, nil, self.player.shownPx) -- wHPBarType 1: the $6D cap
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80) Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
hudTile(0x73, 144, 80) hudTile(0x73, 144, 80)
hudTile(0x77, 144, 88) hudTile(0x77, 144, 88)
+1 -1
View File
@@ -108,7 +108,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, { HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
hp = shownHP(battler), hp = shownHP(battler),
stats = battler.mon.stats, stats = battler.mon.stats,
}, nil, monoMode(), tw - 5) }, nil, monoMode(), tw - 5, battler.shownPx)
if player then if player then
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp), Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
+6
View File
@@ -334,6 +334,12 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
battle.player.mon.hp, battle.player.mon.status battle.player.mon.hp, battle.player.mon.status
battle.enemy.shownHP, battle.enemy.shownStatus = battle.enemy.shownHP, battle.enemy.shownStatus =
battle.enemy.mon.hp, battle.enemy.mon.status battle.enemy.mon.hp, battle.enemy.mon.status
-- the bar's own pixel length is derived HUD state, not captured: it
-- settles with shownHP above (UpdateHPBar_AnimateHPBar's end position)
local Timing = require("src.core.Timing")
for _, b in ipairs({ battle.player, battle.enemy }) do
b.shownPx = Timing.hpBarPixels(b.mon.hp, math.max(1, b.mon.stats.hp))
end
local ow = game.overworld local ow = game.overworld
if not ow or type(ow.restoreBattleContinuation) ~= "function" if not ow or type(ow.restoreBattleContinuation) ~= "function"
+53 -133
View File
@@ -289,38 +289,6 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac
end end
end end
-- A square icon control: the header's gear and quit, and the game panel's
-- manage button. Inverts to a solid white fill when hot, the same signal
-- every other control here uses, and rounds to the shared control radius.
-- `image` draws a texture; `drawFn(x, y, size, hot)` draws a hand-rolled
-- glyph (the quit X, which ships no asset).
local function iconButton(imp, key, x, y, size, image, action, drawFn)
Kit._audit("control", x, y, size, size, key)
local focused = Kit.focusable(key, x, y, size, size)
local hot = focused or Kit.hover(x, y, size, size)
Theme.fillRounded(x, y, size, size, hot and PAL.ink or PAL.surface, 1)
Theme.strokeRounded(x, y, size, size, PAL.line,
hot and Theme.A.focus or Theme.A.hairline, 1)
if image then
local iw, ih = image:getDimensions()
local pad = math.floor(size * 0.24)
local s = math.min((size - 2 * pad) / iw, (size - 2 * pad) / ih)
if hot then love.graphics.setColor(0, 0, 0, 1)
else love.graphics.setColor(1, 1, 1, 0.85) end
love.graphics.draw(image, Theme.snap(x + (size - iw * s) / 2),
Theme.snap(y + (size - ih * s) / 2), 0, s, s)
love.graphics.setColor(1, 1, 1, 1)
elseif drawFn then
drawFn(x, y, size, hot)
end
if action and (Kit.press(x, y, size, size) or Kit._activateId == key) then
queueAction(imp, key, action)
end
end
-- The cartridge colour for a game, matching its tab in the header. Play
-- wears it, so "which game is this button going to boot" is answered before
-- the label is read. Unknown versions fall back to the commit green.
local CART_COLOR = { local CART_COLOR = {
red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold, red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold,
gold = PAL.railAmber, gold = PAL.railAmber,
@@ -687,7 +655,7 @@ local function buildModScopeRow(imp, x, y, w, m)
end end
-- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar -- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar
local profiles, activeProf = LauncherMods.getProfiles() local _, activeProf = LauncherMods.getProfiles()
local isCompact = (w < math.floor(500 * m.s)) local isCompact = (w < math.floor(500 * m.s))
local nameText = tostring(activeProf or "Default") local nameText = tostring(activeProf or "Default")
local profLabel = isCompact and nameText or Strings("Profile: %s", nameText) local profLabel = isCompact and nameText or Strings("Profile: %s", nameText)
@@ -696,38 +664,30 @@ local function buildModScopeRow(imp, x, y, w, m)
local gearX = x + w - gearW local gearX = x + w - gearW
local profX = gearX - profW - math.floor(4 * m.s) local profX = gearX - profW - math.floor(4 * m.s)
-- Tapping main profile button cycles to the next profile (styles match iconButton) btn(imp, profX, y, profW, h, "mod-scope-profile", profLabel, {
Kit._audit("control", profX, y, profW, h, "mod-scope-profile") face = "invert", font = "micro",
local focused = Kit.focusable("mod-scope-profile", profX, y, profW, h) action = function()
local hot = focused or Kit.hover(profX, y, profW, h) local list, cur = LauncherMods.getProfiles()
Theme.fillRounded(profX, y, profW, h, hot and PAL.ink or PAL.surface, 1) local nextIdx = 1
Theme.strokeRounded(profX, y, profW, h, PAL.line, for i, p in ipairs(list) do
hot and Theme.A.focus or Theme.A.hairline, 1) if p.name == cur then
Kit.textCenterBold("micro", profLabel, profX, nextIdx = (i % #list) + 1
y + (h - Kit.textHeight("micro")) / 2, profW, break
hot and PAL.inverse or PAL.heading) end
if Kit.press(profX, y, profW, h) or Kit._activateId == "mod-scope-profile" then
local nextIdx = 1
for i, p in ipairs(profiles) do
if p.name == activeProf then
nextIdx = (i % #profiles) + 1
break
end end
end local nextProf = list[nextIdx] and list[nextIdx].name
local nextProf = profiles[nextIdx] and profiles[nextIdx].name if nextProf then
if nextProf then
queueAction(imp, "mod-scope-profile", function()
LauncherMods.applyProfile(nextProf) LauncherMods.applyProfile(nextProf)
if imp._refreshMods then imp:_refreshMods() end if imp._refreshMods then imp:_refreshMods() end
end) end
end end,
end })
-- Tapping gear button opens the Profile Manager modal
imp._gearIcon = imp._gearIcon or (love and love.graphics and love.graphics.newImage and love.graphics.newImage("assets/launcher/gear.png")) imp._gearIcon = imp._gearIcon or (love and love.graphics and love.graphics.newImage and love.graphics.newImage("assets/launcher/gear.png"))
iconButton(imp, "mod-profile-gear", gearX, y, gearW, imp._gearIcon, function() btn(imp, gearX, y, gearW, gearW, "mod-profile-gear", "", {
imp._profilesPopup = true face = "invert", image = imp._gearIcon,
end) action = function() imp._profilesPopup = true end,
})
if #options >= 2 then if #options >= 2 then
for _, opt in ipairs(options) do for _, opt in ipairs(options) do
@@ -836,8 +796,7 @@ local function buildHeader(imp, m)
local padX = math.floor(12 * m.s) local padX = math.floor(12 * m.s)
local chipW = math.max(tw + 2 * padX, gear) local chipW = math.max(tw + 2 * padX, gear)
local lx = m.x + m.pad local lx = m.x + m.pad
Theme.fill(lx, by, chipW, gear, PAL.bg, 1) Kit.card(lx, by, chipW, gear, "badge")
Theme.stroke(lx, by, chipW, gear, PAL.yellow, Theme.A.hover, 1)
local th = Kit.textHeight("small") local th = Kit.textHeight("small")
Kit.text("small", label, lx + math.floor((chipW - tw) / 2), Kit.text("small", label, lx + math.floor((chipW - tw) / 2),
by + math.floor((gear - th) / 2), PAL.yellow) by + math.floor((gear - th) / 2), PAL.yellow)
@@ -856,17 +815,20 @@ local function buildHeader(imp, m)
imp._gearIcon = imp._gearIcon imp._gearIcon = imp._gearIcon
or love.graphics.newImage("assets/launcher/gear.png") or love.graphics.newImage("assets/launcher/gear.png")
rx = rx - gear rx = rx - gear
iconButton(imp, "gear", rx, by, gear, imp._gearIcon, btn(imp, rx, by, gear, gear, "gear", "", {
function() imp:_openSettings() end) face = "invert", image = imp._gearIcon,
action = function() imp:_openSettings() end,
})
-- Quit, top-right corner. btn(imp, quitX, by, gear, gear, "quit", "", {
iconButton(imp, "quit", quitX, by, gear, nil, face = "invert",
function() imp:_quitApp() end, action = function() imp:_quitApp() end,
function(x, y, size, hot) drawFn = function(x, y, w, h, hot)
local pad = math.floor(size * 0.32) local pad = math.floor(w * 0.32)
drawCross(x + pad, y + pad, size - 2 * pad, drawCross(x + pad, y + pad, w - 2 * pad,
hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 }) hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 })
end) end,
})
-- The self-update control lives in the FOOTER next to the BCG mark (small, -- The self-update control lives in the FOOTER next to the BCG mark (small,
-- out of the wordmark's way -- it used to overlap the logo on a phone). It -- out of the wordmark's way -- it used to overlap the logo on a phone). It
@@ -885,22 +847,16 @@ local function buildHeader(imp, m)
-- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not -- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not
-- collide. -- collide.
local tabs = { local tabs = {
{ id = "red", letter = "R", label = Strings("RED"), color = PAL.railRed }, { id = "red", letter = "R", color = PAL.railRed },
{ id = "blue", letter = "B", label = Strings("BLUE"), color = PAL.railBlue }, { id = "blue", letter = "B", color = PAL.railBlue },
{ id = "yellow", letter = "Y", label = Strings("YELLOW"), color = PAL.railGold }, { id = "yellow", letter = "Y", color = PAL.railGold },
{ id = "gold", letter = "G", label = Strings("GOLD"), color = PAL.railAmber }, { id = "gold", letter = "G", color = PAL.railAmber },
{ id = "mods", icon = imp._modsIcon, label = Strings("MODS") }, { id = "mods", icon = imp._modsIcon },
{ id = "find", icon = imp._findIcon, label = Strings("FIND MODS") }, { id = "find", icon = imp._findIcon },
} }
local tabH = m.chip local tabH = m.chip
local tx = m.x + m.pad local tx = m.x + m.pad
local ty = y + math.floor(6 * m.s) local ty = y + math.floor(6 * m.s)
-- Wrap the strip instead of running off the edge.
--
-- Six tabs used to escape a phone width when an active icon tab spelled its
-- name out (FIND MODS at 412x915). Game tabs (R/B/Y/G) stay glyph-only even
-- when active; only MODS / FIND MODS expand. Still wrap when the next tab
-- would not fit so the divider below moves with the row count.
local tabLeft = tx local tabLeft = tx
local tabRight = m.x + m.w - m.pad local tabRight = m.x + m.w - m.pad
local tabGap = math.floor(6 * m.s) local tabGap = math.floor(6 * m.s)
@@ -908,51 +864,16 @@ local function buildHeader(imp, m)
for _, t in ipairs(tabs) do for _, t in ipairs(tabs) do
local active = imp.tab == t.id local active = imp.tab == t.id
local key = "tab-" .. t.id local key = "tab-" .. t.id
-- Cartridge tabs stay square (letter only). Icon tabs still expand to local w = tabH
-- show MODS / FIND MODS when selected.
local expand = active and t.icon ~= nil
local labelW = expand and Kit.textWidth("tab", t.label) or 0
local w = expand and (tabH + math.floor(8 * m.s) + labelW + math.floor(12 * m.s))
or tabH
-- Never wrap the first tab of a row: if one tab alone is wider than the
-- panel there is nowhere better to put it, and wrapping would loop.
if tx > tabLeft and tx + w > tabRight then if tx > tabLeft and tx + w > tabRight then
tx = tabLeft tx = tabLeft
ty = ty + tabH + tabRowGap ty = ty + tabH + tabRowGap
end end
Kit._audit("control", tx, ty, w, tabH, key) btn(imp, tx, ty, w, tabH, key, "", {
local focused = Kit.focusable(key, tx, ty, w, tabH) face = "tab", font = "tab", color = t.color, active = active,
local hot = focused or Kit.hover(tx, ty, w, tabH) image = t.icon, letter = t.letter,
local invert = active or hot action = function() imp:_switchTab(t.id) end,
local tint = t.color or PAL.ink })
Theme.fillRounded(tx, ty, w, tabH, invert and tint or PAL.surface, 1)
if not invert then
Theme.strokeRounded(tx, ty, w, tabH, tint,
t.color and Theme.A.hover or Theme.A.hairline, 1)
end
-- Ink on a filled tab must contrast with THAT fill: black on the light
-- red/blue/gold cartridge colours, which are all high-luminance.
local ink = invert and PAL.inverse or (t.color or PAL.text)
if t.icon then
local iw, ih = t.icon:getDimensions()
local pad = math.floor(tabH * 0.24)
local s = math.min((tabH - 2 * pad) / iw, (tabH - 2 * pad) / ih)
if invert then love.graphics.setColor(0, 0, 0, 1)
else love.graphics.setColor(1, 1, 1, 0.9) end
love.graphics.draw(t.icon, Theme.snap(tx + (tabH - iw * s) / 2),
Theme.snap(ty + (tabH - ih * s) / 2), 0, s, s)
love.graphics.setColor(1, 1, 1, 1)
else
Kit.textCenter("tab", t.letter, tx,
ty + (tabH - Kit.textHeight("tab")) / 2, tabH, ink)
end
if expand then
Kit.text("tab", t.label, tx + tabH + math.floor(4 * m.s),
ty + (tabH - Kit.textHeight("tab")) / 2, ink)
end
if Kit.press(tx, ty, w, tabH) or Kit._activateId == key then
queueAction(imp, key, function() imp:_switchTab(t.id) end)
end
tx = tx + w + tabGap tx = tx + w + tabGap
end end
@@ -1454,8 +1375,10 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
version, gameName, function() imp:play(version, true) end) version, gameName, function() imp:play(version, true) end)
imp._gearIcon = imp._gearIcon imp._gearIcon = imp._gearIcon
or love.graphics.newImage("assets/launcher/gear.png") or love.graphics.newImage("assets/launcher/gear.png")
iconButton(imp, "manage-" .. version, lx + lw - mgW, ly, mgW, btn(imp, lx + lw - mgW, ly, mgW, mgW, "manage-" .. version, "", {
imp._gearIcon, function() imp._gameManage = version end) face = "invert", image = imp._gearIcon,
action = function() imp._gameManage = version end,
})
ly = ly + playH + gap ly = ly + playH + gap
end end
@@ -1743,8 +1666,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
local focused = Kit.focusable(rowKey, x, ry, w, rowH) local focused = Kit.focusable(rowKey, x, ry, w, rowH)
local hot = focused or Kit.hover(x, ry, w, rowH) local hot = focused or Kit.hover(x, ry, w, rowH)
if isFullyDisabled then if isFullyDisabled then
Theme.fillRounded(x, ry, w, rowH, PAL.bg, 0.8, Theme.cardRadius()) Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted")
Theme.strokeRounded(x, ry, w, rowH, PAL.muted, hot and Theme.A.hover or 0.25, 1, Theme.cardRadius())
else else
Kit.card(x, ry, w, rowH, hot) Kit.card(x, ry, w, rowH, hot)
end end
@@ -3020,8 +2942,7 @@ local function buildSettingsModal(imp, m)
else else
local row = item.row local row = item.row
local key = "set-" .. i local key = "set-" .. i
Theme.strokeRounded(px + pad, ry, pw - 2 * pad, rowH, PAL.line, Kit.card(px + pad, ry, pw - 2 * pad, rowH, "hairline")
Theme.A.hairline, 1)
local ix = px + pad + math.floor(12 * m.s) local ix = px + pad + math.floor(12 * m.s)
-- Where the label prints, and where the control band starts. Stacked: -- Where the label prints, and where the control band starts. Stacked:
-- label on its own full-width line, controls on the line below it. -- label on its own full-width line, controls on the line below it.
@@ -3136,8 +3057,7 @@ local function buildDepResolverModal(imp, m)
cy = cy + Kit.textHeight("small") + math.floor(10 * m.s) cy = cy + Kit.textHeight("small") + math.floor(10 * m.s)
-- Security Disclaimer Banner Callout Card -- Security Disclaimer Banner Callout Card
Theme.fillRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.rowBg, 1, Theme.radius()) Kit.card(px + pad, cy, pw - 2 * pad, warnH, "warn")
Theme.strokeRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.yellow, Theme.A.hover, 1, Theme.radius())
local warnMsg = Strings("Caution: Only pull dependencies from sources you trust.\nVerify source repositories before fetching.") local warnMsg = Strings("Caution: Only pull dependencies from sources you trust.\nVerify source repositories before fetching.")
Kit.text("micro", warnMsg, px + pad + math.floor(12 * m.s), cy + math.floor(5 * m.s), PAL.yellow) Kit.text("micro", warnMsg, px + pad + math.floor(12 * m.s), cy + math.floor(5 * m.s), PAL.yellow)
cy = cy + warnH + math.floor(12 * m.s) cy = cy + warnH + math.floor(12 * m.s)
@@ -3173,7 +3093,7 @@ local function buildDepResolverModal(imp, m)
if ry + rowH >= cy and ry <= cy + listH then if ry + rowH >= cy and ry <= cy + listH then
-- Item Card Fill & Stroke (matching launcher card interiors & radius) -- Item Card Fill & Stroke (matching launcher card interiors & radius)
local hot = Kit.hover(px + pad, ry, pw - 2 * pad, rowH) local hot = Kit.hover(px + pad, ry, pw - 2 * pad, rowH)
Theme.row(px + pad, ry, pw - 2 * pad, rowH, hot and "hover" or "normal") Kit.card(px + pad, ry, pw - 2 * pad, rowH, hot and "rowHover" or "row")
local ix = px + pad + math.floor(12 * m.s) local ix = px + pad + math.floor(12 * m.s)
local innerW = pw - 2 * pad - math.floor(24 * m.s) local innerW = pw - 2 * pad - math.floor(24 * m.s)
+120 -11
View File
@@ -9,15 +9,16 @@
-- cable pull. -- cable pull.
-- --
-- Cable rules: no experience, no money, no items; either side may RUN -- Cable rules: no experience, no money, no items; either side may RUN
-- (a draw); a fainted mon is auto-replaced by the next healthy party -- (a draw); a fainted mon is replaced from the party menu and the chosen
-- member (the original prompts; documented divergence). Badge stat -- slot rides the wire, the way ChooseNextMon hands it to
-- boosts don't apply on either side (divergence: Gen 1 famously kept -- LinkBattleExchangeData. Badge stat boosts don't apply on either side
-- them in link battles). -- (divergence: Gen 1 famously kept them in link battles).
local Fingerprint = require("src.link.Fingerprint") local Fingerprint = require("src.link.Fingerprint")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake") local Handshake = require("src.link.Handshake")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Party = require("src.pokemon.Party")
local Protocol = require("src.link.Protocol") local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local TurnOrder = require("src.battle.TurnOrder") local TurnOrder = require("src.battle.TurnOrder")
@@ -257,7 +258,9 @@ function LinkBattle.new(game, net, opts)
self.enemyParty = theirParty self.enemyParty = theirParty
self.playerParty = myParty -- intro ball row uses the clamped copies self.playerParty = myParty -- intro ball row uses the clamped copies
self.opponentName = theirName self.opponentName = theirName
self.introText = Strings("%s wants\nto battle!", theirName) -- _TrainerWantsToFightText (data/text/text_2.asm:1257): wIsInBattle == 2
-- takes PrintBeginningBattleText's .trainerBattle arm, link included
self.introText = Strings("%s wants\nto fight!", theirName)
self.remoteHashes = {} self.remoteHashes = {}
self.localHashes = {} self.localHashes = {}
self.remoteParts = {} self.remoteParts = {}
@@ -327,6 +330,68 @@ function LinkBattle.new(game, net, opts)
end) end)
end end
-- ChooseNextMon (engine/battle/core.asm:1086-1103): the replacement after
-- a faint is a free party-menu pick in a link battle too -- DisplayPartyMenu
-- runs first, a fainted pick or a cancel goes back to it
-- (.goBackToPartyMenu), and only the chosen slot rides
-- LinkBattleExchangeData.
local function chooseReplacement(s)
s.linkReplacement = nil
s:uiNext(function()
return s:buildScreen("PartyMenu", {
battle = s,
party = myParty,
forceSwitch = true,
onSwitch = function(mon)
if mon.hp > 0 then s.linkReplacement = mon end
end,
})
end)
s:actNext(function()
local mon = s.linkReplacement
s.linkReplacement = nil
if s.result then return end
if not mon then
chooseReplacement(s)
return
end
for i, m in ipairs(myParty) do
if m == mon then send({ type = "replace", index = i }) break end
end
sendOutPlayer(s, mon)
end)
end
-- ReplaceFaintedEnemyMon (core.asm:892-905) reads the peer's slot back out
-- of the exchange, and EnemySendOutFirstMon (core.asm:1315-1320) decodes it
-- as wSerialExchangeNybbleReceiveData - 4. HandlePlayerMonFainted
-- (core.asm:989-996) always runs ChooseNextMon BEFORE that read, so on a
-- double faint both machines commit their own slot before they block on the
-- peer's; our queue can reach the enemy's handler first, so the wait
-- pre-empts itself with the local pick rather than deadlocking on a message
-- neither side is going to send.
local function awaitReplacement(s)
s:actNext(function()
if s.result then return end
if s.player.mon.hp <= 0 and not s.linkChoiceQueued
and Party.firstHealthy(myParty) then
s.linkChoiceQueued = true
chooseReplacement(s)
awaitReplacement(s)
return
end
local idx = s.remoteReplace
if not idx then
awaitReplacement(s)
return
end
s.remoteReplace = nil
local mon = theirParty[idx]
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(theirParty) end
sendOutEnemy(s, mon)
end)
end
-- decode a remote action message against the enemy battler -- decode a remote action message against the enemy battler
local function decodeTheirAction(s, msg) local function decodeTheirAction(s, msg)
return decodeWireAction(s, msg, s.enemy) return decodeWireAction(s, msg, s.enemy)
@@ -540,12 +605,17 @@ function LinkBattle.new(game, net, opts)
submit(s, { type = "action", kind = "run" }, nil) submit(s, { type = "action", kind = "run" }, nil)
end end
-- fainted mons auto-replace with the next healthy teammate, in party -- linkChoiceQueued: awaitReplacement already pre-empted itself with this
-- order, identically on both machines -- side's ChooseNextMon and put the slot on the wire, so the handler the
-- faint queued has nothing left to do but drop the flag
self.playerMonFainted = function(s) self.playerMonFainted = function(s)
if s.linkChoiceQueued then
s.linkChoiceQueued = nil
return
end
for _, mon in ipairs(myParty) do for _, mon in ipairs(myParty) do
if mon.hp > 0 then if mon.hp > 0 then
s:act(function() sendOutPlayer(s, mon) end) if not s.result then chooseReplacement(s) end
return return
end end
end end
@@ -558,7 +628,7 @@ function LinkBattle.new(game, net, opts)
self.enemyMonFainted = function(s) self.enemyMonFainted = function(s)
for _, mon in ipairs(theirParty) do for _, mon in ipairs(theirParty) do
if mon.hp > 0 then if mon.hp > 0 then
s:act(function() sendOutEnemy(s, mon) end) if not s.result then awaitReplacement(s) end
return return
end end
end end
@@ -579,6 +649,9 @@ function LinkBattle.new(game, net, opts)
s.remoteHashes[msg.turn or 0] = msg.value s.remoteHashes[msg.turn or 0] = msg.value
s.remoteParts[msg.turn or 0] = msg.parts s.remoteParts[msg.turn or 0] = msg.parts
checkHashes(s) checkHashes(s)
elseif msg.type == "replace" then
local idx = math.floor(tonumber(msg.index) or 1)
s.remoteReplace = math.max(1, math.min(#theirParty, idx))
elseif msg.type == "bye" then elseif msg.type == "bye" then
-- only a draw if our own simulation hasn't already decided -- only a draw if our own simulation hasn't already decided
-- (the winner's bye can arrive while we're still animating) -- (the winner's bye can arrive while we're still animating)
@@ -770,6 +843,34 @@ function LinkBattle.newSpectator(game, net, opts)
end) end)
end end
local function awaitHostReplacement(s)
s:actNext(function()
if s.result then return end
local idx = table.remove(s.hostReplace, 1)
if not idx then
awaitHostReplacement(s)
return
end
local mon = hostParty[idx]
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(hostParty) end
sendOutHost(s, mon)
end)
end
local function awaitGuestReplacement(s)
s:actNext(function()
if s.result then return end
local idx = table.remove(s.guestReplace, 1)
if not idx then
awaitGuestReplacement(s)
return
end
local mon = guestParty[idx]
if not mon or mon.hp <= 0 then mon = Party.firstHealthy(guestParty) end
sendOutGuest(s, mon)
end)
end
local function resolveSpecTurn(s, hostMsg, guestMsg) local function resolveSpecTurn(s, hostMsg, guestMsg)
if hostMsg.kind == "run" or guestMsg.kind == "run" then if hostMsg.kind == "run" or guestMsg.kind == "run" then
endSpectate(s, "The match ended.") endSpectate(s, "The match ended.")
@@ -840,7 +941,7 @@ function LinkBattle.newSpectator(game, net, opts)
self.playerMonFainted = function(s) self.playerMonFainted = function(s)
for _, mon in ipairs(hostParty) do for _, mon in ipairs(hostParty) do
if mon.hp > 0 then if mon.hp > 0 then
s:act(function() sendOutHost(s, mon) end) if not s.result then awaitHostReplacement(s) end
return return
end end
end end
@@ -852,7 +953,7 @@ function LinkBattle.newSpectator(game, net, opts)
self.enemyMonFainted = function(s) self.enemyMonFainted = function(s)
for _, mon in ipairs(guestParty) do for _, mon in ipairs(guestParty) do
if mon.hp > 0 then if mon.hp > 0 then
s:act(function() sendOutGuest(s, mon) end) if not s.result then awaitGuestReplacement(s) end
return return
end end
end end
@@ -862,6 +963,7 @@ function LinkBattle.newSpectator(game, net, opts)
end end
self.hostMsg, self.guestMsg = nil, nil self.hostMsg, self.guestMsg = nil, nil
self.hostReplace, self.guestReplace = {}, {}
local baseUpdate = self.update local baseUpdate = self.update
self.update = function(s, dt) self.update = function(s, dt)
net:update() net:update()
@@ -875,6 +977,13 @@ function LinkBattle.newSpectator(game, net, opts)
s.hostMsg, s.guestMsg = nil, nil s.hostMsg, s.guestMsg = nil, nil
resolveSpecTurn(s, h, g) resolveSpecTurn(s, h, g)
end end
elseif inner.type == "replace" then
local idx = math.floor(tonumber(inner.index) or 1)
if msg.side == "host" then
table.insert(s.hostReplace, math.max(1, math.min(#hostParty, idx)))
else
table.insert(s.guestReplace, math.max(1, math.min(#guestParty, idx)))
end
elseif inner.type == "bye" or inner.type == "forfeit" then elseif inner.type == "bye" or inner.type == "forfeit" then
if not s.result then endSpectate(s, "The match ended.") end if not s.result then endSpectate(s, "The match ended.") end
end end
+87 -17
View File
@@ -19,6 +19,7 @@ LinkState.__index = LinkState
LinkState.isOpaque = true LinkState.isOpaque = true
local CURSOR = 0xED local CURSOR = 0xED
local CURSOR_HOLLOW = 0xEC
local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under
-- LuaJIT even though # still reports the full size, so -- LuaJIT even though # still reports the full size, so
-- the level picker cycles this string instead of nil, -- the level picker cycles this string instead of nil,
@@ -114,6 +115,7 @@ end
function LinkState:exitWith(message, reason) function LinkState:exitWith(message, reason)
DiscordPresence.setJoinCode(nil) DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED self.game.linkSession = nil -- back to the player's own GAME SPEED
if self.game.linkNet == self.net then self.game.linkNet = nil end
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") }) Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
if self.net then self.net:close() end if self.net then self.net:close() end
self.game.stack:pop() self.game.stack:pop()
@@ -519,6 +521,9 @@ function LinkState:startMode(mode, isHost)
}) })
self.net:send(self.trade:opening()) self.net:send(self.trade:opening())
self.index = 1 self.index = 1
self.theirIndex = 1
self.side = "mine"
self.pickChoice = nil
else else
self.stage = "battleWait" self.stage = "battleWait"
-- the host deals the shared RNG seed for the lockstep simulation -- the host deals the shared RNG seed for the lockstep simulation
@@ -536,7 +541,14 @@ end
-- trade flow -- trade flow
-- ------------------------------------------------------------------- -- -------------------------------------------------------------------
function LinkState:openStats(mon)
if not mon then return end
self.game.linkNet = self.net
Screens.push(self.game, "SummaryMenu", mon)
end
function LinkState:updateTrade(input) function LinkState:updateTrade(input)
if self.game.linkNet == self.net then self.game.linkNet = nil end
for _, msg in ipairs(self.net:poll()) do for _, msg in ipairs(self.net:poll()) do
local reply = self.trade:handle(msg) local reply = self.trade:handle(msg)
if reply then self.net:send(reply) end if reply then self.net:send(reply) end
@@ -591,10 +603,51 @@ function LinkState:updateTrade(input)
return return
end end
if t.stage == "picking" and input:wasPressed("up") then -- pokered engine/link/cable_club.asm TradeCenter_SelectMon: A on one of
self.index = math.max(1, self.index - 1) -- your own mons opens the "STATS TRADE" row (.displayStatsTradeMenu)
-- and only TRADE commits the pick, while the enemy list carries its own
-- cursor whose A shows that mon's status pages (.displayEnemyMonStats).
-- The cart's enemy path sets hl but never wMonDataLocation, the way the
-- battle menu's STATS (engine/battle/core.asm) does, so LoadMonData_ reads
-- the player's party and it draws YOUR mon at that slot -- an omission,
-- not behaviour, so we show the peer's mon.
if t.stage == "picking" and self.pickChoice then
if input:wasPressed("left") then
self.pickChoice = 1
elseif input:wasPressed("right") then
self.pickChoice = 2
elseif input:wasPressed("b") then
self.pickChoice = nil -- .cancelPlayerMonChoice: back to the list, not
-- out of the trade
elseif input:wasPressed("a") then
if self.pickChoice == 1 then
self.pickChoice = nil
self:openStats(self.game.save.party[self.index])
elseif t:canPick(self.index) then
self.pickChoice = nil
self.side = "mine"
self.net:send(t:pick(self.index))
end
end
elseif t.stage == "picking" and input:wasPressed("up") then
if self.side == "theirs" then
self.theirIndex = math.max(1, self.theirIndex - 1)
else
self.index = math.max(1, self.index - 1)
end
elseif t.stage == "picking" and input:wasPressed("down") then elseif t.stage == "picking" and input:wasPressed("down") then
self.index = math.min(#self.game.save.party, self.index + 1) if self.side == "theirs" then
self.theirIndex = math.min(#(t.theirParty or {}), self.theirIndex + 1)
else
self.index = math.min(#self.game.save.party, self.index + 1)
end
elseif t.stage == "picking" and input:wasPressed("right") then
if t.theirParty and #t.theirParty > 0 then
self.side = "theirs"
self.theirIndex = math.min(self.theirIndex, #t.theirParty)
end
elseif t.stage == "picking" and input:wasPressed("left") then
self.side = "mine"
elseif self.confirmed == nil and input:wasPressed("b") then elseif self.confirmed == nil and input:wasPressed("b") then
-- once confirm=true has been sent to the peer, backing out here -- once confirm=true has been sent to the peer, backing out here
-- would desync the two sides (the peer may already be committing -- would desync the two sides (the peer may already be committing
@@ -603,8 +656,10 @@ function LinkState:updateTrade(input)
self.net:send({ type = "bye" }) self.net:send({ type = "bye" })
self:exitWith(Strings("The trade was\ncancelled.")) self:exitWith(Strings("The trade was\ncancelled."))
elseif t.stage == "picking" and input:wasPressed("a") then elseif t.stage == "picking" and input:wasPressed("a") then
if t:canPick(self.index) then if self.side == "theirs" then
self.net:send(t:pick(self.index)) self:openStats((t.theirParty or {})[self.theirIndex])
else
self.pickChoice = 1
end end
elseif t.stage == "confirming" and self.confirmed == nil then elseif t.stage == "confirming" and self.confirmed == nil then
if input:wasPressed("a") then if input:wasPressed("a") then
@@ -738,25 +793,40 @@ function LinkState:draw()
local label = (mon.nickname or def.name):sub(1, 8) local label = (mon.nickname or def.name):sub(1, 8)
if not t:canPick(i) then label = label .. "X" end if not t:canPick(i) then label = label .. "X" end
Font.draw(label, 16, 20 + i * 12) Font.draw(label, 16, 20 + i * 12)
if i == self.index then Font.drawCode(CURSOR, 8, 20 + i * 12) end if i == self.index and self.side ~= "theirs" then
Font.drawCode(CURSOR, 8, 20 + i * 12)
end
end end
Font.draw(Strings("THEIRS"), 84, 20) Font.draw(Strings("THEIRS"), 84, 20)
for i, mon in ipairs(t.theirParty or {}) do for i, mon in ipairs(t.theirParty or {}) do
local def = self.game.data.pokemon[mon.species] local def = self.game.data.pokemon[mon.species]
Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12) Font.draw((mon.nickname or def.name):sub(1, 8), 92, 20 + i * 12)
if t.theirPick == i then Font.drawCode(CURSOR, 84, 20 + i * 12) end if self.side == "theirs" and i == self.theirIndex then
Font.drawCode(CURSOR, 84, 20 + i * 12)
elseif t.theirPick == i then
Font.drawCode(CURSOR_HOLLOW, 84, 20 + i * 12)
end
end end
local hint if self.pickChoice then
if t.stage == "waitRecords" then hint = "Comparing games..." Font.draw(Strings("STATS"), 16, 128)
elseif t.stage == "waitParty" then hint = "Exchanging data..." Font.draw(Strings("TRADE"), 96, 128)
elseif t.stage == "picking" then Font.drawCode(CURSOR, self.pickChoice == 1 and 8 or 88, 128)
hint = t:canPick(self.index) and "Pick one to trade" else
or Strings("X: not on theirs") local hint
elseif t.stage == "waitPick" then hint = "Waiting for them..." if t.stage == "waitRecords" then hint = "Comparing games..."
elseif t.stage == "confirming" then elseif t.stage == "waitParty" then hint = "Exchanging data..."
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel") elseif t.stage == "picking" then
if self.side == "theirs" then hint = Strings("A: stats")
else
hint = t:canPick(self.index) and "Pick one to trade"
or Strings("X: not on theirs")
end
elseif t.stage == "waitPick" then hint = "Waiting for them..."
elseif t.stage == "confirming" then
hint = self.confirmed and "Waiting..." or Strings("A: trade B: cancel")
end
Font.draw(hint or "", 8, 132)
end end
Font.draw(hint or "", 8, 132)
elseif self.stage == "battleWait" or self.stage == "battleRunning" then elseif self.stage == "battleWait" or self.stage == "battleRunning" then
drawTitle("LINK BATTLE") drawTitle("LINK BATTLE")
+4 -2
View File
@@ -140,10 +140,12 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
if #mon.moves < 4 then if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp }) table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId }) Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
require("src.core.Sound").play(game.data, "Get_Item1") -- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
-- (learn_move.asm), so the jingle rides the box
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
romText(game.data, "_LearnedMove1Text", romText(game.data, "_LearnedMove1Text",
"%s learned\n%s!", name, mdef.name), nextStep)) "%s learned\n%s!", name, mdef.name), nextStep,
TextBox.soundOpts(game, "Get_Item1")))
else else
-- LearnMoveFromLevelUp with a full moveset: the forget UI -- LearnMoveFromLevelUp with a full moveset: the forget UI
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep) Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
+9 -2
View File
@@ -141,13 +141,20 @@ end
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has -- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
-- red channel 0, so the tint zeroes the whole bar's red and the zone's -- red channel 0, so the tint zeroes the whole bar's red and the zone's
-- red-channel-keyed shade shader then maps every pixel to color 3 = black. -- red-channel-keyed shade shader then maps every pixel to color 3 = black.
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments) --
-- pixels: an explicit 0..48 bar length on GetHPBarLength's scale, for a
-- caller that is animating the bar between two HP values
-- (UpdateHPBar_AnimateHPBar); it scales with `segments` like the color
-- thresholds do. Without it the length comes from mon.hp as before.
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill, segments, pixels)
local x, y = tx * 8, ty * 8 local x, y = tx * 8, ty * 8
segments = math.max(1, math.floor(segments or 6)) segments = math.max(1, math.floor(segments or 6))
HudTiles.tile(0x71, x, y) HudTiles.tile(0x71, x, y)
HudTiles.tile(0x62, x + 8, y) HudTiles.tile(0x62, x + 8, y)
local px = 0 local px = 0
if mon.stats.hp > 0 and mon.hp > 0 then if pixels then
px = math.max(0, math.floor(pixels * segments / 6))
elseif mon.stats.hp > 0 and mon.hp > 0 then
px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp)) px = math.max(1, math.floor(mon.hp * segments * 8 / mon.stats.hp))
end end
local tint local tint
+6 -4
View File
@@ -691,10 +691,12 @@ function PaletteFX.spriteObp(spriteDef, seed)
return PaletteFX.darkObp(w.spritePalettes[group], group) return PaletteFX.darkObp(w.spritePalettes[group], group)
end end
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar -- GetHealthBarColor (home/palettes.asm) on the standard 48px bar. It reads
function PaletteFX.barPalName(hp, maxHp) -- the bar's own length, so a caller mid-drain passes the animated `pixels`
local px = maxHp > 0 and math.floor(hp * 48 / maxHp) or 0 -- rather than let it be re-derived from hp.
if hp > 0 and px < 1 then px = 1 end function PaletteFX.barPalName(hp, maxHp, pixels)
local px = pixels or (maxHp > 0 and math.floor(hp * 48 / maxHp) or 0)
if not pixels and hp > 0 and px < 1 then px = 1 end
return px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR" return px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
end end
+20
View File
@@ -100,6 +100,26 @@ function TextBox.new(game, text, onDone, opts)
return self return self
end end
-- soundOpts: a jingle carried at the end of the string as a trailing text
-- command (sound_get_item_1 and friends -> home/text.asm TextCommand_SOUND),
-- which runs PlaySound then WaitForSoundToFinish once the last page has
-- typed, so the box holds until the fanfare is over. Merges into a caller's
-- opts table; auto.wait keeps the trailing button press the plain A/B path
-- gives every other box.
function TextBox.soundOpts(game, sound, opts)
opts = opts or {}
local auto = opts.auto
-- auto = true is the no-button-wait arming (text_opts); keep that choice
if auto == true then auto = { wait = false } end
auto = auto or {}
if auto.wait == nil then auto.wait = true end
auto.delay = auto.delay or 0
auto.sound = type(sound) == "function" and sound
or function() return require("src.core.Sound").play(game.data, sound) end
opts.auto = auto
return opts
end
-- The runtime tokens substitute() knows, as handlers the tokens registry -- The runtime tokens substitute() knows, as handlers the tokens registry
-- serves. Each is fn(game, arg) -> replacement, or nil to drop the token. -- serves. Each is fn(game, arg) -> replacement, or nil to drop the token.
-- RAM keeps pokered's stale-buffer semantics: give_item copies the item -- RAM keeps pokered's stale-buffer semantics: give_item copies the item
+15 -2
View File
@@ -592,6 +592,17 @@ function Commands.play_sound(ctx, soundId)
require("src.core.Sound").play(ctx.game.data, soundId) require("src.core.Sound").play(ctx.game.data, soundId)
end end
-- text_sound <soundId>: the jingle the ROM parks at the END of a string as
-- a trailing text command (sound_get_item_1, sound_get_key_item ->
-- home/text.asm TextCommand_SOUND). It arms the NEXT show_text the same
-- way play_cry arms ctx.pendingCry, so the fanfare fires once the last page
-- has typed and the box holds on WaitForSoundToFinish before the button
-- wait. play_sound stays the bare PlaySound used for the non-blocking
-- beats (Bill's teleporter, the S.S. Anne horn).
function Commands.text_sound(ctx, soundId)
ctx.textOpts = TextBox.soundOpts(ctx.game, soundId, ctx.textOpts)
end
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks -- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); blocks
-- until it finishes so heal-rest scripts (Mom, captain text_asm) match -- until it finishes so heal-rest scripts (Mom, captain text_asm) match
-- Gen1's wait-on-channel loop. The map theme resumes when it ends -- Gen1's wait-on-channel loop. The map theme resumes when it ends
@@ -1050,8 +1061,10 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
onDone = function() runner:resume() end, onDone = function() runner:resume() end,
}) })
runner:yield() runner:yield()
-- TradedForText (sound_get_key_item) then the dialogset's thanks -- TradedForText carries sound_get_key_item after the text, so the jingle
require("src.core.Sound").play(data, "Get_Key_Item") -- rides the box and blocks it (home/text.asm TextCommand_SOUND), then the
-- dialogset's thanks
Commands.text_sound(ctx, "Get_Key_Item")
say(texts.tradedFor or "_TradedForText") say(texts.tradedFor or "_TradedForText")
say(texts.thanks or "_Thanks" .. dialogset .. "Text") say(texts.thanks or "_Thanks" .. dialogset .. "Text")
end end
+7 -6
View File
@@ -33,12 +33,12 @@ local function save_name(game)
return game.save.player.name return game.save.player.name
end end
local function showMessages(game, msgs, onDone) local function showMessages(game, msgs, onDone, opts)
if not msgs or #msgs == 0 then if not msgs or #msgs == 0 then
if onDone then onDone() end if onDone then onDone() end
return return
end end
game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone)) game.stack:push(TextBox.new(game, table.concat(msgs, "\f"), onDone, opts))
end end
-- run the use-flow for an item on a chosen target. `picker` is the party -- run the use-flow for an item on a chosen target. `picker` is the party
@@ -181,9 +181,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
end end
if #target.moves < 4 then if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp }) table.insert(target.moves, { id = moveId, pp = mdef.pp })
require("src.core.Sound").play(game.data, "Get_Item1") -- LearnedMove1Text: text_far, sound_get_item_1, text_promptbutton
-- (learn_move.asm), so the jingle rides the box
showMessages(game, { Strings("%s learned\n%s!", target.nickname or showMessages(game, { Strings("%s learned\n%s!", target.nickname or
game.data.pokemon[target.species].name, mdef.name) }) game.data.pokemon[target.species].name, mdef.name) }, nil,
TextBox.soundOpts(game, "Get_Item1"))
if result == "learn" then consume(game, id) end if result == "learn" then consume(game, id) end
list.items = buildItems(game) list.items = buildItems(game)
list.index = math.min(list.index, math.max(1, #list.items)) list.index = math.min(list.index, math.max(1, #list.items))
@@ -333,10 +335,9 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
local mdef = game.data.moves[moveId] local mdef = game.data.moves[moveId]
if #target.moves < 4 then if #target.moves < 4 then
table.insert(target.moves, { id = moveId, pp = mdef.pp }) table.insert(target.moves, { id = moveId, pp = mdef.pp })
require("src.core.Sound").play(game.data, "Get_Item1")
local name = target.nickname or def.name local name = target.nickname or def.name
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) }, showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
nextStep) nextStep, TextBox.soundOpts(game, "Get_Item1"))
else else
require("src.ui.Screens").push(game, "MoveLearnMenu", require("src.ui.Screens").push(game, "MoveLearnMenu",
target, moveId, nextStep) target, moveId, nextStep)
+22 -8
View File
@@ -46,9 +46,27 @@ function EvolutionState:sgbPalettes(game)
return P.wholeNamed(game.data, "MEWMON") return P.wholeNamed(game.data, "MEWMON")
end end
local FLASH_FRAMES = 220
-- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031) -- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031)
local CANCEL_GRACE_FRAMES = 80 local CANCEL_GRACE_FRAMES = 80
-- .animLoop starts at `lb bc, $1, $10` and runs 8 iterations: iteration k holds
-- the old pic for c = 18-2k frames inside Evolution_CheckForCancel (72 in all),
-- then Evolution_BackAndForthAnim swaps b = k times, each swap a pair of
-- Evolution_ChangeMonPic that end in Delay3 (6 frames a swap, 216 in all).
local ANIM_LOOP_FRAMES = 288
local FLASH_FRAMES = CANCEL_GRACE_FRAMES + ANIM_LOOP_FRAMES
-- which pic is on screen t frames into .animLoop
local function evoShowsNew(t)
for b = 1, 8 do
local hold = 18 - 2 * b
if t < hold then return false end
t = t - hold
local swap = b * 6
if t < swap then return t % 6 < 3 end
t = t - swap
end
return true
end
local function frontSprite(game, species, mon) local function frontSprite(game, species, mon)
local path, trueColor = require("src.pokemon.Sprites").path( local path, trueColor = require("src.pokemon.Sprites").path(
@@ -140,14 +158,10 @@ function EvolutionState:draw()
else else
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
end end
elseif evoShowsNew(self.t - CANCEL_GRACE_FRAMES) then
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
else else
local period = math.max(4, 28 - math.floor(self.t / 40) * 6) sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
local showNew = math.floor(self.t / period) % 2 == 1
if showNew then
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
else
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
end
end end
if sprite then if sprite then
local x = math.floor((160 - sprite:getWidth()) / 2) local x = math.floor((160 - sprite:getWidth()) / 2)
+16 -3
View File
@@ -6,6 +6,7 @@ local GameVersion = require("src.core.GameVersion")
local Music = require("src.core.Music") local Music = require("src.core.Music")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local Timing = require("src.core.Timing")
local IntroMovie = {} local IntroMovie = {}
IntroMovie.__index = IntroMovie IntroMovie.__index = IntroMovie
@@ -232,6 +233,10 @@ function IntroMovie:fightStep()
self:finish() self:finish()
return return
end end
-- an op that consumed frames ends ON its last frame; only the instant
-- ops (sfx / pose / frame, which are plain writes between DelayFrames
-- calls in PlayIntroScene) chain into the next op the same frame
local timed = false
if op.sfx then if op.sfx then
Sound.play(self.game.data, op.sfx) Sound.play(self.game.data, op.sfx)
elseif op.pose then elseif op.pose then
@@ -250,6 +255,7 @@ function IntroMovie:fightStep()
end end
self.opTimer = self.opTimer + 1 self.opTimer = self.opTimer + 1
if self.opTimer < (op.px or math.abs(op.dx)) then return end if self.opTimer < (op.px or math.abs(op.dx)) then return end
timed = true
elseif op.anim then elseif op.anim then
-- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5) -- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5)
if self.opTimer % 5 == 0 then if self.opTimer % 5 == 0 then
@@ -259,9 +265,11 @@ function IntroMovie:fightStep()
end end
self.opTimer = self.opTimer + 1 self.opTimer = self.opTimer + 1
if self.opTimer < #ANIM[op.anim] * 5 then return end if self.opTimer < #ANIM[op.anim] * 5 then return end
timed = true
elseif op.wait then elseif op.wait then
self.opTimer = self.opTimer + 1 self.opTimer = self.opTimer + 1
if self.opTimer < op.wait then return end if self.opTimer < op.wait then return end
timed = true
elseif op.fade then elseif op.fade then
self.opTimer = self.opTimer + 1 self.opTimer = self.opTimer + 1
self.fade = self.opTimer / op.fade self.fade = self.opTimer / op.fade
@@ -270,6 +278,7 @@ function IntroMovie:fightStep()
end end
self.opIndex = self.opIndex + 1 self.opIndex = self.opIndex + 1
self.opTimer = 0 self.opTimer = 0
if timed then return end
end end
end end
@@ -284,8 +293,9 @@ function IntroMovie:update(dt)
return return
end end
local input = self.game.input local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") if input:wasPressed("a") or input:wasPressed("start") then
or input:wasPressed("start") then -- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
-- on a fresh START or A -- B alone never skips the intro.
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the -- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
-- white hold stands in for that beat before the title is built. -- white hold stands in for that beat before the title is built.
self:exitToTitle() self:exitToTitle()
@@ -300,7 +310,10 @@ function IntroMovie:update(dt)
end end
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
else else
self:fightStep() -- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing
-- (intro.asm:337), so PlayIntroScene's first op is not on the music's
-- own frame
if self.timer > Timing.DELAY3 then self:fightStep() end
end end
end end
+88 -35
View File
@@ -87,6 +87,15 @@ local function refuseUnavailable(self)
t._SleepingPikachuText1 or Strings("There isn't any\nresponse..."))) t._SleepingPikachuText1 or Strings("There isn't any\nresponse...")))
end end
-- .newBadgeRequired (start_sub_menus.asm): every badge-gated arm of
-- .outOfBattleMovePointers prints this and jumps back to the open submenu
local function refuseBadge(self)
local TextBox = require("src.render.TextBox")
local t = self.game.data and self.game.data.text or {}
self.game.stack:push(TextBox.new(self.game,
t._NewBadgeRequiredText or Strings("No! A new BADGE\nis required.")))
end
-- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is -- where DIG escapes work: escape_rope_tilesets.asm (Agatha's room is
-- excluded by map id in ItemUseEscapeRope) -- excluded by map id in ItemUseEscapeRope)
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true, local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
@@ -411,6 +420,23 @@ function PartyMenu:update(dt)
-- flyTo (OverworldController) validates the fly-warp + runs the -- flyTo (OverworldController) validates the fly-warp + runs the
-- departure/warp, so we just hand it the chosen mapId (#195). -- departure/warp, so we just hand it the chosen mapId (#195).
local ow = self.game.overworld local ow = self.game.overworld
-- .fly checks THUNDERBADGE first, then CheckIfInOutsideMap
-- (OVERWORLD + PLATEAU -- Route 23 / Indigo Plateau outdoor -- not
-- OVERWORLD alone, #83); both refusals loop back to the submenu
if ow and not ow:partyKnows("FLY") then
refuseBadge(self)
return
end
if ow and not Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets")) then
local TextBox = require("src.render.TextBox")
local def = self.game.data.pokemon[mon.species]
local txt = (self.game.data.text._CannotFlyHereText
or Strings("{RAM:wNameBuffer} can't\nFLY here."))
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message
end
self.game.stack:pop() -- close the party menu self.game.stack:pop() -- close the party menu
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId) Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
if ow then ow:flyTo(mapId) end if ow then ow:flyTo(mapId) end
@@ -423,9 +449,17 @@ function PartyMenu:update(dt)
-- over the menu, and the cave is lit when the blink hands the -- over the menu, and the cave is lit when the blink hands the
-- screen back, never under the text (#385). -- screen back, never under the text (#385).
local ow = self.game.overworld local ow = self.game.overworld
if ow and not ow:partyKnows("FLASH") then
refuseBadge(self)
return
end
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition") local Transition = require("src.render.Transition")
self.game.save.flashLit = true -- .flash prints on any map, but the light it records is this map's:
-- home/overworld.asm re-arms wMapPalOffset on the next dark map, so
-- a FLASH used in daylight must not carry into Rock Tunnel
local wasDark = ow and ow.dark
if wasDark then self.game.save.flashLit = true end
self.game.stack:push(TextBox.new(self.game, self.game.stack:push(TextBox.new(self.game,
self.game.data.text._FlashLightsAreaText self.game.data.text._FlashLightsAreaText
or Strings("A blinding FLASH\nlights the area!"), function() or Strings("A blinding FLASH\nlights the area!"), function()
@@ -440,13 +474,13 @@ function PartyMenu:update(dt)
-- seconds of per-pixel atlas baking on a phone -- on screen as a -- seconds of per-pixel atlas baking on a phone -- on screen as a
-- solid white frame with nothing under it, which reads as a -- solid white frame with nothing under it, which reads as a
-- lockup (#610). -- lockup (#610).
ow:setDark(false) if wasDark then ow:setDark(false) end
self.game.stack:push(Transition.whiteFlash(self.game)) self.game.stack:push(Transition.whiteFlash(self.game))
end)) end))
return return
elseif action == "surf" then elseif action == "surf" then
-- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time -- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
-- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F -- then IsSurfingAllowed (the Cycling Road / Seafoam B4F
-- current refusals, both of which loop back to the submenu), then -- current refusals, both of which loop back to the submenu), then
-- ItemUseSurfboard: while surfing it tries to dismount instead; -- ItemUseSurfboard: while surfing it tries to dismount instead;
-- otherwise it mounts only if the FACING tile is water, else -- otherwise it mounts only if the FACING tile is water, else
@@ -503,8 +537,8 @@ function PartyMenu:update(dt)
return -- .loop: submenu stays open behind the message return -- .loop: submenu stays open behind the message
elseif action == "cut" then elseif action == "cut" then
-- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm): -- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm):
-- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the -- CASCADEBADGE-gated (useCutFieldMove); _NothingToCutText loops back
-- submenu when the FACING tile isn't a cuttable tree. -- to the submenu when the FACING tile isn't a cuttable tree.
local ow = self.game.overworld local ow = self.game.overworld
local reason = ow:useCutFieldMove() local reason = ow:useCutFieldMove()
if reason == "ok" then if reason == "ok" then
@@ -522,7 +556,7 @@ function PartyMenu:update(dt)
self.game.stack:push(TextBox.new(self.game, txt)) self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message return -- .loop: submenu stays open behind the message
elseif action == "strength" then elseif action == "strength" then
-- start_sub_menus.asm .strength: RAINBOWBADGE-gated (list time); -- start_sub_menus.asm .strength: RAINBOWBADGE-gated;
-- predef PrintStrengthText (field_move_messages.asm) sets -- predef PrintStrengthText (field_move_messages.asm) sets
-- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate -- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate
-- push_boulder.asm reads -- then prints _UsedStrengthText (no -- push_boulder.asm reads -- then prints _UsedStrengthText (no
@@ -532,6 +566,10 @@ function PartyMenu:update(dt)
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white -- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
-- before CloseTextDisplay returns to the map. -- before CloseTextDisplay returns to the map.
local ow = self.game.overworld local ow = self.game.overworld
if ow and not ow:partyKnows("STRENGTH") then
refuseBadge(self)
return
end
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition") local Transition = require("src.render.Transition")
local def = self.game.data.pokemon[mon.species] local def = self.game.data.pokemon[mon.species]
@@ -565,6 +603,33 @@ function PartyMenu:update(dt)
-- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares -- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
-- the exact departure; the fade + warp fire when the spin ends. -- the exact departure; the fade + warp fire when the spin ends.
local ow = self.game.overworld local ow = self.game.overworld
if entry.move == "TELEPORT" then
-- .teleport: TELEPORT works only OUTDOORS (CheckIfInOutsideMap --
-- OVERWORLD + PLATEAU, #83); dark maps don't block it
if ow and not Map.isOutside(ow.map.def,
FieldDefaults.field(self.game.data, "outsideTilesets")) then
local TextBox = require("src.render.TextBox")
local def = self.game.data.pokemon[mon.species]
local txt = (self.game.data.text._CannotUseTeleportNowText
or Strings("{RAM:wNameBuffer} can't\nuse TELEPORT now."))
:gsub("{RAM:wNameBuffer}", mon.nickname or def.name)
self.game.stack:push(TextBox.new(self.game, txt))
return -- .loop: submenu stays open behind the message
end
elseif ow and not (DIG_TILESETS[ow.map.def.tileset]
and ow.map.id ~= "AGATHAS_ROOM") then
-- .dig runs ItemUseEscapeRope (it sets wCurItem = ESCAPE_ROPE):
-- usable in the dungeon tilesets of escape_rope_tilesets.asm minus
-- Agatha's room, even in the dark (Rock Tunnel); anywhere else
-- .notUsable -> ItemUseNotTime, the same line BagMenu prints for a
-- bagged ESCAPE ROPE
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game,
self.game.data.text._ItemUseNotTimeText
or Strings("OAK: %s!\nThis isn't the\ntime to use that!",
self.game.save.player.name)))
return -- .loop: submenu stays open behind the message
end
self.game.stack:pop() self.game.stack:pop()
if ow then ow:beginTeleportOut() end if ow then ow:beginTeleportOut() end
return return
@@ -658,42 +723,30 @@ function PartyMenu:update(dt)
-- Battle still excludes this list via `not self.battle`. Softboiled -- Battle still excludes this list via `not self.battle`. Softboiled
-- can appear for a fainted user; its heal transfer then no-ops. -- can appear for a fainted user; its heal transfer then no-ops.
if not self.battle and ow then if not self.battle and ow then
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU -- -- GetMonFieldMoves (engine/menus/text_box.asm) matches the mon's
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83) -- four moves against FieldMoveDisplayData and nothing else -- no
local outside = Map.isOutside(ow.map.def, -- badge, no map, no tileset test. Every one of those lives in
FieldDefaults.field(self.game.data, "outsideTilesets")) -- .outOfBattleMovePointers, i.e. on selection, where the refusal
-- prints and .loop returns to this still-open submenu (#1022).
for _, mv in ipairs(mon.moves) do for _, mv in ipairs(mon.moves) do
if mv.id == "FLY" and outside if mv.id == "FLY" then
and self.game.save.inventory.THUNDERBADGE then
table.insert(items, { label = Strings("FLY"), action = "fly" }) table.insert(items, { label = Strings("FLY"), action = "fly" })
elseif mv.id == "FLASH" and ow.dark elseif mv.id == "FLASH" then
and self.game.save.inventory.BOULDERBADGE then
table.insert(items, { label = Strings("FLASH"), action = "flash" }) table.insert(items, { label = Strings("FLASH"), action = "flash" })
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE then elseif mv.id == "CUT" then
-- CUT/SURF/STRENGTH are party-menu field moves too
-- (start_sub_menus.asm .outOfBattleMovePointers); listed here
-- with the same list-time badge filter this file already uses
-- for FLY/FLASH. The facing-tile/activation check happens on
-- selection (useCutFieldMove/useSurfFieldMove).
table.insert(items, { label = Strings("CUT"), action = "cut" }) table.insert(items, { label = Strings("CUT"), action = "cut" })
elseif mv.id == "SURF" and self.game.save.inventory.SOULBADGE then elseif mv.id == "SURF" then
table.insert(items, { label = Strings("SURF"), action = "surf" }) table.insert(items, { label = Strings("SURF"), action = "surf" })
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then elseif mv.id == "STRENGTH" then
table.insert(items, { label = Strings("STRENGTH"), action = "strength" }) table.insert(items, { label = Strings("STRENGTH"), action = "strength" })
elseif mv.id == "SOFTBOILED" then elseif mv.id == "SOFTBOILED" then
table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" }) table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" })
elseif mv.id == "TELEPORT" and outside then elseif mv.id == "TELEPORT" then
-- TELEPORT works only OUTDOORS (start_sub_menus.asm table.insert(items, { label = Strings("TELEPORT"),
-- .teleport -> CheckIfInOutsideMap); dark maps don't action = "escape", move = "TELEPORT" })
-- block it elseif mv.id == "DIG" then
table.insert(items, { label = Strings("TELEPORT"), action = "escape" }) table.insert(items, { label = Strings("DIG"),
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset] action = "escape", move = "DIG" })
and ow.map.id ~= "AGATHAS_ROOM" then
-- DIG runs ItemUseEscapeRope (.dig sets wCurItem =
-- ESCAPE_ROPE): usable in the dungeon tilesets of
-- escape_rope_tilesets.asm minus Agatha's room, even in
-- the dark (Rock Tunnel)
table.insert(items, { label = Strings("DIG"), action = "escape" })
end end
end end
end end
+128 -78
View File
@@ -368,6 +368,7 @@ Kit._navPrevN = 0
Kit._navSeen = {} Kit._navSeen = {}
Kit._navQueue = nil Kit._navQueue = nil
Kit._activateId = nil Kit._activateId = nil
Kit._ringShown = false
-- Register a focusable. Returns true when it currently holds the ring. -- Register a focusable. Returns true when it currently holds the ring.
-- Shielded widgets do not register: while a modal owns the frame the ring -- Shielded widgets do not register: while a modal owns the frame the ring
@@ -383,11 +384,12 @@ function Kit.focusable(id, x, y, w, h)
-- First focusable ever drawn adopts the ring, so keyboard users start -- First focusable ever drawn adopts the ring, so keyboard users start
-- somewhere rather than nowhere. -- somewhere rather than nowhere.
if Kit.focusId == nil then Kit.focusId = id end if Kit.focusId == nil then Kit.focusId = id end
return Kit.focusId == id return Kit._ringShown and Kit.focusId == id
end end
function Kit.navigate(dir) function Kit.navigate(dir)
Kit._navQueue = dir Kit._navQueue = dir
Kit._ringShown = true
end end
function Kit.activateFocused() function Kit.activateFocused()
@@ -396,6 +398,7 @@ end
function Kit.setFocus(id) function Kit.setFocus(id)
Kit.focusId = id Kit.focusId = id
Kit._ringShown = id ~= nil
end end
-- Pick the nearest focusable in `dir` from the current one. Candidates must -- Pick the nearest focusable in `dir` from the current one. Candidates must
@@ -530,13 +533,10 @@ Kit._audit = audit
function Kit.tapMin() return math.floor(30 * Kit.scale) end function Kit.tapMin() return math.floor(30 * Kit.scale) end
-- ---------------------------------------------------------------- surfaces -- ---------------------------------------------------------------- surfaces
function Kit.card(x, y, w, h, emphasis) function Kit.card(x, y, w, h, variant)
Theme.card(x, y, w, h, emphasis) Theme.card(x, y, w, h, variant)
end end
-- A list row. `id` opts it into the focus ring; pass nil for decorative
-- rows. Returns (clicked, inkColor) -- a selected row fills white, so the
-- caller must print with the returned ink or it will draw white on white.
function Kit.row(x, y, w, h, selected, id) function Kit.row(x, y, w, h, selected, id)
audit("row", x, y, w, h, id or "row") audit("row", x, y, w, h, id or "row")
local focused = id and Kit.focusable(id, x, y, w, h) or false local focused = id and Kit.focusable(id, x, y, w, h) or false
@@ -560,7 +560,7 @@ end
-- hairline says the same thing for one rect.) -- hairline says the same thing for one rect.)
function Kit.emptyBox(x, y, w, h, message) function Kit.emptyBox(x, y, w, h, message)
if not G then return end if not G then return end
Theme.strokeRounded(x, y, w, h, PAL.line, 0.22, 1, Theme.radius()) Theme.card(x, y, w, h, "empty")
Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale), Kit.textCenter("button", Kit.ellipsize("button", message, w - 24 * Kit.scale),
x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted) x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted)
end end
@@ -598,61 +598,130 @@ local KINDS = {
disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true }, disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true },
} }
Kit.KINDS = KINDS Kit.KINDS = KINDS
local NO_OPTS = {}
-- opts: { kind, font, enabled, align, id, glow, fill, ink }
-- id -- opts into the focus ring (give every real control one)
-- glow -- a pulsing outline for "something is waiting for you" (the
-- update button). No blend-mode change: the alpha of the
-- existing outline is animated instead.
-- fill/ink -- override the kind's colours. The ONE caller is the
-- launcher's Play button, which wears its cartridge colour
-- (red/blue/gold) rather than a semantic one: on that screen
-- "which game am I launching" outranks "what kind of verb is
-- this", and the colour is already the tab's identity.
-- Returns true when activated, by click OR by the focus ring's Enter/A.
function Kit.button(x, y, w, h, label, opts) function Kit.button(x, y, w, h, label, opts)
opts = opts or {} opts = opts or NO_OPTS
local enabled = opts.enabled ~= false local enabled = opts.enabled ~= false
-- Disabled buttons audit too: they stay visible, so they still must not
-- paint over a neighbour.
audit("control", x, y, w, h, label) audit("control", x, y, w, h, label)
local focused = enabled and opts.id local focused = enabled and opts.id
and Kit.focusable(opts.id, x, y, w, h) or false and Kit.focusable(opts.id, x, y, w, h) or false
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
if enabled and opts.fill then
kind = { fill = opts.fill, ink = opts.ink or PAL.inverse }
end
local hot = enabled and Kit.hover(x, y, w, h) local hot = enabled and Kit.hover(x, y, w, h)
local face = opts.face or "fill"
if G then local B = Theme.BUTTON
-- The fill IS the control: a rounded, embossed, colour-coded key. A local radius = opts.radius or B.radius
-- disabled button keeps its shape in a dead grey rather than local active = opts.active or opts.on
-- disappearing, so a layout never reflows on state. local invert = false
Theme.fillRounded(x, y, w, h, kind.fill, enabled and 1 or 0.45) local fill, ink, stroke, strokeA, doEmboss, doRing, glowA
Theme.emboss(x, y, w, h, enabled and (hot and 1.3 or 1) or 0.4) if face == "invert" then
if hot or focused then invert = hot
-- White ring outside the fill: legible on green, blue, yellow, red and fill = invert and (opts.hotFill or PAL.ink) or (opts.fill or PAL.surface)
-- white alike, which one darker/lighter shade per colour would not be. ink = invert and (opts.hotInk or PAL.inverse) or (opts.ink or PAL.heading)
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, stroke = opts.stroke or PAL.line
Theme.A.focus, 2, Theme.radius() + 2) strokeA = invert and Theme.A.focus or Theme.A.hairline
elseif opts.glow and enabled then doRing = focused and not hot
-- "Something is waiting for you" (the update button): a pulsing ring. elseif face == "tab" then
-- Pure alpha on one existing stroke -- no extra draw calls, no blend invert = active or focused or hot
-- mode change. local tint = opts.color or opts.fill or PAL.ink
local a = 0.25 + 0.75 * (0.5 + 0.5 * math.sin(Kit.time * 3)) fill = invert and tint or PAL.surface
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, a, 2, ink = invert and PAL.inverse or (opts.color or PAL.text)
Theme.radius() + 2) if not invert then
stroke = tint
strokeA = opts.color and Theme.A.hover or Theme.A.hairline
end end
local fname = opts.font or "button" elseif face == "chip" then
local ink = enabled and kind.ink or PAL.inverse local c = opts.color or PAL.line
local ty = y + (h - Kit.textHeight(fname)) / 2 invert = active and true or false
local shown = Kit.ellipsize(fname, label, w - 16 * Kit.scale) if active then
-- Button labels are bold: they are the shortest, most-scanned text on fill = c
-- screen and sit on a saturated fill. ink = PAL.inverse
if opts.align == "left" then doEmboss = true
Kit.textBold(fname, shown, x + 10 * Kit.scale, ty, ink)
else else
Kit.textCenterBold(fname, shown, x, ty, w, ink) fill = PAL.bg
ink = c
stroke = c
strokeA = (focused or hot) and Theme.A.focus or Theme.A.hover
end
doRing = focused or hot
else
local kind = KINDS[enabled and (opts.kind or "ghost") or "disabled"]
fill = (enabled and opts.fill) or kind.fill
ink = (enabled and opts.ink) or kind.ink
doEmboss = true
doRing = hot or focused
if opts.glow and enabled and not doRing then
glowA = B.glowBase + B.glowAmp * (0.5 + 0.5 * math.sin(Kit.time * B.glowHz))
end
end
if opts.emboss ~= nil then doEmboss = opts.emboss end
if opts.ring ~= nil then doRing = opts.ring end
if G then
Theme.fillRounded(x, y, w, h, fill, enabled and 1 or B.disabledA, radius)
if doEmboss then
local es = enabled and ((hot or focused) and B.embossHot or B.embossRest)
or B.embossDisabled
Theme.emboss(x, y, w, h, es)
end
if strokeA then
Theme.strokeRounded(x, y, w, h, stroke, strokeA, 1, radius)
end
if doRing then
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
Theme.A.focus, B.ringWidth, radius + B.ringPad)
elseif glowA then
Theme.strokeRounded(x - B.ringPad, y - B.ringPad,
w + 2 * B.ringPad, h + 2 * B.ringPad, PAL.lineStrong,
glowA, B.ringWidth, radius + B.ringPad)
end
local fname = opts.font or ((face == "chip") and "micro" or "button")
local ty = y + (h - Kit.textHeight(fname)) / 2
local image = opts.image
local drawFn = opts.drawFn
local letter = opts.letter
local hasLabel = label and label ~= ""
local bold = opts.bold
if bold == nil then bold = face ~= "tab" end
if image then
local box = h
local boxX, boxY = x, y
if not hasLabel then
box = math.min(w, h)
boxX = x + (w - box) / 2
boxY = y + (h - box) / 2
end
local iw, ih = image:getDimensions()
local pad = math.floor(box * (opts.iconPad or B.iconPad))
local s = math.min((box - 2 * pad) / iw, (box - 2 * pad) / ih)
if invert then Theme.col(PAL.inverse, 1)
else Theme.col(PAL.ink, B.iconRestA) end
G.draw(image, Theme.snap(boxX + (box - iw * s) / 2),
Theme.snap(boxY + (box - ih * s) / 2), 0, s, s)
if hasLabel then
local lx = x + h + B.letterGap * Kit.scale
if bold then Kit.textBold(fname, label, lx, ty, ink)
else Kit.text(fname, label, lx, ty, ink) end
end
elseif drawFn then
drawFn(x, y, w, h, invert or hot or focused)
elseif letter then
Kit.textCenter(fname, letter, x, ty, h, ink)
if hasLabel then
local lx = x + h + B.letterGap * Kit.scale
if bold then Kit.textBold(fname, label, lx, ty, ink)
else Kit.text(fname, label, lx, ty, ink) end
end
elseif hasLabel then
local shown = Kit.ellipsize(fname, label, w - B.labelInset * Kit.scale)
if opts.align == "left" then
local lx = x + B.labelPad * Kit.scale
if bold then Kit.textBold(fname, shown, lx, ty, ink)
else Kit.text(fname, shown, lx, ty, ink) end
elseif bold then
Kit.textCenterBold(fname, shown, x, ty, w, ink)
else
Kit.textCenter(fname, shown, x, ty, w, ink)
end
end end
end end
if not enabled then return false end if not enabled then return false end
@@ -661,7 +730,6 @@ function Kit.button(x, y, w, h, label, opts)
and Kit._activateId == opts.id) and Kit._activateId == opts.id)
end end
-- A small square control: +/- steppers, arrow cyclers, the row X.
function Kit.stepper(x, y, w, h, glyph, opts) function Kit.stepper(x, y, w, h, glyph, opts)
opts = opts or {} opts = opts or {}
opts.kind = opts.kind or "ghost" opts.kind = opts.kind or "ghost"
@@ -669,31 +737,13 @@ function Kit.stepper(x, y, w, h, glyph, opts)
return Kit.button(x, y, w, h, glyph, opts) return Kit.button(x, y, w, h, glyph, opts)
end end
-- A pill toggle (badges, dex SEEN/OWN, sub-tabs). `on` inverts it. local CHIP_OPTS = { face = "chip", font = "micro" }
function Kit.chip(x, y, w, h, label, on, color, id) function Kit.chip(x, y, w, h, label, on, color, id)
audit("control", x, y, w, h, label) CHIP_OPTS.active = on
local focused = id and Kit.focusable(id, x, y, w, h) or false CHIP_OPTS.color = color
local c = color or PAL.line CHIP_OPTS.id = id
if G then return Kit.button(x, y, w, h, label, CHIP_OPTS)
local hot = focused or Kit.hover(x, y, w, h)
if on then
Theme.fillRounded(x, y, w, h, c, 1)
Theme.emboss(x, y, w, h, 1)
Kit.textCenterBold("micro", label, x,
y + (h - Kit.textHeight("micro")) / 2, w, PAL.inverse)
else
Theme.fillRounded(x, y, w, h, PAL.bg, 1)
Theme.strokeRounded(x, y, w, h, c,
hot and Theme.A.focus or Theme.A.hover, 1)
Kit.textCenterBold("micro", label, x,
y + (h - Kit.textHeight("micro")) / 2, w, c)
end
if hot then
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong,
Theme.A.focus, 2, Theme.radius() + 2)
end
end
return Kit.press(x, y, w, h) or (id ~= nil and Kit._activateId == id)
end end
-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE" -- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE"
+95 -20
View File
@@ -35,8 +35,8 @@ local PAL = {
-- it in the same hue, so a card reads as a raised object rather than as an -- it in the same hue, so a card reads as a raised object rather than as an
-- outline drawn on the page. These are still flat fills -- the depth comes -- outline drawn on the page. These are still flat fills -- the depth comes
-- from the value step plus Theme.shadow, not from a gradient. -- from the value step plus Theme.shadow, not from a gradient.
field = { 16, 8, 10 }, -- the page BEHIND the cards field = { 20, 20, 20 }, -- the page BEHIND the cards
bg = { 0, 0, 0 }, -- true black: button rests, field interiors bg = { 20, 20, 20 }, -- light black
surface = { 28, 21, 24 }, -- card interiors surface = { 28, 21, 24 }, -- card interiors
rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it
raised = { 44, 34, 38 }, -- hover feedback raised = { 44, 34, 38 }, -- hover feedback
@@ -82,6 +82,67 @@ Theme.A = {
disabled = 0.30, disabled = 0.30,
} }
Theme.BUTTON = {
radius = 8,
ringPad = 2,
ringWidth = 2,
labelInset = 16,
labelPad = 10,
iconPad = 0.24,
letterGap = 4,
disabledA = 0.45,
embossHot = 1.3,
embossRest = 1,
embossDisabled = 0.4,
glowHz = 3,
glowBase = 0.25,
glowAmp = 0.75,
iconRestA = 0.85,
}
Theme.CARD = {
radius = 14,
shadow = true,
fill = PAL.surface,
fillA = 1,
stroke = PAL.line,
strokeA = Theme.A.hairline,
}
Theme.CARD_VARIANT = {
emphasis = { strokeA = Theme.A.focus },
muted = {
fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = 0.25, shadow = false,
},
mutedHot = {
fill = PAL.bg, fillA = 0.8, stroke = PAL.muted, strokeA = Theme.A.hover, shadow = false,
},
empty = { fillA = 0, strokeA = 0.22, shadow = false, radius = "ctl" },
warn = {
fill = PAL.rowBg, stroke = PAL.yellow, strokeA = Theme.A.hover,
shadow = false, radius = "ctl",
},
row = {
fill = PAL.rowBg, stroke = PAL.line, strokeA = Theme.A.hairline,
shadow = false, radius = "ctl",
},
rowHover = {
fill = PAL.raised, stroke = PAL.line, strokeA = Theme.A.hover,
shadow = false, radius = "ctl",
},
rowSelected = {
fill = PAL.ink, strokeA = 0, shadow = false, radius = "ctl",
},
hairline = {
fillA = 0, stroke = PAL.line, strokeA = Theme.A.hairline,
shadow = false, radius = "ctl",
},
badge = {
fill = PAL.bg, stroke = PAL.yellow, strokeA = Theme.A.hover,
shadow = false, radius = 0,
},
}
local G = love and love.graphics or nil local G = love and love.graphics or nil
local has = {} local has = {}
@@ -123,11 +184,11 @@ end
-- Controls get the smaller one, containers the larger, so a button never -- Controls get the smaller one, containers the larger, so a button never
-- looks like a card and a card never looks like a button. -- looks like a card and a card never looks like a button.
function Theme.radius() function Theme.radius()
return 8 return Theme.BUTTON.radius
end end
function Theme.cardRadius() function Theme.cardRadius()
return 14 return Theme.CARD.radius
end end
-- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider -- DROP SHADOW. Three stacked rounded rects at low alpha, each one step wider
@@ -206,28 +267,42 @@ end
-- The design's only container: a rounded surface a few values above the -- The design's only container: a rounded surface a few values above the
-- field, its own drop shadow, and a white hairline. `emphasis` raises the -- field, its own drop shadow, and a white hairline. `emphasis` raises the
-- outline to full white (used for the focused/active card). -- outline to full white (used for the focused/active card).
function Theme.card(x, y, w, h, emphasis) function Theme.card(x, y, w, h, variant)
local r = Theme.cardRadius() local spec = Theme.CARD
Theme.shadow(x, y, w, h, r) local v
Theme.fillRounded(x, y, w, h, PAL.surface, 1, r) if variant == true then
Theme.strokeRounded(x, y, w, h, PAL.line, v = Theme.CARD_VARIANT.emphasis
emphasis and Theme.A.focus or Theme.A.hairline, 1, r) elseif type(variant) == "string" then
v = Theme.CARD_VARIANT[variant]
elseif type(variant) == "table" then
v = variant
end
local radius = (v and v.radius) or spec.radius
if radius == "ctl" then radius = Theme.BUTTON.radius end
local fill = (v and v.fill) or spec.fill
local fillA = spec.fillA
if v and v.fillA ~= nil then fillA = v.fillA end
local stroke = (v and v.stroke) or spec.stroke
local strokeA = spec.strokeA
if v and v.strokeA ~= nil then strokeA = v.strokeA end
local shadow = spec.shadow
if v and v.shadow ~= nil then shadow = v.shadow end
local strokeW = (v and v.strokeW) or 1
if fillA > 0 and fill then
if shadow then Theme.shadow(x, y, w, h, radius) end
Theme.fillRounded(x, y, w, h, fill, fillA, radius)
end
if strokeA > 0 and stroke then
Theme.strokeRounded(x, y, w, h, stroke, strokeA, strokeW, radius)
end
end end
-- A list row. Three states, each one rect plus one outline:
-- normal one value below the card it sits in, hairline
-- hover lifted fill, brighter hairline
-- selected WHITE fill (callers print ink = PAL.inverse over it)
function Theme.row(x, y, w, h, state) function Theme.row(x, y, w, h, state)
local r = Theme.radius()
if state == "selected" then if state == "selected" then
Theme.fillRounded(x, y, w, h, PAL.ink, 1, r) Theme.card(x, y, w, h, "rowSelected")
return PAL.inverse return PAL.inverse
end end
Theme.fillRounded(x, y, w, h, Theme.card(x, y, w, h, state == "hover" and "rowHover" or "row")
state == "hover" and PAL.raised or PAL.rowBg, 1, r)
Theme.strokeRounded(x, y, w, h, PAL.line,
state == "hover" and Theme.A.hover or Theme.A.hairline, 1, r)
return PAL.text return PAL.text
end end
+15 -5
View File
@@ -8,7 +8,7 @@ local SpriteRenderer = require("src.render.SpriteRenderer")
local NPC = {} local NPC = {}
NPC.__index = NPC NPC.__index = NPC
local STEP_FRAMES = 16 local STEP_FRAMES = 32
local FACING_FROM_RANGE = { local FACING_FROM_RANGE = {
DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right", DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right",
@@ -52,7 +52,16 @@ function NPC:facePlayer(player)
end end
function NPC:update(map, entities) function NPC:update(map, entities)
-- self.stepFrames overrides the shared 16-frame walk for an object whose -- An NPC tile is 32 frames, half the player's rate: TryWalking loads
-- WALKANIMATIONCOUNTER with $10 and UpdateSpriteInWalkingAnimation adds
-- the 1px step vector once per call (engine/overworld/movement.asm), but
-- UpdateSprites runs once per OverworldLoop pass and every pass opens
-- with two DelayFrame calls (home/overworld.asm) -- so those 16 ticks
-- cost 32 frames for one 16px cell, against AdvancePlayerSprite's 8
-- ticks of 2px. That halving is why pokeyellow's NormalPikachuFollow
-- needs TryDoubleAddPikachuStepVectorToScreenPixelCoords to keep up.
--
-- self.stepFrames overrides the shared walk for an object whose
-- step has to stay in phase with something else: Yellow's follower -- step has to stay in phase with something else: Yellow's follower
-- Pikachu takes the player's own step length, halved while it is more -- Pikachu takes the player's own step length, halved while it is more
-- than a cell behind (FastPikachuFollow, engine/pikachu/ -- than a cell behind (FastPikachuFollow, engine/pikachu/
@@ -78,7 +87,7 @@ function NPC:update(map, entities)
return return
end end
local d = Collision.DELTA[self.facing] local d = Collision.DELTA[self.facing]
-- 1px per frame at the default length; a shortened step scales instead, -- 1px per 2 frames at the default length; a shortened step scales instead,
-- so the cell still lands on a 16px boundary (Player:update does the -- so the cell still lands on a 16px boundary (Player:update does the
-- same for the bicycle) -- same for the bicycle)
local moved = math.floor(self.progress * 16 * span / stepLen) local moved = math.floor(self.progress * 16 * span / stepLen)
@@ -113,8 +122,9 @@ end
function NPC:walkPhase() function NPC:walkPhase()
if not self.moving then return 0 end if not self.moving then return 0 end
local p = self.progress % 16 local stepLen = self.stepFrames or STEP_FRAMES
return (p >= 4 and p < 12) and 1 or 0 local p = self.progress % stepLen
return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0
end end
-- Same contract as Player:pose -- the sheet, position, facing and step -- Same contract as Player:pose -- the sheet, position, facing and step
+106 -64
View File
@@ -384,12 +384,21 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- NPC instances persist across connection crossings in self.npcPool -- NPC instances persist across connection crossings in self.npcPool
-- (keyed by NPC.id): a neighbor map's wandering ghosts ARE the -- (keyed by NPC.id): a neighbor map's wandering ghosts ARE the
-- objects that become the real NPCs when the player crosses the -- objects that become the real NPCs when the player crosses the
-- seam, so nothing snaps back to its spawn point in view of the -- seam, so a map the player is not entering keeps its ghosts alive
-- survey zoom. Warps rebuild from scratch, like the original's -- in view of the survey zoom. The map being entered does not --
-- per-entry sprite init (home/overworld.asm LoadMapHeader -- crossing a seam runs .loadNewMap -> LoadMapHeader, whose
-- .loadSpriteData). -- .loadSpriteData zeroes the sprite state data and re-seeds every
-- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data
-- (home/overworld.asm), so an NPC who walked up to the player stands
-- on her spawn cell again the next time that map loads (#1028). Only
-- the save-side spawn flags survive, and those live in Game.save, not
-- here. Warps rebuild the whole pool from scratch.
if not (opts and opts.seamless and self.npcPool) then if not (opts and opts.seamless and self.npcPool) then
self.npcPool = {} self.npcPool = {}
elseif fromMapId ~= mapId then
for _, obj in ipairs(self.map.def.objects or {}) do
self.npcPool[mapId .. "_obj_" .. obj.index] = nil
end
end end
self.npcs = {} self.npcs = {}
for _, obj in ipairs(self.map.def.objects or {}) do for _, obj in ipairs(self.map.def.objects or {}) do
@@ -1986,10 +1995,12 @@ function OverworldState:tryHiddenObject(fx, fy)
end end
save.hiddenTaken[key] = true save.hiddenTaken[key] = true
local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item
-- hidden items always play SFX_GET_ITEM_2 (hidden_items.asm) -- hidden items always play SFX_GET_ITEM_2, and FoundHiddenItemText's
require("src.core.Sound").play(Game.data, "Get_Item2") -- text_asm tail runs it as PlaySoundWaitForCurrent +
-- WaitForSoundToFinish once the box has printed (hidden_items.asm)
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
Strings("%s found\n%s!", save.player.name, name))) Strings("%s found\n%s!", save.player.name, name),
nil, TextBox.soundOpts(Game, "Get_Item2")))
return true return true
end end
end end
@@ -2001,9 +2012,9 @@ function OverworldState:tryHiddenObject(fx, fy)
if not save.inventory.COIN_CASE then return false end if not save.inventory.COIN_CASE then return false end
save.hiddenTaken[key] = true save.hiddenTaken[key] = true
save.coins = math.min(9999, (save.coins or 0) + h.coins) save.coins = math.min(9999, (save.coins or 0) + h.coins)
require("src.core.Sound").play(Game.data, "Get_Item2")
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
Strings("%s found\n%d coins!", save.player.name, h.coins))) Strings("%s found\n%d coins!", save.player.name, h.coins),
nil, TextBox.soundOpts(Game, "Get_Item2")))
return true return true
end end
end end
@@ -2650,10 +2661,11 @@ function OverworldState:talkTo(npc)
end end
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
local ddef = Game.data.items[d.item] local ddef = Game.data.items[d.item]
require("src.core.Sound").play(Game.data, -- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm)
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
Strings("%s found\n%s!", Game.save.player.name, name))) Strings("%s found\n%s!", Game.save.player.name, name), nil,
TextBox.soundOpts(Game,
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")))
return return
end end
@@ -3182,6 +3194,58 @@ local function giveVictoryItem(reward)
return true return true
end end
-- A gym's reward text is not one box. Every gym script carries a sound
-- command right after the FIRST label of each reward group
-- (scripts/PewterGym.asm PewterGymBrockReceivedBoulderBadgeText's
-- sound_level_up, PewterGymReceivedTM34Text's sound_get_item_1, and the
-- equivalents in the other seven), and home/text.asm TextCommand_SOUND
-- plays it only once that page has typed out, then blocks on
-- WaitForSoundToFinish before the next page prints. So the pages
-- accumulate and split into separate boxes at the sound points, each box
-- chained off the previous one's button press.
local function rewardChain()
local chain = { boxes = {}, pending = {} }
function chain.flush(sound)
if #chain.pending > 0 then
table.insert(chain.boxes,
{ text = table.concat(chain.pending, "\f"), sound = sound })
chain.pending = {}
end
end
-- one reward group; `sound` rides its first page, where the scripts put it
function chain.add(labels, sound)
local text = Game.data.text or {}
local n = 0
for _, label in ipairs(labels or {}) do
if text[label] and text[label] ~= "" then
table.insert(chain.pending, text[label])
n = n + 1
if n == 1 and sound then chain.flush(sound) end
end
end
end
-- the synthetic stand-in line a reward with no `dialogue` shows
function chain.line(str, sound)
table.insert(chain.pending, str)
if sound then chain.flush(sound) end
end
function chain.push(done)
chain.flush()
local function step(i)
local box = chain.boxes[i]
if not box then
if done then done() end
return
end
local opts = box.sound and TextBox.soundOpts(Game, box.sound) or nil
Game.stack:push(TextBox.new(Game, box.text,
function() step(i + 1) end, opts))
end
step(1)
end
return chain
end
-- Badges/items awarded after specific battles (data/scripts/victories.lua). -- Badges/items awarded after specific battles (data/scripts/victories.lua).
-- `deactivate` retires unfought gym/dojo trainers the way the originals' -- `deactivate` retires unfought gym/dojo trainers the way the originals'
-- SetEvent / SetEventRange do after the leader victory. -- SetEvent / SetEventRange do after the leader victory.
@@ -3218,44 +3282,31 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
-- retries the hand-over later (offerGymTm via gyms.lua) -- retries the hand-over later (offerGymTm via gyms.lua)
tmGiven = giveVictoryItem(reward) tmGiven = giveVictoryItem(reward)
end end
local lines = {} local chain = rewardChain()
if reward.dialogue then if reward.dialogue then
local text = Game.data.text or {} chain.add(reward.dialogue, reward.badgeSound)
for _, label in ipairs(reward.dialogue) do
if text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
if reward.item then if reward.item then
for _, label in ipairs(reward.tmPre or {}) do chain.add(reward.tmPre)
if text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
if tmGiven then if tmGiven then
for _, label in ipairs(reward.tmDialogue or {}) do chain.add(reward.tmDialogue, reward.tmSound)
if text[label] and text[label] ~= "" then else
table.insert(lines, text[label]) chain.add({ reward.noRoom })
end
end
elseif reward.noRoom and text[reward.noRoom] and text[reward.noRoom] ~= "" then
table.insert(lines, text[reward.noRoom])
end end
end end
elseif reward.badge or reward.item then elseif reward.badge or reward.item then
if reward.badge then if reward.badge then
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
or reward.badge or reward.badge
table.insert(lines, Strings("%s received\nthe %s!", Game.save.player.name, name)) chain.line(Strings("%s received\nthe %s!", Game.save.player.name, name),
reward.badgeSound)
end end
if tmGiven then if tmGiven then
local name = Game.stringBuffer or reward.item local name = Game.stringBuffer or reward.item
table.insert(lines, Strings("%s received\n%s!", Game.save.player.name, name)) chain.line(Strings("%s received\n%s!", Game.save.player.name, name),
reward.tmSound)
end end
end end
if #lines > 0 then chain.push()
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f")))
end
self:runVictoryHook() self:runVictoryHook()
end end
@@ -3266,24 +3317,14 @@ end
-- show again, then the same GiveItem check decides between the received -- show again, then the same GiveItem check decides between the received
-- lines and the "make room" text. -- lines and the "make room" text.
function OverworldState:offerGymTm(reward, done) function OverworldState:offerGymTm(reward, done)
local text = Game.data.text or {} local chain = rewardChain()
local lines = {} chain.add(reward.tmPre)
local function addLine(label)
if label and text[label] and text[label] ~= "" then
table.insert(lines, text[label])
end
end
for _, label in ipairs(reward.tmPre or {}) do addLine(label) end
if giveVictoryItem(reward) then if giveVictoryItem(reward) then
for _, label in ipairs(reward.tmDialogue or {}) do addLine(label) end chain.add(reward.tmDialogue, reward.tmSound)
else else
addLine(reward.noRoom) chain.add({ reward.noRoom })
end
if #lines > 0 then
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"), done))
elseif done then
done()
end end
chain.push(done)
end end
-- pokered reloads the map after every battle, re-running the map -- pokered reloads the map after every battle, re-running the map
@@ -3661,8 +3702,9 @@ function OverworldState:onStepComplete()
end end
end end
-- wild encounters in grass, on water while surfing, or -- on indoor -- wild encounters in grass, on water while surfing (at the map's water
-- maps whose tileset is not FOREST -- on EVERY tile -- rate, which is 0 on every indoor map), or -- on indoor maps whose
-- tileset is not FOREST -- on every other tile
-- (wild_encounters.asm: caves, towers, the Mansion, Power Plant) -- (wild_encounters.asm: caves, towers, the Mansion, Power Plant)
-- The cooldown is checked after all other step processing so repel and -- The cooldown is checked after all other step processing so repel and
-- movement systems continue to advance during the protected steps. -- movement systems continue to advance during the protected steps.
@@ -3670,10 +3712,10 @@ function OverworldState:onStepComplete()
local encDef = Game.data.encounters[self.map.id] local encDef = Game.data.encounters[self.map.id]
local enc local enc
local indoor = Game.data.field.indoorEncounters local indoor = Game.data.field.indoorEncounters
if p.surfing and encDef and encDef.water and self.map:isWaterCell(p.cellX, p.cellY) then if self.map:isGrassCell(p.cellX, p.cellY) then
enc = self:rollEncounter({ grass = encDef.water }, "water")
elseif self.map:isGrassCell(p.cellX, p.cellY) then
enc = self:rollEncounter(encDef, "grass") enc = self:rollEncounter(encDef, "grass")
elseif p.surfing and self.map:isWaterCell(p.cellX, p.cellY) then
enc = self:rollEncounter({ grass = encDef and encDef.water }, "water")
elseif indoor and self.map.def.index >= indoor.firstIndoorMap elseif indoor and self.map.def.index >= indoor.firstIndoorMap
and self.map.def.tileset ~= indoor.excludedTileset then and self.map.def.tileset ~= indoor.excludedTileset then
enc = self:rollEncounter(encDef, "indoor") enc = self:rollEncounter(encDef, "indoor")
@@ -3794,10 +3836,10 @@ function OverworldState:checkBadgeGate()
if Game.save.inventory[g.badge] then if Game.save.inventory[g.badge] then
if not Game.save.flags[passedFlag] then if not Game.save.flags[passedFlag] then
Game.save.flags[passedFlag] = true Game.save.flags[passedFlag] = true
-- Route22GateGuardGoRightAheadText plays sound_get_item_1 -- Route22GateGuardGoRightAheadText carries sound_get_item_1
require("src.core.Sound").play(Game.data, "Get_Item1")
Game.stack:push(TextBox.new(Game, Game.stack:push(TextBox.new(Game,
t["_" .. g.passText] or Strings("Go right ahead!"))) t["_" .. g.passText] or Strings("Go right ahead!"),
nil, TextBox.soundOpts(Game, "Get_Item1")))
end end
return false return false
end end
@@ -3822,11 +3864,11 @@ function OverworldState:checkBadgeGate()
and Game.data.items[guard.badge].name or guard.badge and Game.data.items[guard.badge].name or guard.badge
if Game.save.inventory[guard.badge] then if Game.save.inventory[guard.badge] then
Game.save.flags[guard.event] = true Game.save.flags[guard.event] = true
-- Route23OhThatIsTheBadgeText plays sound_get_item_1 -- Route23OhThatIsTheBadgeText carries sound_get_item_1
require("src.core.Sound").play(Game.data, "Get_Item1")
local text = (t["_" .. g.passText] or local text = (t["_" .. g.passText] or
Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName) Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName)
Game.stack:push(TextBox.new(Game, text)) Game.stack:push(TextBox.new(Game, text,
nil, TextBox.soundOpts(Game, "Get_Item1")))
return false return false
end end
-- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED -- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED
@@ -4470,7 +4512,7 @@ function OverworldState:scriptMove(entity, dir, tiles, onDone)
}) })
end end
-- A step-in-place beat: the entity plays one walk-cycle animation (16 -- A step-in-place beat: the entity plays one walk-cycle animation (32
-- frames) without translating, keeping its current facing. Ports the -- frames) without translating, keeping its current facing. Ports the
-- NPC_CHANGE_FACING movement byte (engine/overworld/movement.asm -- NPC_CHANGE_FACING movement byte (engine/overworld/movement.asm
-- ChangeFacingDirection -> zero-delta TryWalking), used for Oak marching -- ChangeFacingDirection -> zero-delta TryWalking), used for Oak marching
@@ -4483,7 +4525,7 @@ end
-- Advance scripted moves in two phases so a chained step (a new move -- Advance scripted moves in two phases so a chained step (a new move
-- queued by a completing move's onDone) begins the SAME frame the -- queued by a completing move's onDone) begins the SAME frame the
-- previous one ends -- back-to-back 16-frame tiles like the GB's -- previous one ends -- back-to-back 32-frame tiles like the GB's
-- simulated-joypad / NPC scripted movement, with no idle frame between -- simulated-joypad / NPC scripted movement, with no idle frame between
-- tiles. Phase 1 retires finished moves (which may chain new ones); -- tiles. Phase 1 retires finished moves (which may chain new ones);
-- phase 2 then starts every not-yet-moving move. -- phase 2 then starts every not-yet-moving move.
+10 -1
View File
@@ -821,11 +821,13 @@ end
function PikachuFollower.onFanClubEntered(game, ow) function PikachuFollower.onFanClubEntered(game, ow)
if not (GameVersion.isYellow() and ow.map if not (GameVersion.isYellow() and ow.map
and ow.map.id == "POKEMON_FAN_CLUB") then return end and ow.map.id == "POKEMON_FAN_CLUB") then return end
local active = game.save.pikachuMapScriptActive
game.save.pikachuMapScriptActive = true
if active then return end
local starter = PikachuFollower.starterInParty(game.save) local starter = PikachuFollower.starterInParty(game.save)
local npc = findFollower(ow) local npc = findFollower(ow)
if not npc or (starter and starter.status) then return end if not npc or (starter and starter.status) then return end
ow.pikachuFanClubScene = true ow.pikachuFanClubScene = true
ow.pikachuMapScriptActive = true
ow.player.facing = "down" ow.player.facing = "down"
for _, other in ipairs(ow.npcs or {}) do for _, other in ipairs(ow.npcs or {}) do
if other.def and other.def.name == "POKEMONFANCLUB_SEEL" then if other.def and other.def.name == "POKEMONFANCLUB_SEEL" then
@@ -844,6 +846,13 @@ function PikachuFollower.onBillsHouseEnter(game, ow)
if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then
return return
end end
-- BillsHouse_CheckMetBill (scripts/BillsHouse.asm:22-40) sets
-- BIT_PIKACHU_MAP_SCRIPT_ACTIVE and rets nz before it looks at
-- EVENT_MET_BILL_2; the bit rides sMainData, so a reload inside the house
-- resumes at BillsHouseScript1's bare ret (#919).
local active = game.save.pikachuMapScriptActive
game.save.pikachuMapScriptActive = true
if active then return end
if game.save.flags.EVENT_MET_BILL_2 then return end if game.save.flags.EVENT_MET_BILL_2 then return end
-- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused -- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused
-- walk while CheckPikachuStatusCondition comes back clear -- walk while CheckPikachuStatusCondition comes back clear
@@ -191,12 +191,12 @@ return function(game)
return cond() return cond()
end end
-- Jump the flash clock to just under FLASH_FRAMES (220) once the shot is -- Jump the flash clock to just under FLASH_FRAMES (368) once the shot is
-- taken. EvolutionState:update only compares self.t against that constant, -- taken. EvolutionState:update only compares self.t against that constant,
-- so this ends the animation early without touching the palette logic. -- so this ends the animation early without touching the palette logic.
local function skipToEnd() local function skipToEnd()
local st = evoTop() local st = evoTop()
if st then st.t = 214 end if st then st.t = 362 end
end end
local function startEvo(species, into) local function startEvo(species, into)
@@ -212,7 +212,7 @@ return function(game)
-- ---- case 1: the reported case, SGB, mid-flash -------------------------- -- ---- case 1: the reported case, SGB, mid-flash --------------------------
local weedle = startEvo("WEEDLE", "KAKUNA") local weedle = startEvo("WEEDLE", "KAKUNA")
U.wait(24) -- into the flash, far short of FLASH_FRAMES = 220 U.wait(24) -- into the flash, far short of FLASH_FRAMES = 368
U.shot(game, DIR .. "/evo279_1_flash_sgb.png") U.shot(game, DIR .. "/evo279_1_flash_sgb.png")
local c1 = probe("flash/SGB", { local c1 = probe("flash/SGB", {
monYellow1 = YELLOW[2], monYellow2 = YELLOW[3], monYellow1 = YELLOW[2], monYellow2 = YELLOW[3],
@@ -6,7 +6,7 @@
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled. -- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
-- --
-- Case 1 (level path, cancelable): open EvolutionState directly, wait past -- Case 1 (level path, cancelable): open EvolutionState directly, wait past
-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=220), -- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=368),
-- press B, and assert the mon stays CATERPIE with the "stopped evolving" -- press B, and assert the mon stays CATERPIE with the "stopped evolving"
-- text on screen. -- text on screen.
-- Case 1b: after cancel, checkParty with no level-ups must not re-offer; -- Case 1b: after cancel, checkParty with no level-ups must not re-offer;
@@ -81,7 +81,7 @@ return function(game)
Evolution.evolve(game, mon, "METAPOD", function() done1 = true end) Evolution.evolve(game, mon, "METAPOD", function() done1 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
U.wait(100) -- past the 80-frame pre-animLoop delay, still under 220 U.wait(100) -- past the 80-frame pre-animLoop delay, still under 368
U.log("case1 flash", "t=", top().t, "species=", mon.species) U.log("case1 flash", "t=", top().t, "species=", mon.species)
U.shot(game, DIR .. "/evo213_1_evolving.png") U.shot(game, DIR .. "/evo213_1_evolving.png")
@@ -133,8 +133,8 @@ return function(game)
local done2 = false local done2 = false
Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end) Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
-- let the full flash run (FLASH_FRAMES=220) without pressing B -- let the full flash run (FLASH_FRAMES=368) without pressing B
waitFor(function() return not evoTop() end, 400) waitFor(function() return not evoTop() end, 500)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case2)") error("Congratulations text not shown (case2)")
end end
+3 -3
View File
@@ -89,8 +89,8 @@ return function(game)
Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end) Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
-- let the full flash run (FLASH_FRAMES=220) with no input, then apply -- let the full flash run (FLASH_FRAMES=368) with no input, then apply
waitFor(function() return not evoTop() end, 400) waitFor(function() return not evoTop() end, 500)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case1)") error("Congratulations text not shown (case1)")
end end
@@ -124,7 +124,7 @@ return function(game)
local done2 = false local done2 = false
Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end) Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
waitFor(function() return not evoTop() end, 400) waitFor(function() return not evoTop() end, 500)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case2)") error("Congratulations text not shown (case2)")
end end
@@ -0,0 +1,188 @@
-- Driver: a trainer who walked up to the player must be back on her spawn
-- cell the next time her map loads, connection crossings included (#1028).
-- .loadNewMap calls LoadMapHeader for a seam crossing exactly as a warp does,
-- and .loadSpriteData zeroes the sprite state data and re-seeds every
-- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data
-- (pokered home/overworld.asm), so only the save-side defeat flag survives.
--
-- Route 3's Lass (data/maps/objects/Route3.asm object_event 23, 4,
-- SPRITE_COOLTRAINER_F, STAY, LEFT, ..., OPP_LASS) has sight range 4, so
-- standing above the third Bug Catcher at (19,4) is inside her line and she
-- walks dist-1 = 3 cells west to (20,4) -- the reporter's exact setup.
--
-- POKEPORT_DRIVER=tests/drivers/trainer_reset_bug1028_test.lua \
-- POKEPORT_IDENTITY=bug1028 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Zoom = require("src.render.Zoom")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local MAP = "ROUTE_3"
local TARGET = 6
local SPAWN = { x = 23, y = 4 }
local STAND = { x = 19, y = 4 }
local pass = true
local function check(label, ok)
if not ok then pass = false end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function lass()
local ow = game.overworld
for _, n in ipairs(ow and ow.npcs or {}) do
if n.def and n.def.index == TARGET then return n end
end
return nil
end
local function at(n) return n and (n.cellX .. "," .. n.cellY) or "absent" end
-- hold a direction until cond() or the budget runs out, then let the
-- half-finished step land
local function holdUntil(btn, cond, budget)
local first = true
for _ = 1, budget or 900 do
if cond() then break end
if first then table.insert(game.input.pressQueue, btn); first = false end
game.input.state[btn] = true
coroutine.yield()
end
game.input.state[btn] = false
for _ = 1, 40 do
local ow = game.overworld
if ow and not ow.player.moving then break end
coroutine.yield()
end
U.wait(4)
return cond()
end
local function walkTo(btn, axis, want, budget)
return holdUntil(btn, function()
local p = game.overworld and game.overworld.player
return p and p[axis] == want and not p.moving
end, budget)
end
local function onMap(id)
return game.overworld and game.overworld.map and game.overworld.map.id == id
end
-- mash A until the battle stack unwinds back to the overworld
local function mashToOverworld(budget)
for _ = 1, budget or 4000 do
if game.stack:top() == game.overworld and not game.overworld.engaging then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
-- absolute zoom: reset first so every shot frames the same amount of world
local function survey(n)
Zoom.reset()
U.wait(2)
for _ = 1, n do game:zoomStep(-1) end
U.wait(30)
end
-- back up to the sighting cell so the before/after shots share a camera
local function returnToStand()
walkTo("right", "cellX", 11)
walkTo("up", "cellY", 4)
walkTo("right", "cellX", STAND.x)
end
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 80),
Pokemon.new(game.data, "SNORLAX", 80),
}
game.save.player.name = "MATT"
U.teleport(game, MAP, STAND.x, STAND.y, "right")
U.wait(20)
-- only the target Lass may engage: the reporter has already cleared the
-- Bug Catchers and Youngsters ahead of her
local ow = game.overworld
for _, n in ipairs(ow.npcs) do
if n.def.trainerClass and n.def.index ~= TARGET then
game.save.defeatedTrainers[n.id] = true
end
end
local l = lass()
check("Lass starts on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y,
l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y)
U.log("spawn:", at(l))
-- she sights the player and walks up, then the battle runs
for _ = 1, 600 do
if ow.engaging then break end
coroutine.yield()
end
check("the Lass sighted the player at " .. STAND.x .. "," .. STAND.y, ow.engaging == true)
check("the battle ran to completion", mashToOverworld())
U.wait(30)
l = lass()
local movedX, movedY = l and l.cellX, l and l.cellY
U.log("after the walk-up she stands at", at(l))
check("she is off her spawn cell after walking up",
l ~= nil and not (movedX == SPAWN.x and movedY == SPAWN.y))
check("she is recorded as defeated", game.save.defeatedTrainers[l.id] == true)
survey(4)
U.shot(game, DIR .. "/bug1028_1_walked_up.png")
Zoom.reset()
U.wait(5)
-- west along row 4, down the single gap at column 11, then west on row 9
-- to the Pewter City seam
walkTo("left", "cellX", 11)
walkTo("down", "cellY", 9)
check("reached the descent column", game.overworld.player.cellX == 11
and game.overworld.player.cellY == 9)
-- crossing x=0 westward is the seam: crossConnection, not a warp
holdUntil("left", function() return onMap("PEWTER_CITY") end, 1200)
check("crossed the seam into Pewter City", onMap("PEWTER_CITY"))
survey(12)
U.shot(game, DIR .. "/bug1028_2_pewter_survey.png")
Zoom.reset()
U.wait(5)
-- and back east into Route 3, the map load that must re-seed her
holdUntil("right", function() return onMap(MAP) end, 1200)
check("crossed back into Route 3", onMap(MAP))
U.wait(30)
l = lass()
U.log("after the return crossing she stands at", at(l))
check("she is back on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y,
l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y)
check("she is still recorded as defeated",
l ~= nil and game.save.defeatedTrainers[l.id] == true)
-- same cell, same zoom as shot 1: she should now be three cells further
-- east, and being defeated she must not re-engage on the way back in
returnToStand()
check("walked back to the sighting cell",
game.overworld.player.cellX == STAND.x
and game.overworld.player.cellY == STAND.y)
check("a defeated trainer does not re-engage", game.overworld.engaging ~= true)
survey(4)
U.shot(game, DIR .. "/bug1028_3_reset.png")
Zoom.reset()
U.log(pass and "RESULT PASS" or "RESULT FAIL")
love.event.quit(pass and 0 or 1)
while true do coroutine.yield() end
end
@@ -20,7 +20,7 @@ local PartyMenu = require("src.ui.PartyMenu")
-- minimal stack/input doubles matching the StateStack and Input surfaces, -- minimal stack/input doubles matching the StateStack and Input surfaces,
-- plus an overworld stub: PALLET_TOWN's OVERWORLD tileset passes -- plus an overworld stub: PALLET_TOWN's OVERWORLD tileset passes
-- CheckIfInOutsideMap, and the badges cover the list-time HM gates -- CheckIfInOutsideMap
local function newGame(moves, inventory) local function newGame(moves, inventory)
local game = { local game = {
data = { pokemon = { LAPRAS = { name = "LAPRAS" }, data = { pokemon = { LAPRAS = { name = "LAPRAS" },
@@ -87,14 +87,15 @@ openSubmenu(pm2)
same(actions(pm2.subItems), { "stats", "switch" }, same(actions(pm2.subItems), { "stats", "switch" },
"no field moves: the submenu is just STATS/SWITCH") "no field moves: the submenu is just STATS/SWITCH")
-- the badge gates still filter the list: the same Lapras without the -- GetMonFieldMoves is badge-blind: the same Lapras without the badges
-- badges keeps its moves but shows none of them -- still lists both HMs, and .outOfBattleMovePointers refuses on
-- selection instead (#1022)
local noBadges = newGame( local noBadges = newGame(
{ { id = "STRENGTH", pp = 15 }, { id = "SURF", pp = 15 } }) { { id = "STRENGTH", pp = 15 }, { id = "SURF", pp = 15 } })
local pm3 = PartyMenu.new(noBadges, {}) local pm3 = PartyMenu.new(noBadges, {})
noBadges.stack:push(pm3) noBadges.stack:push(pm3)
openSubmenu(pm3) openSubmenu(pm3)
same(actions(pm3.subItems), { "stats", "switch" }, same(actions(pm3.subItems), { "strength", "surf", "stats", "switch" },
"ungated badges keep the HM moves out of the submenu") "the HM moves are listed without the badges (#1022)")
T.finish("party_fieldmove_order_bug792") T.finish("party_fieldmove_order_bug792")
@@ -16,6 +16,7 @@ local T = require("tests.modkit")
local boxes = {} local boxes = {}
package.loaded["src.render.TextBox"] = { package.loaded["src.render.TextBox"] = {
new = function(_, s, done) return { text = s, onDone = done } end, new = function(_, s, done) return { text = s, onDone = done } end,
soundOpts = function() return {} end,
} }
package.loaded["src.core.Sound"] = { play = function() end } package.loaded["src.core.Sound"] = { play = function() end }
package.loaded["src.inventory.Bag"] = { package.loaded["src.inventory.Bag"] = {
+7
View File
@@ -187,6 +187,13 @@ local function runOne(seed)
local function drive(side) local function drive(side)
local bt = side.bt local bt = side.bt
if bt.result then return end if bt.result then return end
local top = side.game.stack:top()
if top and top.forceSwitch and top.party then
for i, mon in ipairs(top.party) do
if mon.hp > 0 then top.index = i break end
end
return
end
if bt.phase ~= "menu" then side.menuFrames = 0 end if bt.phase ~= "menu" then side.menuFrames = 0 end
if bt.phase == "moveSelect" then if bt.phase == "moveSelect" then
local usable = {} local usable = {}
+7
View File
@@ -181,6 +181,13 @@ local function playMatch(hostEntry, guestEntry, rnd, label)
local function drive(side) local function drive(side)
local bt = side.bt local bt = side.bt
if bt.result then return end if bt.result then return end
local top = side.game.stack:top()
if top and top.forceSwitch and top.party then
for i, mon in ipairs(top.party) do
if mon.hp > 0 then top.index = i break end
end
return
end
if bt.phase ~= "menu" then side.menuFrames = 0 end if bt.phase ~= "menu" then side.menuFrames = 0 end
if bt.phase == "moveSelect" then if bt.phase == "moveSelect" then
local usable = {} local usable = {}
+1 -1
View File
@@ -604,7 +604,7 @@ do
local game = uiGame({ mon }) local game = uiGame({ mon })
Bag.add(game.save, "RARE_CANDY", 1) Bag.add(game.save, "RARE_CANDY", 1)
game.stack:push(BagMenu.new(game)) game.stack:push(BagMenu.new(game))
for _ = 1, 600 do for _ = 1, 800 do
local top = game.stack:top() local top = game.stack:top()
if not top then break end if not top then break end
pressed = { a = true } pressed = { a = true }
+3 -1
View File
@@ -584,7 +584,9 @@ check(not fpm.submenu and forced == fgame.save.party[1],
-- ------- issues #320/#385: the STRENGTH texts print over the party menu -- ------- issues #320/#385: the STRENGTH texts print over the party menu
do do
local owStub = { strengthActive = false, local owStub = { strengthActive = false,
map = { def = { tileset = "OVERWORLD" } }, dark = false } map = { def = { tileset = "OVERWORLD" } }, dark = false,
partyKnows = function(self, id) return self.knows == id end,
knows = "STRENGTH" }
local sgame = partyGame() local sgame = partyGame()
sgame.overworld = owStub sgame.overworld = owStub
sgame.data.text = {} -- the strength texts fall back to Strings sources sgame.data.text = {} -- the strength texts fall back to Strings sources
+10
View File
@@ -49,6 +49,14 @@ function Link.prepare(data)
return Input return Input
end end
local function steerReplacement(game)
local top = game.stack:top()
if not (top and top.forceSwitch and top.party) then return end
for i, mon in ipairs(top.party) do
if mon.hp > 0 then top.index = i return end
end
end
-- run a full lockstep battle over a loopback pair, mashing A on both -- run a full lockstep battle over a loopback pair, mashing A on both
-- sides, and report whether any turn's hashes disagreed -- sides, and report whether any turn's hashes disagreed
function Link.lockstep(gameA, gameB, opts) function Link.lockstep(gameA, gameB, opts)
@@ -80,7 +88,9 @@ function Link.lockstep(gameA, gameB, opts)
while (resA == nil or resB == nil) and guard < limit do while (resA == nil or resB == nil) and guard < limit do
guard = guard + 1 guard = guard + 1
Input.pressed = { a = true } Input.pressed = { a = true }
steerReplacement(gameA)
gameA.stack:update(1 / 60) gameA.stack:update(1 / 60)
steerReplacement(gameB)
gameB.stack:update(1 / 60) gameB.stack:update(1 / 60)
end end
+11 -4
View File
@@ -315,12 +315,19 @@ do
local Renderer = require("src.render.Renderer") local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController") local OW = require("src.world.OverworldController")
-- the reward text is a CHAIN of boxes split at each gym script's sound
-- command, so walk it: read a box's pages, close it, let its onDone push
-- the next one
local function stackedDialogue() local function stackedDialogue()
local top = Game.stack:top()
if not (top and top.pages) then return "" end
local parts = {} local parts = {}
for _, page in ipairs(top.pages) do local top = Game.stack:top()
parts[#parts + 1] = table.concat(page, "\n") while top and top.pages do
for _, page in ipairs(top.pages) do
parts[#parts + 1] = table.concat(page, "\n")
end
Game.stack:pop()
if top.onDone then top.onDone() end
top = Game.stack:top()
end end
return table.concat(parts, "\n") return table.concat(parts, "\n")
end end
+10 -8
View File
@@ -176,7 +176,8 @@ check(not onStack(pmSurf), "party menu closes after a successful SURF")
check(sawText("got on"), "_SurfingGotOnText shown on a successful SURF") check(sawText("got on"), "_SurfingGotOnText shown on a successful SURF")
-- =========================================================================== -- ===========================================================================
-- I: list-time badge filter, CUT/SURF/STRENGTH absent without the badge -- I: GetMonFieldMoves is badge-blind -- CUT/SURF/STRENGTH are listed with or
-- without the badge, and .outOfBattleMovePointers refuses on selection (#1022)
-- =========================================================================== -- ===========================================================================
Game.save.party = { mkMon("SQUIRTLE", "CUT", "SURF", "STRENGTH") } Game.save.party = { mkMon("SQUIRTLE", "CUT", "SURF", "STRENGTH") }
Game.save.inventory = {} Game.save.inventory = {}
@@ -185,8 +186,8 @@ local pmNoBadge = PartyMenu.new(Game)
Game.stack:push(pmNoBadge) Game.stack:push(pmNoBadge)
frame({ "a" }) frame({ "a" })
local actsOff = submenuActions(pmNoBadge) local actsOff = submenuActions(pmNoBadge)
check(not actsOff.cut and not actsOff.surf and not actsOff.strength, check(actsOff.cut and actsOff.surf and actsOff.strength,
"no CUT/SURF/STRENGTH submenu entries without the required badges") "CUT/SURF/STRENGTH submenu entries listed without the badges (#1022)")
popToOW() popToOW()
Game.save.inventory = { CASCADEBADGE = true, SOULBADGE = true, RAINBOWBADGE = true } Game.save.inventory = { CASCADEBADGE = true, SOULBADGE = true, RAINBOWBADGE = true }
local pmBadge = PartyMenu.new(Game) local pmBadge = PartyMenu.new(Game)
@@ -194,7 +195,7 @@ Game.stack:push(pmBadge)
frame({ "a" }) frame({ "a" })
local actsOn = submenuActions(pmBadge) local actsOn = submenuActions(pmBadge)
check(actsOn.cut and actsOn.surf and actsOn.strength, check(actsOn.cut and actsOn.surf and actsOn.strength,
"CUT/SURF/STRENGTH submenu entries appear once the badges are held") "CUT/SURF/STRENGTH submenu entries still there once the badges are held")
-- =========================================================================== -- ===========================================================================
-- I: CUT from the party menu. The Cerulean tree BLOCK (50, at block 9,14) -- I: CUT from the party menu. The Cerulean tree BLOCK (50, at block 9,14)
@@ -471,8 +472,9 @@ local pmLobby = PartyMenu.new(Game)
Game.stack:push(pmLobby) Game.stack:push(pmLobby)
frame({ "a" }) frame({ "a" })
local actsLobby = submenuActions(pmLobby) local actsLobby = submenuActions(pmLobby)
check(not actsLobby.fly, "FLY omitted inside Indigo Plateau lobby") check(actsLobby.fly, "FLY still listed inside Indigo Plateau lobby (#1022)")
check(not actsLobby.escape, "TELEPORT omitted inside Indigo Plateau lobby") check(actsLobby.escape,
"TELEPORT still listed inside Indigo Plateau lobby (#1022)")
popToOW() popToOW()
-- restore fainted field-move mon for the STRENGTH/SURF cases below -- restore fainted field-move mon for the STRENGTH/SURF cases below
@@ -486,8 +488,8 @@ Game.save.inventory = {
ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right") ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
clearCaptured() clearCaptured()
local pmFaintStr = PartyMenu.new(Game) local pmFaintStr = PartyMenu.new(Game)
-- Seafoam is not OVERWORLD, so FLY is omitted: CUT, STRENGTH, SURF, STATS, SWITCH -- move order on the mon: FLY, CUT, STRENGTH, SURF, then STATS, SWITCH
selectSubItem(pmFaintStr, 2) selectSubItem(pmFaintStr, 3)
eq(Game.overworld.strengthActive, true, eq(Game.overworld.strengthActive, true,
"fainted mon can activate STRENGTH from the party menu") "fainted mon can activate STRENGTH from the party menu")
check(sawText("used") and sawText("STRENGTH"), check(sawText("used") and sawText("STRENGTH"),
+11 -5
View File
@@ -72,13 +72,19 @@ Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init() Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init() Game.stack = StateStack; StateStack:init()
-- concatenates the pages of the TextBox checkVictoryRewards pushed -- concatenates the pages of the box CHAIN checkVictoryRewards pushed: the
-- reward text splits into a box per gym-script sound command, so close each
-- one and let its onDone push the next
local function stackedDialogue() local function stackedDialogue()
local top = Game.stack:top()
if not (top and top.pages) then return "" end
local parts = {} local parts = {}
for _, page in ipairs(top.pages) do local top = Game.stack:top()
parts[#parts + 1] = table.concat(page, "\n") while top and top.pages do
for _, page in ipairs(top.pages) do
parts[#parts + 1] = table.concat(page, "\n")
end
Game.stack:pop()
if top.onDone then top.onDone() end
top = Game.stack:top()
end end
return table.concat(parts, "\n") return table.concat(parts, "\n")
end end
+1
View File
@@ -178,6 +178,7 @@ check(#moves == 0, "the parked Pikachu of the confused beat stays put")
-- BillsHouseScript0 skips the whole entry beat for a statused starter -- BillsHouseScript0 skips the whole entry beat for a statused starter
-- (CheckPikachuStatusCondition, scripts/BillsHouse.asm:45-46) -- (CheckPikachuStatusCondition, scripts/BillsHouse.asm:45-46)
ow.pikachuBillsScene = nil ow.pikachuBillsScene = nil
yellowGame.save.pikachuMapScriptActive = nil
moves = {} moves = {}
yellowGame.save.party = { { species = "PIKACHU", hp = 12, status = "PAR" } } yellowGame.save.party = { { species = "PIKACHU", hp = 12, status = "PAR" } }
PikachuFollower.onBillsHouseEnter(yellowGame, ow) PikachuFollower.onBillsHouseEnter(yellowGame, ow)
+1 -1
View File
@@ -42,7 +42,7 @@ local ow = {
Follower.onFanClubEntered({ save = save, data = Data }, ow) Follower.onFanClubEntered({ save = save, data = Data }, ow)
check(ow.pikachuFanClubScene, "Fan Club disables normal Pikachu following") check(ow.pikachuFanClubScene, "Fan Club disables normal Pikachu following")
check(ow.pikachuMapScriptActive, "Fan Club sets the map-script flag") check(save.pikachuMapScriptActive, "Fan Club sets the map-script flag")
eq(ow.player.facing, "down", "Fan Club resets the player direction") eq(ow.player.facing, "down", "Fan Club resets the player direction")
eq(moves[1] and moves[1][1], "up", "Fan Club starts with slide-up displacement") eq(moves[1] and moves[1][1], "up", "Fan Club starts with slide-up displacement")
eq(moves[1] and moves[1][2], 1, "Fan Club slide-up spans one tile") eq(moves[1] and moves[1][2], 1, "Fan Club slide-up spans one tile")
+2 -2
View File
@@ -2562,8 +2562,8 @@ do
end end
eq(fanfares, 1, "one caught fanfare per capture") eq(fanfares, 1, "one caught fanfare per capture")
eq(tinks, 3, "three wobble tinks on a $43 capture") eq(tinks, 3, "three wobble tinks on a $43 capture")
check(fanfareAt and caughtAt and fanfareAt < caughtAt, check(fanfareAt and caughtAt and caughtAt < fanfareAt,
"Caught_Mon sounds with the caught text, not after its dismissal") "Caught_Mon sounds once the caught text is out, before its prompt")
eq(cb4.result, "caught", "the capture resolved the battle") eq(cb4.result, "caught", "the capture resolved the battle")
-- the nickname AskName that follows clears it (ClearSprites), so the -- the nickname AskName that follows clears it (ClearSprites), so the
-- assertion is sampled while the caught text is up -- assertion is sampled while the caught text is up