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
+5 -3
View File
@@ -486,9 +486,11 @@ has its own entry points for (`start_battle "wild" species level`, `warp`,
**by name, before the first row runs**, so a mod never gets a half-run queue.
`marchInPlace` still has no Gen 2 equivalent (the Gen 2 movement stream has no
byte for it) and returns `nil, reason` rather than approximating one.
`availableFieldActions` and `useFieldAction` expose the same contextual
bicycle and fishing records in both games. Each engine keeps ownership of its
inventory, terrain, surfing, bike, and fishing rules.
`availableFieldActions` and `useFieldAction` expose the same contextual field
item and move records in both games. Gold extends the shared ids with its own
`headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and `squirtbottle`
actions. Each engine keeps ownership of its inventory, badges, terrain,
surfing, bike, fishing, and field-move rules.
**Hooks and events that fire on Gold.** Every name below is the Gen 1 name
carrying the Gen 1 payload keys, because Gold's call sites reuse them rather
+11 -7
View File
@@ -147,19 +147,23 @@ Companion UIs and alternate party screens can call
operation is accepted only during idle overworld play; menus, movement,
scripts, battles, and transitions leave the party untouched.
## Contextual field items
## Contextual field actions
`mod.world:availableFieldActions()` returns the field items that can start at
the player's current position. Red and Gold currently expose `bicycle` and
`fish`; fishing rows include the owned rods that are valid choices. The list
is empty while the world is busy, while riding states or terrain forbid an
action, or when the required item is not owned.
`mod.world:availableFieldActions()` returns the field items and moves that can
start at the player's current position. Both games expose `bicycle`, `fish`,
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
contextual `squirtbottle` key item. Fishing rows include the owned rods that
are valid choices. The list is empty while the world is busy, and omits an
action whenever its item, move, badge, terrain, or engine state forbids it.
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
and chooses automatically when only one rod is available. Invalid, stale, and
busy requests return `nil` plus a reason without changing game state. Mods do
not need generation-specific bike, collision, or fishing logic.
not need generation-specific badge, terrain, bike, fishing, or field-move
logic. Action lists are extensible; callers should render the records they
understand and ignore unknown ids rather than assuming a fixed list length.
## Rendering pipelines
+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
+95 -10
View File
@@ -1,11 +1,13 @@
-- Contextual bicycle and fishing actions share one public contract in both
-- generations while each engine keeps ownership of its own field-item path.
-- Contextual field actions share one public contract in both generations
-- while each engine keeps ownership of its own field-item and move paths.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness").suite("mod world field items")
local T = require("tests.harness").suite("mod world field actions")
local facingWater = false
local redCut, redSurf = false, "no_water"
local redMoves = {}
local redWorld = {
isOverworld = true,
map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } },
@@ -14,11 +16,15 @@ local redWorld = {
scriptMoves = {},
bikeAllowed = function() return true end,
facingIsShoreOrWater = function() return facingWater end,
useCutFieldMove = function() return redCut and "ok" or "nothing" end,
useSurfFieldMove = function() return redSurf end,
partyKnows = function(_, move) return redMoves[move] end,
useBicycle = function(self) self.bikeUsed = true return true end,
useFishingRod = function(self, rod) self.rodUsed = rod return true end,
}
local redGame = {
data = { items = { OLD_ROD = { name = "OLD ROD" } } },
data = { field = { outsideTilesets = { "OVERWORLD" } },
items = { OLD_ROD = { name = "OLD ROD" } } },
save = { player = { name = "RED" }, party = {},
inventory = { BICYCLE = 1, OLD_ROD = 1 } },
stack = { states = { redWorld } },
@@ -30,8 +36,11 @@ local RedAPI = require("src.world.WorldAPI")
local red = RedAPI.new(redGame, "fixture")
local RedWorld = require("src.world.OverworldController")
T.check(type(RedWorld.useBicycle) == "function"
and type(RedWorld.useFishingRod) == "function",
"Red keeps field-item execution in its world")
and type(RedWorld.useFishingRod) == "function"
and type(RedWorld.useFlashFieldMove) == "function"
and type(RedWorld.useStrengthFieldMove) == "function"
and type(RedWorld.stopSurfing) == "function",
"Red keeps field-action execution in its world")
local actions = red:availableFieldActions()
T.eq(actions[1].id, "bicycle", "Red lists an owned usable bicycle")
T.check(red:useFieldAction("bicycle"), "Red accepts the listed bicycle")
@@ -55,26 +64,98 @@ ok, err = red:useFieldAction("bicycle")
T.check(not ok and err == "world is busy",
"Red refuses a stale action while busy")
redWorld.player.moving = false
facingWater, redCut, redSurf = false, true, "ok"
redWorld.dark = true
for _, move in ipairs({ "STRENGTH", "FLASH", "TELEPORT" }) do
redMoves[move] = { species = "MEW", moves = { { id = move } } }
end
redWorld.player.facingCell = function() return 4, 5 end
redWorld.tryCut = function(self) self.cutUsed = true return true end
redWorld.trySurf = function(self) self.surfUsed = true end
redWorld.useStrengthFieldMove = function(self) self.strengthUsed = true return true end
redWorld.useFlashFieldMove = function(self) self.flashUsed = true return true end
redWorld.beginTeleportOut = function(self) self.teleportUsed = true end
actions = red:availableFieldActions()
local byId = {}
for _, action in ipairs(actions) do byId[action.id] = action end
T.check(byId.cut and byId.surf and byId.strength and byId.flash
and byId.teleport, "Red lists field moves that can start now")
for _, id in ipairs({ "cut", "surf", "strength", "flash", "teleport" }) do
T.check(red:useFieldAction(id), "Red accepts listed " .. id)
end
T.check(redWorld.cutUsed and redWorld.surfUsed and redWorld.strengthUsed
and redWorld.flashUsed and redWorld.teleportUsed,
"Red delegates every move to its overworld path")
redSurf = "dismount"
redWorld.player.surfing = true
redWorld.stopSurfing = function(self) self.dismounted = true end
T.check(red:useFieldAction("surf") and redWorld.dismounted,
"Red delegates the contextual SURF dismount")
redWorld.player.surfing, redSurf = false, "ok"
redCut = false
ok, err = red:useFieldAction("cut")
T.check(not ok and err == "field action unavailable",
"Red revalidates a stale field move")
redWorld.map.id = "ROCK_TUNNEL_1F"
redWorld.map.def.tileset = "CAVERN"
redMoves.DIG = { species = "MEW", moves = { { id = "DIG" } } }
redWorld.beginTeleportOut = function(self) self.digUsed = true end
byId = {}
for _, action in ipairs(red:availableFieldActions()) do byId[action.id] = action end
T.check(byId.dig and not byId.teleport,
"Red distinguishes dungeon DIG from outdoor TELEPORT")
T.check(red:useFieldAction("dig") and redWorld.digUsed,
"Red delegates DIG to its escape path")
local goldWorld = {
map = { id = "ROUTE_29", def = { environment = "ROUTE" } },
player = {}, playerState = "normal",
acceptsMenuInput = function() return true end,
playerCollision = function() return 0x00 end,
alwaysOnBike = function() return false end,
fieldContext = function() return { facingColl = 0x20 } end,
useFieldItem = function(self, item) self.itemUsed = item return "used" end,
squirtbottleTreeScript = function() return { { op = "end" } } end,
useFieldMove = function(self, move)
self.moveUsed = move
return { ok = true }
end,
}
local goldGame = {
data = { items = { OLD_ROD = { name = "OLD ROD" } } },
save = { inventory = { BICYCLE = 1, OLD_ROD = 1 } },
data = { items = { OLD_ROD = { name = "OLD ROD" },
SQUIRTBOTTLE = { name = "SQUIRTBOTTLE" } } },
save = { inventory = { BICYCLE = 1, OLD_ROD = 1, SQUIRTBOTTLE = 1 },
player = { badges = { FOG = true } },
party = { { moves = { { id = "SURF" }, { id = "SWEET_SCENT" },
{ id = "TELEPORT" } } } } },
world = goldWorld,
}
goldWorld.fieldContext = function(_, mon) return {
save = goldGame.save, party = goldGame.save.party, mon = mon,
facing = "right", facingColl = 0x29, playerColl = 0,
environment = "ROUTE", playerState = "normal", alwaysOnBike = false,
dark = false, canEscapeRope = false,
} end
local GoldAPI = require("src.world.gen2.WorldAPI")
local gold = GoldAPI.new(goldGame, "fixture")
actions = gold:availableFieldActions()
byId = {}
for _, action in ipairs(actions) do byId[action.id] = action end
T.eq(actions[1].id, "bicycle", "Gold shares the bicycle action id")
T.eq(actions[2].rods[1].id, "OLD_ROD", "Gold shares the rod shape")
T.eq(actions[2].id, "fish", "Gold preserves the original action order")
T.check(byId.surf and byId.sweet_scent and byId.teleport,
"Gold lists field moves through its generic dispatcher")
T.eq(byId.fish.rods[1].id, "OLD_ROD", "Gold shares the rod shape")
T.check(byId.squirtbottle,
"Gold lists the SquirtBottle only at its matching tree")
T.check(gold:useFieldAction("sweet_scent"),
"Gold accepts a listed field move")
T.eq(goldWorld.moveUsed, "SWEET_SCENT",
"Gold delegates moves to its own field-move path")
T.check(gold:useFieldAction("fish", { rod = "OLD_ROD" }),
"Gold accepts the same fishing request")
T.eq(goldWorld.itemUsed, "OLD_ROD",
@@ -84,5 +165,9 @@ ok, err = gold:useFieldAction("fish", { rod = "SUPER_ROD" })
T.check(not ok and err == "fishing rod unavailable",
"Gold rejects an unowned rod")
T.eq(goldWorld.itemUsed, used, "a rejected Gold rod changes nothing")
T.check(gold:useFieldAction("squirtbottle"),
"Gold accepts the contextual SquirtBottle")
T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE",
"Gold delegates the SquirtBottle to its field-item path")
T.finish()