mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
Merge pull request #1402 from AverageConsumer/codex/mod-field-advanced-actions
This commit is contained in:
+15
-6
@@ -223,20 +223,29 @@ scripts, battles, and transitions leave the party untouched.
|
||||
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.
|
||||
contextual `squirtbottle` key item. Red additionally exposes `softboiled` with
|
||||
eligible `sources`; each source contains its eligible `targets`. 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.
|
||||
The optional second return is `"world is busy"` during transient input locks
|
||||
or `"no overworld"` before a playable world exists.
|
||||
|
||||
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 badge, terrain, bike, fishing, or field-move
|
||||
and chooses automatically when only one rod is available. Red's `softboiled`
|
||||
accepts one-based `{ sourceSlot, targetSlot }` values copied from its action
|
||||
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
|
||||
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
|
||||
|
||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||
|
||||
+2
-16
@@ -628,22 +628,8 @@ function PartyMenu:update(dt)
|
||||
end
|
||||
if self.softboiledFrom then
|
||||
local user = party[self.softboiledFrom]
|
||||
local heal = math.floor(user.stats.hp / 5)
|
||||
if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp
|
||||
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
|
||||
self.softboiledFrom = nil
|
||||
self.game.overworld:useSoftboiledFieldMove(user, mon)
|
||||
elseif self.swapFrom then
|
||||
if self.swapFrom ~= self.index then
|
||||
party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom]
|
||||
|
||||
@@ -828,6 +828,23 @@ function OverworldState:useStrengthFieldMove(mon, onClose)
|
||||
return true
|
||||
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
|
||||
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
|
||||
-- inclusive map-id ranges -- faithful to the original's omissions
|
||||
|
||||
+91
-2
@@ -37,6 +37,57 @@ local function validPartySlot(party, slot)
|
||||
and party[slot] ~= nil
|
||||
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)
|
||||
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
||||
end
|
||||
@@ -141,10 +192,14 @@ function WorldAPI:availableFieldActions()
|
||||
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
|
||||
if ow:partyKnows("TELEPORT") and outside(game, ow) then
|
||||
out[#out + 1] = { id = "teleport", label = "TELEPORT" }
|
||||
end
|
||||
local sources = softboiledSources(game)
|
||||
if #sources > 0 then
|
||||
out[#out + 1] = { id = "softboiled", label = "SOFTBOILED",
|
||||
sources = sources }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
@@ -187,10 +242,44 @@ function WorldAPI:useFieldAction(id, opts)
|
||||
elseif id == "dig" or id == "teleport" then
|
||||
ow:beginTeleportOut()
|
||||
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
|
||||
return nil, "field action unavailable"
|
||||
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.
|
||||
-- `rows` describes collision terrain; optional `tileRows` reduces each real
|
||||
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
|
||||
|
||||
@@ -24,7 +24,11 @@ local redWorld = {
|
||||
}
|
||||
local redGame = {
|
||||
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 = {},
|
||||
inventory = { BICYCLE = 1, OLD_ROD = 1 } },
|
||||
stack = { states = { redWorld } },
|
||||
@@ -42,6 +46,7 @@ T.check(type(RedWorld.useBicycle) == "function"
|
||||
and type(RedWorld.useFishingRod) == "function"
|
||||
and type(RedWorld.useFlashFieldMove) == "function"
|
||||
and type(RedWorld.useStrengthFieldMove) == "function"
|
||||
and type(RedWorld.useSoftboiledFieldMove) == "function"
|
||||
and type(RedWorld.stopSurfing) == "function",
|
||||
"Red keeps field-action execution in its world")
|
||||
local actions = red:availableFieldActions()
|
||||
@@ -93,6 +98,40 @@ T.check(redWorld.cutUsed and redWorld.surfUsed and redWorld.strengthUsed
|
||||
and redWorld.flashUsed and redWorld.teleportUsed,
|
||||
"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"
|
||||
redWorld.player.surfing = true
|
||||
redWorld.stopSurfing = function(self) self.dismounted = true end
|
||||
|
||||
Reference in New Issue
Block a user