-- 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 function PikachuFollower.isStarterPikachu(save, mon) if not (mon and mon.species == "PIKACHU") then return false end local player = save.player or {} return mon.otId == player.id and mon.ot == player.name end function PikachuFollower.isFollowingDisabled(ow) return ow and (ow.pikachuBillsScene or ow.pikachuFanClubScene or ow.pikachuPewterSleepScene) and true or false 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 -- save.pikachuInBall mirrors DisablePikachuOverworldSpriteDrawing (pokeyellow -- scripts/OaksLab.asm); nil falls back to the rival-fight flag (#1009) if save.pikachuInBall == nil then if not save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then return false end elseif save.pikachuInBall 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 -- the live follower, for a caller that has to carry it across a setMap -- that rebuilds ow.npcs (OverworldState:crossConnection, #427) function PikachuFollower.current(ow) local npc = findFollower(ow) return npc end function PikachuFollower.onMapEntered(game, ow, opts, viaMapLoad) -- Bill's House owns a short scripted scene that deliberately keeps -- Pikachu off the normal trailing loop. A new map instance ends it. ow.pikachuBillsScene = nil ow.pikachuFanClubScene = nil remove(ow) if not shouldSpawn(game, ow) then return end -- opts.keepPikachu is the follower a connection crossing kept alive: -- LoadMapHeader's connection path sets wPikachuSpawnState = 2 and bit 4 -- of wPikachuOverworldStateFlags, so SchedulePikachuSpawnForAfterText -- takes .normal_spawn_state -- map coords rebased, sprite data and -- follow command buffer left alone. Re-list the same instance and let -- rebase() shift its cell; a warp arrives without it and respawns -- under the player, the full spawn path of that same routine (the -- viaMapLoad spawn below, #863). local keep = opts and opts.keepPikachu if keep then table.insert(ow.npcs, keep) table.insert(ow.entities, keep) return end local x, y = spawnCell(ow) -- a fresh map entry (warp, boot) parks the follower ON the player's -- cell instead: it stays hidden under him (the draw-sort tie-break in -- OverworldController) and walks out of the warp behind him as the -- trail opens up. Mid-map respawns (bike dismount, revive) keep the -- behind-the-facing cell (#863) if viaMapLoad then x, y = ow.player.cellX, ow.player.cellY end 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 -- Every counter below burns one unit per UpdateSprites call, and a -- standing OverworldLoop spends two DelayFrames on each pass (home/ -- overworld.asm: OverworldLoop delays, falls into OverworldLoopLessDelay -- which delays again, then .noDirectionButtonsPressed loops back), so the -- whole Func_fc803 family runs at half this port's 60Hz fixed step: the -- first glance is $20 CALLS, 64 frames, not 32 (#424). npc.idleClock = ((npc.idleClock or 0) + 1) % 2 if npc.idleClock ~= 0 then 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 -- Slide the follower into the connected map's coordinate frame by the -- delta crossConnection applied to the player: the two maps are one -- continuous world, so a seam is a pure translation. MapX/MapY in -- wSpritePikachuStateData2 are all .normal_spawn_state rewrites there, -- never the pixel coords, which is why the original walks through a seam -- instead of popping (#427). function PikachuFollower.rebase(ow, dx, dy) local npc = findFollower(ow) 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 -- Func_fc82e: the player is taking a step, so any idle pose is over if npc.idle then idleReset(npc) end end local trail = ow.pikachuTrail if trail then trail.x, trail.y = trail.x + dx, trail.y + dy end 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) if ow.pikachuBillsScene or ow.pikachuFanClubScene or ow.pikachuPewterSleepScene then return end 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 local stepDir = destY > trail.y and "down" or destY < trail.y and "up" or destX > trail.x and "right" or "left" -- A ledge hop commits TWO steps (checkLedgeHop -> scriptMove(p, dir, 2), -- the two simulated presses of HandleLedges) but only ONE follow -- command: Func_fcc08 sees BIT_LEDGE_OR_FISHING and defers to -- Func_fcc64, which appends the $5-$8 hop on the takeoff step and -- appends nothing on the landing step (bit 6 of -- wPikachuOverworldStateFlags toggles between the two). With no command -- behind it the hop cannot leave the buffer -- Func_fcc92 only pops once -- a second command is queued -- so Pikachu walks up to the cell the -- player took off from, waits there two cells behind (the Func_fc842 -- idle rolls), and hops one player step later (#424, after #409). if trail.ledgeHop == stepDir then trail.ledgeHop = nil trail.x, trail.y = destX, destY else trail.ledgeHop = ledgeStep(game, ow, trail.x, trail.y, stepDir) and stepDir or nil npc.goalX, npc.goalY = trail.x, trail.y trail.x, trail.y = destX, destY end 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). The trail above holds -- this back until the player commits a further step, so it fires from the -- cell on top of the ledge, a step late (#424). 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 -- the hop is never a Fast step: Func_fc7aa jumps to Func_fca0a on the $4 -- movement status BEFORE it asks AreThereAtLeastTwoSteps..., so its two -- cells ride one normal step's frames even though the goal is two away. if far > 1 and not npc.hopStep 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. Each -- script's BASE 5x5 frame is ripped as pikachu/pikapic_N.png (#561); the -- pikaframe overlays it alternates with are a second full-body pose out of -- the same blob and are still unported, so picLift below stands in for -- their motion and the battle front pic covers caches built before the -- rip (#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 } -- ExecutePikaPicAnimScript spends a Delay3 on every pass of its loop -- (pikachu_pic_animation.asm PikaPicAnimTimerAndJoypad), so one script tick -- is three 60Hz frames: the flat 50 frame hold this port used was under a -- third of even the shortest script (#424). local PIKAPIC_TICK = 3 local PIKAPIC_LIFT = 4 -- px the stand-in pic rises on an overlay run -- pikaemotion_pikapic's script id per emotion: emotion N takes -- PikaPicAnimScript N, except the four listed here (data/pikachu/ -- pikachu_emotions.asm). local PIKAPIC_SCRIPT = { [29] = 10, [30] = 20, [31] = 23, [32] = 23 } -- Per script: pikapic_setduration's tick count, and for the scripts whose -- overlay is a whole second pose, that frameset's run lengths in ticks -- (data/pikachu/pikachu_pic_objects.asm PikaPicAnimBGFrames_*, which script -- N reaches as frameset N+5, or N+6 from script 10 up). The list alternates -- pikaframedelay (the base pic alone) and pikaframe (the overlay) starting -- with a delay, so a frameset that opens on a pikaframe opens with a zero -- here; the frameset restarts until pikapic_looptofinish runs the duration -- out. Scripts 1, 2, 3, 5, 6, 8 and 9 are left without a list on purpose: -- their overlays (PikaAnimTilemap_14 to _22) only paint a few tiles over a -- pic that otherwise stands still, so with no tiles to paint the port has -- nothing to show for them and must not bob the whole picture instead. local PIKAPIC = { [1] = { dur = 40 }, [2] = { dur = 44 }, [3] = { dur = 80 }, [4] = { dur = 70, seq = { 8, 8, 20, 8 } }, [5] = { dur = 32 }, [6] = { dur = 50 }, [7] = { dur = 58, seq = { 0, 8, 2, 8, 2, 8 } }, [8] = { dur = 44 }, [9] = { dur = 56 }, [10] = { dur = 56, seq = { 8, 11, 5 } }, [11] = { dur = 100, seq = { 20, 8, 20, 8 } }, [12] = { dur = 50, seq = { 13, 12, 100, 8 } }, [13] = { dur = 50, seq = { 5, 5, 5, 5, 100 } }, [14] = { dur = 40, seq = { 2, 2, 2, 2 } }, [15] = { dur = 50, seq = { 5, 5, 5, 5 } }, [16] = { dur = 32, seq = { 0, 8, 100 } }, [17] = { dur = 100, seq = { 10, 3, 3, 3, 100 } }, [18] = { dur = 32, seq = { 3, 100, 8, 8 } }, [19] = { dur = 44, seq = { 0, 6, 6, 6, 6 } }, [20] = { dur = 50, seq = { 8, 12, 8, 12 } }, [21] = { dur = 40, seq = { 8, 104 } }, [22] = { dur = 40, seq = { 8, 100 } }, [23] = { dur = 70, seq = { 16, 16, 16, 16 } }, [24] = { dur = 60, seq = { 6, 6, 6, 6, 100 } }, [25] = { dur = 50, seq = { 6, 106 } }, [26] = { dur = 100, seq = { 20, 8, 20, 116 } }, [27] = { dur = 30, seq = { 4, 100 } }, [28] = { dur = 64, seq = { 12, 12, 12, 100 } }, } 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) if ow.pikachuPewterSleepScene then local finish = done done = function() ow.pikachuPewterSleepScene = nil if finish then finish() end end end 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. local Sprites = require("src.pokemon.Sprites") -- The chosen script's own base frame (its first pikapic_loadgfx, ripped as -- pikachu/pikapic_N.png). Red/Blue have no such art and Yellow caches -- built before the rip do not carry it, so both fall back to the battle -- front pic that stood in for every script before (#561). local script = PIKAPIC_SCRIPT[emotion] or emotion local pic = "assets/generated/pikachu/pikapic_" .. script .. ".png" if not require("src.render.Assets").exists(pic) then pic = Sprites.path(game.data, "PIKACHU", "front", { kind = "overworld" }) end local anim = PIKAPIC[script] or PIKAPIC[1] local hold = anim.dur * PIKAPIC_TICK ow.emote = { npc = npc, frames = hold, bubble = bi or false, pikaPic = pic, pikaSeq = anim.seq, pikaTotal = hold, skippable = true, onDone = done, } end -- Where the framed pic sits this frame. The overlay a pikaframe run draws -- is a second full-body pose (PikaAnimTilemap_23 and up replace all 5x5 -- tiles) out of gfx/pikachu/unknown_*, which the cache does not carry, so -- the port lifts the one pic it has for the length of those runs -- the jump -- the happy emotions make inside the box (#424, still on #407's stand-in). function PikachuFollower.picLift(emote) local seq = emote and emote.pikaSeq if not seq then return 0 end local loop = 0 for _, run in ipairs(seq) do loop = loop + run end if loop <= 0 then return 0 end local elapsed = math.max(0, (emote.pikaTotal or 0) - (emote.frames or 0)) local tick = math.floor(elapsed / PIKAPIC_TICK) % loop for i, run in ipairs(seq) do if tick < run then return i % 2 == 0 and PIKAPIC_LIFT or 0 end tick = tick - run end return 0 end -- Bill's House has three map-scripted Yellow companion beats -- (BillsHouseScript0/2/5): Pikachu walks over to investigate Bill, waits at -- the cell separator, then reacts when Bill reappears. Keep it at the -- machine until this map instance is discarded, just like the cartridge's -- disabled following state. local function billsHouseEmotion(game, ow, npc, bubble) local Sprites = require("src.pokemon.Sprites") ow.emote = { npc = npc, frames = 50, bubble = bubbleIndex(game, bubble) or false, pikaPic = Sprites.path(game.data, "PIKACHU", "front", { kind = "overworld" }), } end local function movePikachu(ow, npc, steps, onDone) npc.goalX, npc.goalY = nil, nil idleReset(npc) local function nextStep(i) local step = steps[i] if not step then if onDone then onDone() end return end ow:scriptMove(npc, step[1], step[2], function() nextStep(i + 1) end) end nextStep(1) end function PikachuFollower.onFanClubEntered(game, ow) if not (GameVersion.isYellow() and ow.map and ow.map.id == "POKEMON_FAN_CLUB") then return end local active = game.save.pikachuMapScriptActive game.save.pikachuMapScriptActive = true if active then return end local starter = PikachuFollower.starterInParty(game.save) local npc = findFollower(ow) if not npc or (starter and starter.status) then return end ow.pikachuFanClubScene = true ow.player.facing = "down" for _, other in ipairs(ow.npcs or {}) do if other.def and other.def.name == "POKEMONFANCLUB_SEEL" then other.movementStatus = 2 other.facing = "down" break end end billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE") movePikachu(ow, npc, { { "up", 1 }, { "right", 3 }, { "up", 1 } }, function() npc.facing = "up" end) end function PikachuFollower.onBillsHouseEnter(game, ow) if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") then return end -- BillsHouse_CheckMetBill (scripts/BillsHouse.asm:22-40) sets -- BIT_PIKACHU_MAP_SCRIPT_ACTIVE and rets nz before it looks at -- EVENT_MET_BILL_2; the bit rides sMainData, so a reload inside the house -- resumes at BillsHouseScript1's bare ret (#919). local active = game.save.pikachuMapScriptActive game.save.pikachuMapScriptActive = true if active then return end if game.save.flags.EVENT_MET_BILL_2 then return end -- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused -- walk while CheckPikachuStatusCondition comes back clear -- (engine/pikachu/pikachu_status.asm:140, carry when the starter's status -- byte is nonzero); a statused starter keeps trailing the player instead, -- and that is the one state that lets onBillWalksAroundPlayer below fire, -- since the confused beat ends in DisablePikachuFollowingPlayer (#455). local starter = PikachuFollower.starterInParty(game.save) if starter and starter.status then return end local npc = findFollower(ow) if not npc then return end ow.pikachuBillsScene = true movePikachu(ow, npc, { { "right", 3 }, { "up", 1 } }, function() billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE") end) end -- BillsHousePikachuWatchPlayer (scripts/BillsHouse_2.asm:133-156), reached -- from BillsHouseScript2 (scripts/BillsHouse.asm:58-69) only when the player -- faces down -- so Bill has to walk around them -- and Pikachu is still -- following, i.e. the confused beat above was skipped. Both tables go -- through TryApplyPikachuMovementData -- (engine/events/try_pikachu_movement.asm:1-15), which keeps the data only -- when the caller's b equals GetPikachuFacingDirectionAndReturnToE -- (engine/pikachu/pikachu_follow.asm:1110-1151). That byte is not the -- sprite's facing: it is Pikachu's position relative to the player, UP when -- Pikachu's MapY sits above the player's, DOWN below, and on a shared row -- LEFT when west, RIGHT when east ($ff on the same cell). So WatchPlayer1 -- (b = SPRITE_FACING_UP) means "Pikachu stands above the player" and -- WatchPlayer2 (b = SPRITE_FACING_RIGHT) means "Pikachu stands level with -- and east of the player". ApplyPikachuMovementData_ walks a whole table -- synchronously (engine/pikachu/pikachu_movement.asm:9-18), so the second -- call does observe the finished walk; the two are mutually exclusive by -- geometry rather than by timing, since both routes end at -- (playerX - 1, playerY), west of the player, where the RIGHT test fails. -- Each closes on PIKAMOVEMENT_LOOK_RIGHT, leaving Pikachu watching the -- player while Bill talks; PIKAMOVEMENT_DELAY is dropped here as in the -- beats above (#455). function PikachuFollower.onBillWalksAroundPlayer(game, ow) if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE") or ow.pikachuBillsScene then return end local npc = findFollower(ow) if not npc or not ow.player then return end local steps if npc.cellY < ow.player.cellY then -- PikachuMovement_WatchPlayer1 steps = { { "left", 1 }, { "down", 1 } } elseif npc.cellY == ow.player.cellY and npc.cellX > ow.player.cellX then -- PikachuMovement_WatchPlayer2 steps = { { "up", 1 }, { "left", 2 }, { "down", 1 } } else return end movePikachu(ow, npc, steps, function() npc.facing = "right" end) end function PikachuFollower.onBillEnteredMachine(game, ow) if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end local npc = findFollower(ow) if not npc then return end -- BillsHouseScript3 (pokeyellow scripts/BillsHouse.asm:100-115). The two -- movement tables are named the wrong way round in the cartridge source: -- hl is seeded with ..._EnterCellSeparatorDown, then -- and a ; cp SPRITE_FACING_DOWN (SPRITE_FACING_DOWN is 0) -- jr nz, .applyPikachuMovement -- keeps that seeded table when the player is NOT facing down, and only the -- fallthrough -- facing down -- swaps in ..._EnterCellSeparatorNotDown. -- So facing down takes the long way round the cell separator and every -- other facing walks straight up. Following the label names instead of the -- branch inverts the scene (#455). local steps = ow.player.facing == "down" and { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } } or { { "up", 3 } } movePikachu(ow, npc, steps, function() -- PIKAMOVEMENT_LOOK_UP closes the detour table before the bubble npc.facing = "up" billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE") end) end function PikachuFollower.onBillExitedMachine(game, ow) if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end local npc = findFollower(ow) if not npc then return end idleReset(npc) npc.facing = "left" billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE") end -- OaksLabPikachuMovementScript (pokeyellow scripts/OaksLab_2.asm): the -- companion clears the cell the rival stops on (#1021) function PikachuFollower.oaksLabMakeWay(game, ow, done) if not GameVersion.isYellow() then return false end local npc = findFollower(ow) if not npc or not ow.player then return false end local p = ow.player local steps, facing if p.cellY == 3 then -- .movement2, b = SPRITE_FACING_LEFT if not (npc.cellY == p.cellY and npc.cellX < p.cellX) then return false end steps, facing = { { "down", 1 }, { "right", 1 } }, "up" else -- OaksLabPikachuMovementData1, b = SPRITE_FACING_DOWN if npc.cellY <= p.cellY then return false end steps, facing = { { "left", 1 }, { "up", 1 } }, "right" end movePikachu(ow, npc, steps, function() npc.facing = facing -- PIKAMOVEMENT_LOOK_UP / _LOOK_RIGHT ends each table if done then done() end end) return true 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