feat(yellow): port SurfingPikachu behaviour

Yellow's IsSurfingPikachuInParty swaps the player's overworld sheet to
a Pikachu-on-a-surfboard when the party mon that knows SURF is a
Pikachu. The recomp was missing both halves of this: the sheet was
never extracted, and the engine had no seam for the swap.

Extraction: SurfingPikachuSprite (gfx/sprites/surfing_pikachu.2bpp)
loads outside SpriteSheetPointerTable via
LoadSurfingPlayerSpriteGraphics2, the same bypass RedBikeSprite uses.
Added the symbol to the Yellow manifest and a parallel extract in
RomExtractor / build_rom_data / extract/sprites, minting
SPRITE_SURFING_PIKACHU. PaletteFX colors it (player OBP palette, same
as the bike).

Engine: new field.playerSprites.surfPikachu (default
SPRITE_SURFING_PIKACHU, guarded so before extraction the ride keeps
the Seel). Player.new caches surfPikachuSprite; pose() picks it when
surfing and the SURF-mon is a Pikachu. New
OverworldState:syncSurfingPikachu derives the flag from partyKnows at
every surf-state toggle (mount, dismount, fly, teleport, blackout,
forced-surf tile, boot-restore). Runtime-only, re-derived at load so a
party change between save and load is honored.

Lane B: RFC at docs/rfcs/0001-surfing-pikachu-sprite.md.
Backward-compatible — existing mods see no change (surf still defaults
to SPRITE_SEEL; surfPikachu only resolves on a Yellow import after
regeneration). Parity tests in tests/parity_surfing_pikachu_sprite.lua
(12/12) and tests/mod_world_tests.lua (19229/19229 with the new
boot-seed checks). tests/parity_cinnabar_east_surf.lua (24/24)
unchanged.

Regeneration required: re-run make_yellow_manifest.py against a
pret/pokeyellow checkout, then re-import the Yellow ROM.
This commit is contained in:
Andi Miller
2026-08-01 15:39:46 +01:00
parent 22fd196045
commit 1fe35335a5
13 changed files with 437 additions and 15 deletions
+6
View File
@@ -77,6 +77,12 @@ local SAFARI = {
local PLAYER_SPRITES = {
walk = "SPRITE_RED", surf = "SPRITE_SEEL",
bike = "SPRITE_RED_BIKE", fly = "SPRITE_BIRD",
-- Yellow's IsSurfingPikachuInParty: when the SURF-mon is a Pikachu,
-- the player rides this sheet. GFX loads via
-- LoadSurfingPlayerSpriteGraphics2, not SpriteSheetPointerTable, so
-- it needs a manifest extract (RFC 0001). Guarded in Player.new so
-- before extraction lands the ride keeps the Seel.
surfPikachu = "SPRITE_SURFING_PIKACHU",
}
-- The player's own trainer art: RedPicBack (the battle back pic, up until
+25
View File
@@ -389,6 +389,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
and not self.map:isWalkableCell(x, y)
and self.map:isWaterCell(x, y)
end
-- re-derive from the live party: a reloaded save with the SURF-Pikachu
-- since deposited should not render the Pikachu sheet.
-- ponytail: re-derived rather than persisted.
self:syncSurfingPikachu()
end
-- crossConnection re-arms this after setMap; clear so a warp/reload
-- cannot leave a stale deferred PlayMapMusic pending
@@ -1484,6 +1488,21 @@ function OverworldState:partyKnows(moveId)
return partyKnowsVanilla(moveId)
end
-- IsSurfingPikachuInParty (home/map_objects.asm): when the SURF-mon
-- is a Pikachu, pose() renders the Pikachu surf sprite. Called at
-- every surf-state change so a reloaded save picks the right sheet
-- after a party change. No-op when not surfing.
function OverworldState:syncSurfingPikachu()
local p = self.player
if not p then return end
if not p.surfing then
p.surfingPikachu = false
return
end
local mon = self:partyKnows("SURF")
p.surfingPikachu = mon ~= nil and mon.species == "PIKACHU" or false
end
-- The rejection loop shared by the Good and Super Rods
-- (item_effects.asm ItemUseGoodRod .RandomLoop / ReadSuperRodData): an
-- odd random byte is no bite; otherwise a 2-bit pick rerolls until it
@@ -1580,6 +1599,7 @@ function OverworldState:flyTo(mapId)
Game.save.onBike = false
Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE
self.player.surfing = false
self:syncSurfingPikachu()
-- the bird carries the player off westward before the warp
-- (engine/overworld/player_animations.asm LoadBirdSpriteGraphics)
self.flyAnim = { frames = 48 }
@@ -1607,6 +1627,7 @@ function OverworldState:beginTeleportOut(onDone)
end
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
self.player.surfing = false
self:syncSurfingPikachu()
self.player.inputLocked = true
-- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so
-- spinRise lifts the sprite (Player:pose) while spinFrames counts down
@@ -2270,6 +2291,7 @@ function OverworldState:trySurf(fx, fy, onClose)
Game.stack:push(TextBox.new(Game, text, function()
if onClose then onClose() end
p.surfing = true
self:syncSurfingPikachu()
require("src.core.Music").setSurfing(Game.data, true)
Game.stack:push(require("src.render.Transition").whiteFlash(Game, nil,
function() self:stepForwardOrCrossEdge(p.facing) end))
@@ -3154,6 +3176,7 @@ function OverworldState:onStepComplete()
-- dismounting a surf: landing on a walkable cell ends it
if p.surfing and self.map:isWalkableCell(p.cellX, p.cellY) then
p.surfing = false
self:syncSurfingPikachu()
require("src.core.Music").setSurfing(Game.data, false)
end
@@ -3455,6 +3478,7 @@ function OverworldState:checkForcedMovement()
end
elseif tile.mode == "surf" then
p.surfing = true
self:syncSurfingPikachu()
require("src.core.Music").setSurfing(Game.data, true)
end
return false
@@ -3733,6 +3757,7 @@ end
function OverworldState:warpToHealPoint(onDone, opts)
local heal = self:healPoint()
self.player.surfing = false
self:syncSurfingPikachu()
-- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear
-- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm)
Game.save.forcedBike = nil
+8
View File
@@ -41,11 +41,18 @@ function Player.new(data, cx, cy, facing)
-- LoadSurfingPlayerSpriteGraphics, home/overworld.asm)
local walkId = FieldDefaults.fieldValue(data, "playerSprites", "walk")
local surfId = FieldDefaults.fieldValue(data, "playerSprites", "surf")
local surfPikaId = FieldDefaults.fieldValue(data, "playerSprites", "surfPikachu")
local bikeId = FieldDefaults.fieldValue(data, "playerSprites", "bike")
self.sprite = SpriteRenderer.new(data.sprites[walkId], "player")
if surfId and data.sprites[surfId] then
self.surfSprite = SpriteRenderer.new(data.sprites[surfId], "player")
end
-- Yellow's surfing-Pikachu ride (Yellow LoadSurfingPlayerSpriteGraphics2,
-- paired with field.playerSprites.surfPikachu). rotated in at pose()
-- when the SURF-mon is a Pikachu.
if surfPikaId and data.sprites[surfPikaId] then
self.surfPikachuSprite = SpriteRenderer.new(data.sprites[surfPikaId], "player")
end
if bikeId and data.sprites[bikeId] then
self.bikeSprite = SpriteRenderer.new(data.sprites[bikeId], "player")
end
@@ -288,6 +295,7 @@ function Player:pose()
-- RodResponse (engine/items/item_effects.asm) zeroes wWalkBikeSurfState
-- across FishingAnim, so casting from the water shows the on-foot sheet
local sprite = (self.fishing and self.sprite)
or (self.surfing and self.surfingPikachu and self.surfPikachuSprite)
or (self.surfing and self.surfSprite)
or (self.onBike and self.bikeSprite) or self.sprite
return sprite, self.px, py, facing, phase, flip, hopping