Spider Car Unleashed

CLOSES #1483, CLOSES #1610, CLOSES #1615, CLOSES #1646, CLOSES #1649, CLOSES #1651, CLOSES #1653, CLOSES #1656, CLOSES #1683, CLOSES #1685, CLOSES #1686, CLOSES #1687, CLOSES #1688, CLOSES #1689, CLOSES #1690, CLOSES #1693, CLOSES #1694, CLOSES #1695, CLOSES #1696, CLOSES #1702, CLOSES #1704, CLOSES #1705, CLOSES #1706, CLOSES #1707, CLOSES #1708, CLOSES #1710, CLOSES #1711, CLOSES #1712, CLOSES #1713, CLOSES #1716, CLOSES #1717, CLOSES #1718, CLOSES #1719, CLOSES #1720, CLOSES #1721, CLOSES #1725, CLOSES #1732, CLOSES #1745, CLOSES #1748, CLOSES #1749, CLOSES #1751, CLOSES #1754
This commit is contained in:
bryanthaboi
2026-08-24 07:52:05 -04:00
parent f895293217
commit 1905261c5b
180 changed files with 19567 additions and 1556 deletions
+17
View File
@@ -42,6 +42,23 @@ function NPC.new(data, mapId, objDef)
return self
end
-- LoadMapHeader .loadSpriteData zeroes the sprite state data and re-seeds
-- MAPY/MAPX from the map header -- home/overworld.asm:2133
function NPC:resetToSpawn()
local def = self.def
self.cellX, self.cellY = def.x, def.y
self.px, self.py = self.cellX * 16, self.cellY * 16
self.facing = FACING_FROM_RANGE[def.range] or "down"
self.targetX, self.targetY = nil, nil
self.moving = false
self.marching = false
self.hopStep = nil
self.progress = 0
self.animClock = 0
self.stepFlip = false
self.timer = love.math.random(30, 120)
end
function NPC:facePlayer(player)
local dx = player.cellX - self.cellX
local dy = player.cellY - self.cellY
+205 -51
View File
@@ -81,6 +81,24 @@ local HEAL_BALL_XY = {
-- swaps the two middle shades of the monitor/ball art in place
local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 }
-- scripts/VermilionDock.asm:39 VermilionDockSSAnneLeavesScript: her hull is
-- the four blocks (5..8, 1..2) of VermilionDock.blk
local SS_ANNE_BLOCK = { x = 5, y = 1, w = 4, h = 2 }
-- scripts/VermilionDock.asm:164 VermilionDock_SyncScrollWithLY splits rSCX at
-- LY $50, so her top 16px -- the player's own cell row -- never scrolls
local SS_ANNE_KEEP_PX = 16
-- scripts/VermilionDock.asm:79 `ld e, $8`: eight 16px columns, and each
-- .delay_between_drifts pass is eight frames per pixel
local SS_ANNE_SAIL_PX, SS_ANNE_PX_FRAMES = 128, 8
-- scripts/VermilionDock.asm:182 VermilionDock_EraseSSAnne: block 1 is the
-- shoreline row she sat in, block 13 the open water below it
local SS_ANNE_WATER = { 1, 13 }
-- scripts/VermilionDock.asm:142 VermilionDock_EmitSmokePuff: a puff per
-- column off the front smokestack, drifting east 2px per drift step
local SS_ANNE_SMOKE = { dx = 64, dy = 20, drift = 2, every = 16, count = 5 }
-- scripts/VermilionDock.asm:65 `ldh [rOBP1], a` with a = 0: the puff is white
local SS_ANNE_SMOKE_MAP = { [0] = 0, [1] = 0, [2] = 0, [3] = 0 }
-- Fishing rod placement (FishingRodOAM, engine/overworld/player_animations
-- .asm). Those dbsprite rows are raw shadow-OAM bytes like HEAL_BALL_XY
-- above (screen = tile*8 + pixel - 8/16), measured against the player
@@ -320,6 +338,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
self.parallelQueue = {}
end
self.marchers = {}
self.shipAnim = nil
local queue = self.pendingScripts
if queue then
for i = #queue, 1, -1 do
@@ -413,12 +432,14 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
-- (home/overworld.asm), so an NPC who walked up to the player stands
-- on her spawn cell again the next time that map loads (#1028). Only
-- the save-side spawn flags survive, and those live in Game.save, not
-- here. Warps rebuild the whole pool from scratch.
-- here. Warps rebuild the whole pool from scratch; a seam crossing arms
-- the re-seed and applyPendingSpawnResets lands it off camera (#1755).
if not (opts and opts.seamless and self.npcPool) then
self.npcPool = {}
elseif fromMapId ~= mapId then
for _, obj in ipairs(self.map.def.objects or {}) do
self.npcPool[mapId .. "_obj_" .. obj.index] = nil
local npc = self.npcPool[mapId .. "_obj_" .. obj.index]
if npc then npc.pendingSpawnReset = true end
end
end
self.npcs = {}
@@ -624,6 +645,41 @@ function OverworldState:rebuildNeighbors()
end
end
-- the deferred half of the seam re-seed armed in setMap (#1755): the cart's
-- connected map has no sprites to be seen snapping -- home/overworld.asm:2133
function OverworldState:applyPendingSpawnResets()
local pool = self.npcPool
if not (pool and self.camera) then return end
local due
for _, npc in pairs(pool) do
if npc.pendingSpawnReset then
due = due or {}
due[npc] = true
end
end
if not due then return end
local cam = self.camera
local vw, vh = Game.renderer:worldViewSize()
local function onCamera(px, py)
return px + 16 > cam.x - 16 and px < cam.x + vw + 16
and py + 16 > cam.y - 16 and py < cam.y + vh + 16
end
for _, mv in ipairs(self.scriptMoves or {}) do due[mv.entity] = nil end
for entity in pairs(self.marchers or {}) do due[entity] = nil end
for _, npc in ipairs(self.npcs or {}) do
if due[npc] and onCamera(npc.px, npc.py) then due[npc] = nil end
end
for _, g in ipairs(self.ghosts or {}) do
if due[g.npc] and onCamera(g.npc.px + g.ox, g.npc.py + g.oy) then
due[g.npc] = nil
end
end
for npc in pairs(due) do
npc.pendingSpawnReset = nil
npc:resetToSpawn()
end
end
-- SGB overworld palette (engine/gfx/palettes.asm SetPal_Overworld):
-- towns use their own palette, routes PAL_ROUTE, interiors the town or
-- route they are in (wLastMap = our lastOutdoor), with tileset and
@@ -1068,6 +1124,27 @@ function OverworldState:update(dt)
if da.onDone then da.onDone() end
end
end
-- scripts/VermilionDock.asm:80 .shift_columns_up
if self.shipAnim and not self.shipAnim.gone then
local sa = self.shipAnim
sa.frames = sa.frames + 1
if sa.frames >= SS_ANNE_PX_FRAMES then
sa.frames = 0
sa.off = sa.off + 1
if sa.off % SS_ANNE_SMOKE.every == 1
and #sa.puffs < SS_ANNE_SMOKE.count then
sa.puffs[#sa.puffs + 1] = { x = sa.px - sa.off + SS_ANNE_SMOKE.dx,
y = sa.py + SS_ANNE_SMOKE.dy }
end
for _, p in ipairs(sa.puffs) do p.x = p.x + SS_ANNE_SMOKE.drift end
if sa.off >= SS_ANNE_SAIL_PX then
sa.gone, sa.puffs = true, {}
local done = sa.onDone
sa.onDone = nil
if done then done() end
end
end
end
if self.cutAnim then
local ca = self.cutAnim
ca.frames = ca.frames - 1
@@ -1227,6 +1304,7 @@ function OverworldState:update(dt)
-- escort then walks an extra tile before PlayerEntryMovementRLE, and
-- the player lands on desk Oak.
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
or (self.hopLand or 0) > 0
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive
if not scripted and not self.transitioning then
@@ -1236,6 +1314,7 @@ function OverworldState:update(dt)
-- direction handling (JoypadOverworld runs the map script first) --
-- the player can never start another step after being spotted.
scripted = self.runner:isRunning() or #self.scriptMoves > 0
or (self.hopLand or 0) > 0
or self.engaging or self.emote or self.teleportOut
or self.flyAnim or self.flyArrive
end
@@ -1244,6 +1323,7 @@ function OverworldState:update(dt)
if not scripted and not self.transitioning and Game.stack:top() == self then
self:handleInput()
end
if (self.hopLand or 0) > 0 then self.hopLand = self.hopLand - 1 end
local stepped = self.player:update()
-- the warp-arrival cell goes stale the instant the player's real cell
@@ -1271,6 +1351,7 @@ function OverworldState:update(dt)
self.camera:follow(self.player.px, self.player.py,
Game.renderer:worldViewSize())
self:applyPendingSpawnResets()
-- pan_camera offset rides on top of the follow; the ramp resumes its
-- runner when it lands
@@ -1509,6 +1590,76 @@ function OverworldState:startDustAnim(cx, cy, onDone)
self.dustAnim = { x = cx, y = cy, frames = 32, onDone = onDone }
end
-- scripts/VermilionDock.asm:39 VermilionDockSSAnneLeavesScript: snapshot her
-- hull tiles, flood her box with water, and slide the snapshot west from there
function OverworldState:startSsAnneDeparture(onDone)
local map = self.map
local tx0, ty0 = SS_ANNE_BLOCK.x * 4, SS_ANNE_BLOCK.y * 4
local tiles = {}
for row = 1, SS_ANNE_BLOCK.h * 4 do
local r = {}
for col = 1, SS_ANNE_BLOCK.w * 4 do
r[col] = map:tileAt(tx0 + col - 1, ty0 + row - 1)
end
tiles[row] = r
end
for bx = SS_ANNE_BLOCK.x, SS_ANNE_BLOCK.x + SS_ANNE_BLOCK.w - 1 do
for by = SS_ANNE_BLOCK.y, SS_ANNE_BLOCK.y + SS_ANNE_BLOCK.h - 1 do
map:setBlock(bx, by, SS_ANNE_WATER[by - SS_ANNE_BLOCK.y + 1] or 13)
end
end
map.renderer:rebuild()
self.shipAnim = { px = tx0 * 8, py = ty0 * 8, tiles = tiles,
off = 0, frames = 0, puffs = {}, onDone = onDone }
end
-- scripts/VermilionDock.asm:164: the rSCX split keeps her top 16px (the
-- shoreline row and the gangway under the player) put while the rest sails
function OverworldState:drawShipAnim(camX, camY)
local sa = self.shipAnim
if not sa then return end
local renderer = self.map.renderer
local img, quads = renderer.image, renderer.quads
local ox, oy = -math.floor(camX), -math.floor(camY)
local keep = SS_ANNE_KEEP_PX / 8
love.graphics.setColor(1, 1, 1, 1)
for row = 1, #sa.tiles do
if row <= keep or not sa.gone then
local slide = row > keep and sa.off or 0
local wy = sa.py + (row - 1) * 8 + oy
for col = 1, #sa.tiles[row] do
local quad = quads[sa.tiles[row][col]]
if quad then
love.graphics.draw(img, quad, sa.px + (col - 1) * 8 - slide + ox, wy)
end
end
end
end
if #sa.puffs == 0 then return end
local fxDef = Game.data.field.overworldFx
local smoke = fxDef and fxDef.smoke
if not smoke then return end
if self.smokeImg == nil then
local ok, image = pcall(love.graphics.newImage, smoke.path)
self.smokeImg = ok and image or false
end
if not self.smokeImg then return end
local shader = PaletteFX.shader()
if shader then
PaletteFX.sendColors(shader,
PaletteFX.permute(PaletteFX.GRAYS, SS_ANNE_SMOKE_MAP))
love.graphics.setShader(shader)
end
for _, p in ipairs(sa.puffs) do
for i = 0, 1 do
for j = 0, 1 do
love.graphics.draw(self.smokeImg, p.x + i * 8 + ox, p.y + j * 8 + oy)
end
end
end
if shader then love.graphics.setShader() end
end
-- Ledge hops (data/tilesets/ledge_tiles.asm): standing tile + ledge tile
-- in front + matching input direction -> jump two cells.
function OverworldState:checkLedgeHop(dir)
@@ -1542,17 +1693,22 @@ function OverworldState:checkLedgeHop(dir)
return false
end
require("src.core.Sound").play(Game.data, "Ledge")
local hop = (p.stepFramesCur or p.stepFrames or 16) * 2
p.ledgeHop = true -- BIT_LEDGE_OR_FISHING, no bike speedup mid-hop
local hop = p:stepLength() * 2
p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic)
self:scriptMove(p, dir, 1, function() self:checkEdgeExit(dir) end)
self:scriptMove(p, dir, 1, function()
self:checkEdgeExit(dir)
self:finishLedgeHop()
end)
return true
end
if not Collision.occupied(self.entities, lx, ly, p)
and self.map:isWalkableCell(lx, ly) then
require("src.core.Sound").play(Game.data, "Ledge")
local hop = (p.stepFramesCur or p.stepFrames or 16) * 2
p.ledgeHop = true -- BIT_LEDGE_OR_FISHING, no bike speedup mid-hop
local hop = p:stepLength() * 2
p.hopFrames, p.hopTotal = hop, hop -- jump arc (cosmetic)
self:scriptMove(p, dir, 2)
self:scriptMove(p, dir, 2, function() self:finishLedgeHop() end)
return true
end
end
@@ -1560,6 +1716,13 @@ function OverworldState:checkLedgeHop(dir)
return false
end
-- _HandleMidJump .finishedJump lands with UpdateSprites + Delay3 before it
-- clears the joypad bytes -- engine/overworld/player_animations.asm:509
function OverworldState:finishLedgeHop()
self.player.ledgeHop = nil
self.hopLand = 3
end
-- walking off the map edge: connection crossing or edge warp (exit mats)
function OverworldState:checkEdgeExit(dir)
local p = self.player
@@ -1665,9 +1828,7 @@ function OverworldState:crossConnection(dir, conn)
-- fresh walk-cycle clock so the seam step always shows leg frames
-- (mid-cycle stand phase would otherwise look like a slide)
p.animClock = 0
p.stepFramesCur = Game.save.onBike
and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8)
or (FieldDefaults.world(Game.data, "stepFrames") or 16)
p.stepFramesCur = p:stepLength()
require("src.core.FixedStep"):discardCatchup()
return true
end
@@ -2413,16 +2574,12 @@ function OverworldState:trashCanSwitch(canIndex)
local adj = tc.adjacent[puz.first]
local masked = require("bit").band(love.math.random(0, 255), #adj)
puz.second = masked == 0 and 0 or adj[masked]
-- VermilionGymTrashSuccessText1's text_asm tail plays SFX_SWITCH only
-- after the text has printed (text_far ...; text_asm;
-- WaitForSoundToFinish; PlaySound SFX_SWITCH; WaitForSoundToFinish),
-- and DisplayTextID's WaitForTextScrollButtonPress then holds the box
-- until the player dismisses it -- so the beep belongs on close, not
-- open.
-- engine/events/hidden_events/vermilion_gym_trash.asm:130 (text_asm tail:
-- SFX_SWITCH once the text has printed, before the button wait) (#1702)
Game.stack:push(TextBox.new(Game,
t._VermilionGymTrashSuccessText1
or Strings("Hey! There's a\nswitch under the\ntrash!\fThe 1st electric\nlock opened!"),
function() require("src.core.Sound").play(Game.data, "Switch") end))
nil, TextBox.soundOpts(Game, "Switch")))
return
end
-- .trySecondLock
@@ -2433,25 +2590,28 @@ function OverworldState:trashCanSwitch(canIndex)
-- the clear floor block opens the doors (VermilionGymSetDoorTile)
local door = FieldDefaults.fieldValue(Game.data, "hiddenExtras",
"trashCans", "doorBlock")
self:replaceBlock(door.bx, door.by, door.block)
-- SuccessText3's text_asm tail plays SFX_GO_INSIDE after the text
-- prints, so the beep fires as the box closes, not as it opens.
-- engine/events/hidden_events/vermilion_gym_trash.asm:153 beeps as the
-- text finishes; scripts/VermilionGym.asm:30 beeps on the swap (#1702)
Game.stack:push(TextBox.new(Game,
t._VermilionGymTrashSuccessText3
or Strings("The 2nd electric\nlock opened!\fThe motorized door\nopened!"),
function() require("src.core.Sound").play(Game.data, "Go_Inside") end))
function()
require("src.core.Sound").play(Game.data, "Go_Inside")
self:replaceBlock(door.bx, door.by, door.block)
end,
TextBox.soundOpts(Game, "Go_Inside")))
else
-- wrong can: ResetEvent EVENT_1ST_LOCK_OPENED and immediately
-- re-roll the first switch (Random & $e)
save.flags.EVENT_1ST_LOCK_OPENED = nil
puz.first = love.math.random(0, 7) * 2
puz.second = nil
-- VermilionGymTrashFailText's text_asm tail plays SFX_DENIED after the
-- text prints, so the beep fires as the box closes, not as it opens.
-- engine/events/hidden_events/vermilion_gym_trash.asm:162 (text_asm tail:
-- SFX_DENIED once the text has printed, before the button wait) (#1702)
Game.stack:push(TextBox.new(Game,
t._VermilionGymTrashFailText
or Strings("Nope! There's\nonly trash here.\fHey! The electric\nlocks were reset!"),
function() require("src.core.Sound").play(Game.data, "Denied") end))
nil, TextBox.soundOpts(Game, "Denied")))
end
end
@@ -2899,10 +3059,10 @@ function OverworldState:talkTo(npc)
if entry then
if entry.mart then
npc:facePlayer(self.player)
Game.stack:push(TextBox.new(Game, romText(Game.data, "_PokemartGreetingText", "Hi there!\nMay I help you?"), function()
Screens.push(Game, "ShopMenu", entry.mart)
unfreeze()
end))
-- the greeting stays in the box under the menu, so ShopMenu owns it
-- now -- home/text_script.asm:143
Screens.push(Game, "ShopMenu", entry.mart)
unfreeze()
return
end
if entry.nurse then
@@ -3365,22 +3525,18 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
or (header and header.won and Game.data.text[header.won])
local BattleState = require("src.battle.BattleState")
local stingPlayed = false
-- home/trainers.asm:109 prints, :123 engages (BIT_SEEN_BY_TRAINER =
-- self.engaging), then home/text_script.asm:96 waits for A (#764, #1683)
local function playMeetSting()
if stingPlayed or self.engaging then return end
stingPlayed = true
local theme = meetTrainerTheme(d.trainerClass)
if theme then require("src.core.Music").play(Game.data, theme) end
end
local function startBattle(options)
self.cancelledTrainerSight = nil
-- TalkToTrainer (home/trainers.asm:88) prints the before-battle text
-- FIRST and only then runs `call EngageMapTrainer` / `jp
-- StartTrainerBattle`, so a trainer challenged on foot gets the sting
-- over the battle transition rather than under the dialogue. Its
-- `bit BIT_SEEN_BY_TRAINER, [hl] / ret nz` guard is self.engaging
-- here: TrainerEngage (engine/overworld/trainer_sight.asm:224) already
-- started the sting before the "!" bubble on the sight path, so it
-- must not restart. Script-driven challenges (gyms.lua leaders,
-- scripts/SilphCo11F.asm:269 Giovanni, scripts/FightingDojo.asm:122)
-- all `call EngageMapTrainer` too, and reach this same path (#764).
if not self.engaging then
local theme = meetTrainerTheme(d.trainerClass)
if theme then require("src.core.Music").play(Game.data, theme) end
end
playMeetSting()
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty,
options)
battle.checkpointOrigin = {
@@ -3451,7 +3607,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
if skipBattleText then
prepareBattle()
else
Game.stack:push(TextBox.new(Game, battleText, prepareBattle))
Game.stack:push(TextBox.new(Game, battleText, prepareBattle,
{ auto = { wait = true, delay = 0, sound = playMeetSting } }))
end
end
@@ -4395,18 +4552,11 @@ function OverworldState:afterBattle(result, battle)
Logger.info("battle over: %s (lead %s %d/%d)", tostring(result),
lead and lead.species or "-", lead and lead.hp or 0,
lead and lead.stats.hp or 0)
local Evolution = require("src.pokemon.Evolution")
local function evolutions()
-- Only mons that gained a level this battle (EXP.ALL included).
-- Scanning the whole party re-offered B-cancelled evolutions forever (#213).
Evolution.checkParty(Game, nil, battle and battle.leveledUp)
end
if result == "lose" then
local oaksLabRival = battle and battle.oppClass == "OPP_RIVAL1"
and self.map and self.map.id == "OAKS_LAB"
if oaksLabRival then
-- stay in the lab; OaksLabRivalEndBattleScript heals and continues
evolutions()
return
end
-- blackout: revive the party at the last heal point; half the
@@ -4419,7 +4569,7 @@ function OverworldState:afterBattle(result, battle)
/ (FieldDefaults.world(Game.data, "blackoutMoneyDivisor") or 2))
Runtime.emit("world.blacked_out",
{ save = Game.save, healTarget = self:healPoint() })
self:warpToHealPoint(evolutions)
self:warpToHealPoint()
else
-- EndTrainerBattle sets BIT_CUR_MAP_LOADED_1 (home/trainers.asm), which
-- re-runs the floor's door callback: beating the last Rocket Hideout guard
@@ -4429,7 +4579,6 @@ function OverworldState:afterBattle(result, battle)
if Game.save.safari and Game.save.safari.balls <= 0 then
self:safariGameOver(Strings("PA: You're out of\nSAFARI BALLs!"))
end
evolutions()
end
end
@@ -4858,6 +5007,9 @@ function OverworldState:updateScriptMoves()
e.facing = mv.dir
local tx, ty = Collision.target(e.cellX, e.cellY, mv.dir)
e.targetX, e.targetY = tx, ty
-- a simulated d-pad press runs at the CURRENT walk/bike speed, not
-- whatever the last real step left behind -- home/overworld.asm:276
if e.stepLength then e.stepFramesCur = e:stepLength() end
e.moving = true
e.progress = 0
mv.remaining = mv.remaining - 1
@@ -5018,6 +5170,7 @@ function OverworldState:drawWorld()
for _, nb in ipairs(self.neighbors) do
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
end
self:drawShipAnim(cam.x, bgY)
end
-- per-billboard SGB palette source; only needed (and only paid for) when
-- tilting. nil headless / on stale palettes -> billboards go uncolorized.
@@ -5374,6 +5527,7 @@ function OverworldState:drawWorld()
for _, nb in ipairs(self.neighbors) do
nb.map.renderer:drawMapOnly(cam.x - nb.ox, bgY - nb.oy, vw, vh)
end
self:drawShipAnim(cam.x, bgY)
end
end
+21 -16
View File
@@ -110,6 +110,26 @@ function Player:turnWindow()
return frames
end
-- the bicycle doubles walking speed (8 frames per step); movement.speed
-- lets a mod multiply or replace that (running shoes, dash, etc.)
-- DoBikeSpeedup is skipped mid-hop -- home/overworld.asm:283
function Player:stepLength()
local Game = require("src.core.Game")
local save = Game.save
local onBike = (save and save.onBike and not self.ledgeHop) or false
local frames = onBike and self.bikeStepFrames or self.stepFrames or STEP_FRAMES
if Runtime.wantsHook("movement.speed") then
frames = Runtime.call("movement.speed", function(f) return f end, frames, {
onBike = onBike,
surfing = self.surfing and true or false,
player = self,
input = Game.input,
save = save,
})
end
return math.max(1, math.floor(tonumber(frames) or STEP_FRAMES))
end
-- Attempt to start a step; returns "moved"|"turned"|"blocked"|nil.
function Player:tryMove(dir, map, entities)
if self.moving or self.inputLocked then return nil end
@@ -145,22 +165,7 @@ function Player:tryMove(dir, map, entities)
self.moving = true
self.bumpFrames = nil -- a real step supersedes any in-place bonk
self.progress = 0
-- the bicycle doubles walking speed (8 frames per step); movement.speed
-- lets a mod multiply or replace that (running shoes, dash, etc.)
local Game = require("src.core.Game")
local save = Game.save
local frames = (save and save.onBike) and self.bikeStepFrames
or self.stepFrames or STEP_FRAMES
if Runtime.wantsHook("movement.speed") then
frames = Runtime.call("movement.speed", function(f) return f end, frames, {
onBike = save and save.onBike or false,
surfing = self.surfing and true or false,
player = self,
input = Game.input,
save = save,
})
end
self.stepFramesCur = math.max(1, math.floor(tonumber(frames) or STEP_FRAMES))
self.stepFramesCur = self:stepLength()
return "moved"
end
+6 -2
View File
@@ -102,8 +102,8 @@ FieldMoves.TEXT = {
.. "\fA #MON may be\nable to pass it."),
ASK_WHIRLPOOL = Strings.source("A whirlpool is in\nthe way."
.. "\fWant to use\nWHIRLPOOL?"),
-- Not a cart line: the stand-in destination prompt World:askFlyPoint uses
-- until the POKeGEAR's MAP card grows _FlyMap's cursor mode.
-- Not a cart line: the prompt World:askFlyPoint falls back to when there is
-- no screen at all to push -- a headless probe, never a real run.
ASK_FLY_TO = Strings.source("Fly to %s?"),
}
@@ -183,6 +183,10 @@ function FieldMoves.bindEngineFlags(order)
-- The flag IS wPlayerGender's bit 0, so World routes it to the gender byte
-- (data/events/engine_flags.asm:131, constants/engine_flags.asm:121).
FieldMoves.FEMALE_FLAG = byName[FieldMoves.FEMALE_FLAG_NAME]
-- Crystal's ENGINE_MOBILE_SYSTEM (constants/engine_flags.asm:25) has no Gold
-- row, so every id from BUG_CONTEST_TIMER up shifts one.
FieldMoves.BUG_CONTEST_FLAG = byName["ENGINE_BUG_CONTEST_TIMER"] or 16
FieldMoves.BIKE_SHOP_CALL_FLAG = byName["ENGINE_BIKE_SHOP_CALL_ENABLED"] or 19
return flags
end
+22 -1
View File
@@ -31,6 +31,8 @@ local MOVE = {
STANDING_LEFT = 8,
STANDING_RIGHT = 9,
SPINRANDOM_FAST = 10,
-- data/sprites/map_objects.asm:181-187
POKEMON = 0x16,
SPINCOUNTERCLOCKWISE = 0x1e,
SPINCLOCKWISE = 0x1f,
-- The three rows whose palette-flags byte is `STRENGTH_BOULDER | BIG_OBJECT`
@@ -123,6 +125,11 @@ local SPIN_NEXT = {
-- each quarter, no Random anywhere in the loop.
local SPIN_TURN_FRAMES = 16
-- SetFacingBounce steps OBJECT_STEP_FRAME once a frame and reads bit 3, so a
-- mon object holds each icon frame for eight -- map_object_action.asm:184-201
local BOUNCE_PERIOD = 16
local BOUNCE_HALF = 8
local function rand(a, b)
if love and love.math and love.math.random then
return love.math.random(a, b)
@@ -252,6 +259,8 @@ function NPC.new(mapId, objDef, spriteDef)
bigObject = BIG_OBJECT[movement] == true,
bigFacing = NPC.bigFacing(movement, spriteDef and spriteDef.id),
fixedFacing = FIXED_FACING_MOVE[movement] or nil,
bouncing = movement == MOVE.POKEMON or nil,
bounceStep = 0,
timer = rand(30, 120),
sprite = SpriteRenderer.new(spriteDef, string.format("%s_obj_%d", mapId, objDef.index or 0)),
-- The sheet is grayscale and carries no alpha; PAL_OW_* crossed with the
@@ -501,6 +510,14 @@ function NPC:walkPhase()
return (p >= frames / 4 and p < frames * 3 / 4) and 1 or 0
end
-- OBJECT_ACTION_BOUNCE's two columns, SetFacingBounce and
-- SetFacingFreezeBounce -- engine/overworld/map_object_action.asm:184-201
function NPC:bounceFrame()
if not self.bouncing then return nil end
if self.frozen then return 0 end
return ((self.bounceStep or 0) >= BOUNCE_HALF) and 1 or 0
end
-- Gen 1's seven-value entity pose (src/world/NPC.lua:124), on the class so a
-- mod poses the object it is FOLLOWING, not only one it built itself.
function NPC:pose()
@@ -522,6 +539,9 @@ function NPC:update(map, entities)
self.spawnLatched = true
self.inGrass = NPC.grassAt(map, self.cellX, self.cellY)
end
if self.bouncing and not self.frozen then
self.bounceStep = ((self.bounceStep or 0) + 1) % BOUNCE_PERIOD
end
-- The teleport step type owns the object outright (it replaces
-- STEP_TYPE_FROM_MOVEMENT until its last beat), so it runs above the frozen
-- gate the way the walk interpolation does.
@@ -763,7 +783,8 @@ function NPC:draw(ox, oy, scale)
else
self.sprite:draw(
self.px, self.py + yOffset, 0, 0,
self.facing, self:walkPhase(), self.stepFlip)
self.facing, self:walkPhase(), self.stepFlip,
false, false, self:bounceFrame())
end
G.pop()
end
+84 -12
View File
@@ -23,6 +23,19 @@ local JUMP_Y = {
-11, -10, -9, -8, -6, -4, 0, 0,
}
-- FacingFish*'s loose rod OAM, offset from the sprite's top-left, and which
-- 8x8 of the sheet's rod row it draws (data/sprites/facings.asm:122-152).
local ROD_OAM = {
down = { dx = 0, dy = 16, tile = 0 },
up = { dx = 0, dy = -8, tile = 0 },
left = { dx = -8, dy = 5, tile = 1, flip = true },
right = { dx = 16, dy = 5, tile = 1 },
}
-- The sheet row LoadFishingGFX lays over each standing frame's bottom tiles
-- (engine/events/fishing_gfx.asm:2-20).
local FISH_ROW = { down = 0, up = 1, left = 2, right = 2 }
function Player.new(cx, cy, facing, spriteDef)
local self = setmetatable({
cellX = cx, cellY = cy,
@@ -138,6 +151,17 @@ function Player:scriptStep(dir)
return true
end
-- CounterclockwiseSpinAction's .facings, seeded from the current direction by
-- Movement_step_dig -- map_object_action.asm:96-152, movement.asm:113-116
local SPIN_FACINGS = { "down", "right", "up", "left" }
local SPIN_START = { down = 0, right = 1, up = 2, left = 3 }
function Player:scriptSpin(frames)
if not frames or frames <= 0 then return end
self.spinFrames = frames
self.spinTimer = (SPIN_START[self.facing] or 0) * 4
end
-- Gen 1's name for the cell being faced (src/world/Player.lua), so a mod that
-- wraps World:interact asks one question of either generation.
function Player:facingCell()
@@ -158,6 +182,11 @@ function Player:update()
if self.turnTimer > 0 then
self.turnTimer = self.turnTimer - 1
end
if self.spinFrames then
self.spinTimer = (self.spinTimer or 0) + 1
self.spinFrames = self.spinFrames - 1
if self.spinFrames <= 0 then self.spinFrames = nil end
end
if not self.moving then
-- Re-arm turn-in-place once a poll finds no held direction (caller
-- clears this while a dir is held; we only set it from idle).
@@ -170,17 +199,27 @@ function Player:update()
-- and the facing-delta math walked only half of it, leaving the sprite a
-- cell behind where the grid said the player was.
local frames = self.stepFrames or STEP_FRAMES
local adv = math.floor(self.progress * 16 / frames)
local dx = (self.targetX or self.cellX) - self.cellX
local dy = (self.targetY or self.cellY) - self.cellY
self.px = self.cellX * 16 + dx * adv
self.py = self.cellY * 16 + dy * adv
-- engine/overworld/map_objects.asm:331 -- AddStepVector moves the object
-- every frame, so the span is the whole move, not one cell scaled by dx.
local span = math.max(math.abs(dx), math.abs(dy), 1)
local adv = math.floor(self.progress * 16 * span / frames)
self.px = self.cellX * 16 + (dx / span) * adv
self.py = self.cellY * 16 + (dy / span) * adv
if self.jumping then
-- engine/overworld/map_objects.asm:1815
local idx = math.floor((self.progress - 1) / 2) + 1
-- engine/overworld/map_objects.asm:1796 -- one table entry per cart frame,
-- tweened across our doubled step (#1713)
local t = (self.progress - 1) * (#JUMP_Y - 1)
/ math.max(frames - 1, 1) + 1
local idx = math.floor(t)
if idx < 1 then idx = 1 end
if idx > #JUMP_Y then idx = #JUMP_Y end
self.spriteYOffset = JUMP_Y[idx]
if idx >= #JUMP_Y then
self.spriteYOffset = JUMP_Y[#JUMP_Y]
else
self.spriteYOffset = math.floor(
JUMP_Y[idx] + (JUMP_Y[idx + 1] - JUMP_Y[idx]) * (t - idx) + 0.5)
end
end
if self.progress >= frames then
self.cellX, self.cellY = self.targetX, self.targetY
@@ -195,12 +234,36 @@ function Player:update()
return false
end
-- FacingFishDown/Up/Left/Right: the standing frame's bottom tile row swapped
-- for the fishing sheet, plus the loose rod tile -- facings.asm:122-152 (#1708)
function Player:drawFishing(yOffset)
local sprite = self.sprite
local py = self.py + yOffset
local facing = self.facing
sprite:draw(self.px, py, 0, 0, facing, 0, false, true)
if not self.fishQuads then
self.fishQuads = { pose = {}, rod = {} }
for i = 0, 2 do
self.fishQuads.pose[i] = love.graphics.newQuad(0, i * 8, 16, 8, 16, 32)
end
for i = 0, 1 do
self.fishQuads.rod[i] = love.graphics.newQuad(i * 8, 24, 8, 8, 16, 32)
end
end
local sx, sy = sprite:getScreenOrigin(self.px, py, 0, 0)
sprite:drawTile(self.fishSheet, sx,
sy + math.max(0, sprite.frameHeight - 8), facing == "right",
self.fishQuads.pose[FISH_ROW[facing] or 0])
local oam = ROD_OAM[facing] or ROD_OAM.down
sprite:drawTile(self.fishSheet, sx + oam.dx, sy + oam.dy, oam.flip,
self.fishQuads.rod[oam.tile])
end
function Player:draw(ox, oy, scale)
local G = love.graphics
-- OBJECT_SPRITE_Y_OFFSET: added to the OBJ's y as it is written to OAM, so
-- it moves the sprite without moving the player off the tile they are
-- standing on. StepFunction_GotBite's `xor 1` rod bob and the fly take-off
-- lift both ride this one byte.
-- standing on. StepFunction_GotBite's `xor 1` rod bob rides this one byte.
local yOffset = self.spriteYOffset or 0
if self.jumping then
-- engine/overworld/map_objects.asm:1995
@@ -217,9 +280,18 @@ function Player:draw(ox, oy, scale)
G.scale(scale, scale)
-- Chris is PAL_OW_RED; World:applyPalettes keeps the SpriteRenderer's
-- OBJ palette current.
self.sprite:draw(
self.px, self.py + yOffset, 0, 0,
self.facing, self:walkPhase(), self.stepFlip)
if self.fishing and self.fishSheet then
self:drawFishing(yOffset)
else
local facing, phase = self.facing, self:walkPhase()
-- OBJECT_ACTION_SPIN (map_object_action.asm:96-152), for step_dig.
if self.spinFrames then
facing = SPIN_FACINGS[math.floor(self.spinTimer / 4) % 4 + 1]
phase = 0
end
self.sprite:draw(
self.px, self.py + yOffset, 0, 0, facing, phase, self.stepFlip)
end
G.pop()
return
end
+3 -3
View File
@@ -23,7 +23,7 @@
--
-- Everything here is love-free and takes its state as arguments so the whole
-- chain is testable without a world.
local Bike = require("src.world.gen2.Bike")
local FieldMoves = require("src.world.gen2.FieldMoves")
local Breeding = require("src.core.gen2.Breeding")
local Happiness = require("src.core.gen2.Happiness")
local Phone = require("src.core.gen2.Phone")
@@ -118,7 +118,7 @@ end
local function bikeShopCallEnabled(save)
local flags = save.engineFlags
if type(flags) == "table" then
local set = flags[Bike.ENGINE_BIKE_SHOP_CALL_ENABLED]
local set = flags[FieldMoves.BIKE_SHOP_CALL_FLAG]
if set ~= nil then return set == true end
end
return save.bikeShopCall == true
@@ -137,7 +137,7 @@ function StepEvents.bikeStep(save, opts)
Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_BIKESHOP)
-- `res STATUSFLAGS2_BIKE_SHOP_CALL_F`: one call, ever.
if type(save.engineFlags) == "table" then
save.engineFlags[Bike.ENGINE_BIKE_SHOP_CALL_ENABLED] = nil
save.engineFlags[FieldMoves.BIKE_SHOP_CALL_FLAG] = nil
end
save.bikeShopCall = false
return true
+290 -65
View File
@@ -56,6 +56,7 @@ local Roamers = require("src.core.gen2.Roamers")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Sound = require("src.core.Sound")
local SpriteRenderer = require("src.render.SpriteRenderer")
local StepEvents = require("src.world.gen2.StepEvents")
local Tilt = require("src.render.Tilt")
local Strings = require("src.core.Strings")
@@ -85,6 +86,7 @@ local SFX = {
EXIT_BUILDING = 35,
JUMP_OVER_LEDGE = 0x16,
BUMP = 0x24,
FLY = 0x18,
}
local EMOTE_SHOCK = 0
@@ -757,6 +759,15 @@ function World:load()
self.tilesets = tilesets
self.roofs = self:dataTable("gen2Roofs", "data/generated/roofs.lua")
self.sprites = self:dataTable("gen2Sprites", "data/generated/sprites.lua")
-- A pre-#1748 cache stamps a SpriteMons row `frames = 1`, leaving
-- OBJECT_ACTION_BOUNCE one frame -- engine/overworld/map_object_action.asm:184
for _, def in pairs(self.sprites or {}) do
if type(def) == "table" and (def.frames or 1) < 2
and type(def.source) == "string"
and def.source:find("^ROM:SpriteMons") then
def.frames = 2
end
end
-- A cache from before the palette stage existed simply has no palettes.lua;
-- everything below falls back to the grayscale path rather than failing.
self.palettes = self:dataTable("gen2Palettes", "data/generated/palettes.lua")
@@ -800,6 +811,14 @@ function World:load()
local okImg, img = pcall(Assets.image, emotes.grassRustle)
if okImg then self.grassRustleImage = img end
end
-- LoadFishingGFX's two sheets
-- (../pokecrystal/engine/events/fishing_gfx.asm:7-12)
if emotes.fishing and pcall(Assets.image, emotes.fishing) then
self.fishingSheet = emotes.fishing
end
if emotes.fishingFemale and pcall(Assets.image, emotes.fishingFemale) then
self.fishingSheetFemale = emotes.fishingFemale
end
end
-- The heal machine's two OBJ tiles and their CGB palette, for the
-- Pokecenter light show (World:startHealMachineAnim). A cache from before
@@ -1427,6 +1446,9 @@ function World:busy()
-- and the waterfall climb are all applymovement / pause commands inside a
-- queued script, so nothing else may run under them.
or self.fieldMove ~= nil
-- FlyFromAnim and FlyToAnim are blocking `callasm`s inside .FlyScript
-- (engine/events/overworld.asm:599, :605).
or self.flyAnim ~= nil
end
-- CheckMenuOW (engine/overworld/events.asm:802) is the tail of OWPlayerInput,
@@ -1434,12 +1456,15 @@ end
-- away while wScriptRunning is non-zero (events.asm:238-243). Two more gates
-- sit above it even with no script up: PlayerMovement answering
-- PLAYERMOVEMENT_CONTINUE, i.e. the player is mid-step (events.asm:474-477),
-- and CheckStandingOnIce carrying (events.asm:479-480). START and SELECT are
-- read NOWHERE else in the overworld, so a press that arrives while any of
-- those hold is not queued or deferred, it is never read at all.
-- and CheckStandingOnIce carrying (events.asm:479-480). The frame a step is
-- QUEUED answers PLAYERMOVEMENT_FINISH instead (player_movement.asm:455-461),
-- which is zero, so the poll still runs there -- and on the Cycling Road's
-- forced roll that landing frame is the only one there is (#1718).
function World:acceptsMenuInput()
if self.battleActive or self:busy() then return false end
if self.player and self.player.moving then return false end
if self.player and self.player.moving and not self.stepFinished then
return false
end
-- The same latch pair World:step's slide uses: a latched direction on an ice
-- tile is CheckStandingOnIce's carry.
if self.turningDirection and Permissions.isIce(self:playerCollision()) then
@@ -1695,7 +1720,7 @@ function World:engineFlag(flag)
-- how the two would come apart -- the officer's `setflag` and the results
-- script's `clearflag` are the only writers, and both go through the pair
-- below.
if flag == BugContest.ENGINE_BUG_CONTEST_TIMER and save then
if flag == FieldMoves.BUG_CONTEST_FLAG and save then
return BugContest.isActive(save)
end
-- Badges live in save.player.badges, not in the flag table: on the cart the
@@ -1736,7 +1761,7 @@ end
function World:setEngineFlag(flag, value)
if flag == nil then return end
local save = self.game and self.game.save
if flag == BugContest.ENGINE_BUG_CONTEST_TIMER and save then
if flag == FieldMoves.BUG_CONTEST_FLAG and save then
-- Route35NationalParkGate_OkayToProceed sets the flag BEFORE `special
-- GiveParkBalls`, so starting here and starting again there is the cart's
-- own order and the second start is what puts the balls on the counter.
@@ -2042,7 +2067,7 @@ function World:breedmonSpriteDef(species)
local def = {
id = "SPRITE_DAY_CARE_MON",
image = entry.image,
frames = 1,
frames = 2,
walker = false,
spriteType = "POKEMON_SPRITE",
palette = "PAL_OW_RED",
@@ -2311,6 +2336,9 @@ end
-- rang the ordinary item jingle while the item argument was thrown away. An
-- item the cache cannot name takes the `cp TM_HM / jr z` fall-through, SFX.ITEM.
function World:specialSound(itemIndex)
-- The `waitsfx` above it (scripting.asm:445): SFX_READ_TEXT_2 ($08), which
-- the box rings on its own press, outranks SFX_GET_TM ($9b) here (#1483).
Sound.waitSfxDone()
local id = itemIndex and self:itemIdByIndex(itemIndex)
local items = self.game and self.game.data and self.game.data.items
local def = id and items and items[id]
@@ -3482,14 +3510,6 @@ function World:updateMapSetup()
if ms.phase == "out" then
ms.step = ms.step + 1
self.fade, self.fadeLevel = "white", ms.step / FADE_STEPS
-- FlyFromAnim carries the player up and off the map under the fade. The
-- bird's own frames are not in the cache, but the lift is: it is the same
-- OBJECT_SPRITE_Y_OFFSET sine the teleport step type walks
-- (src/script/gen2/Movement.lua), stepped over the fade's four levels.
if ms.lift and self.player then
self.player.spriteYOffset = Movement.teleportYOffset(
Movement.TELEPORT_RISE_HEIGHT + ms.step * FADE_STEPS)
end
if ms.step >= FADE_STEPS then
-- setMap clears self.fade (a map load repaints everything), so the sheet
-- has to be re-armed at full strength on the far side for the fade in to
@@ -3501,16 +3521,12 @@ function World:updateMapSetup()
return
end
ms.step = ms.step - 1
-- FlyToAnim, the same curve read backwards: the player comes down onto the
-- destination tile as the fade lets go of the screen.
if ms.lift and self.player then
self.player.spriteYOffset = Movement.teleportYOffset(
Movement.TELEPORT_RISE_HEIGHT + ms.step * FADE_STEPS)
end
if ms.step <= 0 then
self.fade, self.fadeLevel = nil, nil
self.mapSetup = nil
if ms.lift and self.player then self.player.spriteYOffset = 0 end
-- `callasm FlyToAnim` is the command straight after `newloadmap
-- MAPSETUP_TELEPORT` (engine/events/overworld.asm:604-605).
if ms.flyIn then self:startFlyAnim("to", ms.flyIn) end
return
end
self.fadeLevel = ms.step / FADE_STEPS
@@ -3865,6 +3881,15 @@ function World:updateMovement()
while st.i <= #st.bytes do
local b = st.bytes[st.i]
st.i = st.i + 1
-- Movement_step_dig spins for the frames in the byte that follows
-- -- engine/overworld/movement.asm:113-131 (#1716)
if b == Movement.STEP_DIG then
local duration = st.bytes[st.i] or 0
st.i = st.i + 1
if ent.scriptSpin then ent:scriptSpin(duration) end
st.sleep = duration
return
end
-- engine/overworld/movement.asm:163
if b == 0x57 then
local duration = st.bytes[st.i] or 0
@@ -3890,6 +3915,11 @@ function World:updateMovement()
elseif act.kind == "step" then
local fromX, fromY = ent.cellX, ent.cellY
if ent:scriptStep(act.dir) then
-- TurningStep's OBJECT_ACTION_SPIN, for the length of the step
-- (engine/overworld/movement.asm:693-699).
if act.spin and ent.scriptSpin then
ent:scriptSpin(ent.stepFrames or 16)
end
self:followStep(ent, fromX, fromY)
end
return
@@ -4797,15 +4827,35 @@ end
-- src/world/gen2/Bike.lua owns every decision; this is the world state those
-- decisions read and the presentation they end in.
-- ../pokecrystal/constants/engine_flags.asm:25 ENGINE_MOBILE_SYSTEM shifts every
-- later id up one: :36 ALWAYS_ON_BIKE against ../pokegold's :35.
function World:engineFlagId(name, goldId)
local order = self.constants and self.constants.engineFlagOrder
if type(order) ~= "table" then return goldId end
local ids = self.engineFlagIds
if not ids then
ids = {}
for index, entry in pairs(order) do
if type(index) == "number" and type(entry) == "string" then
ids[entry] = index - 1
end
end
self.engineFlagIds = ids
end
return ids[name] or goldId
end
-- wBikeFlags' three bits are ENGINE_* ids like any other flag, so the map
-- callbacks that set them (Route16AlwaysOnBikeCallback,
-- Route17AlwaysOnBikeCallback) already land on save.engineFlags.
function World:alwaysOnBike()
return self:engineFlag(Bike.ENGINE_ALWAYS_ON_BIKE)
return self:engineFlag(World.engineFlagId(
self, "ENGINE_ALWAYS_ON_BIKE", Bike.ENGINE_ALWAYS_ON_BIKE))
end
function World:downhill()
return self:engineFlag(Bike.ENGINE_DOWNHILL)
return self:engineFlag(World.engineFlagId(
self, "ENGINE_DOWNHILL", Bike.ENGINE_DOWNHILL))
end
-- GetPlayerTilePermission's operand: the collision under the player's feet.
@@ -4963,6 +5013,11 @@ function World:beginFishing(outcome, wild)
if self.player then
self.player.fishing = true
self.player.fishingState = self.fishing
-- LoadFishingGFX reads wPlayerGender
-- (../pokecrystal/engine/events/fishing_gfx.asm:8-12)
self.player.fishSheet =
(FieldMoves.isFemale(self:playerGender()) and self.fishingSheetFemale)
or self.fishingSheet
end
end
@@ -5345,6 +5400,9 @@ function World:fieldContext(mon)
-- digFromMenu reads this rather than re-deriving the banked warp.
canEscapeRope = self:escapeRopeTarget() ~= nil,
playerState = self.playerState,
-- SurfFunction.TrySurf and TrySurfOW both refuse while wBikeFlags'
-- ALWAYS_ON_BIKE is set (engine/events/overworld.asm:343-345, :498-500).
alwaysOnBike = self:alwaysOnBike(),
strengthActive = self.strengthActive,
-- FlashFunction tests wTimeOfDayPalset, not the map header, so a
-- PALETTE_DARK map that FLASH has already lit refuses a second FLASH.
@@ -5524,15 +5582,34 @@ function World:runCut(result)
end
-- Script_UsedWhirlpool, which is Script_Cut with DisappearWhirlpool and
-- PlayWhirlpoolSound (a bare SFX.SURF) in place of the snip.
-- PlayWhirlpoolSound in place of the snip.
function World:runWhirlpool(result)
self:setNickname(result.mon)
self:showText(Strings(result.text), function()
self:replaceBlock(result.blockIndex, result.replacement)
self:playSfx(SFX.SURF)
self:playWhirlpoolSound()
end)
end
-- PlayWhirlpoolSound is WaitSFX, SFX_SURF, WaitSFX, never a bare PlaySFX
-- -- engine/events/field_moves.asm:5-10 (#1717)
function World:playWhirlpoolSound()
self.fieldMove = { phase = "whirlpoolsfx", waiting = true, left = 180 }
end
-- PlayerMovementPointers' .force_turn arm and the Script_ForcedMovement it
-- calls -- events.asm:786-793, forced_movement.asm:1-51 (#1716)
local FORCED_BACK = { up = "down", down = "up", left = "right", right = "left" }
function World:runForcedMovement()
local p = self.player
if not p or p.moving or self.moveState then return false end
local back = FORCED_BACK[p.facing]
if not back then return false end
self:beginMovement(0, Movement.forcedMovementBytes(back))
return true
end
-- Script_UseFlash: the text plays SFX.FLASH from inside itself
-- (UseFlashTextScript's text_asm), and BlindingFlash then sets
-- STATUSFLAGS_FLASH_F and reloads the palettes. Setting the flag is all there
@@ -5685,7 +5762,7 @@ function World:runFieldMove(result)
elseif action == "waterfall" then
self:runWaterfall(result)
elseif action == "fly" then
self:openFlyMap()
self:openFlyMap(result.mon)
elseif action == "headbutt" then
self:runHeadbutt(result.facingX, result.facingY, result.mon)
elseif action == "sweetscent" then
@@ -5869,25 +5946,138 @@ function World:flyPoints()
self.game and self.game.save, self.landmarks, self:region())
end
-- .FlyScript: WarpToSpawnPoint, then `newloadmap MAPSETUP.TELEPORT` brings the
-- map up with the player back in PLAYER_NORMAL. MapSetupScript_Teleport opens
-- on FadeOutToWhite and falls through into _Warp, so flying is bracketed by the
-- same pair of fades a door is, which is where the two fly animations ride:
-- `lift` below hands them to World:updateMapSetup.
function World:flyTo(spawnId)
-- FlyFromAnim / FlyToAnim (engine/events/field_moves.asm:300, :334) and the two
-- curves they run (engine/sprite_anims/functions.asm:1350, :1418).
local FLY = {
FROM_FRAMES = 128, TO_FRAMES = 64, HOVER = 0x40,
AMP_MAX = 0x40, TO_AMP = 11 * 8, RISE = 84,
}
-- FlyFunction_InitGFX's GetSpeciesIcon (engine/events/field_moves.asm:390):
-- the icon of the mon in wCurPartyMon, on PAL_OW_RED like every other OW OBJ.
function World:flyIconFor(mon)
if type(mon) ~= "table" then return nil end
local data = self.game and self.game.data
local icons = data and data.gen2Icons
local iconId = mon.isEgg and "ICON_EGG"
or (icons and icons.species and mon.species
and icons.species[mon.species])
local entry = iconId and icons and icons.icons and icons.icons[iconId]
if not (entry and entry.image) then return nil end
local def = {
id = "SPRITE_FLY_MON", image = entry.image, frames = 2, walker = false,
spriteType = "POKEMON_SPRITE", palette = "PAL_OW_RED", paletteId = 0,
species = mon.species, icon = iconId,
}
local ok, icon = pcall(SpriteRenderer.new, def, "gen2fly")
if not (ok and icon) then return nil end
local daytime = self.daytime or Palettes.daytimeFor(
self.map and self.map.def, self:hour(), self.flashUsed)
local colors = Palettes.spritePalette(self.palettes, daytime, def)
if colors then
icon:setObjPalette(colors, ("gen2:%s:0"):format(tostring(daytime)))
end
return icon
end
-- False when there is no icon sheet (or no love at all): the caller then flies
-- the way it always did rather than parking the world on an animation.
function World:startFlyAnim(phase, mon, onDone)
local icon = self:flyIconFor(mon)
local p = self.player
if not (icon and p) then return false end
local landing = phase == "to"
self.flyAnim = {
phase = phase, icon = icon, onDone = onDone, t = 0,
px = p.px, py = p.py, xoff = 0, wave = 0,
left = landing and FLY.TO_FRAMES or FLY.FROM_FRAMES,
hover = landing and 0 or FLY.HOVER,
amp = landing and FLY.TO_AMP or 0,
y = landing and -FLY.RISE or 0,
}
return true
end
-- FlyFunction_FrameTimer (engine/events/field_moves.asm:409) over the two
-- AnimSeq_Fly* curves; the wobble is Sprites_Cosine's d * cos(n * pi / 32).
function World:stepFlyAnim()
local fa = self.flyAnim
if not fa then return end
local left = fa.left
if left <= 0 then
local done = fa.onDone
self.flyAnim = nil
if done then done() end
return
end
fa.left = left - 1
if left >= 0x40 and left % 8 == 0 then
self:playSfxNamed("Sfx_Fly", SFX.FLY)
end
fa.t = fa.t + 1
local amp = fa.amp
if fa.phase == "to" then
if fa.y >= 0 then return end
fa.y = fa.y + 2
if amp > 0 then fa.amp = amp - 2 end
else
if fa.hover > 0 then
fa.hover = fa.hover - 1
return
end
if fa.y <= -FLY.RISE then return end
fa.y = fa.y - 2
if amp < FLY.AMP_MAX then fa.amp = amp + 8 end
end
fa.xoff = math.floor(amp * math.cos((fa.wave % 64) * math.pi / 32))
fa.wave = fa.wave + 1
end
-- .Frameset_RedWalk is two 8-frame icon beats, the fourth mirrored
-- -- data/sprite_anims/framesets.asm:81-86
function World:drawFlyAnim(s, billboard)
local fa = self.flyAnim
if not (fa and fa.icon) then return end
local G = love.graphics
local cam = self.camera
local px = fa.px + fa.xoff
local py = fa.py + fa.y
local ox = math.floor((0 - cam.x) * s)
local oy = math.floor((0 - cam.y) * s)
local beat = math.floor(fa.t / 8) % 4
local function body()
G.setColor(1, 1, 1, 1)
G.push()
G.translate(ox, oy)
G.scale(s, s)
fa.icon:draw(px, py, 0, 0, "down", 0, false, false, beat == 3, beat % 2)
G.pop()
end
if billboard then
billboard(ox + (px + 8) * s, oy + (py + 16) * s, body)
else
body()
end
end
-- .FlyScript: FlyFromAnim, WarpToSpawnPoint, `newloadmap MAPSETUP_TELEPORT`,
-- then FlyToAnim -- engine/events/overworld.asm:595-609
function World:flyTo(spawnId, mon)
local spawn = self.landmarks and self.landmarks.spawns
and self.landmarks.spawns[spawnId]
if not (spawn and spawn.map and self.maps and self.maps[spawn.map]) then
return false
end
self:applyPlayerState(FieldMoves.PLAYER_NORMAL)
local ok = self:runMapSetup(MAPSETUP.TELEPORT, function()
return self:setMap(spawn.map, spawn.x, spawn.y, "down")
end)
-- FlyFromAnim / FlyToAnim ride the setup script's own two fades: the take-off
-- lift under the fade out, the landing under the fade in.
if self.mapSetup then self.mapSetup.lift = true end
return ok
local function warp()
self:applyPlayerState(FieldMoves.PLAYER_NORMAL)
local ok = self:runMapSetup(MAPSETUP.TELEPORT, function()
return self:setMap(spawn.map, spawn.x, spawn.y, "down")
end)
if self.mapSetup then self.mapSetup.flyIn = mon end
return ok
end
if self:startFlyAnim("from", mon, warp) then return true end
return warp()
end
-- _FlyMap: the town map with the cursor locked to visited flypoints, A takes
@@ -5898,7 +6088,7 @@ end
-- "Where?" plate over it instead of the card strip. A run with no love at all
-- (a headless probe) has no screen to push, so the destinations are offered
-- one at a time through the same yesorno box every other field move uses.
function World:openFlyMap()
function World:openFlyMap(mon)
local points = self:flyPoints()
if #points == 0 then return false end
-- Loaded on demand and through pcall: a headless run has no love, and this
@@ -5909,28 +6099,31 @@ function World:openFlyMap()
save = self.game.save,
currentLandmark = self:currentLandmarkId(),
fly = points,
-- TownMapMon draws wCurPartyMon's icon as the cursor
-- (../pokecrystal/engine/pokegear/pokegear.asm:2708-2721).
flyMon = mon,
onFly = function(spawnId)
self.game.stack:pop()
self:flyTo(spawnId)
self:flyTo(spawnId, mon)
end,
onClose = function() self.game.stack:pop() end,
})
return true
end
self:askFlyPoint(points, 1)
self:askFlyPoint(points, 1, mon)
return true
end
function World:askFlyPoint(points, index)
function World:askFlyPoint(points, index, mon)
local row = points[index]
if not row then return end
local name = (row.name or row.landmark):gsub("\n", " ")
self:showText(Strings(FieldMoves.TEXT.ASK_FLY_TO, name), function()
self:askYesNo(function(yes)
if yes then
self:flyTo(row.spawn)
self:flyTo(row.spawn, mon)
else
self:askFlyPoint(points, index + 1)
self:askFlyPoint(points, index + 1, mon)
end
end)
end)
@@ -5958,6 +6151,18 @@ function World:updateFieldMove()
st.timer = st.timer - 1
return
end
if st.phase == "whirlpoolsfx" then
st.left = (st.left or 0) - 1
if st.waiting then
if Sound.sfxBusy() and st.left > 0 then return end
st.waiting = nil
self:playSfxNamed("Sfx_Surf", SFX.SURF)
return
end
if Sound.sfxBusy() and st.left > 0 then return end
self.fieldMove = nil
return
end
if st.phase == "strength" then
self.fieldMove = nil
if st.text then self:showText(Strings(st.text)) end
@@ -6056,6 +6261,9 @@ function World:startBattle(opts, onDone)
-- wInBattleTowerBattle (../pokecrystal/engine/events/battle_tower/
-- battle_tower.asm:220-223), which turns DoBadgeTypeBoosts off.
battleTower = opts.battleTower,
-- wTimeOfDay, for BattleCommand_TimeBasedHealContinue
-- (engine/battle/effect_commands.asm:6401-6404).
timeOfDay = self:timeOfDayId(),
})
self:playBattleMusic(opts)
local function pushBattle()
@@ -8639,8 +8847,10 @@ function World:setMap(mapId, cx, cy, facing, opts)
-- MAPCALLBACK_NEWMAP runs, because the Cycling Road's callback is what sets
-- them straight back again: leaving them set is how one visit to Route 17
-- would keep the player glued to the bike for the rest of the game.
self:setEngineFlag(Bike.ENGINE_ALWAYS_ON_BIKE, false)
self:setEngineFlag(Bike.ENGINE_DOWNHILL, false)
self:setEngineFlag(World.engineFlagId(
self, "ENGINE_ALWAYS_ON_BIKE", Bike.ENGINE_ALWAYS_ON_BIKE), false)
self:setEngineFlag(World.engineFlagId(
self, "ENGINE_DOWNHILL", Bike.ENGINE_DOWNHILL), false)
-- "Respawn in Pokemon Centers" (home/map.asm, LoadMapAttributes' .SetSpawn):
-- walking from an OUTDOOR map into an INDOOR one whose tileset is
-- TILESET_POKECENTER rewrites wLastSpawnMapGroup / wLastSpawnMapNumber, and
@@ -9741,6 +9951,7 @@ end
function World:stepBody()
if not self.map or not self.player then return end
self.stepFinished = false
self:pollTimeOfDay()
-- ShakeScreen and the `musicfadeout` tail both run UNDER a script (the VM is
-- parked on the earthquake's own waitFrames while the screen is still
@@ -9821,6 +10032,7 @@ function World:stepBody()
-- it ticks here above the input gate the same way the emote does; its
-- last flash's onDone is what resumes the nurse.
if self.healAnim then self:stepHealAnim() end
if self.flyAnim then self:stepFlyAnim() end
-- HandleCmdQueue sits in the overworld loop, once a frame, above the input
-- gate: it is what drops a boulder that is already sitting on a hole, and it
@@ -9841,8 +10053,9 @@ function World:stepBody()
-- Freeze player input while a script / textbox / cutscene move is up.
if self:busy() then
-- Keep scripted entities animating mid-step.
if self.player and self.player.moving then
-- Keep scripted entities animating mid-step. A step_dig spin has the
-- player standing still, so it has to tick here too.
if self.player and (self.player.moving or self.player.spinFrames) then
self:playerStepGrass()
if self.player:update() then
self.player.inGrass =
@@ -9856,6 +10069,7 @@ function World:stepBody()
local p = self.player
self:playerStepGrass()
local landed = p:update()
self.stepFinished = landed
-- CopyCoordsTileToLastCoordsTile -> SetTallGrassFlags, which is what a step
-- ENDS on (engine/overworld/map_objects.asm:196-208, :247).
if landed then p.inGrass = self:grassAt(p.cellX, p.cellY) end
@@ -9917,6 +10131,11 @@ function World:stepBody()
local dir = self.heldDir
if not p.moving then
local coll = self:playerCollision()
-- .CheckTile tests CheckWhirlpoolTile above the nybble ladder
-- -- engine/overworld/player_movement.asm:117-123 (#1716)
if Permissions.isWhirlpool(coll) and self:runForcedMovement() then
return
end
local current = Permissions.currentDirection(coll)
or Permissions.doorForcedDirection(coll)
if current then
@@ -10030,18 +10249,21 @@ function World:drawPeople(s, billboard)
local G = love.graphics
local p = self.player
local cam = self.camera
local drawList = {
{ kind = "player", py = p.py, ox = 0, oy = 0 },
}
for _, npc in ipairs(self.npcs) do
drawList[#drawList + 1] = {
kind = "npc", npc = npc, ox = 0, oy = 0, py = npc.py,
}
end
for _, g in ipairs(self.ghosts) do
drawList[#drawList + 1] = {
kind = "npc", npc = g.npc, ox = g.ox, oy = g.oy, py = g.oy + g.npc.py,
}
-- .FlyScript hides the map's objects from HideSprites until its
-- LoadWalkingSpritesGFX tail -- engine/events/overworld.asm:597, :608
local drawList = {}
if not self.flyAnim then
drawList[1] = { kind = "player", py = p.py, ox = 0, oy = 0 }
for _, npc in ipairs(self.npcs) do
drawList[#drawList + 1] = {
kind = "npc", npc = npc, ox = 0, oy = 0, py = npc.py,
}
end
for _, g in ipairs(self.ghosts) do
drawList[#drawList + 1] = {
kind = "npc", npc = g.npc, ox = g.ox, oy = g.oy, py = g.oy + g.npc.py,
}
end
end
table.sort(drawList, function(a, b) return a.py < b.py end)
@@ -10076,6 +10298,7 @@ function World:drawPeople(s, billboard)
self:drawEmote(s, billboard)
self:drawHealAnim(s, billboard)
self:drawFlyAnim(s, billboard)
end
-- Split out of drawPeople so World:drawPipeline composites the one copy the
@@ -10143,11 +10366,12 @@ function World:drawPipeline(id, w, h, s)
-- the art: nil, like Gen 1 returns in its true-colour modes.
paletteFor = function() return nil end,
spriteColors = function() return nil end,
-- Gold's only standing effects; it has no dust/cutTree/bird/rod overlay,
-- and Gen 1's `at` skips a nil body, so those keys are simply absent.
-- Gold's only standing effects; it has no dust/cutTree/rod overlay, and
-- Gen 1's `at` skips a nil body, so those keys are simply absent.
fx = {
emote = function() self:drawEmote(1, nil) end,
heal = function() self:drawHealAnim(1, nil) end,
bird = function() self:drawFlyAnim(1, nil) end,
},
}
-- `project(wx, wy)` -> canvas pixels, nil behind the camera. s = 1 lays the
@@ -10165,6 +10389,7 @@ function World:drawPipeline(id, w, h, s)
end
self:drawEmote(1, at)
self:drawHealAnim(1, at)
self:drawFlyAnim(1, at)
end
local override = Pipelines.drawWorld(id, ctx)
-- world post-processes fold in here, so they never touch the text box on top