mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
1905261c5b
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
271 lines
11 KiB
Lua
271 lines
11 KiB
Lua
-- A ledge hop advances every logic frame, the way every other step does.
|
|
--
|
|
-- luajit tests/gen2_ledge_hop_test.lua
|
|
--
|
|
-- A hop is the engine's only two-cell move (World:tryLedgeJump, STEP_LEDGE).
|
|
-- On the cart it is jump_step, i.e. STEP_WALK (pokegold/engine/overworld/
|
|
-- movement.asm:595-597), so GetStepVector hands StepFunction_PlayerJump the
|
|
-- `db 0, 2, 8, 2` row (map_objects.asm:365-381) and .stepjump / .stepland run
|
|
-- it as two 8-frame beats: 16 frames, 2px EVERY frame, and UpdateJumpPosition
|
|
-- (:1796-1817) adds the speed to OBJECT_JUMP_HEIGHT and indexes its 16-entry
|
|
-- arc at height/2, one entry per frame.
|
|
--
|
|
-- We render Gen 2 at twice that temporal resolution (Player.STEP_FRAMES = 16
|
|
-- for one cell), so the hop is 32 frames and has to move 1px and half an arc
|
|
-- entry on every one of them. It used to compute the sub-cell offset per CELL
|
|
-- and then scale it by the cell delta, so half the hop's frames moved nothing
|
|
-- and the other half jumped 2px -- and since the camera follows px/py, the
|
|
-- whole map scrolled at 30Hz. The arc had the matching quantum with the
|
|
-- opposite sign, which dragged the sprite back UP the screen five times. #1713
|
|
--
|
|
-- Self-contained: no cache, no love. tests/drivers/gold_ledge_hop_bug1713_
|
|
-- test.lua is the half a person watches.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local S = require("tests.harness").suite("gen2 ledge hop")
|
|
local check, eq = S.check, S.eq
|
|
|
|
local Player = require("src.world.gen2.Player")
|
|
local Permissions = require("src.world.gen2.Permissions")
|
|
local World = require("src.world.gen2.World")
|
|
|
|
local DELTA = { up = { 0, -1 }, down = { 0, 1 },
|
|
left = { -1, 0 }, right = { 1, 0 } }
|
|
|
|
-- ---- one move, frame by frame ----------------------------------------------
|
|
--
|
|
-- What the renderer reads each frame: the position the camera follows, and the
|
|
-- OBJECT_SPRITE_Y_OFFSET added on top of it. Row 1 is the standing frame
|
|
-- before the move starts, so row i+1 is frame i.
|
|
local function playMove(dir, opts)
|
|
opts = opts or {}
|
|
local p = Player.new(5, 5, dir)
|
|
local d = DELTA[dir]
|
|
local cells = opts.jump and 2 or 1
|
|
p.targetX, p.targetY = p.cellX + d[1] * cells, p.cellY + d[2] * cells
|
|
p.moving = true
|
|
p.progress = 0
|
|
p.jumping = opts.jump or nil
|
|
p.stepFrames = opts.stepFrames
|
|
or (opts.jump and Player.STEP_FRAMES * 2 or Player.STEP_FRAMES)
|
|
local rows = { { px = p.px, py = p.py, off = 0 } }
|
|
local frames = 0
|
|
while p.moving and frames < 200 do
|
|
p:update()
|
|
frames = frames + 1
|
|
rows[#rows + 1] = { px = p.px, py = p.py, off = p.spriteYOffset or 0 }
|
|
end
|
|
return p, rows, frames
|
|
end
|
|
|
|
-- Per-frame change in one recorded column.
|
|
local function steps(rows, key)
|
|
local out = {}
|
|
for i = 2, #rows do out[i - 1] = rows[i][key] - rows[i - 1][key] end
|
|
return out
|
|
end
|
|
|
|
local function countIf(list, fn)
|
|
local n = 0
|
|
for _, v in ipairs(list) do if fn(v) then n = n + 1 end end
|
|
return n
|
|
end
|
|
|
|
-- ---- the control: an ordinary one-cell walk --------------------------------
|
|
--
|
|
-- The hop's fix must not have been bought by moving this.
|
|
do
|
|
local p, rows, frames = playMove("down")
|
|
eq(frames, Player.STEP_FRAMES, "a walk is 16 logic frames")
|
|
local dy = steps(rows, "py")
|
|
eq(countIf(dy, function(v) return v ~= 1 end), 0,
|
|
"and every one of them moves the player exactly one pixel down")
|
|
eq(countIf(steps(rows, "px"), function(v) return v ~= 0 end), 0,
|
|
"with no sideways drift")
|
|
eq(p.cellY, 6, "it lands one cell on")
|
|
eq(p.py, 6 * 16, "on the pixel the cell says")
|
|
|
|
-- .DoStep's STEP_BIKE arm: the same one cell in half the frames, so the
|
|
-- per-frame advance doubles. This is what a span applied to the wrong
|
|
-- denominator breaks first.
|
|
local bike, bikeRows, bikeFrames =
|
|
playMove("right", { stepFrames = Player.STEP_FRAMES / 2 })
|
|
eq(bikeFrames, 8, "a bike step is 8 logic frames")
|
|
eq(countIf(steps(bikeRows, "px"), function(v) return v ~= 2 end), 0,
|
|
"each moving two pixels, so a bike still covers one cell")
|
|
eq(bike.cellX, 6, "and lands one cell on, not two")
|
|
end
|
|
|
|
-- ---- the hop ---------------------------------------------------------------
|
|
--
|
|
-- The bug, stated as numbers: half these frames used to move nothing and the
|
|
-- other half used to move two pixels.
|
|
local hopP, hopRows, hopFrames = playMove("down", { jump = true })
|
|
eq(hopFrames, Player.STEP_FRAMES * 2, "a ledge hop is 32 logic frames")
|
|
local hopDy = steps(hopRows, "py")
|
|
eq(countIf(hopDy, function(v) return v ~= 1 end), 0,
|
|
"every frame of a ledge hop moves the player a pixel")
|
|
eq(countIf(hopDy, function(v) return math.abs(v) >= 2 end), 0,
|
|
"and no frame of it moves two")
|
|
eq(countIf(steps(hopRows, "px"), function(v) return v ~= 0 end), 0,
|
|
"a hop straight down never moves sideways")
|
|
-- The near miss: span math applied without the matching frames scaling covers
|
|
-- one cell in 32 frames instead of two.
|
|
eq(hopRows[#hopRows].py - hopRows[1].py, 32,
|
|
"the 32 frames add up to 32 pixels, which is two cells")
|
|
eq(hopP.cellY, 7, "and the grid agrees the player crossed two cells")
|
|
eq(hopP.py, 7 * 16, "landing on the landing cell's own pixel")
|
|
eq(countIf(hopRows, function(r)
|
|
return r.px % 1 ~= 0 or r.py % 1 ~= 0
|
|
end), 0, "and no frame leaves the player on a half pixel")
|
|
|
|
-- ---- the arc ---------------------------------------------------------------
|
|
--
|
|
-- UpdateJumpPosition's .y_offsets: -4 up to -12 and back to 0, one entry per
|
|
-- cart frame, so across our doubled step it is half an entry per frame.
|
|
do
|
|
local off = {}
|
|
for i = 2, #hopRows do off[#off + 1] = hopRows[i].off end
|
|
local peak = 0
|
|
for i = 1, #off do if off[i] < peak then peak = off[i] end end
|
|
-- The arc rises, then falls, and changes its mind exactly once: any extra
|
|
-- sign change in the offsets is the sprite wobbling.
|
|
local moves = {}
|
|
for i = 2, #off do
|
|
local v = off[i] - off[i - 1]
|
|
if v ~= 0 then moves[#moves + 1] = v end
|
|
end
|
|
local turns = 0
|
|
for i = 2, #moves do
|
|
if (moves[i] > 0) ~= (moves[i - 1] > 0) then turns = turns + 1 end
|
|
end
|
|
eq(off[1], -4, "the arc opens on the table's first entry")
|
|
eq(peak, -12, "peaks 12 pixels up, the table's own peak")
|
|
eq(off[#off], 0, "and is back on the ground for the landing frame")
|
|
eq(turns, 1, "rising then falling, once, with no wobble in between")
|
|
eq(countIf(off, function(v) return v > 0 or v < -12 end), 0,
|
|
"and never leaves the range the table covers")
|
|
|
|
-- The composed screen position, which is what an eye actually tracks: the
|
|
-- camera-following py plus the sprite offset. The first frame lifts the
|
|
-- sprite -- that is the take-off -- and after it a hop DOWN may never go
|
|
-- backwards. Before the fix it did so on five frames.
|
|
local screen, back = {}, 0
|
|
for i = 2, #hopRows do
|
|
screen[#screen + 1] = hopRows[i].py + hopRows[i].off
|
|
end
|
|
for i = 2, #screen do
|
|
if screen[i] < screen[i - 1] then back = back + 1 end
|
|
end
|
|
eq(back, 0, "the arc never drags the sprite back up the screen")
|
|
check(screen[1] < hopRows[1].py + hopRows[1].off,
|
|
"the one time it does rise is the take-off, on the first frame")
|
|
eq(countIf(steps(hopRows, "py"), function(v) return v ~= 1 end), 0,
|
|
"while the map underneath scrolls one pixel a frame throughout")
|
|
end
|
|
|
|
-- A sideways hop is the same move on the other axis: Gold's $a0 is HOP_RIGHT.
|
|
for _, dir in ipairs({ "left", "right" }) do
|
|
local p, rows, frames = playMove(dir, { jump = true })
|
|
local want = DELTA[dir][1]
|
|
eq(frames, 32, ("a %s hop is 32 logic frames too"):format(dir))
|
|
eq(countIf(steps(rows, "px"), function(v) return v ~= want end), 0,
|
|
("and moves one pixel %s on every one of them"):format(dir))
|
|
eq(countIf(steps(rows, "py"), function(v) return v ~= 0 end), 0,
|
|
("a %s hop never slides the player up or down a row"):format(dir))
|
|
eq(p.cellX, 5 + want * 2, ("it lands two cells %s"):format(dir))
|
|
end
|
|
|
|
-- The step ends clean: nothing about the jump is left set for the next walk.
|
|
eq(hopP.moving, false, "the hop ends the step")
|
|
eq(hopP.jumping, nil, "and clears the jump")
|
|
eq(hopP.spriteYOffset, 0, "and puts the sprite back on its feet")
|
|
|
|
-- ---- the same hop, through World -------------------------------------------
|
|
--
|
|
-- Player alone cannot prove the wiring: World:tryLedgeJump is what picks the
|
|
-- two-cell target and the doubled duration (World.lua's STEP_LEDGE arm), and a
|
|
-- fix that only touched one of the two would still stutter in the game.
|
|
local COLL_FLOOR, COLL_WALL, COLL_HOP_DOWN = 0x00, 0x07, 0xa3
|
|
|
|
local function ledgeWorld(px, py, dir, coll)
|
|
local game = {
|
|
data = { items = {}, moves = {}, pokemon = {} },
|
|
save = { player = { name = "GOLD", badges = {} }, party = {},
|
|
inventory = {} },
|
|
}
|
|
local world = World.new(game)
|
|
game.world = world
|
|
local cells = {}
|
|
cells[py * 100 + px] = coll or COLL_HOP_DOWN
|
|
local d = DELTA[dir]
|
|
-- The refused single step, which is what turns the walk into a jump.
|
|
cells[(py + d[2]) * 100 + (px + d[1])] = COLL_WALL
|
|
local map
|
|
map = {
|
|
id = "ROUTE_29",
|
|
width = 10, height = 10,
|
|
def = { objects = {}, bgEvents = {}, environment = "ROUTE",
|
|
tileset = "TILESET_JOHTO", width = 10, height = 10 },
|
|
cellCollision = function(_, x, y)
|
|
return cells[y * 100 + x] or COLL_FLOOR
|
|
end,
|
|
inBounds = function(_, x, y)
|
|
return x >= 0 and y >= 0 and x < 20 and y < 20
|
|
end,
|
|
isWalkable = function(_, x, y)
|
|
return map:inBounds(x, y)
|
|
and Permissions.isWalkable(map:cellCollision(x, y))
|
|
end,
|
|
warpAt = function() return nil end,
|
|
connection = function() return nil end,
|
|
}
|
|
world.map = map
|
|
world.maps = { ROUTE_29 = map.def }
|
|
world.player = Player.new(px, py, dir)
|
|
world.player.turnArmed = false
|
|
world.entities = { world.player }
|
|
world.npcs = {}
|
|
world.encounters = {}
|
|
world.noWildEncounters = true
|
|
world.pollTimeOfDay = function() end
|
|
world.updatePeople = function() end
|
|
return world
|
|
end
|
|
|
|
check(Permissions.isLedge(COLL_HOP_DOWN), "$a3 is a ledge")
|
|
check((Permissions.ledgeFacings(COLL_HOP_DOWN) or {}).down,
|
|
"and its row hops DOWN")
|
|
|
|
do
|
|
local world = ledgeWorld(4, 5, "down")
|
|
local p = world.player
|
|
local rows, airborne = {}, false
|
|
for _ = 1, 120 do
|
|
world.heldDir = (not airborne) and "down" or nil
|
|
local wasAirborne = airborne
|
|
world:step()
|
|
if p.jumping then airborne = true end
|
|
if airborne then
|
|
rows[#rows + 1] = { px = p.px, py = p.py, off = p.spriteYOffset or 0 }
|
|
end
|
|
if wasAirborne and not p.moving then break end
|
|
end
|
|
check(#rows > 0, "holding down on a ledge starts a hop")
|
|
eq(#rows - 1, 32, "which runs the doubled 32-frame duration")
|
|
eq(p.cellY, 7, "and lands two cells down, on the far side of the wall")
|
|
local dy = steps(rows, "py")
|
|
eq(countIf(dy, function(v) return v ~= 1 end), 0,
|
|
"every frame of the in-game hop scrolls the map exactly one pixel")
|
|
eq(rows[#rows].py - rows[1].py, 32,
|
|
"so the whole hop is 32 pixels of travel, not 16")
|
|
local screen, back = {}, 0
|
|
for i = 2, #rows do screen[#screen + 1] = rows[i].py + rows[i].off end
|
|
for i = 2, #screen do
|
|
if screen[i] < screen[i - 1] then back = back + 1 end
|
|
end
|
|
eq(back, 0, "and after the take-off the sprite never reverses")
|
|
end
|
|
|
|
S.finish()
|