Files
gen1recomp/src/world/gen2/Follower.lua
T
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

249 lines
8.5 KiB
Lua

-- Gen 2 party follower: the entity and trail loop Gold's cart has no
-- counterpart for, shaped like src/world/PikachuFollower.lua because that is
-- the surface Gen 1 follower mods drive (docs/mod-api-gen2-compat.md).
local Logger = require("src.core.Logger")
local Map = require("src.world.gen2.Map")
local NPC = require("src.world.gen2.Npc")
local Follower = {}
-- above every extracted object_event index, so the `<map>_obj_<n>` id
-- src/world/gen2/Npc.lua:165 seeds from can never collide with a map object
local INDEX = 250
-- Gold ships no such record: a mod patches the `sprites` registry (routed to
-- data.gen2Sprites, src/mods/Schemas.lua:475) before shouldSpawn says yes.
Follower.SPRITE = "SPRITE_PIKACHU"
local warnedSprite = false
-- Gold has no companion, so vanilla answers no. A local VARIABLE, not a
-- function: setShouldSpawn writes the same cell debug.setupvalue reaches.
local shouldSpawn
shouldSpawn = function(_game, _world)
return false
end
function Follower.setShouldSpawn(fn)
local previous = shouldSpawn
shouldSpawn = fn or previous
return previous
end
local function spriteDefFor(world)
local sprites = world.sprites or {}
local def = sprites[Follower.SPRITE]
if def then return def end
-- Loud, then the player's sheet: a mod overwrites npc.sprite the line after
-- NPC.new, so a missing record must not decide whether the entity exists.
if not warnedSprite then
warnedSprite = true
Logger.warn("gen2 follower: no %s sprite record; using the player sheet",
Follower.SPRITE)
end
return world.player and world.player.spriteDef
end
local function makeFollower(_game, world, x, y, facing)
local def = spriteDefFor(world)
if not def then return nil end
local npc = NPC.new(world.map.id, {
-- STANDING_DOWN, not STILL: STILL carries FIXED_FACING
-- (src/world/gen2/Npc.lua:61) and a follower has to turn.
index = INDEX, name = "FOLLOWER", sprite = Follower.SPRITE,
movement = NPC.MOVE.STANDING_DOWN, x = x, y = y,
}, def)
npc.follower = true
-- the Gen 1 name a mod tests when it hunts the stock companion
-- (src/world/PikachuFollower.lua:141)
npc.pikachuFollower = true
npc.passable = true -- never blocks a step (src/world/gen2/Player.lua verdict)
npc.facing = facing or "down"
return npc
end
local function findFollower(world)
for i, npc in ipairs(world.npcs or {}) do
if npc.pikachuFollower then return npc, i end
end
return nil
end
local function remove(world)
local npc, i = findFollower(world)
if not npc then return end
table.remove(world.npcs, i)
for j, e in ipairs(world.entities or {}) do
if e == npc then table.remove(world.entities, j) break end
end
world.follower = nil
end
-- behind the player's facing when walkable, else his own cell: it trails out
-- on the next step (src/world/PikachuFollower.lua:169)
local function spawnCell(world)
local p = world.player
local d = Map.DELTA[p.facing] or Map.DELTA.down
local bx, by = p.cellX - d[1], p.cellY - d[2]
if world.map:inBounds(bx, by) and world.map:isWalkableCell(bx, by) then
return bx, by
end
return p.cellX, p.cellY
end
function Follower.current(world)
return (findFollower(world))
end
function Follower.onMapEntered(game, world, opts, viaMapLoad)
if not (world and world.map and world.player) then return end
remove(world)
if not shouldSpawn(game, world) then return end
-- keepPikachu is Gen 1's spelling of the same opt (src/world/PikachuFollower
-- .lua:191); a mod passing it must not get a fresh spawn at every seam.
local keep = opts and (opts.keepFollower or opts.keepPikachu)
if keep then
table.insert(world.npcs, keep)
table.insert(world.entities, keep)
world.follower = keep
return
end
local x, y = spawnCell(world)
-- a fresh load parks it under the player and it walks out as the trail
-- opens; a mid-map respawn keeps the behind-the-facing cell (#863)
if viaMapLoad then x, y = world.player.cellX, world.player.cellY end
local npc = makeFollower(game, world, x, y, world.player.facing)
if not npc then return end
table.insert(world.npcs, npc)
table.insert(world.entities, npc)
world.follower = npc
world.followerTrail = { x = world.player.cellX, y = world.player.cellY }
-- Gen 1's name for the same table, by reference: rebase mutates it in
-- place, so a mod that resets ow.pikachuTrail still moves the live trail.
world.pikachuTrail = world.followerTrail
end
-- One follow step per logic frame, called from World:step after
-- World:updatePeople -- src/world/OverworldController.lua:1039's position.
function Follower.update(game, world)
if not (world and world.map and world.player) then return end
local npc = findFollower(world)
if not npc then
if shouldSpawn(game, world) then Follower.onMapEntered(game, world) end
return
end
if not shouldSpawn(game, world) then
remove(world)
return
end
world.follower = npc
local p = world.player
local trail = world.followerTrail
if not trail then
trail = { x = p.cellX, y = p.cellY }
world.followerTrail = trail
world.pikachuTrail = trail
end
-- the commit, not the landing: targetX/Y is the live destination, which is
-- what keeps the gap at one cell (src/world/PikachuFollower.lua:429, #410)
local destX = p.targetX or p.cellX
local destY = p.targetY or p.cellY
if destX ~= trail.x or destY ~= trail.y then
npc.goalX, npc.goalY = trail.x, trail.y
trail.x, trail.y = destX, destY
end
if npc.moving then return end
if not npc.goalX then return end
local gx, gy = npc.goalX, npc.goalY
if npc.cellX == gx and npc.cellY == gy then
npc.goalX, npc.goalY = nil, nil
return
end
-- more than a screen behind (a warp, a scripted move): snap, do not walk
local far = math.abs(npc.cellX - gx) + math.abs(npc.cellY - gy)
if far > 6 then
npc.cellX, npc.cellY = gx, gy
npc.px, npc.py = gx * 16, gy * 16
npc.goalX, npc.goalY = nil, nil
return
end
local dir
if npc.cellX < gx then dir = "right"
elseif npc.cellX > gx then dir = "left"
elseif npc.cellY < gy then dir = "down"
else dir = "up" end
npc.facing = dir
npc.stepDir = dir
local d = Map.DELTA[dir]
npc.targetX, npc.targetY = npc.cellX + d[1], npc.cellY + d[2]
-- the player's own step length, halved while more than a cell behind:
-- FastPikachuFollow (src/world/PikachuFollower.lua:509)
local stepLen = p.stepFrames or 16
if far > 1 then stepLen = math.max(1, math.floor(stepLen / 2)) end
npc.stepFrames = stepLen
npc.moving = true
npc.progress = 0
-- World:updatePeople already ran, so burn the first frame here or the
-- follower loses a pixel a tile (src/world/PikachuFollower.lua:520)
npc:update(world.map, world.entities)
end
-- The two Gen 1 members a follower mod replaces outright. Gold has neither a
-- companion to talk to nor a walking starter, so both answer honestly nil.
function Follower.talk(_game, _world, _npc, _done)
return false
end
function Follower.starterInParty(_save, _needHealthy)
return nil
end
-- Drop the follower from the DRAW list while leaving it in the UPDATE list,
-- so it hides in place and keeps trailing (src/world/PikachuFollower.lua:952).
-- Re-adding faces it down, as the Gen 1 arm does.
function Follower.setVisible(world, visible)
local npc = findFollower(world)
if not npc then return end
local entities = world.entities or {}
for i, e in ipairs(entities) do
if e == npc then
if visible then return end
table.remove(entities, i)
return
end
end
if not visible then return end
npc.facing = "down"
table.insert(entities, npc)
end
-- The follower when it is STANDING on that cell, which is the test an
-- interact hook wants: mid-step it is between two (src/world/PikachuFollower
-- .lua:965).
function Follower.at(world, cx, cy)
local npc = findFollower(world)
if not npc or npc.moving then return nil end
if npc.cellX == cx and npc.cellY == cy then return npc end
return nil
end
-- slide into a connected map's frame by the seam's delta, the way
-- src/world/PikachuFollower.lua:386 rebases the Gen 1 arm
function Follower.rebase(world, dx, dy)
local npc = findFollower(world)
if npc then
npc.cellX, npc.cellY = npc.cellX + dx, npc.cellY + dy
npc.px, npc.py = npc.px + dx * 16, npc.py + dy * 16
if npc.targetX then npc.targetX = npc.targetX + dx end
if npc.targetY then npc.targetY = npc.targetY + dy end
if npc.goalX then npc.goalX = npc.goalX + dx end
if npc.goalY then npc.goalY = npc.goalY + dy end
end
local trail = world.followerTrail
if trail then trail.x, trail.y = trail.x + dx, trail.y + dy end
end
return Follower