mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
679 lines
27 KiB
Lua
679 lines
27 KiB
Lua
-- Yellow's overworld companion Pikachu (pokeyellow engine/pikachu/
|
|
-- pikachu_follow.asm ShouldPikachuSpawn / SpawnPikachu_, plus the
|
|
-- talk-to-it mood beat of engine/pikachu/pikachu_emotions.asm
|
|
-- TalkToPikachu). The follower is an NPC-shaped entity that lives in
|
|
-- ow.npcs (so the standard update/draw walk cycle runs) but never in
|
|
-- ow.entities -- like the original it does not block the player: walk
|
|
-- onto its cell and it simply trails to the cell you vacated.
|
|
--
|
|
-- Happiness rides in save.pikachuHappiness (wPikachuHappiness, seeded 90
|
|
-- by init_player_data.asm) and mood in save.pikachuMood (wPikachuMood,
|
|
-- neutral 128). modifyHappiness below is the full ModifyPikachuHappiness
|
|
-- port (engine/events/pikachu_happiness.asm): the HappinessChangeTable
|
|
-- delta picked by the current happiness hundred-band, then the
|
|
-- PikachuMoods byte nudging the mood; onStep is poison.asm's
|
|
-- UpdatePikachuHappinessAndMood (256-step coin-flip WALKING bump, mood
|
|
-- converging by 1 per step toward 128).
|
|
|
|
local Collision = require("src.world.Collision")
|
|
local GameVersion = require("src.core.GameVersion")
|
|
|
|
local PikachuFollower = {}
|
|
|
|
local INDEX = 99 -- synthetic object index, clear of any map's real objects
|
|
|
|
local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" }
|
|
|
|
-- wPikachuHappiness boot value (engine/movie/oak_speech/
|
|
-- init_player_data.asm: happiness = 90)
|
|
local function happiness(save)
|
|
if save.pikachuHappiness == nil then save.pikachuHappiness = 90 end
|
|
return save.pikachuHappiness
|
|
end
|
|
|
|
function PikachuFollower.bumpHappiness(save, delta)
|
|
save.pikachuHappiness =
|
|
math.max(0, math.min(255, happiness(save) + delta))
|
|
end
|
|
|
|
-- HappinessChangeTable (engine/events/pikachu_happiness.asm): delta by
|
|
-- happiness band (<100 / <200 / rest), plus the PikachuMoods target byte
|
|
-- ($80 leaves the mood alone). Keys mirror the PIKAHAPPY_* constants.
|
|
local HAPPINESS_CHANGES = {
|
|
LEVELUP = { 5, 3, 2, mood = 0x8a },
|
|
USEDITEM = { 5, 3, 2, mood = 0x83 },
|
|
USEDXITEM = { 1, 1, 0, mood = 0x80 },
|
|
GYMLEADER = { 3, 2, 1, mood = 0x80 },
|
|
USEDTMHM = { 1, 1, 0, mood = 0x94 },
|
|
WALKING = { 2, 1, 1, mood = 0x80 },
|
|
DEPOSITED = { -3, -3, -5, mood = 0x62 },
|
|
FAINTED = { -1, -1, -1, mood = 0x6c },
|
|
PSNFNT = { -5, -5, -10, mood = 0x62 },
|
|
CARELESSTRAINER = { -5, -5, -10, mood = 0x6c },
|
|
TRADE = { -10, -10, -20, mood = 0x00 },
|
|
}
|
|
|
|
-- the companion mon: a healthy (or any) party PIKACHU stands in for the
|
|
-- original's OT-checked starter, same approximation as shouldSpawn
|
|
function PikachuFollower.starterInParty(save, needHealthy)
|
|
for _, mon in ipairs(save.party or {}) do
|
|
if mon.species == "PIKACHU"
|
|
and (not needHealthy or (mon.hp or 0) > 0) then
|
|
return mon
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- ModifyPikachuHappiness. mon is the party mon the event applied to for
|
|
-- the per-mon reasons (IsThisPartyMonStarterPikachu); GYMLEADER and
|
|
-- WALKING instead require any healthy starter in the party
|
|
-- (IsStarterPikachuAliveInOurParty).
|
|
function PikachuFollower.modifyHappiness(save, reason, mon)
|
|
if not GameVersion.isYellow() then return end
|
|
local row = HAPPINESS_CHANGES[reason]
|
|
if not row then return end
|
|
if reason == "GYMLEADER" or reason == "WALKING" then
|
|
if not PikachuFollower.starterInParty(save, true) then return end
|
|
elseif not (mon and mon.species == "PIKACHU") then
|
|
return
|
|
end
|
|
local h = happiness(save)
|
|
local band = h < 100 and 1 or h < 200 and 2 or 3
|
|
save.pikachuHappiness = math.max(0, math.min(255, h + row[band]))
|
|
-- PikachuMoods: bytes above $80 only ever raise the mood (and defer to
|
|
-- a pending scripted emotion modifier), bytes below only lower it
|
|
local b = row.mood
|
|
if b ~= 0x80 then
|
|
local mood = save.pikachuMood or 128
|
|
if b > 0x80 then
|
|
if mood < b and not save.pikachuEmotionModifier then
|
|
save.pikachuMood = b
|
|
end
|
|
elseif mood > b then
|
|
save.pikachuMood = b
|
|
end
|
|
end
|
|
end
|
|
|
|
-- UpdatePikachuHappinessAndMood (engine/events/poison.asm): every 256th
|
|
-- step a coin flip on the WALKING bump; every step the mood converges by
|
|
-- 1 toward the neutral 128.
|
|
function PikachuFollower.onStep(save)
|
|
if not GameVersion.isYellow() then return end
|
|
save.pikachuWalkSteps = ((save.pikachuWalkSteps or 0) + 1) % 256
|
|
local rand = love and love.math and love.math.random or math.random
|
|
if save.pikachuWalkSteps == 0 and rand(0, 1) == 1 then
|
|
PikachuFollower.modifyHappiness(save, "WALKING")
|
|
end
|
|
local mood = save.pikachuMood or 128
|
|
if mood < 128 then
|
|
save.pikachuMood = mood + 1
|
|
elseif mood > 128 then
|
|
save.pikachuMood = mood - 1
|
|
end
|
|
end
|
|
|
|
-- ShouldPikachuSpawn, approximated: Yellow, the lab gift happened, and a
|
|
-- healthy Pikachu is in the party (the original checks the starter's OT
|
|
-- identity; a traded second Pikachu standing in is accepted here).
|
|
-- Surfing and biking hide the follower (BIT_PIKACHU_SPAWN flags).
|
|
local function shouldSpawn(game, ow)
|
|
if not GameVersion.isYellow() then return false end
|
|
local save = game.save
|
|
if not (save.flags and save.flags.EVENT_GOT_STARTER) then return false end
|
|
if save.onBike or (ow.player and ow.player.surfing) then return false end
|
|
if not (game.data.sprites and game.data.sprites.SPRITE_PIKACHU) then
|
|
return false
|
|
end
|
|
for _, mon in ipairs(save.party or {}) do
|
|
if mon.species == "PIKACHU" and (mon.hp or 0) > 0 then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function makeFollower(game, ow, x, y, facing)
|
|
local NPC = require("src.world.NPC")
|
|
local npc = NPC.new(game.data, ow.map.id, {
|
|
index = INDEX, name = "PIKACHU_FOLLOWER", sprite = "SPRITE_PIKACHU",
|
|
movement = "STAY", range = "NONE", x = x, y = y,
|
|
})
|
|
npc.pikachuFollower = true
|
|
npc.passable = true -- never blocks a step (Collision.occupied)
|
|
npc.facing = facing or "down"
|
|
-- the idle animations below pose the walk cycle with no step under it,
|
|
-- which NPC:walkPhase (moving-only) cannot express. An instance field
|
|
-- shadows the class method, so NPC:pose keeps working unchanged (#411).
|
|
npc.walkPhase = function(self)
|
|
local idle = self.idle
|
|
if idle and idle.phase then return idle.phase % 2 end
|
|
return NPC.walkPhase(self)
|
|
end
|
|
return npc
|
|
end
|
|
|
|
local function findFollower(ow)
|
|
for i, npc in ipairs(ow.npcs or {}) do
|
|
if npc.pikachuFollower then return npc, i end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function remove(ow)
|
|
local npc, i = findFollower(ow)
|
|
if not npc then return end
|
|
table.remove(ow.npcs, i)
|
|
for j, e in ipairs(ow.entities or {}) do
|
|
if e == npc then table.remove(ow.entities, j) break end
|
|
end
|
|
end
|
|
|
|
-- spawn cell: directly behind the player's facing when that cell is
|
|
-- walkable, else the player's own cell (it trails out on the next step)
|
|
local function spawnCell(ow)
|
|
local p = ow.player
|
|
local dx = p.facing == "left" and 1 or p.facing == "right" and -1 or 0
|
|
local dy = p.facing == "up" and 1 or p.facing == "down" and -1 or 0
|
|
local bx, by = p.cellX + dx, p.cellY + dy
|
|
if ow.map:inBounds(bx, by) and ow.map:isWalkableCell(bx, by) then
|
|
return bx, by
|
|
end
|
|
return p.cellX, p.cellY
|
|
end
|
|
|
|
function PikachuFollower.onMapEntered(game, ow)
|
|
remove(ow)
|
|
if not shouldSpawn(game, ow) then return end
|
|
local x, y = spawnCell(ow)
|
|
local npc = makeFollower(game, ow, x, y, ow.player.facing)
|
|
table.insert(ow.npcs, npc)
|
|
-- entities is the draw list; passable keeps it out of collision
|
|
table.insert(ow.entities, npc)
|
|
ow.pikachuTrail = { x = ow.player.cellX, y = ow.player.cellY }
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Idle behavior (pikachu_follow.asm Func_fc803 and the Func_fc842 roll it
|
|
-- hands off to). Standing still, the follower burns down a frame
|
|
-- counter; at zero it either looks in a random direction (Random & $c,
|
|
-- another $20 frames later) or, when the buffered follow command puts it
|
|
-- two or more cells off the player (ComputePikachuFollowCommand's 5-8
|
|
-- band), rolls one of four in-place animations: a bounce, the walk cycle
|
|
-- on the spot, a two frame shuffle, or a clockwise spin. Func_fc82e
|
|
-- drops whichever is running the moment the player takes a step. Nothing
|
|
-- here plays a bubble or a cry -- those are TalkToPikachu's alone (#411).
|
|
-- ---------------------------------------------------------------------
|
|
|
|
local IDLE_LOOK = 0x20 -- Func_fc803's pause between random glances
|
|
local IDLE_REST = 0x10 -- Func_fc835's pause after an animation ends
|
|
local IDLE_FRAME = 8 -- frames per sprite frame in Func_fc8f8/92b/95d
|
|
|
|
local FACINGS = { "down", "up", "left", "right" }
|
|
-- Func_fc95d .Facings, the order the spin turns through
|
|
local CLOCKWISE = { down = "left", left = "up", up = "right", right = "down" }
|
|
|
|
-- Pointer_fc8d6, transposed to (dx, dy): the asm stores (y, x) and walks
|
|
-- the table backwards as the $11 counter runs down, so entry N here is
|
|
-- what counter N draws. A sway four pixels right then four left with the
|
|
-- body bobbing up twice, netting zero displacement.
|
|
local BOUNCE = {
|
|
{ 0, 0 }, { -1, -2 }, { -2, -4 }, { -3, -2 }, { -4, 0 },
|
|
{ -3, -2 }, { -2, -4 }, { -1, -2 }, { 0, 0 }, { 1, -2 },
|
|
{ 2, -4 }, { 3, -2 }, { 4, 0 }, { 3, -2 }, { 2, -4 },
|
|
{ 1, -2 }, { 0, 0 },
|
|
}
|
|
|
|
local function randomInt(a, b)
|
|
local rand = love and love.math and love.math.random or math.random
|
|
return rand(a, b)
|
|
end
|
|
|
|
-- back onto the cell's own pixels: while the follower stands, nothing else
|
|
-- writes px/py, so the bounce offset has to be undone from here
|
|
local function idleReset(npc)
|
|
npc.idle = nil
|
|
npc.px, npc.py = npc.cellX * 16, npc.cellY * 16
|
|
end
|
|
|
|
-- ComputePikachuFollowCommand: the command the idle state reads back is
|
|
-- 1-4 while the follower sits within a cell of the player and 5-8 once it
|
|
-- is two or more off, Y deciding whenever the rows differ. Returns the
|
|
-- facing those 5-8 encode (Func_fc862 turns that way before it bounces),
|
|
-- or nil for the near band, which only ever glances.
|
|
local function strandedFacing(ow, npc)
|
|
local p = ow.player
|
|
local dy = p.cellY - npc.cellY
|
|
if dy ~= 0 then
|
|
if dy > -2 and dy < 2 then return nil end
|
|
return dy > 0 and "down" or "up"
|
|
end
|
|
local dx = p.cellX - npc.cellX
|
|
if dx > -2 and dx < 2 then return nil end
|
|
return dx > 0 and "right" or "left"
|
|
end
|
|
|
|
-- Func_fc842: an even roll over the four PointerTable_fc85a entries
|
|
local function startIdleAnim(npc, facing)
|
|
local roll = randomInt(0, 3)
|
|
if roll == 0 then
|
|
-- Func_fc862 turns toward the player, then asm_fc87f bounces
|
|
npc.facing = facing or npc.facing
|
|
npc.idle = { kind = "bounce", frames = 0x11 }
|
|
elseif roll == 1 then
|
|
npc.idle = { kind = "walk", frames = 0x30, tick = 0, phase = 0 }
|
|
elseif roll == 2 then
|
|
npc.idle = { kind = "shuffle", frames = 0x20, tick = 0, phase = 0 }
|
|
else
|
|
npc.idle = { kind = "spin", frames = 0x20, tick = 0 }
|
|
end
|
|
end
|
|
|
|
local function idleTick(ow, npc)
|
|
-- Func_fc82e: a step in progress ends the idle state outright
|
|
if ow.player.moving then idleReset(npc) return end
|
|
local idle = npc.idle
|
|
if not idle then
|
|
idle = { kind = "wait", frames = IDLE_LOOK }
|
|
npc.idle = idle
|
|
end
|
|
if idle.kind == "wait" then
|
|
idle.frames = idle.frames - 1
|
|
if idle.frames > 0 then return end
|
|
local facing = strandedFacing(ow, npc)
|
|
if facing then
|
|
startIdleAnim(npc, facing)
|
|
else
|
|
npc.facing = FACINGS[randomInt(1, 4)]
|
|
idle.frames = IDLE_LOOK
|
|
end
|
|
return
|
|
end
|
|
if idle.kind == "bounce" then
|
|
local o = BOUNCE[idle.frames] or BOUNCE[1]
|
|
npc.px = npc.cellX * 16 + o[1]
|
|
npc.py = npc.cellY * 16 + o[2]
|
|
else
|
|
idle.tick = idle.tick + 1
|
|
if idle.tick >= IDLE_FRAME then
|
|
idle.tick = 0
|
|
if idle.kind == "walk" then
|
|
-- Func_fc8f8 runs the anim counter through all four frames; the
|
|
-- top bit is the mirrored foot, which is our stepFlip
|
|
idle.phase = (idle.phase + 1) % 4
|
|
npc.stepFlip = idle.phase >= 2
|
|
elseif idle.kind == "shuffle" then
|
|
idle.phase = idle.phase == 0 and 1 or 0 -- Func_fc92b's xor $1
|
|
else
|
|
npc.facing = CLOCKWISE[npc.facing] or "down"
|
|
end
|
|
end
|
|
end
|
|
idle.frames = idle.frames - 1
|
|
if idle.frames <= 0 then
|
|
-- Func_fc835: a $10 frame rest, then the idle counter again
|
|
idleReset(npc)
|
|
npc.idle = { kind = "wait", frames = IDLE_REST }
|
|
end
|
|
end
|
|
|
|
-- The cell ahead is a ledge the player just hopped (data/tilesets/
|
|
-- ledge_tiles.asm, the same row match OverworldState:checkLedgeHop makes).
|
|
-- The follower only ever retraces cells the player stood on, so a ledge
|
|
-- tile in the trail means the player jumped it (#409).
|
|
local function ledgeStep(game, ow, cx, cy, dir)
|
|
local map = ow.map
|
|
local d = Collision.DELTA[dir]
|
|
local fx, fy = cx + d[1], cy + d[2]
|
|
local lx, ly = cx + d[1] * 2, cy + d[2] * 2
|
|
if not (map:inBounds(fx, fy) and map:inBounds(lx, ly)) then return false end
|
|
local tileset = map.def.tileset
|
|
local standing = map:cellTile(cx, cy)
|
|
local front = map:cellTile(fx, fy)
|
|
for _, ledge in ipairs(game.data.field.ledges or {}) do
|
|
if (ledge.tileset or "OVERWORLD") == tileset
|
|
and ledge.facing == dir and ledge.input == dir
|
|
and ledge.standingTile == standing and ledge.ledgeTile == front then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- one follow step per frame: chase the cell the player last vacated
|
|
-- (pikachu_follow.asm keeps it one walk step behind)
|
|
function PikachuFollower.update(game, ow)
|
|
if ow.pikaHop then return end -- the counter hop owns the follower (#417)
|
|
local npc = findFollower(ow)
|
|
if not npc then
|
|
if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end
|
|
return
|
|
end
|
|
if not shouldSpawn(game, ow) then
|
|
remove(ow)
|
|
return
|
|
end
|
|
local p = ow.player
|
|
local trail = ow.pikachuTrail
|
|
if not trail then
|
|
trail = { x = p.cellX, y = p.cellY }
|
|
ow.pikachuTrail = trail
|
|
end
|
|
-- The follow command is queued the frame the player COMMITS a step, not
|
|
-- the frame it lands: home/overworld.asm .noCollision sets wWalkCounter
|
|
-- and calls Func_fcc08 (pikachu_follow.asm Func_fcc42 reads the direction
|
|
-- of the step just started) before AdvancePlayerSprite, so Pikachu walks
|
|
-- into the cell the player is vacating during that same step and rests
|
|
-- exactly one cell behind. Waiting for p.cellX to change put a whole
|
|
-- extra step between them -- the two-tile gap of issue #410. targetX/Y
|
|
-- is the committed destination while a step is in flight and nil when
|
|
-- standing, so a warp or teleport still registers here (and the far > 6
|
|
-- snap below still catches it).
|
|
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
|
|
-- standing still with nothing to chase is the idle state (Func_fc803);
|
|
-- once a step is under way NPC:update owns px/py, so only the idle
|
|
-- record is dropped here -- never the interpolated pixels
|
|
if npc.moving then npc.idle = nil return end
|
|
if not npc.goalX then idleTick(ow, npc) return end
|
|
local gx, gy = npc.goalX, npc.goalY
|
|
if npc.cellX == gx and npc.cellY == gy then
|
|
npc.goalX, npc.goalY = nil, nil
|
|
idleTick(ow, npc)
|
|
return
|
|
end
|
|
-- fell more than a screen behind (forced movement, warp math): snap
|
|
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
|
|
npc.idle = nil -- the snap already rewrote px/py
|
|
return
|
|
end
|
|
idleReset(npc) -- a real step overrides whatever the idle pose was
|
|
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.targetX = npc.cellX + (dir == "right" and 1 or dir == "left" and -1 or 0)
|
|
npc.targetY = npc.cellY + (dir == "down" and 1 or dir == "up" and -1 or 0)
|
|
-- the cell ahead is the ledge the player hopped: clear both cells in one
|
|
-- step instead of stopping on the ledge (#409). pikachu_follow.asm
|
|
-- Func_fcc08 appends the $5-$8 hop commands while BIT_LEDGE_OR_FISHING
|
|
-- is set, and Func_fca0a runs them as two AddPikachuStepVector cells over
|
|
-- one normal step's frames -- no arc and no shadow, the hop command only
|
|
-- doubles the step vector (NPC:update's hopStep span).
|
|
if ledgeStep(game, ow, npc.cellX, npc.cellY, dir) then
|
|
local d = Collision.DELTA[dir]
|
|
npc.targetX, npc.targetY = npc.cellX + d[1] * 2, npc.cellY + d[2] * 2
|
|
npc.goalX, npc.goalY = npc.targetX, npc.targetY
|
|
npc.hopStep = true
|
|
end
|
|
-- walk at the player's own step length (the bicycle is moot: shouldSpawn
|
|
-- hides the follower on a bike, ShouldPikachuSpawn's wWalkBikeSurfState
|
|
-- check), and halve it while more than one cell behind -- that is
|
|
-- FastPikachuFollow, which pikachu_follow.asm picks whenever two or more
|
|
-- steps are queued (AreThereAtLeastTwoStepsInPikachuFollowCommandBuffer:
|
|
-- walk counter $4 instead of NormalPikachuFollow's $8).
|
|
local stepLen = p.stepFramesCur or 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
|
|
-- this frame's npc:update loop already ran (OverworldState:update walks
|
|
-- self.npcs, then calls here), so burn the step's first frame now.
|
|
-- Without it the step costs a frame more than the player's and Pikachu
|
|
-- trails a pixel further every tile.
|
|
npc:update(ow.map, ow.entities)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- TalkToPikachu (engine/pikachu/pikachu_emotions.asm + data/pikachu/
|
|
-- pikachu_emotions.asm): pick a scripted emotion, then play its bubble
|
|
-- and voiced PCM clip, and raise the framed Pikachu picture the original
|
|
-- puts over the map (pikaemotion_pikapic -> pikachu_pic_animation.asm
|
|
-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. The
|
|
-- per-emotion animation frames (gfx/pikachu/unknown_*) are not extracted,
|
|
-- so the front pic stands in for all twenty of them (#407).
|
|
-- ---------------------------------------------------------------------
|
|
|
|
-- PikachuEmotionTable, reduced to each entry's bubble + pikaemotion_pcm
|
|
-- clip (bubble names are the *_BUBBLE constants; nil cry = silent).
|
|
-- turnAway is pikaemotion_9 (face away from the player, emotion 30).
|
|
local EMOTIONS = {
|
|
[1] = {},
|
|
[2] = { bubble = "SMILE_BUBBLE", cry = 35 },
|
|
[3] = { cry = 40 },
|
|
[4] = { cry = 29 },
|
|
[5] = { cry = 31 },
|
|
[6] = { bubble = "SKULL_BUBBLE" },
|
|
[7] = { cry = 1 },
|
|
[8] = { cry = 39 },
|
|
[9] = { bubble = "SKULL_BUBBLE", cry = 6 },
|
|
[10] = { bubble = "HEART_BUBBLE", cry = 5 },
|
|
[11] = { bubble = "ZZZ_BUBBLE", cry = 37 },
|
|
[12] = {},
|
|
[13] = {},
|
|
[14] = { bubble = "BOLT_BUBBLE", cry = 10 },
|
|
[15] = { cry = 34 },
|
|
[16] = { cry = 33 },
|
|
[17] = { cry = 13 },
|
|
[18] = {},
|
|
[19] = { bubble = "HEART_BUBBLE", cry = 33 },
|
|
[20] = { bubble = "HEART_BUBBLE", cry = 5 },
|
|
[21] = { bubble = "FISH_BUBBLE" },
|
|
[22] = { cry = 4 },
|
|
[23] = { cry = 19 },
|
|
[24] = { bubble = "EXCLAMATION_BUBBLE" },
|
|
[25] = { bubble = "BOLT_BUBBLE", cry = 35 },
|
|
[26] = { bubble = "ZZZ_BUBBLE", cry = 37 },
|
|
[27] = { cry = 9 },
|
|
[28] = { cry = 15 },
|
|
[29] = { cry = 5 },
|
|
[30] = { bubble = "HEART_BUBBLE", cry = 5, turnAway = true },
|
|
[31] = { cry = 19 },
|
|
[32] = { cry = 26 },
|
|
}
|
|
|
|
-- GetPikaPicAnimationScriptIndex (engine/pikachu/pikachu_pic_animation
|
|
-- .asm): mood picks the column (PikachuMoodLookupTable), happiness the
|
|
-- row (PikaPicAnimationScriptPointerLookupTable); the cell is the
|
|
-- emotion index.
|
|
local MOOD_THRESHOLDS = { 40, 127, 128, 210, 255 }
|
|
local MOOD_MATRIX = {
|
|
{ limit = 50, 14, 14, 6, 13, 13 },
|
|
{ limit = 100, 9, 9, 5, 12, 12 },
|
|
{ limit = 130, 3, 3, 1, 8, 8 },
|
|
{ limit = 160, 3, 3, 4, 15, 15 },
|
|
{ limit = 200, 17, 17, 7, 2, 2 },
|
|
{ limit = 250, 17, 17, 16, 10, 10 },
|
|
{ limit = 255, 17, 17, 19, 20, 20 },
|
|
}
|
|
|
|
-- wPikachuEmotionModifier values 1-5 (MapSpecificPikachuExpression
|
|
-- .Emotions): scripted one-shots -- 21 is the fishing-rod reaction
|
|
local MODIFIER_EMOTIONS = { 18, 21, 23, 24, 25 }
|
|
|
|
local function moodEmotion(save)
|
|
local mood = save.pikachuMood or 128
|
|
local column = 5
|
|
for i, threshold in ipairs(MOOD_THRESHOLDS) do
|
|
if mood <= threshold then column = i break end
|
|
end
|
|
local h = happiness(save)
|
|
local row = MOOD_MATRIX[#MOOD_MATRIX]
|
|
for _, r in ipairs(MOOD_MATRIX) do
|
|
if h <= r.limit then row = r break end
|
|
end
|
|
return row[column]
|
|
end
|
|
|
|
-- MapSpecificPikachuExpression + TalkToPikachu's selection order
|
|
local function selectEmotion(game, ow, save)
|
|
local mapId = ow.map.id
|
|
-- Fan Club / Pewter Center map beats (the Bill's-house event variant
|
|
-- is owned by that map's script)
|
|
if mapId == "POKEMON_FAN_CLUB" then return 30 end
|
|
if mapId == "PEWTER_POKECENTER" then return 26 end
|
|
local starter = PikachuFollower.starterInParty(save)
|
|
if starter then
|
|
if starter.status == "SLP" then return 11 end
|
|
if starter.status then return 28 end
|
|
end
|
|
if mapId:find("POKEMON_TOWER_", 1, true) == 1 then return 22 end
|
|
local modifier = save.pikachuEmotionModifier
|
|
if modifier and MODIFIER_EMOTIONS[modifier] then
|
|
save.pikachuEmotionModifier = nil
|
|
return MODIFIER_EMOTIONS[modifier]
|
|
end
|
|
return moodEmotion(save)
|
|
end
|
|
|
|
local function bubbleIndex(game, name)
|
|
local sheet = game.data.field and game.data.field.emotionBubbles
|
|
for i, b in ipairs(sheet and sheet.bubbles or {}) do
|
|
if b.name == name then return i end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function PikachuFollower.talk(game, ow, npc, done)
|
|
-- pikachu_follow.asm steps the follower on the player's own walk clock,
|
|
-- so it is never mid-tile while the player stands and can always be
|
|
-- addressed; this port's follow is a frame late, so land the step here
|
|
-- rather than answer from between two cells (#407). The emote hold
|
|
-- returns before the npc update loop, so a follower left mid-step would
|
|
-- freeze between cells for the whole beat.
|
|
if npc.moving then
|
|
npc.cellX, npc.cellY = npc.targetX or npc.cellX, npc.targetY or npc.cellY
|
|
npc.targetX, npc.targetY = nil, nil
|
|
npc.moving = false
|
|
npc.progress = 0
|
|
npc.hopStep = nil
|
|
end
|
|
idleReset(npc) -- the bubble anchor reads px/py, and the hold freezes it
|
|
npc:facePlayer(ow.player)
|
|
ow.player.facing = OPPOSITE[npc.facing] or ow.player.facing
|
|
local save = game.save
|
|
local emotion = selectEmotion(game, ow, save)
|
|
local e = EMOTIONS[emotion] or EMOTIONS[1]
|
|
if e.turnAway then
|
|
npc.facing = ow.player.facing -- pikaemotion_9: back to the player
|
|
end
|
|
local Sound = require("src.core.Sound")
|
|
if e.cry then
|
|
if not Sound.playPikaCry(game.data, e.cry) then
|
|
Sound.playCry(game.data, "PIKACHU")
|
|
end
|
|
end
|
|
-- caches built before the Yellow bubble sheet only carry the three
|
|
-- shared bubbles; a missing crop degrades to a silent hold
|
|
local bi = e.bubble and bubbleIndex(game, e.bubble)
|
|
-- pikaemotion_pikapic: every entry in data/pikachu/pikachu_emotions.asm
|
|
-- ends with one, and its box is the only thing most of them put on
|
|
-- screen (emotion 5, the fresh-save cell, has no bubble at all). The
|
|
-- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame;
|
|
-- Sprites.path keeps a mod's replacement skin in play. The scripts'
|
|
-- 32-58 frame durations bracket the hold below, so it stays at 50.
|
|
local Sprites = require("src.pokemon.Sprites")
|
|
local pic = Sprites.path(game.data, "PIKACHU", "front",
|
|
{ kind = "overworld" })
|
|
ow.emote = {
|
|
npc = npc, frames = 50, bubble = bi or false, pikaPic = pic,
|
|
onDone = done,
|
|
}
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- PikachuWalksToNurseJoy (engine/pikachu/pikachu_emotions.asm, run by
|
|
-- engine/events/pokecenter.asm once the heal is accepted): the companion
|
|
-- looks up ($36) and hops onto the Poke Center counter. The original
|
|
-- picks one of three movement scripts by where it stands -- below the
|
|
-- player (.PikaMovementData1: walk up left, hop up right), left of it
|
|
-- (.PikaMovementData2: hop up right) or right of it (.PikaMovementData3:
|
|
-- hop up left) -- and all three land on the counter tile directly in
|
|
-- front of the player, so the port animates that one hop. Pikachu
|
|
-- already above the player yields zero movement bytes: no beat (#417).
|
|
-- ---------------------------------------------------------------------
|
|
|
|
local HOP_FRAMES = 32 -- the port's ledge-hop arc (Player:pose hopTotal)
|
|
|
|
function PikachuFollower.hopToCounter(ow, done)
|
|
local npc = GameVersion.isYellow() and findFollower(ow) or nil
|
|
local p = ow.player
|
|
local cx, cy = p:facingCell()
|
|
-- the nurse is talked to across a counter tile (OverworldState:interact);
|
|
-- anything else is the .pikachu_above_player no-op path
|
|
if not npc or p.facing ~= "up" or not ow.map:isCounterCell(cx, cy) then
|
|
if done then done() end
|
|
return
|
|
end
|
|
npc.goalX, npc.goalY = nil, nil
|
|
npc.targetX, npc.targetY = nil, nil
|
|
npc.moving, npc.progress, npc.hopStep = false, 0, nil
|
|
npc.idle = nil
|
|
npc.facing = "up" -- $36, look up
|
|
ow.pikaHop = {
|
|
npc = npc, frames = 0, cellX = cx, cellY = cy, onDone = done,
|
|
fromX = npc.px, fromY = npc.py, toX = cx * 16, toY = cy * 16,
|
|
}
|
|
end
|
|
|
|
-- One frame of that hop. OverworldState:update holds the world for it the
|
|
-- way it holds for the heal machine (only the top state updates, so this
|
|
-- has to sit between the two text boxes); the arc matches Player:pose's
|
|
-- ledge hop -- a 10px sine over 32 frames.
|
|
function PikachuFollower.updateHop(ow)
|
|
local h = ow.pikaHop
|
|
if not h then return end
|
|
h.frames = h.frames + 1
|
|
local t = math.min(1, h.frames / HOP_FRAMES)
|
|
h.npc.px = h.fromX + (h.toX - h.fromX) * t
|
|
h.npc.py = h.fromY + (h.toY - h.fromY) * t
|
|
- math.floor(10 * math.sin(t * math.pi) + 0.5)
|
|
if h.frames < HOP_FRAMES then return end
|
|
h.npc.cellX, h.npc.cellY = h.cellX, h.cellY
|
|
h.npc.px, h.npc.py = h.toX, h.toY
|
|
ow.pikaHop = nil
|
|
-- the player has not moved, so the trail restarts under his feet and the
|
|
-- follower only steps back off the counter once he walks away
|
|
ow.pikachuTrail = { x = ow.player.cellX, y = ow.player.cellY }
|
|
if h.onDone then h.onDone() end
|
|
end
|
|
|
|
-- Disable/EnablePikachuOverworldSpriteDrawing around the healing machine
|
|
-- (engine/events/pokecenter.asm): Pikachu goes behind the counter with the
|
|
-- party and comes back standing on it, facing the player -- the respawn is
|
|
-- wPikachuSpawnState = 5, which is .above_player in pikachu_follow.asm,
|
|
-- followed by `lb bc, 15, 0` (sprite struct 15 is Pikachu, image index 0
|
|
-- is facing down). ow.entities is the draw list and ow.npcs the update
|
|
-- list, so dropping it from entities alone hides it in place (#417).
|
|
function PikachuFollower.setVisible(ow, visible)
|
|
local npc = findFollower(ow)
|
|
if not npc then return end
|
|
for i, e in ipairs(ow.entities or {}) do
|
|
if e == npc then table.remove(ow.entities, i) break end
|
|
end
|
|
if visible then
|
|
npc.facing = "down"
|
|
table.insert(ow.entities, npc)
|
|
end
|
|
end
|
|
|
|
-- npc the player is facing, when it is the follower (interact hook)
|
|
function PikachuFollower.at(ow, cx, cy)
|
|
local npc = findFollower(ow)
|
|
if npc and not npc.moving and npc.cellX == cx and npc.cellY == cy then
|
|
return npc
|
|
end
|
|
return nil
|
|
end
|
|
|
|
return PikachuFollower
|