feat(mods): expose Fly and Softboiled field actions

This commit is contained in:
AverageConsumer
2026-08-16 14:57:39 +02:00
parent 1151c188a7
commit a3a20a07e1
5 changed files with 165 additions and 25 deletions
+15 -6
View File
@@ -223,20 +223,29 @@ scripts, battles, and transitions leave the party untouched.
start at the player's current position. Both games expose `bicycle`, `fish`, start at the player's current position. Both games expose `bicycle`, `fish`,
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally `cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
contextual `squirtbottle` key item. Fishing rows include the owned rods that contextual `squirtbottle` key item. Red additionally exposes `softboiled` with
are valid choices. The list is empty while the world is busy, and omits an eligible `sources`; each source contains its eligible `targets`. Fishing rows
action whenever its item, move, badge, terrain, or engine state forbids it. 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.
The optional second return is `"world is busy"` during transient input locks The optional second return is `"world is busy"` during transient input locks
or `"no overworld"` before a playable world exists. or `"no overworld"` before a playable world exists.
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through 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" }` 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 and chooses automatically when only one rod is available. Red's `softboiled`
busy requests return `nil` plus a reason without changing game state. Mods do accepts one-based `{ sourceSlot, targetSlot }` values copied from its action
not need generation-specific badge, terrain, bike, fishing, or field-move record. Invalid, stale, and busy requests return `nil` plus a reason without
changing game state. Mods do not need generation-specific badge, terrain,
bike, fishing, or field-move
logic. Action lists are extensible; callers should render the records they logic. Action lists are extensible; callers should render the records they
understand and ignore unknown ids rather than assuming a fixed list length. understand and ignore unknown ids rather than assuming a fixed list length.
Red exposes FLY separately because it requires a destination picker:
`mod.world:canFly()` reports whether FLY is eligible at the current location,
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
Fly town list. Gold does not expose these two methods yet.
## Read-only battle snapshots ## Read-only battle snapshots
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle `mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
+2 -16
View File
@@ -628,22 +628,8 @@ function PartyMenu:update(dt)
end end
if self.softboiledFrom then if self.softboiledFrom then
local user = party[self.softboiledFrom] local user = party[self.softboiledFrom]
local heal = math.floor(user.stats.hp / 5) self.softboiledFrom = nil
if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp self.game.overworld:useSoftboiledFieldMove(user, mon)
or user.hp <= heal then
self.softboiledFrom = nil
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect.")))
else
user.hp = user.hp - heal
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
self.softboiledFrom = nil
require("src.core.Sound").play(self.game.data, "Heal_HP")
local def = self.game.data.pokemon[mon.species]
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game,
Strings("%s's HP\nwas restored!", mon.nickname or def.name)))
end
elseif self.swapFrom then elseif self.swapFrom then
if self.swapFrom ~= self.index then if self.swapFrom ~= self.index then
party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom] party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom]
+17
View File
@@ -827,6 +827,23 @@ function OverworldState:useStrengthFieldMove(mon, onClose)
return true return true
end end
function OverworldState:useSoftboiledFieldMove(user, target)
local heal = user and user.stats and math.floor(user.stats.hp / 5) or 0
if not user or not user.stats or not target or not target.stats
or target == user or target.hp <= 0
or target.hp >= target.stats.hp or user.hp <= heal then
Game.stack:push(TextBox.new(Game, Strings("It won't have\nany effect.")))
return false
end
user.hp = user.hp - heal
target.hp = math.min(target.stats.hp, target.hp + heal)
require("src.core.Sound").play(Game.data, "Heal_HP")
local def = Game.data.pokemon[target.species]
Game.stack:push(TextBox.new(Game,
Strings("%s's HP\nwas restored!", target.nickname or def.name)))
return true
end
-- The battle transition's dungeon wipe uses the explicit map lists in -- The battle transition's dungeon wipe uses the explicit map lists in
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus -- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
-- inclusive map-id ranges -- faithful to the original's omissions -- inclusive map-id ranges -- faithful to the original's omissions
+91 -2
View File
@@ -37,6 +37,57 @@ local function validPartySlot(party, slot)
and party[slot] ~= nil and party[slot] ~= nil
end end
local function outside(game, ow)
return Map.isOutside(ow.map.def,
FieldDefaults.field(game.data, "outsideTilesets"))
end
local function knows(mon, moveId)
for _, move in ipairs(mon.moves or {}) do
if move.id == moveId then return true end
end
return false
end
local function monInfo(game, mon, slot)
local def = game.data.pokemon[mon.species] or {}
return { slot = slot, species = mon.species,
name = mon.nickname or def.name or mon.species, level = mon.level,
hp = mon.hp, maxHp = mon.stats and mon.stats.hp or mon.hp }
end
local function softboiledSources(game)
local party, sources = game.save.party or {}, {}
for sourceSlot, source in ipairs(party) do
local heal = source.stats and math.floor(source.stats.hp / 5) or 0
if knows(source, "SOFTBOILED") and source.hp > heal then
local info = monInfo(game, source, sourceSlot)
info.targets = {}
for targetSlot, target in ipairs(party) do
if target ~= source and target.hp > 0 and target.stats
and target.hp < target.stats.hp then
info.targets[#info.targets + 1] = monInfo(game, target, targetSlot)
end
end
if #info.targets > 0 then sources[#sources + 1] = info end
end
end
return sources
end
local function flyDestinationAvailable(game, mapId)
local field, save = game.data.field or {}, game.save
for _, id in ipairs(field.flyOrder or {}) do
if id == mapId then
local def = game.data.maps and game.data.maps[id]
return not not (save.visited and save.visited[id]
and field.flyWarps and field.flyWarps[id]
and def and Map.isFlyTown(def))
end
end
return false
end
function WorldAPI.new(game, modId) function WorldAPI.new(game, modId)
return setmetatable({ game = game, modId = modId }, WorldAPI) return setmetatable({ game = game, modId = modId }, WorldAPI)
end end
@@ -141,10 +192,14 @@ function WorldAPI:availableFieldActions()
and ow:partyKnows("DIG") then and ow:partyKnows("DIG") then
out[#out + 1] = { id = "dig", label = "DIG" } out[#out + 1] = { id = "dig", label = "DIG" }
end end
if ow:partyKnows("TELEPORT") and Map.isOutside(ow.map.def, if ow:partyKnows("TELEPORT") and outside(game, ow) then
FieldDefaults.field(game.data, "outsideTilesets")) then
out[#out + 1] = { id = "teleport", label = "TELEPORT" } out[#out + 1] = { id = "teleport", label = "TELEPORT" }
end end
local sources = softboiledSources(game)
if #sources > 0 then
out[#out + 1] = { id = "softboiled", label = "SOFTBOILED",
sources = sources }
end
return out return out
end end
@@ -187,10 +242,44 @@ function WorldAPI:useFieldAction(id, opts)
elseif id == "dig" or id == "teleport" then elseif id == "dig" or id == "teleport" then
ow:beginTeleportOut() ow:beginTeleportOut()
return true return true
elseif id == "softboiled" then
local sourceSlot = opts and tonumber(opts.sourceSlot)
local targetSlot = opts and tonumber(opts.targetSlot)
local allowed
for _, source in ipairs(found.sources or {}) do
if source.slot == sourceSlot then
for _, target in ipairs(source.targets or {}) do
if target.slot == targetSlot then allowed = true break end
end
end
end
if not allowed then return nil, "softboiled target unavailable" end
if ow:useSoftboiledFieldMove(game.save.party[sourceSlot],
game.save.party[targetSlot]) then return true end
end end
return nil, "field action unavailable" return nil, "field action unavailable"
end end
-- FLY needs a destination choice, so it is exposed separately from the
-- immediate actions above. The request is still checked against the same
-- visited-town list as the native Town Map picker before the world may warp.
function WorldAPI:canFly()
local game, ow = self.game, self:overworld()
return not not (ow and ow.map and outside(game, ow) and ow:partyKnows("FLY"))
end
function WorldAPI:flyTo(mapId)
local game, ow = self.game, self:overworld()
if not ow then return nil, NO_OVERWORLD end
if not self:canFly() then return nil, "fly unavailable" end
if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
if not flyDestinationAvailable(game, mapId) then
return nil, "destination unavailable"
end
ow:flyTo(mapId)
return true
end
-- A compact, read-only view of the active map for minimaps and companion UIs. -- A compact, read-only view of the active map for minimaps and companion UIs.
-- `rows` describes collision terrain; optional `tileRows` reduces each real -- `rows` describes collision terrain; optional `tileRows` reduces each real
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest). -- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
+40 -1
View File
@@ -24,7 +24,11 @@ local redWorld = {
} }
local redGame = { local redGame = {
data = { field = { outsideTilesets = { "OVERWORLD" } }, data = { field = { outsideTilesets = { "OVERWORLD" } },
items = { OLD_ROD = { name = "OLD ROD" } } }, items = { OLD_ROD = { name = "OLD ROD" } },
maps = { PALLET_TOWN = { index = 0, tileset = "OVERWORLD" },
ROUTE_4 = { index = 11, tileset = "OVERWORLD" } },
pokemon = { CHANSEY = { name = "CHANSEY" },
PIKACHU = { name = "PIKACHU" } } },
save = { player = { name = "RED" }, party = {}, save = { player = { name = "RED" }, party = {},
inventory = { BICYCLE = 1, OLD_ROD = 1 } }, inventory = { BICYCLE = 1, OLD_ROD = 1 } },
stack = { states = { redWorld } }, stack = { states = { redWorld } },
@@ -42,6 +46,7 @@ T.check(type(RedWorld.useBicycle) == "function"
and type(RedWorld.useFishingRod) == "function" and type(RedWorld.useFishingRod) == "function"
and type(RedWorld.useFlashFieldMove) == "function" and type(RedWorld.useFlashFieldMove) == "function"
and type(RedWorld.useStrengthFieldMove) == "function" and type(RedWorld.useStrengthFieldMove) == "function"
and type(RedWorld.useSoftboiledFieldMove) == "function"
and type(RedWorld.stopSurfing) == "function", and type(RedWorld.stopSurfing) == "function",
"Red keeps field-action execution in its world") "Red keeps field-action execution in its world")
local actions = red:availableFieldActions() local actions = red:availableFieldActions()
@@ -93,6 +98,40 @@ T.check(redWorld.cutUsed and redWorld.surfUsed and redWorld.strengthUsed
and redWorld.flashUsed and redWorld.teleportUsed, and redWorld.flashUsed and redWorld.teleportUsed,
"Red delegates every move to its overworld path") "Red delegates every move to its overworld path")
local source = { species = "CHANSEY", level = 30, hp = 80,
stats = { hp = 100 }, moves = { { id = "SOFTBOILED" } } }
local target = { species = "PIKACHU", level = 20, hp = 10,
stats = { hp = 50 }, moves = {} }
redGame.save.party = { source, target }
redWorld.useSoftboiledFieldMove = function(self, user, recipient)
self.softboiled = { user, recipient }
return true
end
byId = {}
for _, action in ipairs(red:availableFieldActions()) do byId[action.id] = action end
T.check(byId.softboiled and byId.softboiled.sources[1].targets[1].slot == 2,
"Red lists only valid SOFTBOILED targets")
T.check(red:useFieldAction("softboiled", { sourceSlot = 1, targetSlot = 2 }),
"Red accepts a listed SOFTBOILED transfer")
T.check(redWorld.softboiled[1] == source and redWorld.softboiled[2] == target,
"Red delegates SOFTBOILED to its overworld path")
ok, err = red:useFieldAction("softboiled", { sourceSlot = 2, targetSlot = 1 })
T.check(not ok and err == "softboiled target unavailable",
"Red rejects an invalid SOFTBOILED source")
redGame.save.inventory.THUNDERBADGE = 1
redGame.save.visited = { PALLET_TOWN = true, ROUTE_4 = true }
redGame.data.field.flyOrder = { "PALLET_TOWN", "ROUTE_4" }
redGame.data.field.flyWarps = { PALLET_TOWN = true, ROUTE_4 = true }
redMoves.FLY = source
redWorld.flyTo = function(self, mapId) self.flewTo = mapId end
T.check(red:canFly(), "Red exposes FLY only in a valid outdoor context")
T.check(red:flyTo("PALLET_TOWN") and redWorld.flewTo == "PALLET_TOWN",
"Red validates and delegates a visited FLY destination")
ok, err = red:flyTo("ROUTE_4")
T.check(not ok and err == "destination unavailable",
"Red rejects a fly warp that is not a native town destination")
redSurf = "dismount" redSurf = "dismount"
redWorld.player.surfing = true redWorld.player.surfing = true
redWorld.stopSurfing = function(self) self.dismounted = true end redWorld.stopSurfing = function(self) self.dismounted = true end