mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 20:20:19 +02:00
feat(mods): expose Fly and Softboiled field 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`,
|
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
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user