From 2b6473ae03e07bcde11e18d4f5b13b01a730bb9c Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:59:53 +0200 Subject: [PATCH] feat(mods): extend contextual field actions --- docs/mod-api-gen2-compat.md | 8 +- docs/modding.md | 18 ++-- src/ui/PartyMenu.lua | 53 +----------- src/world/OverworldController.lua | 52 +++++++++++ src/world/WorldAPI.lua | 48 +++++++++++ src/world/gen2/WorldAPI.lua | 44 ++++++++++ tests/modkit/cases/world_field_items.lua | 105 ++++++++++++++++++++--- 7 files changed, 258 insertions(+), 70 deletions(-) diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 47c1a5b3..48429c6f 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -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 diff --git a/docs/modding.md b/docs/modding.md index 8a1a287c..ceda0f0b 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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 diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index f056418c..bc125e5c 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -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 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 0b845d84..7008e360 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -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 diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index a907e8f1..4f4200e9 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -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 diff --git a/src/world/gen2/WorldAPI.lua b/src/world/gen2/WorldAPI.lua index c391678a..9c204cf4 100644 --- a/src/world/gen2/WorldAPI.lua +++ b/src/world/gen2/WorldAPI.lua @@ -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 diff --git a/tests/modkit/cases/world_field_items.lua b/tests/modkit/cases/world_field_items.lua index a5a4a869..095c333a 100644 --- a/tests/modkit/cases/world_field_items.lua +++ b/tests/modkit/cases/world_field_items.lua @@ -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()