feat(mods): extend contextual field actions

This commit is contained in:
AverageConsumer
2026-08-15 01:59:53 +02:00
parent fb738fa1ce
commit 2b6473ae03
7 changed files with 258 additions and 70 deletions
+3 -50
View File
@@ -454,30 +454,7 @@ function PartyMenu:update(dt)
refuseBadge(self)
return
end
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
-- .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()
self:close()
-- setDark, not a bare field write: ADVANCED carries the darkness
-- in a baked atlas, so lighting the cave drops every resident map
-- and rebakes this one (#383). It runs HERE, before the blink,
-- because start_sub_menus.asm .flash clears wMapPalOffset before
-- PrintText and blinks last of all: the cave is already lit by the
-- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the
-- blink's completion instead left that rebuild's whole cost --
-- 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).
if wasDark then ow:setDark(false) end
self.game.stack:push(Transition.whiteFlash(self.game))
end))
ow:useFlashFieldMove(function() self:close() end)
return
elseif action == "surf" then
-- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
@@ -505,12 +482,7 @@ function PartyMenu:update(dt)
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
-- steps the player forward onto land (or across a connection
-- strip when the shore is the next map's edge)
self.game.stack:pop()
ow.player.surfing = false
require("src.core.Music").setSurfing(self.game.data, false)
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
ow:stepForwardOrCrossEdge(ow.player.facing)
end))
ow:stopSurfing(function() self.game.stack:pop() end)
return
end
local TextBox = require("src.render.TextBox")
@@ -571,26 +543,7 @@ function PartyMenu:update(dt)
refuseBadge(self)
return
end
local TextBox = require("src.render.TextBox")
local Transition = require("src.render.Transition")
local def = self.game.data.pokemon[mon.species]
local name = mon.nickname or def.name
ow.strengthActive = true
local t1 = (self.game.data.text._UsedStrengthText
or Strings("{RAM:wNameBuffer} used\nSTRENGTH.")):gsub("{RAM:wNameBuffer}", name)
local t2 = (self.game.data.text._CanMoveBouldersText
or Strings("{RAM:wNameBuffer} can\nmove boulders.")):gsub("{RAM:wNameBuffer}", name)
-- like surf (#320, #385): both texts print with the party menu
-- still on screen, and the blink IS the menu closing afterwards,
-- not a flashbang on the empty map
self.game.stack:push(TextBox.new(self.game, t1, function()
self.game.stack:push(TextBox.new(self.game, t2, function()
self:close()
self.game.stack:push(Transition.whiteFlash(self.game))
end))
end, { auto = { sound = function()
return require("src.core.Sound").playCry(self.game.data, mon.species)
end } }))
ow:useStrengthFieldMove(mon, function() self:close() end)
return
elseif action == "softboiled" then
-- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
+52
View File
@@ -784,6 +784,49 @@ function OverworldState:useBicycle()
return true
end
-- Field-move entry points keep presentation and state transitions in the
-- overworld. The party menu and supported mod facade both call these, so a
-- shortcut cannot drift from the game's own move flow.
function OverworldState:useFlashFieldMove(onClose)
-- A FLASH used in daylight must not carry into the next dark map. Lighting
-- also happens before the blink: setDark may rebake an ADVANCED atlas, and
-- putting that work in WhiteFlash:onDone caused the frozen white frame in
-- #610.
local wasDark = self.dark
if wasDark then Game.save.flashLit = true end
Game.stack:push(TextBox.new(Game,
Game.data.text._FlashLightsAreaText
or Strings("A blinding FLASH\nlights the area!"), function()
if onClose then onClose() end
if wasDark then self:setDark(false) end
Game.stack:push(Transition.whiteFlash(Game))
end))
return true
end
function OverworldState:useStrengthFieldMove(mon, onClose)
mon = mon or self:partyKnows("STRENGTH")
if not mon then return false end
local def = Game.data.pokemon[mon.species]
local name = mon.nickname or def.name
self.strengthActive = true
local first = (Game.data.text._UsedStrengthText
or Strings("{RAM:wNameBuffer} used\nSTRENGTH."))
:gsub("{RAM:wNameBuffer}", name)
local second = (Game.data.text._CanMoveBouldersText
or Strings("{RAM:wNameBuffer} can\nmove boulders."))
:gsub("{RAM:wNameBuffer}", name)
Game.stack:push(TextBox.new(Game, first, function()
Game.stack:push(TextBox.new(Game, second, function()
if onClose then onClose() end
Game.stack:push(Transition.whiteFlash(Game))
end))
end, { auto = { sound = function()
return require("src.core.Sound").playCry(Game.data, mon.species)
end } }))
return true
end
-- The battle transition's dungeon wipe uses the explicit map lists in
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
-- inclusive map-id ranges -- faithful to the original's omissions
@@ -2500,6 +2543,15 @@ function OverworldState:trySurf(fx, fy, onClose)
end))
end
function OverworldState:stopSurfing(onClose)
if onClose then onClose() end
self.player.surfing = false
require("src.core.Music").setSurfing(Game.data, false)
Game.stack:push(Transition.whiteFlash(Game, nil, function()
self:stepForwardOrCrossEdge(self.player.facing)
end))
end
function OverworldState:tryCut(fx, fy)
-- UsedCut (engine/overworld/cut.asm) gates on the TILESET before
-- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile
+48
View File
@@ -6,6 +6,8 @@
-- stays unsupported; anything a mod legitimately needs belongs here.
local Logger = require("src.core.Logger")
local FieldDefaults = require("src.world.FieldDefaults")
local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader")
local MapOverview = require("src.world.MapOverview")
local Party = require("src.pokemon.Party")
@@ -15,6 +17,8 @@ local WorldAPI = {}
WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld"
local DIG_TILESETS = { FOREST = true, CEMETERY = true, CAVERN = true,
FACILITY = true, INTERIOR = true }
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
local function acceptsMenuInput(game, ow)
@@ -115,6 +119,30 @@ function WorldAPI:availableFieldActions()
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
end
end
if ow:useCutFieldMove() == "ok" then
out[#out + 1] = { id = "cut", label = "CUT" }
end
local surf = ow:useSurfFieldMove()
if surf == "ok" or surf == "dismount" then
out[#out + 1] = { id = "surf",
label = surf == "dismount" and "LEAVE WATER" or "SURF" }
end
if not ow.strengthActive and ow:partyKnows("STRENGTH") then
out[#out + 1] = { id = "strength", label = "STRENGTH" }
end
if ow.dark and ow:partyKnows("FLASH") then
out[#out + 1] = { id = "flash", label = "FLASH" }
end
if DIG_TILESETS[ow.map.def.tileset] and ow.map.id ~= "AGATHAS_ROOM"
and ow:partyKnows("DIG") then
out[#out + 1] = { id = "dig", label = "DIG" }
end
if ow:partyKnows("TELEPORT") and Map.isOutside(ow.map.def,
FieldDefaults.field(game.data, "outsideTilesets")) then
out[#out + 1] = { id = "teleport", label = "TELEPORT" }
end
return out
end
@@ -130,6 +158,19 @@ function WorldAPI:useFieldAction(id, opts)
if id == "bicycle" then
if ow:useBicycle() then return true end
elseif id == "cut" then
local x, y = ow.player:facingCell()
if ow:tryCut(x, y) then return true end
elseif id == "surf" then
local mode = ow:useSurfFieldMove()
if mode == "dismount" then
ow:stopSurfing()
return true
elseif mode == "ok" then
local x, y = ow.player:facingCell()
ow:trySurf(x, y)
return true
end
elseif id == "fish" then
local rod = opts and opts.rod
if not rod and #found.rods == 1 then rod = found.rods[1].id end
@@ -137,6 +178,13 @@ function WorldAPI:useFieldAction(id, opts)
if choice.id == rod and ow:useFishingRod(rod) then return true end
end
return nil, "fishing rod unavailable"
elseif id == "strength" then
if ow:useStrengthFieldMove() then return true end
elseif id == "flash" then
if ow:useFlashFieldMove() then return true end
elseif id == "dig" or id == "teleport" then
ow:beginTeleportOut()
return true
end
return nil, "field action unavailable"
end
+44
View File
@@ -36,6 +36,18 @@ WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld"
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
local FIELD_ACTIONS = {
{ id = "cut", move = "CUT" },
{ id = "surf", move = "SURF" },
{ id = "strength", move = "STRENGTH" },
{ id = "flash", move = "FLASH" },
{ id = "headbutt", move = "HEADBUTT" },
{ id = "whirlpool", move = "WHIRLPOOL" },
{ id = "waterfall", move = "WATERFALL" },
{ id = "sweet_scent", move = "SWEET_SCENT" },
{ id = "dig", move = "DIG" },
{ id = "teleport", move = "TELEPORT" },
}
function WorldAPI.new(game, modId)
return setmetatable({ game = game, modId = modId }, WorldAPI)
@@ -96,6 +108,26 @@ function WorldAPI:availableFieldActions()
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
end
end
for _, row in ipairs(FIELD_ACTIONS) do
if not (row.move == "STRENGTH" and world.strengthActive) then
local mon = FieldMoves.partyMoveUser(context.party, row.move, context)
if mon then
context.mon = mon
local result = FieldMoves.fromMenu(row.move, context)
if result.ok then
out[#out + 1] = { id = row.id,
label = row.move:gsub("_", " ") }
end
end
end
end
if (inventory.SQUIRTBOTTLE or 0) > 0
and world:squirtbottleTreeScript() then
out[#out + 1] = { id = "squirtbottle",
label = itemLabel(game, "SQUIRTBOTTLE") }
end
return out
end
@@ -123,6 +155,18 @@ function WorldAPI:useFieldAction(id, opts)
end
end
return nil, "fishing rod unavailable"
elseif id == "squirtbottle" then
local outcome = world:useFieldItem("SQUIRTBOTTLE")
if outcome and outcome ~= "nowhere" then return true end
end
for _, row in ipairs(FIELD_ACTIONS) do
if row.id == id then
local context = world:fieldContext()
local mon = FieldMoves.partyMoveUser(context.party, row.move, context)
local result = mon and world:useFieldMove(row.move, mon)
if result and result.ok then return true end
return nil, "field action unavailable"
end
end
return nil, "field action unavailable"
end