Files
gen1recomp/src/world/gen2/Player.lua
T
1jamie 395c0542a3 crystal bank 1 / attrmap overworld + proper IN_GRASS feet compositing (pret 1:1)
wire up Crystal PalMap tile attrs (bank 1, flips, BG_PRIO) through map bake,
attr grid, and BG-over-OAM blits instead of the gold/silver grassAtlasFor shortcut.
crystal-only: MapAttrGrid + TileAttrs, OAM bottom/top split for IN_GRASS,
keyed grass over feet strip via attrmap, drawBgPriorityOver for wAttrmap bit 7.
gold/silver left alone on the old path — all of this gated behind isCrystal().
fixes standing still in grass with tufts on torso / feet on top of grass (#2080).
RomExtractorGen2 pulls crystal PalMap attrs; SpriteRenderer splits standing
sheets on frameHeight; tests for tile attrs + feet strip regression.
2026-09-02 14:46:24 -05:00

337 lines
12 KiB
Lua

-- Minimal Gen 2 overworld player: tile-grid steps at 16 frames/cell.
-- Draws via shared SpriteRenderer (same 16x96 facing layout as Gen 1).
local Map = require("src.world.gen2.Map")
local Runtime = require("src.mods.Runtime")
local SpriteRenderer = require("src.render.SpriteRenderer")
local Player = {}
Player.__index = Player
local STEP_FRAMES = 16
local TURN_FRAMES = 4
-- The walking duration, exported so World can halve it for a bike step
-- (.DoStep's STEP_BIKE arm, engine/overworld/player_movement.asm). The leg
-- cadence below deliberately does NOT scale with it: animClock keeps counting
-- at the walking rate, which is what stops a bike step flickering the legs.
Player.STEP_FRAMES = STEP_FRAMES
-- engine/overworld/map_objects.asm:1815
local JUMP_Y = {
-4, -6, -8, -10, -11, -12, -12, -12,
-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,
px = cx * 16, py = cy * 16,
facing = facing or "down",
moving = false,
progress = 0,
turnTimer = 0,
turnArmed = true,
stepFlip = false,
-- OBJECT_FLAGS2's IN_GRASS_F (engine/overworld/map_objects.asm:247).
inGrass = false,
animClock = 0,
-- Frames this cell takes; World rewrites it per step from the STEP_* the
-- player's state picks, and a step already under way keeps the one it
-- started with.
stepFrames = STEP_FRAMES,
sprite = nil,
spriteDef = spriteDef,
}, Player)
if spriteDef then
self.sprite = SpriteRenderer.new(spriteDef, "player")
end
return self
end
function Player:setSprite(spriteDef)
if not spriteDef then return end
-- pokegold engine/overworld/overworld.asm:55-64
local ok, sprite = pcall(SpriteRenderer.new, spriteDef, "player")
if not (ok and sprite) then return end
self.spriteDef = spriteDef
self.sprite = sprite
end
-- the movement.collision chain sees the boolean; a wrapper that flips it
-- rewrites ctx.reason to say why (the engine's own reasons are bounds / tile /
-- entity, the same three src/world/Collision.lua names under Gen 1), so the
-- hook stays a single-value middleware.
local function passthrough(allowed) return allowed end
-- The verdict on one step, hoisted so the hooked and unhooked paths cannot
-- drift. World:movePlayer has already vetoed the direction by handing us a
-- refusingMap when GetMovementPermissions says no, so a side-wall veto arrives
-- here as "tile" exactly like a wall does.
local function verdict(self, map, entities, tx, ty)
if not map:inBounds(tx, ty) then return false, "bounds" end
if not map:isWalkable(tx, ty) then return false, "tile" end
if entities then
for _, e in ipairs(entities) do
-- `passable` is the follower's escape, src/world/Collision.lua:20's
-- name and meaning: the player walks straight through it.
if e ~= self and not e.passable then
if e.cellX == tx and e.cellY == ty then return false, "entity" end
if e.moving and e.targetX == tx and e.targetY == ty then
return false, "entity"
end
end
end
end
return true
end
function Player:tryMove(dir, map, entities)
if self.moving then return nil end
if self.facing ~= dir then
self.facing = dir
self.bumpFrames = nil
if self.turnArmed then
self.turnArmed = false
self.turnTimer = TURN_FRAMES
return "turned"
end
end
if self.turnTimer > 0 then return nil end
local d = Map.DELTA[dir]
local tx, ty = self.cellX + d[1], self.cellY + d[2]
local allowed, why = verdict(self, map, entities, tx, ty)
-- Per-step hot path, guarded the way src/world/Collision.lua's canMove is:
-- with an empty chain this costs one table lookup and no ctx allocation.
if Runtime.wantsHook("movement.collision") then
local ctx = { map = map, mover = self, dir = dir,
fromX = self.cellX, fromY = self.cellY,
toX = tx, toY = ty, reason = why }
allowed = Runtime.call("movement.collision", passthrough, allowed, ctx)
why = ctx.reason
end
if not allowed then
-- World:movePlayer tells the two refusals apart: "edge" is what asks the
-- connection table for the neighbouring map, "blocked" is a bump.
-- (engine/overworld/player_movement.asm:93-106, :525-531).
-- engine/overworld/movement.asm:315
self.bumpFrames = 1
return why == "bounds" and "edge" or "blocked"
end
self.targetX, self.targetY = tx, ty
self.moving = true
self.bumpFrames = nil
self.progress = 0
return "moved"
end
-- Cutscene step: ignores collision so Elm walk-up / after-pick paths play.
function Player:scriptFace(dir)
if dir then self.facing = dir end
end
function Player:scriptStep(dir)
if self.moving then return false end
-- A scripted step names its own STEP_* on the cart (SurfStartStep is a slow
-- step), so it never inherits the bike's shorter one.
self.stepFrames = STEP_FRAMES
self.facing = dir or self.facing
local d = Map.DELTA[self.facing]
if not d then return false end
self.targetX, self.targetY = self.cellX + d[1], self.cellY + d[2]
self.moving = true
self.bumpFrames = nil
self.progress = 0
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()
local d = Map.DELTA[self.facing] or Map.DELTA.down
return self.cellX + d[1], self.cellY + d[2]
end
function Player:walkPhase()
-- pokegold engine/overworld/map_objects.asm StepFunction_Turn: forces the
-- walking leg frame for the whole 4-frame turn-in-place.
if self.turnTimer > 0 then return 1 end
if not self.moving then
-- map_object_action.asm:45-69
if (self.bumpFrames or 0) <= 0 then return 0 end
-- map_object_action.asm:98-119
return (math.floor(self.animClock / STEP_FRAMES) % 2 == 1) and 1 or 0
end
local p = self.animClock % STEP_FRAMES
return (p >= 4 and p < 12) and 1 or 0
end
-- map_object_action.asm:71-94
function Player:drawFlip()
if self.moving or (self.bumpFrames or 0) <= 0 then return self.stepFlip end
local mirrored = math.floor(self.animClock / (STEP_FRAMES * 2)) % 2 == 1
return self.stepFlip ~= mirrored
end
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
-- map_objects.asm:1517-1525
if (self.bumpFrames or 0) > 0 then
self.bumpFrames = self.bumpFrames - 1
self.animClock = self.animClock + 1
end
-- 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).
return false
end
self.progress = self.progress + 1
self.animClock = self.animClock + 1
-- Interpolate toward the TARGET cell rather than one cell along the facing:
-- a ledge hop (World:tryLedgeJump, the cart's STEP_LEDGE) is a two-cell move
-- 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 dx = (self.targetX or self.cellX) - self.cellX
local dy = (self.targetY or self.cellY) - self.cellY
-- 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: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
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
self.targetX, self.targetY = nil, nil
self.px, self.py = self.cellX * 16, self.cellY * 16
self.moving = false
self.jumping = nil
self.spriteYOffset = 0
self.stepFlip = not self.stepFlip
return true
end
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, oamRow)
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 rides this one byte.
local yOffset = self.spriteYOffset or 0
if self.sprite then
G.push()
G.translate(ox, oy)
G.scale(scale, scale)
-- Chris is PAL_OW_RED; World:applyPalettes keeps the SpriteRenderer's
-- OBJ palette current.
if self.fishing and self.fishSheet then
self:drawFishing(yOffset)
else
local facing, phase = self.facing, self:walkPhase()
local flip = self:drawFlip()
-- 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, flip,
nil, nil, nil, oamRow)
end
G.pop()
return
end
-- Fallback rectangle if sprites.lua is missing from an old cache.
local x = ox + self.px * scale
local y = oy + (self.py + yOffset) * scale
local s = 16 * scale
G.setColor(0.95, 0.35, 0.25, 1)
G.rectangle("fill", x + s * 0.15, y + s * 0.1, s * 0.7, s * 0.85, 2, 2)
G.setColor(1, 0.9, 0.55, 1)
local notch = s * 0.22
if self.facing == "up" then
G.rectangle("fill", x + s * 0.5 - notch / 2, y + s * 0.05, notch, notch)
elseif self.facing == "down" then
G.rectangle("fill", x + s * 0.5 - notch / 2, y + s * 0.7, notch, notch)
elseif self.facing == "left" then
G.rectangle("fill", x + s * 0.05, y + s * 0.4, notch, notch)
else
G.rectangle("fill", x + s * 0.75, y + s * 0.4, notch, notch)
end
G.setColor(1, 1, 1, 1)
end
return Player