mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
This commit is contained in:
@@ -92,7 +92,7 @@ return {
|
||||
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
|
||||
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
|
||||
"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
|
||||
{ "jump", 13 }, -- 11
|
||||
{ "show_text", "_BoxIsFullText" }, -- 12
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
local function coinGiver(opts)
|
||||
return function(game, ow, npc, done)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Sound = require("src.core.Sound")
|
||||
local t = game.data.text
|
||||
local function push(label, fallback, onDone)
|
||||
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done))
|
||||
local function push(label, fallback, onDone, popts)
|
||||
game.stack:push(TextBox.new(game, t[label] or fallback, onDone or done,
|
||||
popts))
|
||||
end
|
||||
if game.save.flags[opts.event] then
|
||||
push(opts.alreadyGotLabel, opts.alreadyGotFallback)
|
||||
@@ -30,9 +30,10 @@ local function coinGiver(opts)
|
||||
end
|
||||
game.save.coins = math.min(9999, (game.save.coins or 0) + opts.amount)
|
||||
game.save.flags[opts.event] = true
|
||||
Sound.play(game.data, "Get_Item1")
|
||||
-- the ReceivedNCoinsText strings carry sound_get_item_1
|
||||
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
|
||||
|
||||
@@ -22,8 +22,8 @@ return {
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Commands = require("src.script.Commands")
|
||||
local t = game.data.text
|
||||
local function say(label, cb)
|
||||
game.stack:push(TextBox.new(game, t[label] or label, cb))
|
||||
local function say(label, cb, sopts)
|
||||
game.stack:push(TextBox.new(game, t[label] or label, cb, sopts))
|
||||
end
|
||||
|
||||
if game.save.flags.EVENT_GOT_OLD_AMBER then
|
||||
@@ -39,8 +39,9 @@ return {
|
||||
game.save.flags.EVENT_GOT_OLD_AMBER = true
|
||||
Commands.hide_object({ save = game.save, overworld = ow, game = game },
|
||||
"MUSEUM_1F", "MUSEUM1F_OLD_AMBER")
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
say("_Museum1FScientist2ReceivedOldAmberText", done)
|
||||
-- .ReceivedOldAmberText carries sound_get_item_1
|
||||
say("_Museum1FScientist2ReceivedOldAmberText", done,
|
||||
TextBox.soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end,
|
||||
|
||||
|
||||
@@ -38,9 +38,9 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
-- inside give_pokemon). Show the received text first so the
|
||||
-- nickname prompt follows "you got X", matching Gen1.
|
||||
-- The received text carries sound_get_key_item (OaksLab.asm
|
||||
-- OaksLabReceivedMonText); the jingle plays as the box opens
|
||||
-- (same beat as the Yellow port's starter, #668).
|
||||
{ "play_sound", "Get_Key_Item" }, -- 8
|
||||
-- OaksLabReceivedMonText), so the jingle fires once the box has
|
||||
-- typed out and holds it (same beat as the Yellow port's starter, #668).
|
||||
{ "text_sound", "Get_Key_Item" }, -- 8
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
|
||||
{ "give_pokemon", species, 5 }, -- 10
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
|
||||
@@ -54,7 +54,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ "face_object", 1, "up" }, -- 15
|
||||
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
|
||||
{ "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",
|
||||
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
|
||||
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
|
||||
@@ -106,8 +106,8 @@ return {
|
||||
{ "check_item", "OAKS_PARCEL" },
|
||||
{ "jump_if_false", "raise_young" },
|
||||
-- OaksLabOak1Text.got_parcel → RivalArrives + OakGivesPokedex
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||
{ "take_item", "OAKS_PARCEL", 1 },
|
||||
{ "stop_music" },
|
||||
@@ -128,8 +128,8 @@ return {
|
||||
{ "face_object", 1, "up" },
|
||||
{ "face_object", 5, "down" },
|
||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||
{ "face_object", 1, "up" },
|
||||
|
||||
@@ -38,8 +38,8 @@ return {
|
||||
{ "jump_if_false", "raise_young" },
|
||||
-- .DeliverParcelText: parcel handover, then the Pokédex scene
|
||||
-- (OaksLabRivalArrivesAtOaksRequestScript -> OakGivesPokedexScript)
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1DeliverParcelText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ParcelThanksText" },
|
||||
{ "take_item", "OAKS_PARCEL", 1 },
|
||||
{ "stop_music" },
|
||||
@@ -62,8 +62,8 @@ return {
|
||||
{ "face_object", RIVAL, "up" },
|
||||
{ "face_object", OAK1, "down" },
|
||||
{ "show_text", "_OaksLabOakMyInventionPokedexText" },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOakGotPokedexText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX1" },
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_POKEDEX2" },
|
||||
{ "face_object", RIVAL, "up" },
|
||||
@@ -113,8 +113,8 @@ return {
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||
{ "give_item", "POKE_BALL", 5, false },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
@@ -176,7 +176,7 @@ return {
|
||||
-- rival starter baseline (RIVAL_STARTER_JOLTEON) at snatch time
|
||||
rows[#rows + 1] = { "set_field", "rivalStarter", 1 }
|
||||
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", "_OaksLabRivalTakesText3" }
|
||||
rows[#rows + 1] = { "show_text", "_OaksLabRivalTakesText4" }
|
||||
@@ -196,7 +196,7 @@ return {
|
||||
rows[#rows + 1] = { "face_object", OAK1, "down" }
|
||||
-- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013)
|
||||
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] = { "give_pokemon", "PIKACHU", 5 }
|
||||
-- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009)
|
||||
|
||||
@@ -297,6 +297,7 @@ M.BILLS_HOUSE = {
|
||||
|
||||
M.ROUTE_25 = {
|
||||
onEnter = function(game, ow)
|
||||
game.save.pikachuMapScriptActive = nil
|
||||
local flags = game.save.flags
|
||||
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end
|
||||
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
|
||||
-- reachable through this map, so a fresh visit always re-rolls).
|
||||
onEnter = function(game, ow)
|
||||
game.save.pikachuMapScriptActive = nil
|
||||
local puz = game.save.trashPuzzle or {}
|
||||
game.save.trashPuzzle = puz
|
||||
puz.first = love.math.random(0, 7) * 2
|
||||
|
||||
@@ -648,8 +648,10 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
||||
end
|
||||
local idef = game.data.items[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)
|
||||
-- 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,
|
||||
t._MtMoonB2FReceivedFossilText
|
||||
or ("{PLAYER} got the\n" .. game.stringBuffer .. "!"),
|
||||
@@ -662,11 +664,11 @@ local function mtMoonFossil(itemId, otherName, gotFlag)
|
||||
ow.runner:run({
|
||||
{ "walk_npc", 1, dirs },
|
||||
{ "text_opts", { auto = true } },
|
||||
{ "text_sound", "Get_Key_Item" },
|
||||
{ "show_text", "_MtMoonB2FSuperNerdThenThisIsMineText" },
|
||||
{ "play_sound", "Get_Key_Item" },
|
||||
{ "hide_object", "MT_MOON_B2F", otherName },
|
||||
}, { onDone = done })
|
||||
end))
|
||||
end, TextBox.soundOpts(game, "Get_Key_Item")))
|
||||
end }))
|
||||
end
|
||||
end
|
||||
|
||||
+7
-11
@@ -8,9 +8,9 @@ local M = {}
|
||||
|
||||
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")
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
game.stack:push(TextBox.new(game, s, done, opts))
|
||||
end
|
||||
|
||||
-- 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" },
|
||||
-- flag ahead of the jingle, like the Celadon EEVEE (#426)
|
||||
{ "set_flag", "EVENT_GOT_LAPRAS" },
|
||||
{ "play_sound", "Get_Item1" },
|
||||
{ "text_sound", "Get_Item1" },
|
||||
{ "show_text", "_GotMonText", { RAM = "LAPRAS" } },
|
||||
{ "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
|
||||
{ "jump", "end" },
|
||||
@@ -307,12 +307,11 @@ M.COPYCATS_HOUSE_2F = {
|
||||
return
|
||||
end
|
||||
game.stringBuffer = game.data.items.TM_MIMIC.name
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
Bag.remove(game.save, "POKE_DOLL", 1)
|
||||
game.save.flags.EVENT_GOT_TM31 = true
|
||||
push(game, t._CopycatsHouse2FCopycatReceivedTM31Text, function()
|
||||
push(game, t._CopycatsHouse2FCopycatTM31Explanation1Text, done)
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
@@ -478,7 +477,6 @@ M.CELADON_MART_ROOF = {
|
||||
return
|
||||
end
|
||||
game.save.flags[g.flag] = true
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
local subs = { player = game.save.player.name,
|
||||
ram = game.data.items[g.tm].name }
|
||||
local explain = fill(t[g.explain] or "", subs)
|
||||
@@ -490,7 +488,7 @@ M.CELADON_MART_ROOF = {
|
||||
else
|
||||
done()
|
||||
end
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end)
|
||||
end,
|
||||
onCancel = done,
|
||||
@@ -522,7 +520,6 @@ M.ROUTE_24 = {
|
||||
local t = text(game)
|
||||
push(game, t._Route24CooltrainerM1YouBeatOurContestText .. "\f"
|
||||
.. t._Route24CooltrainerM1YouJustEarnedAPrizeText, function()
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
if not require("src.inventory.Bag").add(game.save, "NUGGET", 1,
|
||||
game.data) then
|
||||
push(game, t._Route24CooltrainerM1NoRoomText, done)
|
||||
@@ -531,11 +528,10 @@ M.ROUTE_24 = {
|
||||
flags.EVENT_GOT_NUGGET = true
|
||||
game.stringBuffer = game.data.items.NUGGET.name
|
||||
push(game, t._Route24CooltrainerM1ReceivedNuggetText, function()
|
||||
require("src.core.Sound").play(game.data, "Get_Item1")
|
||||
push(game, t._Route24CooltrainerM1JoinTeamRocketText,
|
||||
battleOrDone)
|
||||
end)
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
end, require("src.render.TextBox").soundOpts(game, "Get_Item1"))
|
||||
return
|
||||
end
|
||||
battleOrDone()
|
||||
|
||||
@@ -4,9 +4,9 @@ local M = {}
|
||||
|
||||
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")
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
game.stack:push(TextBox.new(game, s, done, opts))
|
||||
end
|
||||
|
||||
-- fill the extracted text placeholders ({RAM:...}, {PLAYER})
|
||||
@@ -25,8 +25,8 @@ local function gift(opts)
|
||||
local t = text(game)
|
||||
local itemName = game.data.items[opts.item].name
|
||||
local subs = { ram = itemName, player = game.save.player.name }
|
||||
local function say(label, fallback, cb)
|
||||
push(game, fill(t[label] or fallback, subs), cb)
|
||||
local function say(label, fallback, cb, sopts)
|
||||
push(game, fill(t[label] or fallback, subs), cb, sopts)
|
||||
end
|
||||
if game.save.flags[opts.flag] then
|
||||
say(opts.already or opts.explain, "It's a useful\nitem, isn't it?", done)
|
||||
@@ -39,15 +39,16 @@ local function gift(opts)
|
||||
end
|
||||
game.save.flags[opts.flag] = true
|
||||
local idef = game.data.items[opts.item]
|
||||
require("src.core.Sound").play(game.data,
|
||||
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||
-- the received texts carry sound_get_item_1 / sound_get_key_item, so
|
||||
-- the jingle only fires once that box has typed out
|
||||
say(opts.received, "{PLAYER} received\n{RAM:}!", function()
|
||||
if opts.explain then
|
||||
say(opts.explain, "", done)
|
||||
else
|
||||
done()
|
||||
end
|
||||
end)
|
||||
end, require("src.render.TextBox").soundOpts(game,
|
||||
(idef and idef.keyItem) and "Get_Key_Item" or "Get_Item1"))
|
||||
end
|
||||
if opts.pre then say(opts.pre, opts.preFallback or "", give) else give() end
|
||||
end
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
-- 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
|
||||
-- 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 t = {}
|
||||
@@ -50,6 +58,8 @@ return {
|
||||
{ "PEWTER_CITY", "PEWTERCITY_YOUNGSTER" },
|
||||
{ "ROUTE_22", "ROUTE22_RIVAL1" },
|
||||
},
|
||||
badgeSound = "Get_Item1", -- sound_level_up
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_PewterGymBrockReceivedBoulderBadgeText",
|
||||
"_PewterGymBrockBoulderBadgeInfoText",
|
||||
@@ -64,6 +74,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM11",
|
||||
noRoom = "_CeruleanGymMistyTM11NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CeruleanGymMistyReceivedCascadeBadgeText",
|
||||
},
|
||||
@@ -76,6 +88,7 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM24",
|
||||
noRoom = "_VermilionGymLTSurgeTM24NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2),
|
||||
tmSound = "Get_Key_Item",
|
||||
dialogue = {
|
||||
"_VermilionGymLTSurgeReceivedThunderBadgeText",
|
||||
},
|
||||
@@ -89,6 +102,7 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM21",
|
||||
noRoom = "_CeladonGymTM21NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6),
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CeladonGymErikaReceivedRainbowBadgeText",
|
||||
},
|
||||
@@ -102,6 +116,7 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM06",
|
||||
noRoom = "_FuchsiaGymKogaTM06NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5),
|
||||
tmSound = "Get_Key_Item",
|
||||
dialogue = {
|
||||
"_FuchsiaGymKogaReceivedSoulBadgeText",
|
||||
},
|
||||
@@ -115,6 +130,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM46",
|
||||
noRoom = "_SaffronGymSabrinaTM46NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_SaffronGymSabrinaReceivedMarshBadgeText",
|
||||
},
|
||||
@@ -128,6 +145,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM38",
|
||||
noRoom = "_CinnabarGymBlaineTM38NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6),
|
||||
badgeSound = "Get_Key_Item",
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_CinnabarGymBlaineReceivedVolcanoBadgeText",
|
||||
},
|
||||
@@ -141,6 +160,8 @@ return {
|
||||
gotFlag = "EVENT_GOT_TM27",
|
||||
noRoom = "_ViridianGymGiovanniTM27NoRoomText",
|
||||
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7),
|
||||
badgeSound = "Get_Item1", -- sound_level_up
|
||||
tmSound = "Get_Item1",
|
||||
dialogue = {
|
||||
"_ViridianGymGiovanniReceivedEarthBadgeText",
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
|
||||
## 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**
|
||||
* **UI that stays fixed while the overworld zooms**
|
||||
|
||||
+119
-69
@@ -501,6 +501,9 @@ local function makeBattler(data, mon, isPlayer, save)
|
||||
badgeBoosts = badgeBoosts,
|
||||
statuses = data.statuses,
|
||||
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
|
||||
-- while the tilemap still shows the prior condition until the next
|
||||
-- post-action HUD refresh (core.asm after Execute*Move)
|
||||
@@ -563,10 +566,6 @@ local function markOwned(game, species)
|
||||
local dex = game.save.pokedex
|
||||
if dex then
|
||||
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
|
||||
end
|
||||
end
|
||||
@@ -935,6 +934,17 @@ function BattleState:sayNextWaitSfx(text, sfx)
|
||||
table.insert(self.queue, self.nextInsert, { text = text, waitForLearningSfx = sfx })
|
||||
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)
|
||||
function BattleState:sayNextAuto(text, delay)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
@@ -1022,22 +1032,39 @@ function BattleState:stepHPDrain()
|
||||
and b.shownHP >= b.drainFloor then
|
||||
goal = b.drainFloor
|
||||
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
|
||||
b.drainHold = b.drainHold - 1
|
||||
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
|
||||
local maxHP = math.max(1, b.mon.stats.hp)
|
||||
local playerSide = (b == self.player)
|
||||
local cost = 0
|
||||
local spent = 0
|
||||
-- consume whole HP steps until this frame's budget is spent; on the
|
||||
-- enemy HUD several free steps can land in the same frame
|
||||
while b.shownHP ~= goal and cost < 1 do
|
||||
local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
|
||||
cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP,
|
||||
maxHP, playerSide)
|
||||
b.shownHP = nextHP
|
||||
repeat
|
||||
b.shownHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
|
||||
spent = spent + (playerSide and Timing.HP_BAR_HP_STEP or 0)
|
||||
targetPx = Timing.hpBarPixels(b.shownHP, maxHP)
|
||||
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
|
||||
b.drainHold = math.max(0, cost - 1)
|
||||
b.draining = true
|
||||
busy = true
|
||||
elseif b.draining then
|
||||
@@ -1431,7 +1458,7 @@ end
|
||||
function BattleState:playEntranceCry(battler)
|
||||
local mon = battler and battler.mon
|
||||
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)
|
||||
end
|
||||
|
||||
@@ -1555,7 +1582,23 @@ function BattleState:enter()
|
||||
-- default stays opaque for every other battle and for older saves.
|
||||
self.isOpaque = self:bgMode() ~= "world"
|
||||
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
|
||||
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
|
||||
-- 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
|
||||
local function queueEnemyCry()
|
||||
self:act(function()
|
||||
self:playEntranceCry(self.enemy)
|
||||
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||
end)
|
||||
end
|
||||
-- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild
|
||||
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
|
||||
-- sounds with the "Wild X appeared!" box instead of waiting on the A
|
||||
-- press that clears its `prompt` (#303). The Silph-Scope-less tower
|
||||
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, and PlayCry
|
||||
-- ends in WaitForSoundToFinish, so the cry runs to its end and only then
|
||||
-- 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
|
||||
-- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
|
||||
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
|
||||
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names
|
||||
-- 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.introSfx = require("src.core.Sound").play(self.data,
|
||||
"Trainer_Appeared")
|
||||
@@ -1629,13 +1675,17 @@ function BattleState:enter()
|
||||
-- battle -- not on a switch, and not when the beaten trainer's pic
|
||||
-- scrolls back in (#317, #282)
|
||||
self:act(function() self.introBalls = nil end)
|
||||
if self.kind == "trainer" then
|
||||
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
|
||||
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
|
||||
-- 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 })
|
||||
if self.kind == "trainer" or self.kind == "link" then
|
||||
local foeName = self.trainer and self.trainer.name
|
||||
or self.opponentName or Strings("FOE")
|
||||
if self.showEnemyTrainer then
|
||||
-- EnemySendOutFirstMon (core.asm:1308-1310): SlideTrainerPicOffScreen
|
||||
-- walks the foe's pic off the RIGHT edge (hlcoord 18,0, a = 8 tiles,
|
||||
-- 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.showEnemyTrainer = false
|
||||
-- the slot is EMPTY from here until AnimateSendingOutMon runs below:
|
||||
@@ -1648,7 +1698,7 @@ function BattleState:enter()
|
||||
self.enemySendingOut = true
|
||||
self:slidePic("foe")
|
||||
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()
|
||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||
@@ -1656,18 +1706,6 @@ function BattleState:enter()
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
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
|
||||
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
|
||||
-- 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
|
||||
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
self:markParticipant()
|
||||
end
|
||||
@@ -1876,7 +1914,10 @@ function BattleState:update(dt)
|
||||
if self.phase == "menu" then
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
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.shownStatus = b.mon.status
|
||||
end
|
||||
@@ -2294,10 +2335,10 @@ function BattleState:oldManThrow()
|
||||
return
|
||||
end
|
||||
self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL")
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
end)
|
||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
||||
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
|
||||
-- the fanfare follows the caught text and holds the prompt
|
||||
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -2481,7 +2522,7 @@ function BattleState:resolveSwitch(newMon)
|
||||
self.sendingOut = false
|
||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
end)
|
||||
self:act(function()
|
||||
@@ -3793,7 +3834,7 @@ function BattleState:onFaint(battler)
|
||||
if battler.isPlayer then
|
||||
-- RemoveFaintedPlayerMon (core.asm:1040-1042): the player mon's
|
||||
-- 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
|
||||
-- FaintEnemyPokemon (core.asm:732-771): the enemy faint plays no
|
||||
-- species cry; trainer battles get SFX_FAINT_FALL, then SFX_FAINT_THUD
|
||||
@@ -3808,6 +3849,11 @@ function BattleState:onFaint(battler)
|
||||
end)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
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
|
||||
-- FaintEnemyPokemon's SFX_FAINT_THUD lands as the slide does (after
|
||||
-- Faint_Fall, before EnemyMonFaintedText)
|
||||
@@ -3895,9 +3941,11 @@ function BattleState:awardExp()
|
||||
-- experience.asm:248 fires per grew-level text
|
||||
require("src.world.PikachuFollower")
|
||||
.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()
|
||||
require("src.core.Sound").play(game.data, "Level_Up")
|
||||
return StatBox.new(game, mon)
|
||||
end)
|
||||
-- After PrintStatsBox, experience.asm reloads the active battler's
|
||||
@@ -4042,7 +4090,7 @@ function BattleState:enemyMonFainted()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
self:actNext(function()
|
||||
self:playEntranceCry(self.enemy)
|
||||
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
@@ -4081,7 +4129,7 @@ function BattleState:enemyMonFainted()
|
||||
self:actNext(function()
|
||||
self.sendingOut = false
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
@@ -4277,7 +4325,7 @@ function BattleState:openReplacementMenu()
|
||||
self.sendingOut = false
|
||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||
self:startGrowIn(self.player)
|
||||
self:playEntranceCry(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
end,
|
||||
})
|
||||
@@ -4321,11 +4369,10 @@ function BattleState:safariAction(choice)
|
||||
-- above DoBallTossSpecialEffects's <= ULTRA_BALL check)
|
||||
self:ballChain(self:tossAnimFor("SAFARI_BALL"), caught, shakes, "SAFARI_BALL")
|
||||
if caught then
|
||||
-- ItemUseBallText05's sound_caught_mon: fanfare with the text
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
end)
|
||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
||||
-- ItemUseBallText05: text_far, sound_caught_mon, text_promptbutton --
|
||||
-- the fanfare follows the caught text and holds the prompt
|
||||
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||
-- same ItemUseBall .captured flow as a regular ball
|
||||
self:act(function() self:storeCaughtMon() end)
|
||||
else
|
||||
@@ -4553,8 +4600,12 @@ function BattleState:storeCaughtMon()
|
||||
markOwned(game, species)
|
||||
stampOT(game.save, self.enemy.mon)
|
||||
if isNew then
|
||||
-- _ItemUseBallText06 + ShowPokedexData
|
||||
self:sayNext(Strings("New POKéDEX data\nwill be added for\n%s!", self.enemy.name))
|
||||
-- _ItemUseBallText06 + ShowPokedexData: text_far, sound_dex_page_added,
|
||||
-- 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()
|
||||
return self:buildScreen("DexEntryMenu", species)
|
||||
end)
|
||||
@@ -4704,12 +4755,10 @@ function BattleState:throwBall(ball)
|
||||
self:ballChain(self:tossAnimFor(ball), caught, shakes, ball)
|
||||
if caught then
|
||||
-- ItemUseBallText05 carries sound_caught_mon (item_effects.asm:
|
||||
-- 608-614): the fanfare sounds with the caught message, before
|
||||
-- the prompt, not after the text is dismissed
|
||||
self:actNext(function()
|
||||
require("src.core.Sound").play(self.data, "Caught_Mon")
|
||||
end)
|
||||
self:sayNext(Strings("All right!\n%s was\ncaught!", self.enemy.name))
|
||||
-- 608-614): text_far, sound_caught_mon, text_promptbutton -- the
|
||||
-- fanfare follows the caught message and holds the prompt
|
||||
self:sayNextWaitSfx(Strings("All right!\n%s was\ncaught!", self.enemy.name),
|
||||
function() return require("src.core.Sound").play(self.data, "Caught_Mon") end)
|
||||
self:act(function() self:storeCaughtMon() end)
|
||||
else
|
||||
self:sayNext(self:ballMissMessage(shakes))
|
||||
@@ -5196,7 +5245,8 @@ function BattleState:sgbBattlePals()
|
||||
local function bar(b)
|
||||
if not b then return pals.GREENBAR end
|
||||
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
|
||||
local function mon(b, placeholder)
|
||||
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)
|
||||
drawHPBar(barData, 2, 2,
|
||||
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
|
||||
nil, grayFill)
|
||||
nil, grayFill, nil, self.enemy.shownPx)
|
||||
hudTile(0x74, 8, 24)
|
||||
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
|
||||
hudTile(0x78, 80, 24)
|
||||
@@ -5675,7 +5725,7 @@ function BattleState:drawHUDs(slide)
|
||||
end
|
||||
drawHPBar(barData, 10, 9,
|
||||
{ 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)
|
||||
hudTile(0x73, 144, 80)
|
||||
hudTile(0x77, 144, 88)
|
||||
|
||||
@@ -108,7 +108,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
|
||||
HudTiles.drawHPBar(battle.data, tx + 1, ty + 2, {
|
||||
hp = shownHP(battler),
|
||||
stats = battler.mon.stats,
|
||||
}, nil, monoMode(), tw - 5)
|
||||
}, nil, monoMode(), tw - 5, battler.shownPx)
|
||||
|
||||
if player then
|
||||
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
|
||||
|
||||
@@ -334,6 +334,12 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
|
||||
battle.player.mon.hp, battle.player.mon.status
|
||||
battle.enemy.shownHP, battle.enemy.shownStatus =
|
||||
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
|
||||
if not ow or type(ow.restoreBattleContinuation) ~= "function"
|
||||
|
||||
+53
-133
@@ -289,38 +289,6 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac
|
||||
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 = {
|
||||
red = PAL.railRed, blue = PAL.railBlue, yellow = PAL.railGold,
|
||||
gold = PAL.railAmber,
|
||||
@@ -687,7 +655,7 @@ local function buildModScopeRow(imp, x, y, w, m)
|
||||
end
|
||||
|
||||
-- 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 nameText = tostring(activeProf or "Default")
|
||||
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 profX = gearX - profW - math.floor(4 * m.s)
|
||||
|
||||
-- Tapping main profile button cycles to the next profile (styles match iconButton)
|
||||
Kit._audit("control", profX, y, profW, h, "mod-scope-profile")
|
||||
local focused = Kit.focusable("mod-scope-profile", profX, y, profW, h)
|
||||
local hot = focused or Kit.hover(profX, y, profW, h)
|
||||
Theme.fillRounded(profX, y, profW, h, hot and PAL.ink or PAL.surface, 1)
|
||||
Theme.strokeRounded(profX, y, profW, h, PAL.line,
|
||||
hot and Theme.A.focus or Theme.A.hairline, 1)
|
||||
Kit.textCenterBold("micro", profLabel, profX,
|
||||
y + (h - Kit.textHeight("micro")) / 2, profW,
|
||||
hot and PAL.inverse or PAL.heading)
|
||||
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
|
||||
btn(imp, profX, y, profW, h, "mod-scope-profile", profLabel, {
|
||||
face = "invert", font = "micro",
|
||||
action = function()
|
||||
local list, cur = LauncherMods.getProfiles()
|
||||
local nextIdx = 1
|
||||
for i, p in ipairs(list) do
|
||||
if p.name == cur then
|
||||
nextIdx = (i % #list) + 1
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
local nextProf = profiles[nextIdx] and profiles[nextIdx].name
|
||||
if nextProf then
|
||||
queueAction(imp, "mod-scope-profile", function()
|
||||
local nextProf = list[nextIdx] and list[nextIdx].name
|
||||
if nextProf then
|
||||
LauncherMods.applyProfile(nextProf)
|
||||
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"))
|
||||
iconButton(imp, "mod-profile-gear", gearX, y, gearW, imp._gearIcon, function()
|
||||
imp._profilesPopup = true
|
||||
end)
|
||||
btn(imp, gearX, y, gearW, gearW, "mod-profile-gear", "", {
|
||||
face = "invert", image = imp._gearIcon,
|
||||
action = function() imp._profilesPopup = true end,
|
||||
})
|
||||
|
||||
if #options >= 2 then
|
||||
for _, opt in ipairs(options) do
|
||||
@@ -836,8 +796,7 @@ local function buildHeader(imp, m)
|
||||
local padX = math.floor(12 * m.s)
|
||||
local chipW = math.max(tw + 2 * padX, gear)
|
||||
local lx = m.x + m.pad
|
||||
Theme.fill(lx, by, chipW, gear, PAL.bg, 1)
|
||||
Theme.stroke(lx, by, chipW, gear, PAL.yellow, Theme.A.hover, 1)
|
||||
Kit.card(lx, by, chipW, gear, "badge")
|
||||
local th = Kit.textHeight("small")
|
||||
Kit.text("small", label, lx + math.floor((chipW - tw) / 2),
|
||||
by + math.floor((gear - th) / 2), PAL.yellow)
|
||||
@@ -856,17 +815,20 @@ local function buildHeader(imp, m)
|
||||
imp._gearIcon = imp._gearIcon
|
||||
or love.graphics.newImage("assets/launcher/gear.png")
|
||||
rx = rx - gear
|
||||
iconButton(imp, "gear", rx, by, gear, imp._gearIcon,
|
||||
function() imp:_openSettings() end)
|
||||
btn(imp, rx, by, gear, gear, "gear", "", {
|
||||
face = "invert", image = imp._gearIcon,
|
||||
action = function() imp:_openSettings() end,
|
||||
})
|
||||
|
||||
-- Quit, top-right corner.
|
||||
iconButton(imp, "quit", quitX, by, gear, nil,
|
||||
function() imp:_quitApp() end,
|
||||
function(x, y, size, hot)
|
||||
local pad = math.floor(size * 0.32)
|
||||
drawCross(x + pad, y + pad, size - 2 * pad,
|
||||
btn(imp, quitX, by, gear, gear, "quit", "", {
|
||||
face = "invert",
|
||||
action = function() imp:_quitApp() end,
|
||||
drawFn = function(x, y, w, h, hot)
|
||||
local pad = math.floor(w * 0.32)
|
||||
drawCross(x + pad, y + pad, w - 2 * pad,
|
||||
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,
|
||||
-- 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
|
||||
-- collide.
|
||||
local tabs = {
|
||||
{ id = "red", letter = "R", label = Strings("RED"), color = PAL.railRed },
|
||||
{ id = "blue", letter = "B", label = Strings("BLUE"), color = PAL.railBlue },
|
||||
{ id = "yellow", letter = "Y", label = Strings("YELLOW"), color = PAL.railGold },
|
||||
{ id = "gold", letter = "G", label = Strings("GOLD"), color = PAL.railAmber },
|
||||
{ id = "mods", icon = imp._modsIcon, label = Strings("MODS") },
|
||||
{ id = "find", icon = imp._findIcon, label = Strings("FIND MODS") },
|
||||
{ id = "red", letter = "R", color = PAL.railRed },
|
||||
{ id = "blue", letter = "B", color = PAL.railBlue },
|
||||
{ id = "yellow", letter = "Y", color = PAL.railGold },
|
||||
{ id = "gold", letter = "G", color = PAL.railAmber },
|
||||
{ id = "mods", icon = imp._modsIcon },
|
||||
{ id = "find", icon = imp._findIcon },
|
||||
}
|
||||
local tabH = m.chip
|
||||
local tx = m.x + m.pad
|
||||
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 tabRight = m.x + m.w - m.pad
|
||||
local tabGap = math.floor(6 * m.s)
|
||||
@@ -908,51 +864,16 @@ local function buildHeader(imp, m)
|
||||
for _, t in ipairs(tabs) do
|
||||
local active = imp.tab == t.id
|
||||
local key = "tab-" .. t.id
|
||||
-- Cartridge tabs stay square (letter only). Icon tabs still expand to
|
||||
-- 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.
|
||||
local w = tabH
|
||||
if tx > tabLeft and tx + w > tabRight then
|
||||
tx = tabLeft
|
||||
ty = ty + tabH + tabRowGap
|
||||
end
|
||||
Kit._audit("control", tx, ty, w, tabH, key)
|
||||
local focused = Kit.focusable(key, tx, ty, w, tabH)
|
||||
local hot = focused or Kit.hover(tx, ty, w, tabH)
|
||||
local invert = active or hot
|
||||
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
|
||||
btn(imp, tx, ty, w, tabH, key, "", {
|
||||
face = "tab", font = "tab", color = t.color, active = active,
|
||||
image = t.icon, letter = t.letter,
|
||||
action = function() imp:_switchTab(t.id) end,
|
||||
})
|
||||
tx = tx + w + tabGap
|
||||
end
|
||||
|
||||
@@ -1454,8 +1375,10 @@ local function buildGamePanel(imp, x, y, w, availH, m, version)
|
||||
version, gameName, function() imp:play(version, true) end)
|
||||
imp._gearIcon = imp._gearIcon
|
||||
or love.graphics.newImage("assets/launcher/gear.png")
|
||||
iconButton(imp, "manage-" .. version, lx + lw - mgW, ly, mgW,
|
||||
imp._gearIcon, function() imp._gameManage = version end)
|
||||
btn(imp, lx + lw - mgW, ly, mgW, mgW, "manage-" .. version, "", {
|
||||
face = "invert", image = imp._gearIcon,
|
||||
action = function() imp._gameManage = version end,
|
||||
})
|
||||
ly = ly + playH + gap
|
||||
end
|
||||
|
||||
@@ -1743,8 +1666,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
local focused = Kit.focusable(rowKey, x, ry, w, rowH)
|
||||
local hot = focused or Kit.hover(x, ry, w, rowH)
|
||||
if isFullyDisabled then
|
||||
Theme.fillRounded(x, ry, w, rowH, PAL.bg, 0.8, Theme.cardRadius())
|
||||
Theme.strokeRounded(x, ry, w, rowH, PAL.muted, hot and Theme.A.hover or 0.25, 1, Theme.cardRadius())
|
||||
Kit.card(x, ry, w, rowH, hot and "mutedHot" or "muted")
|
||||
else
|
||||
Kit.card(x, ry, w, rowH, hot)
|
||||
end
|
||||
@@ -3020,8 +2942,7 @@ local function buildSettingsModal(imp, m)
|
||||
else
|
||||
local row = item.row
|
||||
local key = "set-" .. i
|
||||
Theme.strokeRounded(px + pad, ry, pw - 2 * pad, rowH, PAL.line,
|
||||
Theme.A.hairline, 1)
|
||||
Kit.card(px + pad, ry, pw - 2 * pad, rowH, "hairline")
|
||||
local ix = px + pad + math.floor(12 * m.s)
|
||||
-- Where the label prints, and where the control band starts. Stacked:
|
||||
-- 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)
|
||||
|
||||
-- Security Disclaimer Banner Callout Card
|
||||
Theme.fillRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.rowBg, 1, Theme.radius())
|
||||
Theme.strokeRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.yellow, Theme.A.hover, 1, Theme.radius())
|
||||
Kit.card(px + pad, cy, pw - 2 * pad, warnH, "warn")
|
||||
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)
|
||||
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
|
||||
-- Item Card Fill & Stroke (matching launcher card interiors & radius)
|
||||
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 innerW = pw - 2 * pad - math.floor(24 * m.s)
|
||||
|
||||
+120
-11
@@ -9,15 +9,16 @@
|
||||
-- cable pull.
|
||||
--
|
||||
-- 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
|
||||
-- member (the original prompts; documented divergence). Badge stat
|
||||
-- boosts don't apply on either side (divergence: Gen 1 famously kept
|
||||
-- them in link battles).
|
||||
-- (a draw); a fainted mon is replaced from the party menu and the chosen
|
||||
-- slot rides the wire, the way ChooseNextMon hands it to
|
||||
-- LinkBattleExchangeData. Badge stat boosts don't apply on either side
|
||||
-- (divergence: Gen 1 famously kept them in link battles).
|
||||
|
||||
local Fingerprint = require("src.link.Fingerprint")
|
||||
local Font = require("src.render.Font")
|
||||
local Handshake = require("src.link.Handshake")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Party = require("src.pokemon.Party")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
@@ -257,7 +258,9 @@ function LinkBattle.new(game, net, opts)
|
||||
self.enemyParty = theirParty
|
||||
self.playerParty = myParty -- intro ball row uses the clamped copies
|
||||
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.localHashes = {}
|
||||
self.remoteParts = {}
|
||||
@@ -327,6 +330,68 @@ function LinkBattle.new(game, net, opts)
|
||||
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
|
||||
local function decodeTheirAction(s, msg)
|
||||
return decodeWireAction(s, msg, s.enemy)
|
||||
@@ -540,12 +605,17 @@ function LinkBattle.new(game, net, opts)
|
||||
submit(s, { type = "action", kind = "run" }, nil)
|
||||
end
|
||||
|
||||
-- fainted mons auto-replace with the next healthy teammate, in party
|
||||
-- order, identically on both machines
|
||||
-- linkChoiceQueued: awaitReplacement already pre-empted itself with this
|
||||
-- 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)
|
||||
if s.linkChoiceQueued then
|
||||
s.linkChoiceQueued = nil
|
||||
return
|
||||
end
|
||||
for _, mon in ipairs(myParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutPlayer(s, mon) end)
|
||||
if not s.result then chooseReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -558,7 +628,7 @@ function LinkBattle.new(game, net, opts)
|
||||
self.enemyMonFainted = function(s)
|
||||
for _, mon in ipairs(theirParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutEnemy(s, mon) end)
|
||||
if not s.result then awaitReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -579,6 +649,9 @@ function LinkBattle.new(game, net, opts)
|
||||
s.remoteHashes[msg.turn or 0] = msg.value
|
||||
s.remoteParts[msg.turn or 0] = msg.parts
|
||||
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
|
||||
-- only a draw if our own simulation hasn't already decided
|
||||
-- (the winner's bye can arrive while we're still animating)
|
||||
@@ -770,6 +843,34 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
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)
|
||||
if hostMsg.kind == "run" or guestMsg.kind == "run" then
|
||||
endSpectate(s, "The match ended.")
|
||||
@@ -840,7 +941,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
self.playerMonFainted = function(s)
|
||||
for _, mon in ipairs(hostParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutHost(s, mon) end)
|
||||
if not s.result then awaitHostReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -852,7 +953,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
self.enemyMonFainted = function(s)
|
||||
for _, mon in ipairs(guestParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutGuest(s, mon) end)
|
||||
if not s.result then awaitGuestReplacement(s) end
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -862,6 +963,7 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
end
|
||||
|
||||
self.hostMsg, self.guestMsg = nil, nil
|
||||
self.hostReplace, self.guestReplace = {}, {}
|
||||
local baseUpdate = self.update
|
||||
self.update = function(s, dt)
|
||||
net:update()
|
||||
@@ -875,6 +977,13 @@ function LinkBattle.newSpectator(game, net, opts)
|
||||
s.hostMsg, s.guestMsg = nil, nil
|
||||
resolveSpecTurn(s, h, g)
|
||||
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
|
||||
if not s.result then endSpectate(s, "The match ended.") end
|
||||
end
|
||||
|
||||
+87
-17
@@ -19,6 +19,7 @@ LinkState.__index = LinkState
|
||||
LinkState.isOpaque = true
|
||||
|
||||
local CURSOR = 0xED
|
||||
local CURSOR_HOLLOW = 0xEC
|
||||
local ANY = "ANY" -- sentinel: a leading nil array entry breaks ipairs under
|
||||
-- LuaJIT even though # still reports the full size, so
|
||||
-- the level picker cycles this string instead of nil,
|
||||
@@ -114,6 +115,7 @@ end
|
||||
function LinkState:exitWith(message, reason)
|
||||
DiscordPresence.setJoinCode(nil)
|
||||
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") })
|
||||
if self.net then self.net:close() end
|
||||
self.game.stack:pop()
|
||||
@@ -519,6 +521,9 @@ function LinkState:startMode(mode, isHost)
|
||||
})
|
||||
self.net:send(self.trade:opening())
|
||||
self.index = 1
|
||||
self.theirIndex = 1
|
||||
self.side = "mine"
|
||||
self.pickChoice = nil
|
||||
else
|
||||
self.stage = "battleWait"
|
||||
-- the host deals the shared RNG seed for the lockstep simulation
|
||||
@@ -536,7 +541,14 @@ end
|
||||
-- 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)
|
||||
if self.game.linkNet == self.net then self.game.linkNet = nil end
|
||||
for _, msg in ipairs(self.net:poll()) do
|
||||
local reply = self.trade:handle(msg)
|
||||
if reply then self.net:send(reply) end
|
||||
@@ -591,10 +603,51 @@ function LinkState:updateTrade(input)
|
||||
return
|
||||
end
|
||||
|
||||
if t.stage == "picking" and input:wasPressed("up") then
|
||||
self.index = math.max(1, self.index - 1)
|
||||
-- pokered engine/link/cable_club.asm TradeCenter_SelectMon: A on one of
|
||||
-- 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
|
||||
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
|
||||
-- once confirm=true has been sent to the peer, backing out here
|
||||
-- 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:exitWith(Strings("The trade was\ncancelled."))
|
||||
elseif t.stage == "picking" and input:wasPressed("a") then
|
||||
if t:canPick(self.index) then
|
||||
self.net:send(t:pick(self.index))
|
||||
if self.side == "theirs" then
|
||||
self:openStats((t.theirParty or {})[self.theirIndex])
|
||||
else
|
||||
self.pickChoice = 1
|
||||
end
|
||||
elseif t.stage == "confirming" and self.confirmed == nil then
|
||||
if input:wasPressed("a") then
|
||||
@@ -738,25 +793,40 @@ function LinkState:draw()
|
||||
local label = (mon.nickname or def.name):sub(1, 8)
|
||||
if not t:canPick(i) then label = label .. "X" end
|
||||
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
|
||||
Font.draw(Strings("THEIRS"), 84, 20)
|
||||
for i, mon in ipairs(t.theirParty or {}) do
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
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
|
||||
local hint
|
||||
if t.stage == "waitRecords" then hint = "Comparing games..."
|
||||
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
||||
elseif t.stage == "picking" then
|
||||
hint = t:canPick(self.index) and "Pick one to trade"
|
||||
or Strings("X: not on theirs")
|
||||
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")
|
||||
if self.pickChoice then
|
||||
Font.draw(Strings("STATS"), 16, 128)
|
||||
Font.draw(Strings("TRADE"), 96, 128)
|
||||
Font.drawCode(CURSOR, self.pickChoice == 1 and 8 or 88, 128)
|
||||
else
|
||||
local hint
|
||||
if t.stage == "waitRecords" then hint = "Comparing games..."
|
||||
elseif t.stage == "waitParty" then hint = "Exchanging data..."
|
||||
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
|
||||
Font.draw(hint or "", 8, 132)
|
||||
|
||||
elseif self.stage == "battleWait" or self.stage == "battleRunning" then
|
||||
drawTitle("LINK BATTLE")
|
||||
|
||||
@@ -140,10 +140,12 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
if #mon.moves < 4 then
|
||||
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
|
||||
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,
|
||||
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
|
||||
-- LearnMoveFromLevelUp with a full moveset: the forget UI
|
||||
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
|
||||
|
||||
@@ -141,13 +141,20 @@ end
|
||||
-- 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-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
|
||||
segments = math.max(1, math.floor(segments or 6))
|
||||
HudTiles.tile(0x71, x, y)
|
||||
HudTiles.tile(0x62, x + 8, y)
|
||||
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))
|
||||
end
|
||||
local tint
|
||||
|
||||
@@ -691,10 +691,12 @@ function PaletteFX.spriteObp(spriteDef, seed)
|
||||
return PaletteFX.darkObp(w.spritePalettes[group], group)
|
||||
end
|
||||
|
||||
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
|
||||
function PaletteFX.barPalName(hp, maxHp)
|
||||
local px = maxHp > 0 and math.floor(hp * 48 / maxHp) or 0
|
||||
if hp > 0 and px < 1 then px = 1 end
|
||||
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar. It reads
|
||||
-- the bar's own length, so a caller mid-drain passes the animated `pixels`
|
||||
-- rather than let it be re-derived from hp.
|
||||
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"
|
||||
end
|
||||
|
||||
|
||||
@@ -100,6 +100,26 @@ function TextBox.new(game, text, onDone, opts)
|
||||
return self
|
||||
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
|
||||
-- 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
|
||||
|
||||
+15
-2
@@ -592,6 +592,17 @@ function Commands.play_sound(ctx, soundId)
|
||||
require("src.core.Sound").play(ctx.game.data, soundId)
|
||||
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
|
||||
-- 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
|
||||
@@ -1050,8 +1061,10 @@ function Commands.trade(ctx, tradeIndex, doneFlag)
|
||||
onDone = function() runner:resume() end,
|
||||
})
|
||||
runner:yield()
|
||||
-- TradedForText (sound_get_key_item) then the dialogset's thanks
|
||||
require("src.core.Sound").play(data, "Get_Key_Item")
|
||||
-- TradedForText carries sound_get_key_item after the text, so the jingle
|
||||
-- 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.thanks or "_Thanks" .. dialogset .. "Text")
|
||||
end
|
||||
|
||||
+7
-6
@@ -33,12 +33,12 @@ local function save_name(game)
|
||||
return game.save.player.name
|
||||
end
|
||||
|
||||
local function showMessages(game, msgs, onDone)
|
||||
local function showMessages(game, msgs, onDone, opts)
|
||||
if not msgs or #msgs == 0 then
|
||||
if onDone then onDone() end
|
||||
return
|
||||
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
|
||||
|
||||
-- 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
|
||||
if #target.moves < 4 then
|
||||
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
|
||||
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
|
||||
list.items = buildItems(game)
|
||||
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]
|
||||
if #target.moves < 4 then
|
||||
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
|
||||
showMessages(game, { Strings("%s learned\n%s!", name, mdef.name) },
|
||||
nextStep)
|
||||
nextStep, TextBox.soundOpts(game, "Get_Item1"))
|
||||
else
|
||||
require("src.ui.Screens").push(game, "MoveLearnMenu",
|
||||
target, moveId, nextStep)
|
||||
|
||||
@@ -46,9 +46,27 @@ function EvolutionState:sgbPalettes(game)
|
||||
return P.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
local FLASH_FRAMES = 220
|
||||
-- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031)
|
||||
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 path, trueColor = require("src.pokemon.Sprites").path(
|
||||
@@ -140,14 +158,10 @@ function EvolutionState:draw()
|
||||
else
|
||||
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||
end
|
||||
elseif evoShowsNew(self.t - CANCEL_GRACE_FRAMES) then
|
||||
sprite, spriteTrueColor = self.newSprite, self.newSpriteTrueColor
|
||||
else
|
||||
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
|
||||
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
|
||||
sprite, spriteTrueColor = self.oldSprite, self.oldSpriteTrueColor
|
||||
end
|
||||
if sprite then
|
||||
local x = math.floor((160 - sprite:getWidth()) / 2)
|
||||
|
||||
+16
-3
@@ -6,6 +6,7 @@ local GameVersion = require("src.core.GameVersion")
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Timing = require("src.core.Timing")
|
||||
|
||||
local IntroMovie = {}
|
||||
IntroMovie.__index = IntroMovie
|
||||
@@ -232,6 +233,10 @@ function IntroMovie:fightStep()
|
||||
self:finish()
|
||||
return
|
||||
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
|
||||
Sound.play(self.game.data, op.sfx)
|
||||
elseif op.pose then
|
||||
@@ -250,6 +255,7 @@ function IntroMovie:fightStep()
|
||||
end
|
||||
self.opTimer = self.opTimer + 1
|
||||
if self.opTimer < (op.px or math.abs(op.dx)) then return end
|
||||
timed = true
|
||||
elseif op.anim then
|
||||
-- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5)
|
||||
if self.opTimer % 5 == 0 then
|
||||
@@ -259,9 +265,11 @@ function IntroMovie:fightStep()
|
||||
end
|
||||
self.opTimer = self.opTimer + 1
|
||||
if self.opTimer < #ANIM[op.anim] * 5 then return end
|
||||
timed = true
|
||||
elseif op.wait then
|
||||
self.opTimer = self.opTimer + 1
|
||||
if self.opTimer < op.wait then return end
|
||||
timed = true
|
||||
elseif op.fade then
|
||||
self.opTimer = self.opTimer + 1
|
||||
self.fade = self.opTimer / op.fade
|
||||
@@ -270,6 +278,7 @@ function IntroMovie:fightStep()
|
||||
end
|
||||
self.opIndex = self.opIndex + 1
|
||||
self.opTimer = 0
|
||||
if timed then return end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -284,8 +293,9 @@ function IntroMovie:update(dt)
|
||||
return
|
||||
end
|
||||
local input = self.game.input
|
||||
if input:wasPressed("a") or input:wasPressed("b")
|
||||
or input:wasPressed("start") then
|
||||
if input:wasPressed("a") 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
|
||||
-- white hold stands in for that beat before the title is built.
|
||||
self:exitToTitle()
|
||||
@@ -300,7 +310,10 @@ function IntroMovie:update(dt)
|
||||
end
|
||||
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
|
||||
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
|
||||
|
||||
|
||||
+88
-35
@@ -87,6 +87,15 @@ local function refuseUnavailable(self)
|
||||
t._SleepingPikachuText1 or Strings("There isn't any\nresponse...")))
|
||||
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
|
||||
-- excluded by map id in ItemUseEscapeRope)
|
||||
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
|
||||
-- departure/warp, so we just hand it the chosen mapId (#195).
|
||||
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
|
||||
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
|
||||
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
|
||||
-- screen back, never under the text (#385).
|
||||
local ow = self.game.overworld
|
||||
if ow and not ow:partyKnows("FLASH") then
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
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.data.text._FlashLightsAreaText
|
||||
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
|
||||
-- solid white frame with nothing under it, which reads as a
|
||||
-- lockup (#610).
|
||||
ow:setDark(false)
|
||||
if wasDark then ow:setDark(false) end
|
||||
self.game.stack:push(Transition.whiteFlash(self.game))
|
||||
end))
|
||||
return
|
||||
elseif action == "surf" then
|
||||
-- start_sub_menus.asm .surf: SOULBADGE-gated (checked at list time
|
||||
-- above), then IsSurfingAllowed (the Cycling Road / Seafoam B4F
|
||||
-- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
|
||||
-- then IsSurfingAllowed (the Cycling Road / Seafoam B4F
|
||||
-- current refusals, both of which loop back to the submenu), then
|
||||
-- ItemUseSurfboard: while surfing it tries to dismount instead;
|
||||
-- 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
|
||||
elseif action == "cut" then
|
||||
-- start_sub_menus.asm .cut -> predef UsedCut (engine/overworld/cut.asm):
|
||||
-- CASCADEBADGE-gated (list time); _NothingToCutText loops back to the
|
||||
-- submenu when the FACING tile isn't a cuttable tree.
|
||||
-- CASCADEBADGE-gated (useCutFieldMove); _NothingToCutText loops back
|
||||
-- to the submenu when the FACING tile isn't a cuttable tree.
|
||||
local ow = self.game.overworld
|
||||
local reason = ow:useCutFieldMove()
|
||||
if reason == "ok" then
|
||||
@@ -522,7 +556,7 @@ function PartyMenu:update(dt)
|
||||
self.game.stack:push(TextBox.new(self.game, txt))
|
||||
return -- .loop: submenu stays open behind the message
|
||||
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
|
||||
-- BIT_STRENGTH_ACTIVE of wStatusFlags1 -- the sole gate
|
||||
-- push_boulder.asm reads -- then prints _UsedStrengthText (no
|
||||
@@ -532,6 +566,10 @@ function PartyMenu:update(dt)
|
||||
-- .strength, GBPalWhiteOutWithDelay3 blinks the screen white
|
||||
-- before CloseTextDisplay returns to the map.
|
||||
local ow = self.game.overworld
|
||||
if ow and not ow:partyKnows("STRENGTH") then
|
||||
refuseBadge(self)
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
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
|
||||
-- the exact departure; the fade + warp fire when the spin ends.
|
||||
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()
|
||||
if ow then ow:beginTeleportOut() end
|
||||
return
|
||||
@@ -658,42 +723,30 @@ function PartyMenu:update(dt)
|
||||
-- Battle still excludes this list via `not self.battle`. Softboiled
|
||||
-- can appear for a fainted user; its heal transfer then no-ops.
|
||||
if not self.battle and ow then
|
||||
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU --
|
||||
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
|
||||
local outside = Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets"))
|
||||
-- GetMonFieldMoves (engine/menus/text_box.asm) matches the mon's
|
||||
-- four moves against FieldMoveDisplayData and nothing else -- no
|
||||
-- badge, no map, no tileset test. Every one of those lives in
|
||||
-- .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
|
||||
if mv.id == "FLY" and outside
|
||||
and self.game.save.inventory.THUNDERBADGE then
|
||||
if mv.id == "FLY" then
|
||||
table.insert(items, { label = Strings("FLY"), action = "fly" })
|
||||
elseif mv.id == "FLASH" and ow.dark
|
||||
and self.game.save.inventory.BOULDERBADGE then
|
||||
elseif mv.id == "FLASH" then
|
||||
table.insert(items, { label = Strings("FLASH"), action = "flash" })
|
||||
elseif mv.id == "CUT" and self.game.save.inventory.CASCADEBADGE 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).
|
||||
elseif mv.id == "CUT" then
|
||||
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" })
|
||||
elseif mv.id == "STRENGTH" and self.game.save.inventory.RAINBOWBADGE then
|
||||
elseif mv.id == "STRENGTH" then
|
||||
table.insert(items, { label = Strings("STRENGTH"), action = "strength" })
|
||||
elseif mv.id == "SOFTBOILED" then
|
||||
table.insert(items, { label = Strings("SOFTBOILED"), action = "softboiled" })
|
||||
elseif mv.id == "TELEPORT" and outside then
|
||||
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
|
||||
-- .teleport -> CheckIfInOutsideMap); dark maps don't
|
||||
-- block it
|
||||
table.insert(items, { label = Strings("TELEPORT"), action = "escape" })
|
||||
elseif mv.id == "DIG" and DIG_TILESETS[ow.map.def.tileset]
|
||||
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" })
|
||||
elseif mv.id == "TELEPORT" then
|
||||
table.insert(items, { label = Strings("TELEPORT"),
|
||||
action = "escape", move = "TELEPORT" })
|
||||
elseif mv.id == "DIG" then
|
||||
table.insert(items, { label = Strings("DIG"),
|
||||
action = "escape", move = "DIG" })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+128
-78
@@ -368,6 +368,7 @@ Kit._navPrevN = 0
|
||||
Kit._navSeen = {}
|
||||
Kit._navQueue = nil
|
||||
Kit._activateId = nil
|
||||
Kit._ringShown = false
|
||||
|
||||
-- Register a focusable. Returns true when it currently holds 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
|
||||
-- somewhere rather than nowhere.
|
||||
if Kit.focusId == nil then Kit.focusId = id end
|
||||
return Kit.focusId == id
|
||||
return Kit._ringShown and Kit.focusId == id
|
||||
end
|
||||
|
||||
function Kit.navigate(dir)
|
||||
Kit._navQueue = dir
|
||||
Kit._ringShown = true
|
||||
end
|
||||
|
||||
function Kit.activateFocused()
|
||||
@@ -396,6 +398,7 @@ end
|
||||
|
||||
function Kit.setFocus(id)
|
||||
Kit.focusId = id
|
||||
Kit._ringShown = id ~= nil
|
||||
end
|
||||
|
||||
-- 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
|
||||
|
||||
-- ---------------------------------------------------------------- surfaces
|
||||
function Kit.card(x, y, w, h, emphasis)
|
||||
Theme.card(x, y, w, h, emphasis)
|
||||
function Kit.card(x, y, w, h, variant)
|
||||
Theme.card(x, y, w, h, variant)
|
||||
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)
|
||||
audit("row", x, y, w, h, id or "row")
|
||||
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.)
|
||||
function Kit.emptyBox(x, y, w, h, message)
|
||||
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),
|
||||
x, y + (h - Kit.textHeight("button")) / 2, w, PAL.muted)
|
||||
end
|
||||
@@ -598,61 +598,130 @@ local KINDS = {
|
||||
disabled = { fill = PAL.steel, ink = PAL.inverse, flat = true },
|
||||
}
|
||||
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)
|
||||
opts = opts or {}
|
||||
opts = opts or NO_OPTS
|
||||
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)
|
||||
local focused = enabled and opts.id
|
||||
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)
|
||||
|
||||
if G then
|
||||
-- The fill IS the control: a rounded, embossed, colour-coded key. A
|
||||
-- disabled button keeps its shape in a dead grey rather than
|
||||
-- disappearing, so a layout never reflows on state.
|
||||
Theme.fillRounded(x, y, w, h, kind.fill, enabled and 1 or 0.45)
|
||||
Theme.emboss(x, y, w, h, enabled and (hot and 1.3 or 1) or 0.4)
|
||||
if hot or focused then
|
||||
-- White ring outside the fill: legible on green, blue, yellow, red and
|
||||
-- white alike, which one darker/lighter shade per colour would not be.
|
||||
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong,
|
||||
Theme.A.focus, 2, Theme.radius() + 2)
|
||||
elseif opts.glow and enabled then
|
||||
-- "Something is waiting for you" (the update button): a pulsing ring.
|
||||
-- Pure alpha on one existing stroke -- no extra draw calls, no blend
|
||||
-- mode change.
|
||||
local a = 0.25 + 0.75 * (0.5 + 0.5 * math.sin(Kit.time * 3))
|
||||
Theme.strokeRounded(x - 2, y - 2, w + 4, h + 4, PAL.lineStrong, a, 2,
|
||||
Theme.radius() + 2)
|
||||
local face = opts.face or "fill"
|
||||
local B = Theme.BUTTON
|
||||
local radius = opts.radius or B.radius
|
||||
local active = opts.active or opts.on
|
||||
local invert = false
|
||||
local fill, ink, stroke, strokeA, doEmboss, doRing, glowA
|
||||
if face == "invert" then
|
||||
invert = hot
|
||||
fill = invert and (opts.hotFill or PAL.ink) or (opts.fill or PAL.surface)
|
||||
ink = invert and (opts.hotInk or PAL.inverse) or (opts.ink or PAL.heading)
|
||||
stroke = opts.stroke or PAL.line
|
||||
strokeA = invert and Theme.A.focus or Theme.A.hairline
|
||||
doRing = focused and not hot
|
||||
elseif face == "tab" then
|
||||
invert = active or focused or hot
|
||||
local tint = opts.color or opts.fill or PAL.ink
|
||||
fill = invert and tint or PAL.surface
|
||||
ink = invert and PAL.inverse or (opts.color or PAL.text)
|
||||
if not invert then
|
||||
stroke = tint
|
||||
strokeA = opts.color and Theme.A.hover or Theme.A.hairline
|
||||
end
|
||||
local fname = opts.font or "button"
|
||||
local ink = enabled and kind.ink or PAL.inverse
|
||||
local ty = y + (h - Kit.textHeight(fname)) / 2
|
||||
local shown = Kit.ellipsize(fname, label, w - 16 * Kit.scale)
|
||||
-- Button labels are bold: they are the shortest, most-scanned text on
|
||||
-- screen and sit on a saturated fill.
|
||||
if opts.align == "left" then
|
||||
Kit.textBold(fname, shown, x + 10 * Kit.scale, ty, ink)
|
||||
elseif face == "chip" then
|
||||
local c = opts.color or PAL.line
|
||||
invert = active and true or false
|
||||
if active then
|
||||
fill = c
|
||||
ink = PAL.inverse
|
||||
doEmboss = true
|
||||
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
|
||||
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)
|
||||
end
|
||||
|
||||
-- A small square control: +/- steppers, arrow cyclers, the row X.
|
||||
function Kit.stepper(x, y, w, h, glyph, opts)
|
||||
opts = opts or {}
|
||||
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)
|
||||
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)
|
||||
audit("control", x, y, w, h, label)
|
||||
local focused = id and Kit.focusable(id, x, y, w, h) or false
|
||||
local c = color or PAL.line
|
||||
if G then
|
||||
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)
|
||||
CHIP_OPTS.active = on
|
||||
CHIP_OPTS.color = color
|
||||
CHIP_OPTS.id = id
|
||||
return Kit.button(x, y, w, h, label, CHIP_OPTS)
|
||||
end
|
||||
|
||||
-- A status label with no interaction: outlined text, the "INSTALLED"/"UPDATE"
|
||||
|
||||
+95
-20
@@ -35,8 +35,8 @@ local PAL = {
|
||||
-- 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
|
||||
-- from the value step plus Theme.shadow, not from a gradient.
|
||||
field = { 16, 8, 10 }, -- the page BEHIND the cards
|
||||
bg = { 0, 0, 0 }, -- true black: button rests, field interiors
|
||||
field = { 20, 20, 20 }, -- the page BEHIND the cards
|
||||
bg = { 20, 20, 20 }, -- light black
|
||||
surface = { 28, 21, 24 }, -- card interiors
|
||||
rowBg = { 20, 14, 17 }, -- rows inside a card, one step below it
|
||||
raised = { 44, 34, 38 }, -- hover feedback
|
||||
@@ -82,6 +82,67 @@ Theme.A = {
|
||||
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 has = {}
|
||||
@@ -123,11 +184,11 @@ end
|
||||
-- Controls get the smaller one, containers the larger, so a button never
|
||||
-- looks like a card and a card never looks like a button.
|
||||
function Theme.radius()
|
||||
return 8
|
||||
return Theme.BUTTON.radius
|
||||
end
|
||||
|
||||
function Theme.cardRadius()
|
||||
return 14
|
||||
return Theme.CARD.radius
|
||||
end
|
||||
|
||||
-- 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
|
||||
-- field, its own drop shadow, and a white hairline. `emphasis` raises the
|
||||
-- outline to full white (used for the focused/active card).
|
||||
function Theme.card(x, y, w, h, emphasis)
|
||||
local r = Theme.cardRadius()
|
||||
Theme.shadow(x, y, w, h, r)
|
||||
Theme.fillRounded(x, y, w, h, PAL.surface, 1, r)
|
||||
Theme.strokeRounded(x, y, w, h, PAL.line,
|
||||
emphasis and Theme.A.focus or Theme.A.hairline, 1, r)
|
||||
function Theme.card(x, y, w, h, variant)
|
||||
local spec = Theme.CARD
|
||||
local v
|
||||
if variant == true then
|
||||
v = Theme.CARD_VARIANT.emphasis
|
||||
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
|
||||
|
||||
-- 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)
|
||||
local r = Theme.radius()
|
||||
if state == "selected" then
|
||||
Theme.fillRounded(x, y, w, h, PAL.ink, 1, r)
|
||||
Theme.card(x, y, w, h, "rowSelected")
|
||||
return PAL.inverse
|
||||
end
|
||||
Theme.fillRounded(x, y, w, h,
|
||||
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)
|
||||
Theme.card(x, y, w, h, state == "hover" and "rowHover" or "row")
|
||||
return PAL.text
|
||||
end
|
||||
|
||||
|
||||
+15
-5
@@ -8,7 +8,7 @@ local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
local NPC = {}
|
||||
NPC.__index = NPC
|
||||
|
||||
local STEP_FRAMES = 16
|
||||
local STEP_FRAMES = 32
|
||||
|
||||
local FACING_FROM_RANGE = {
|
||||
DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right",
|
||||
@@ -52,7 +52,16 @@ function NPC:facePlayer(player)
|
||||
end
|
||||
|
||||
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
|
||||
-- Pikachu takes the player's own step length, halved while it is more
|
||||
-- than a cell behind (FastPikachuFollow, engine/pikachu/
|
||||
@@ -78,7 +87,7 @@ function NPC:update(map, entities)
|
||||
return
|
||||
end
|
||||
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
|
||||
-- same for the bicycle)
|
||||
local moved = math.floor(self.progress * 16 * span / stepLen)
|
||||
@@ -113,8 +122,9 @@ end
|
||||
|
||||
function NPC:walkPhase()
|
||||
if not self.moving then return 0 end
|
||||
local p = self.progress % 16
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
local stepLen = self.stepFrames or STEP_FRAMES
|
||||
local p = self.progress % stepLen
|
||||
return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0
|
||||
end
|
||||
|
||||
-- Same contract as Player:pose -- the sheet, position, facing and step
|
||||
|
||||
@@ -384,12 +384,21 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- NPC instances persist across connection crossings in self.npcPool
|
||||
-- (keyed by NPC.id): a neighbor map's wandering ghosts ARE 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
|
||||
-- survey zoom. Warps rebuild from scratch, like the original's
|
||||
-- per-entry sprite init (home/overworld.asm LoadMapHeader
|
||||
-- .loadSpriteData).
|
||||
-- seam, so a map the player is not entering keeps its ghosts alive
|
||||
-- in view of the survey zoom. The map being entered does not --
|
||||
-- crossing a seam runs .loadNewMap -> LoadMapHeader, whose
|
||||
-- .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
|
||||
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
|
||||
self.npcs = {}
|
||||
for _, obj in ipairs(self.map.def.objects or {}) do
|
||||
@@ -1986,10 +1995,12 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
end
|
||||
save.hiddenTaken[key] = true
|
||||
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)
|
||||
require("src.core.Sound").play(Game.data, "Get_Item2")
|
||||
-- hidden items always play SFX_GET_ITEM_2, and FoundHiddenItemText's
|
||||
-- text_asm tail runs it as PlaySoundWaitForCurrent +
|
||||
-- WaitForSoundToFinish once the box has printed (hidden_items.asm)
|
||||
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
|
||||
end
|
||||
end
|
||||
@@ -2001,9 +2012,9 @@ function OverworldState:tryHiddenObject(fx, fy)
|
||||
if not save.inventory.COIN_CASE then return false end
|
||||
save.hiddenTaken[key] = true
|
||||
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,
|
||||
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
|
||||
end
|
||||
end
|
||||
@@ -2650,10 +2661,11 @@ function OverworldState:talkTo(npc)
|
||||
end
|
||||
local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item
|
||||
local ddef = Game.data.items[d.item]
|
||||
require("src.core.Sound").play(Game.data,
|
||||
(ddef and ddef.keyItem) and "Get_Key_Item" or "Get_Item1")
|
||||
-- FoundItemText: text_far, sound_get_item_1, text_end (pick_up_item.asm)
|
||||
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
|
||||
end
|
||||
|
||||
@@ -3182,6 +3194,58 @@ local function giveVictoryItem(reward)
|
||||
return true
|
||||
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).
|
||||
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
|
||||
-- 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)
|
||||
tmGiven = giveVictoryItem(reward)
|
||||
end
|
||||
local lines = {}
|
||||
local chain = rewardChain()
|
||||
if reward.dialogue then
|
||||
local text = Game.data.text or {}
|
||||
for _, label in ipairs(reward.dialogue) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
chain.add(reward.dialogue, reward.badgeSound)
|
||||
if reward.item then
|
||||
for _, label in ipairs(reward.tmPre or {}) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
chain.add(reward.tmPre)
|
||||
if tmGiven then
|
||||
for _, label in ipairs(reward.tmDialogue or {}) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
table.insert(lines, text[label])
|
||||
end
|
||||
end
|
||||
elseif reward.noRoom and text[reward.noRoom] and text[reward.noRoom] ~= "" then
|
||||
table.insert(lines, text[reward.noRoom])
|
||||
chain.add(reward.tmDialogue, reward.tmSound)
|
||||
else
|
||||
chain.add({ reward.noRoom })
|
||||
end
|
||||
end
|
||||
elseif reward.badge or reward.item then
|
||||
if reward.badge then
|
||||
local name = Game.data.items[reward.badge] and Game.data.items[reward.badge].name
|
||||
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
|
||||
if tmGiven then
|
||||
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
|
||||
if #lines > 0 then
|
||||
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f")))
|
||||
end
|
||||
chain.push()
|
||||
self:runVictoryHook()
|
||||
end
|
||||
|
||||
@@ -3266,24 +3317,14 @@ end
|
||||
-- show again, then the same GiveItem check decides between the received
|
||||
-- lines and the "make room" text.
|
||||
function OverworldState:offerGymTm(reward, done)
|
||||
local text = Game.data.text or {}
|
||||
local lines = {}
|
||||
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
|
||||
local chain = rewardChain()
|
||||
chain.add(reward.tmPre)
|
||||
if giveVictoryItem(reward) then
|
||||
for _, label in ipairs(reward.tmDialogue or {}) do addLine(label) end
|
||||
chain.add(reward.tmDialogue, reward.tmSound)
|
||||
else
|
||||
addLine(reward.noRoom)
|
||||
end
|
||||
if #lines > 0 then
|
||||
Game.stack:push(TextBox.new(Game, table.concat(lines, "\f"), done))
|
||||
elseif done then
|
||||
done()
|
||||
chain.add({ reward.noRoom })
|
||||
end
|
||||
chain.push(done)
|
||||
end
|
||||
|
||||
-- pokered reloads the map after every battle, re-running the map
|
||||
@@ -3661,8 +3702,9 @@ function OverworldState:onStepComplete()
|
||||
end
|
||||
end
|
||||
|
||||
-- wild encounters in grass, on water while surfing, or -- on indoor
|
||||
-- maps whose tileset is not FOREST -- on EVERY tile
|
||||
-- wild encounters in grass, on water while surfing (at the map's water
|
||||
-- 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)
|
||||
-- The cooldown is checked after all other step processing so repel and
|
||||
-- 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 enc
|
||||
local indoor = Game.data.field.indoorEncounters
|
||||
if p.surfing and encDef and encDef.water and self.map:isWaterCell(p.cellX, p.cellY) then
|
||||
enc = self:rollEncounter({ grass = encDef.water }, "water")
|
||||
elseif self.map:isGrassCell(p.cellX, p.cellY) then
|
||||
if self.map:isGrassCell(p.cellX, p.cellY) then
|
||||
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
|
||||
and self.map.def.tileset ~= indoor.excludedTileset then
|
||||
enc = self:rollEncounter(encDef, "indoor")
|
||||
@@ -3794,10 +3836,10 @@ function OverworldState:checkBadgeGate()
|
||||
if Game.save.inventory[g.badge] then
|
||||
if not Game.save.flags[passedFlag] then
|
||||
Game.save.flags[passedFlag] = true
|
||||
-- Route22GateGuardGoRightAheadText plays sound_get_item_1
|
||||
require("src.core.Sound").play(Game.data, "Get_Item1")
|
||||
-- Route22GateGuardGoRightAheadText carries sound_get_item_1
|
||||
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
|
||||
return false
|
||||
end
|
||||
@@ -3822,11 +3864,11 @@ function OverworldState:checkBadgeGate()
|
||||
and Game.data.items[guard.badge].name or guard.badge
|
||||
if Game.save.inventory[guard.badge] then
|
||||
Game.save.flags[guard.event] = true
|
||||
-- Route23OhThatIsTheBadgeText plays sound_get_item_1
|
||||
require("src.core.Sound").play(Game.data, "Get_Item1")
|
||||
-- Route23OhThatIsTheBadgeText carries sound_get_item_1
|
||||
local text = (t["_" .. g.passText] or
|
||||
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
|
||||
end
|
||||
-- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED
|
||||
@@ -4470,7 +4512,7 @@ function OverworldState:scriptMove(entity, dir, tiles, onDone)
|
||||
})
|
||||
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
|
||||
-- NPC_CHANGE_FACING movement byte (engine/overworld/movement.asm
|
||||
-- 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
|
||||
-- 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
|
||||
-- tiles. Phase 1 retires finished moves (which may chain new ones);
|
||||
-- phase 2 then starts every not-yet-moving move.
|
||||
|
||||
@@ -821,11 +821,13 @@ end
|
||||
function PikachuFollower.onFanClubEntered(game, ow)
|
||||
if not (GameVersion.isYellow() and ow.map
|
||||
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 npc = findFollower(ow)
|
||||
if not npc or (starter and starter.status) then return end
|
||||
ow.pikachuFanClubScene = true
|
||||
ow.pikachuMapScriptActive = true
|
||||
ow.player.facing = "down"
|
||||
for _, other in ipairs(ow.npcs or {}) do
|
||||
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
|
||||
return
|
||||
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
|
||||
-- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused
|
||||
-- walk while CheckPikachuStatusCondition comes back clear
|
||||
|
||||
@@ -191,12 +191,12 @@ return function(game)
|
||||
return cond()
|
||||
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,
|
||||
-- so this ends the animation early without touching the palette logic.
|
||||
local function skipToEnd()
|
||||
local st = evoTop()
|
||||
if st then st.t = 214 end
|
||||
if st then st.t = 362 end
|
||||
end
|
||||
|
||||
local function startEvo(species, into)
|
||||
@@ -212,7 +212,7 @@ return function(game)
|
||||
|
||||
-- ---- case 1: the reported case, SGB, mid-flash --------------------------
|
||||
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")
|
||||
local c1 = probe("flash/SGB", {
|
||||
monYellow1 = YELLOW[2], monYellow2 = YELLOW[3],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
|
||||
--
|
||||
-- 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"
|
||||
-- text on screen.
|
||||
-- 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)
|
||||
|
||||
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.shot(game, DIR .. "/evo213_1_evolving.png")
|
||||
|
||||
@@ -133,8 +133,8 @@ return function(game)
|
||||
local done2 = false
|
||||
Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end)
|
||||
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
|
||||
-- let the full flash run (FLASH_FRAMES=220) without pressing B
|
||||
waitFor(function() return not evoTop() end, 400)
|
||||
-- let the full flash run (FLASH_FRAMES=368) without pressing B
|
||||
waitFor(function() return not evoTop() end, 500)
|
||||
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||
error("Congratulations text not shown (case2)")
|
||||
end
|
||||
|
||||
@@ -89,8 +89,8 @@ return function(game)
|
||||
Evolution.evolve(game, mon, "GYARADOS", function() done1 = true 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
|
||||
waitFor(function() return not evoTop() end, 400)
|
||||
-- let the full flash run (FLASH_FRAMES=368) with no input, then apply
|
||||
waitFor(function() return not evoTop() end, 500)
|
||||
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
|
||||
error("Congratulations text not shown (case1)")
|
||||
end
|
||||
@@ -124,7 +124,7 @@ return function(game)
|
||||
local done2 = false
|
||||
Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true 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
|
||||
error("Congratulations text not shown (case2)")
|
||||
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,
|
||||
-- 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 game = {
|
||||
data = { pokemon = { LAPRAS = { name = "LAPRAS" },
|
||||
@@ -87,14 +87,15 @@ openSubmenu(pm2)
|
||||
same(actions(pm2.subItems), { "stats", "switch" },
|
||||
"no field moves: the submenu is just STATS/SWITCH")
|
||||
|
||||
-- the badge gates still filter the list: the same Lapras without the
|
||||
-- badges keeps its moves but shows none of them
|
||||
-- GetMonFieldMoves is badge-blind: the same Lapras without the badges
|
||||
-- still lists both HMs, and .outOfBattleMovePointers refuses on
|
||||
-- selection instead (#1022)
|
||||
local noBadges = newGame(
|
||||
{ { id = "STRENGTH", pp = 15 }, { id = "SURF", pp = 15 } })
|
||||
local pm3 = PartyMenu.new(noBadges, {})
|
||||
noBadges.stack:push(pm3)
|
||||
openSubmenu(pm3)
|
||||
same(actions(pm3.subItems), { "stats", "switch" },
|
||||
"ungated badges keep the HM moves out of the submenu")
|
||||
same(actions(pm3.subItems), { "strength", "surf", "stats", "switch" },
|
||||
"the HM moves are listed without the badges (#1022)")
|
||||
|
||||
T.finish("party_fieldmove_order_bug792")
|
||||
|
||||
@@ -16,6 +16,7 @@ local T = require("tests.modkit")
|
||||
local boxes = {}
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
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.inventory.Bag"] = {
|
||||
|
||||
@@ -187,6 +187,13 @@ local function runOne(seed)
|
||||
local function drive(side)
|
||||
local bt = side.bt
|
||||
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 == "moveSelect" then
|
||||
local usable = {}
|
||||
|
||||
@@ -181,6 +181,13 @@ local function playMatch(hostEntry, guestEntry, rnd, label)
|
||||
local function drive(side)
|
||||
local bt = side.bt
|
||||
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 == "moveSelect" then
|
||||
local usable = {}
|
||||
|
||||
@@ -604,7 +604,7 @@ do
|
||||
local game = uiGame({ mon })
|
||||
Bag.add(game.save, "RARE_CANDY", 1)
|
||||
game.stack:push(BagMenu.new(game))
|
||||
for _ = 1, 600 do
|
||||
for _ = 1, 800 do
|
||||
local top = game.stack:top()
|
||||
if not top then break end
|
||||
pressed = { a = true }
|
||||
|
||||
@@ -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
|
||||
do
|
||||
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()
|
||||
sgame.overworld = owStub
|
||||
sgame.data.text = {} -- the strength texts fall back to Strings sources
|
||||
|
||||
@@ -49,6 +49,14 @@ function Link.prepare(data)
|
||||
return Input
|
||||
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
|
||||
-- sides, and report whether any turn's hashes disagreed
|
||||
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
|
||||
guard = guard + 1
|
||||
Input.pressed = { a = true }
|
||||
steerReplacement(gameA)
|
||||
gameA.stack:update(1 / 60)
|
||||
steerReplacement(gameB)
|
||||
gameB.stack:update(1 / 60)
|
||||
end
|
||||
|
||||
|
||||
+11
-4
@@ -315,12 +315,19 @@ do
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
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 top = Game.stack:top()
|
||||
if not (top and top.pages) then return "" end
|
||||
local parts = {}
|
||||
for _, page in ipairs(top.pages) do
|
||||
parts[#parts + 1] = table.concat(page, "\n")
|
||||
local top = Game.stack:top()
|
||||
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
|
||||
return table.concat(parts, "\n")
|
||||
end
|
||||
|
||||
+10
-8
@@ -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")
|
||||
|
||||
-- ===========================================================================
|
||||
-- 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.inventory = {}
|
||||
@@ -185,8 +186,8 @@ local pmNoBadge = PartyMenu.new(Game)
|
||||
Game.stack:push(pmNoBadge)
|
||||
frame({ "a" })
|
||||
local actsOff = submenuActions(pmNoBadge)
|
||||
check(not actsOff.cut and not actsOff.surf and not actsOff.strength,
|
||||
"no CUT/SURF/STRENGTH submenu entries without the required badges")
|
||||
check(actsOff.cut and actsOff.surf and actsOff.strength,
|
||||
"CUT/SURF/STRENGTH submenu entries listed without the badges (#1022)")
|
||||
popToOW()
|
||||
Game.save.inventory = { CASCADEBADGE = true, SOULBADGE = true, RAINBOWBADGE = true }
|
||||
local pmBadge = PartyMenu.new(Game)
|
||||
@@ -194,7 +195,7 @@ Game.stack:push(pmBadge)
|
||||
frame({ "a" })
|
||||
local actsOn = submenuActions(pmBadge)
|
||||
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)
|
||||
@@ -471,8 +472,9 @@ local pmLobby = PartyMenu.new(Game)
|
||||
Game.stack:push(pmLobby)
|
||||
frame({ "a" })
|
||||
local actsLobby = submenuActions(pmLobby)
|
||||
check(not actsLobby.fly, "FLY omitted inside Indigo Plateau lobby")
|
||||
check(not actsLobby.escape, "TELEPORT omitted inside Indigo Plateau lobby")
|
||||
check(actsLobby.fly, "FLY still listed inside Indigo Plateau lobby (#1022)")
|
||||
check(actsLobby.escape,
|
||||
"TELEPORT still listed inside Indigo Plateau lobby (#1022)")
|
||||
popToOW()
|
||||
|
||||
-- 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")
|
||||
clearCaptured()
|
||||
local pmFaintStr = PartyMenu.new(Game)
|
||||
-- Seafoam is not OVERWORLD, so FLY is omitted: CUT, STRENGTH, SURF, STATS, SWITCH
|
||||
selectSubItem(pmFaintStr, 2)
|
||||
-- move order on the mon: FLY, CUT, STRENGTH, SURF, then STATS, SWITCH
|
||||
selectSubItem(pmFaintStr, 3)
|
||||
eq(Game.overworld.strengthActive, true,
|
||||
"fainted mon can activate STRENGTH from the party menu")
|
||||
check(sawText("used") and sawText("STRENGTH"),
|
||||
|
||||
@@ -72,13 +72,19 @@ Game.input = Input; Input:init()
|
||||
Game.renderer = Renderer; Renderer: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 top = Game.stack:top()
|
||||
if not (top and top.pages) then return "" end
|
||||
local parts = {}
|
||||
for _, page in ipairs(top.pages) do
|
||||
parts[#parts + 1] = table.concat(page, "\n")
|
||||
local top = Game.stack:top()
|
||||
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
|
||||
return table.concat(parts, "\n")
|
||||
end
|
||||
|
||||
@@ -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
|
||||
-- (CheckPikachuStatusCondition, scripts/BillsHouse.asm:45-46)
|
||||
ow.pikachuBillsScene = nil
|
||||
yellowGame.save.pikachuMapScriptActive = nil
|
||||
moves = {}
|
||||
yellowGame.save.party = { { species = "PIKACHU", hp = 12, status = "PAR" } }
|
||||
PikachuFollower.onBillsHouseEnter(yellowGame, ow)
|
||||
|
||||
@@ -42,7 +42,7 @@ local ow = {
|
||||
|
||||
Follower.onFanClubEntered({ save = save, data = Data }, ow)
|
||||
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(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")
|
||||
|
||||
+2
-2
@@ -2562,8 +2562,8 @@ do
|
||||
end
|
||||
eq(fanfares, 1, "one caught fanfare per capture")
|
||||
eq(tinks, 3, "three wobble tinks on a $43 capture")
|
||||
check(fanfareAt and caughtAt and fanfareAt < caughtAt,
|
||||
"Caught_Mon sounds with the caught text, not after its dismissal")
|
||||
check(fanfareAt and caughtAt and caughtAt < fanfareAt,
|
||||
"Caught_Mon sounds once the caught text is out, before its prompt")
|
||||
eq(cb4.result, "caught", "the capture resolved the battle")
|
||||
-- the nickname AskName that follows clears it (ClearSprites), so the
|
||||
-- assertion is sampled while the caught text is up
|
||||
|
||||
Reference in New Issue
Block a user