mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
feat(mods): extend contextual field actions
This commit is contained in:
@@ -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.
|
**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
|
`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.
|
byte for it) and returns `nil, reason` rather than approximating one.
|
||||||
`availableFieldActions` and `useFieldAction` expose the same contextual
|
`availableFieldActions` and `useFieldAction` expose the same contextual field
|
||||||
bicycle and fishing records in both games. Each engine keeps ownership of its
|
item and move records in both games. Gold extends the shared ids with its own
|
||||||
inventory, terrain, surfing, bike, and fishing rules.
|
`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
|
**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
|
carrying the Gen 1 payload keys, because Gold's call sites reuse them rather
|
||||||
|
|||||||
+11
-7
@@ -147,19 +147,23 @@ Companion UIs and alternate party screens can call
|
|||||||
operation is accepted only during idle overworld play; menus, movement,
|
operation is accepted only during idle overworld play; menus, movement,
|
||||||
scripts, battles, and transitions leave the party untouched.
|
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
|
`mod.world:availableFieldActions()` returns the field items and moves that can
|
||||||
the player's current position. Red and Gold currently expose `bicycle` and
|
start at the player's current position. Both games expose `bicycle`, `fish`,
|
||||||
`fish`; fishing rows include the owned rods that are valid choices. The list
|
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
|
||||||
is empty while the world is busy, while riding states or terrain forbid an
|
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
|
||||||
action, or when the required item is not owned.
|
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
|
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. Invalid, stale, and
|
||||||
busy requests return `nil` plus a reason without changing game state. Mods do
|
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
|
## Rendering pipelines
|
||||||
|
|
||||||
|
|||||||
+3
-50
@@ -454,30 +454,7 @@ function PartyMenu:update(dt)
|
|||||||
refuseBadge(self)
|
refuseBadge(self)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local TextBox = require("src.render.TextBox")
|
ow:useFlashFieldMove(function() self:close() end)
|
||||||
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))
|
|
||||||
return
|
return
|
||||||
elseif action == "surf" then
|
elseif action == "surf" then
|
||||||
-- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
|
-- start_sub_menus.asm .surf: SOULBADGE-gated (useSurfFieldMove),
|
||||||
@@ -505,12 +482,7 @@ function PartyMenu:update(dt)
|
|||||||
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
|
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
|
||||||
-- steps the player forward onto land (or across a connection
|
-- steps the player forward onto land (or across a connection
|
||||||
-- strip when the shore is the next map's edge)
|
-- strip when the shore is the next map's edge)
|
||||||
self.game.stack:pop()
|
ow:stopSurfing(function() self.game.stack:pop() end)
|
||||||
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))
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
@@ -571,26 +543,7 @@ function PartyMenu:update(dt)
|
|||||||
refuseBadge(self)
|
refuseBadge(self)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local TextBox = require("src.render.TextBox")
|
ow:useStrengthFieldMove(mon, function() self:close() end)
|
||||||
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 } }))
|
|
||||||
return
|
return
|
||||||
elseif action == "softboiled" then
|
elseif action == "softboiled" then
|
||||||
-- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
|
-- field SOFTBOILED (StartMenu_Pokemon .softboiled): transfer
|
||||||
|
|||||||
@@ -784,6 +784,49 @@ function OverworldState:useBicycle()
|
|||||||
return true
|
return true
|
||||||
end
|
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
|
-- 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
|
||||||
@@ -2500,6 +2543,15 @@ function OverworldState:trySurf(fx, fy, onClose)
|
|||||||
end))
|
end))
|
||||||
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)
|
function OverworldState:tryCut(fx, fy)
|
||||||
-- UsedCut (engine/overworld/cut.asm) gates on the TILESET before
|
-- UsedCut (engine/overworld/cut.asm) gates on the TILESET before
|
||||||
-- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile
|
-- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
-- stays unsupported; anything a mod legitimately needs belongs here.
|
-- stays unsupported; anything a mod legitimately needs belongs here.
|
||||||
|
|
||||||
local Logger = require("src.core.Logger")
|
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 MapLoader = require("src.world.MapLoader")
|
||||||
local MapOverview = require("src.world.MapOverview")
|
local MapOverview = require("src.world.MapOverview")
|
||||||
local Party = require("src.pokemon.Party")
|
local Party = require("src.pokemon.Party")
|
||||||
@@ -15,6 +17,8 @@ local WorldAPI = {}
|
|||||||
WorldAPI.__index = WorldAPI
|
WorldAPI.__index = WorldAPI
|
||||||
|
|
||||||
local NO_OVERWORLD = "no overworld"
|
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 RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
|
||||||
|
|
||||||
local function acceptsMenuInput(game, ow)
|
local function acceptsMenuInput(game, ow)
|
||||||
@@ -115,6 +119,30 @@ function WorldAPI:availableFieldActions()
|
|||||||
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
|
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
|
||||||
end
|
end
|
||||||
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
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -130,6 +158,19 @@ function WorldAPI:useFieldAction(id, opts)
|
|||||||
|
|
||||||
if id == "bicycle" then
|
if id == "bicycle" then
|
||||||
if ow:useBicycle() then return true end
|
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
|
elseif id == "fish" then
|
||||||
local rod = opts and opts.rod
|
local rod = opts and opts.rod
|
||||||
if not rod and #found.rods == 1 then rod = found.rods[1].id end
|
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
|
if choice.id == rod and ow:useFishingRod(rod) then return true end
|
||||||
end
|
end
|
||||||
return nil, "fishing rod unavailable"
|
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
|
end
|
||||||
return nil, "field action unavailable"
|
return nil, "field action unavailable"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -36,6 +36,18 @@ WorldAPI.__index = WorldAPI
|
|||||||
|
|
||||||
local NO_OVERWORLD = "no overworld"
|
local NO_OVERWORLD = "no overworld"
|
||||||
local RODS = { "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }
|
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)
|
function WorldAPI.new(game, modId)
|
||||||
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
||||||
@@ -96,6 +108,26 @@ function WorldAPI:availableFieldActions()
|
|||||||
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
|
out[#out + 1] = { id = "fish", label = "FISH", rods = rods }
|
||||||
end
|
end
|
||||||
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
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -123,6 +155,18 @@ function WorldAPI:useFieldAction(id, opts)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
return nil, "fishing rod unavailable"
|
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
|
end
|
||||||
return nil, "field action unavailable"
|
return nil, "field action unavailable"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
-- Contextual bicycle and fishing actions share one public contract in both
|
-- Contextual field actions share one public contract in both generations
|
||||||
-- generations while each engine keeps ownership of its own field-item path.
|
-- while each engine keeps ownership of its own field-item and move paths.
|
||||||
|
|
||||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
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 facingWater = false
|
||||||
|
local redCut, redSurf = false, "no_water"
|
||||||
|
local redMoves = {}
|
||||||
local redWorld = {
|
local redWorld = {
|
||||||
isOverworld = true,
|
isOverworld = true,
|
||||||
map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } },
|
map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } },
|
||||||
@@ -14,11 +16,15 @@ local redWorld = {
|
|||||||
scriptMoves = {},
|
scriptMoves = {},
|
||||||
bikeAllowed = function() return true end,
|
bikeAllowed = function() return true end,
|
||||||
facingIsShoreOrWater = function() return facingWater 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,
|
useBicycle = function(self) self.bikeUsed = true return true end,
|
||||||
useFishingRod = function(self, rod) self.rodUsed = rod return true end,
|
useFishingRod = function(self, rod) self.rodUsed = rod return true end,
|
||||||
}
|
}
|
||||||
local redGame = {
|
local redGame = {
|
||||||
data = { items = { OLD_ROD = { name = "OLD ROD" } } },
|
data = { field = { outsideTilesets = { "OVERWORLD" } },
|
||||||
|
items = { OLD_ROD = { name = "OLD ROD" } } },
|
||||||
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 } },
|
||||||
@@ -30,8 +36,11 @@ local RedAPI = require("src.world.WorldAPI")
|
|||||||
local red = RedAPI.new(redGame, "fixture")
|
local red = RedAPI.new(redGame, "fixture")
|
||||||
local RedWorld = require("src.world.OverworldController")
|
local RedWorld = require("src.world.OverworldController")
|
||||||
T.check(type(RedWorld.useBicycle) == "function"
|
T.check(type(RedWorld.useBicycle) == "function"
|
||||||
and type(RedWorld.useFishingRod) == "function",
|
and type(RedWorld.useFishingRod) == "function"
|
||||||
"Red keeps field-item execution in its world")
|
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()
|
local actions = red:availableFieldActions()
|
||||||
T.eq(actions[1].id, "bicycle", "Red lists an owned usable bicycle")
|
T.eq(actions[1].id, "bicycle", "Red lists an owned usable bicycle")
|
||||||
T.check(red:useFieldAction("bicycle"), "Red accepts the listed 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",
|
T.check(not ok and err == "world is busy",
|
||||||
"Red refuses a stale action while 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 = {
|
local goldWorld = {
|
||||||
map = { id = "ROUTE_29", def = { environment = "ROUTE" } },
|
map = { id = "ROUTE_29", def = { environment = "ROUTE" } },
|
||||||
player = {}, playerState = "normal",
|
player = {}, playerState = "normal",
|
||||||
acceptsMenuInput = function() return true end,
|
acceptsMenuInput = function() return true end,
|
||||||
playerCollision = function() return 0x00 end,
|
playerCollision = function() return 0x00 end,
|
||||||
alwaysOnBike = function() return false end,
|
alwaysOnBike = function() return false end,
|
||||||
fieldContext = function() return { facingColl = 0x20 } end,
|
|
||||||
useFieldItem = function(self, item) self.itemUsed = item return "used" 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 = {
|
local goldGame = {
|
||||||
data = { items = { OLD_ROD = { name = "OLD ROD" } } },
|
data = { items = { OLD_ROD = { name = "OLD ROD" },
|
||||||
save = { inventory = { BICYCLE = 1, OLD_ROD = 1 } },
|
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,
|
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 GoldAPI = require("src.world.gen2.WorldAPI")
|
||||||
local gold = GoldAPI.new(goldGame, "fixture")
|
local gold = GoldAPI.new(goldGame, "fixture")
|
||||||
actions = gold:availableFieldActions()
|
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[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" }),
|
T.check(gold:useFieldAction("fish", { rod = "OLD_ROD" }),
|
||||||
"Gold accepts the same fishing request")
|
"Gold accepts the same fishing request")
|
||||||
T.eq(goldWorld.itemUsed, "OLD_ROD",
|
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",
|
T.check(not ok and err == "fishing rod unavailable",
|
||||||
"Gold rejects an unowned rod")
|
"Gold rejects an unowned rod")
|
||||||
T.eq(goldWorld.itemUsed, used, "a rejected Gold rod changes nothing")
|
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()
|
T.finish()
|
||||||
|
|||||||
Reference in New Issue
Block a user