mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
Merge pull request #1020 from ArmstrongThomas/agent/variable-size-overworld-sprites
Support variable-size anchored overworld sprites
This commit is contained in:
@@ -592,6 +592,13 @@ R.sprites = {
|
||||
image = f.path,
|
||||
frames = f.int(1),
|
||||
walker = f.opt(f.bool),
|
||||
-- Optional sheet geometry for mod actors. Defaults match the vanilla
|
||||
-- 16x16 grounded walker; anchors are measured from each frame's
|
||||
-- top-left in pixels (default: bottom-center).
|
||||
frameWidth = f.opt(f.int(1)),
|
||||
frameHeight = f.opt(f.int(1)),
|
||||
anchorX = f.opt(f.num),
|
||||
anchorY = f.opt(f.num),
|
||||
trueColor = f.opt(f.bool),
|
||||
-- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ
|
||||
-- palette assignment without claiming that the image itself came from
|
||||
|
||||
+137
-34
@@ -1,7 +1,8 @@
|
||||
-- Overworld character sprites. A 12-tile sheet (16x96 PNG) holds 6 16x16
|
||||
-- frames: stand down/up/left, walk down/up/left (data/sprites/facings.asm).
|
||||
-- Overworld character sprites. The vanilla 12-tile sheet (16x96 PNG) holds
|
||||
-- 6 16x16 frames: stand down/up/left, walk down/up/left
|
||||
-- (data/sprites/facings.asm). Mod records may opt into another frame size
|
||||
-- and anchor; the defaults below preserve the original grounded placement.
|
||||
-- Right-facing frames are horizontal flips of the left frames.
|
||||
-- Sprites draw 4px above their cell, like the GB engine.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
@@ -76,6 +77,58 @@ local WALK = { down = 3, up = 4, left = 5, right = 5 }
|
||||
SpriteRenderer.STAND = STAND
|
||||
SpriteRenderer.WALK = WALK
|
||||
|
||||
-- Sprite records are anchored at the point where the actor stands in the
|
||||
-- world. In the vanilla renderer that point is the bottom-center of a
|
||||
-- 16x16 frame: the frame starts at (px, py - 4), so the ground point is
|
||||
-- (px + 8, py + 12). Custom anchors are measured from the frame's top-left
|
||||
-- in sheet pixels and may be fractional for a sub-pixel art style.
|
||||
local DEFAULT_FRAME_WIDTH = 16
|
||||
local DEFAULT_FRAME_HEIGHT = 16
|
||||
local DEFAULT_ANCHOR_X = 8
|
||||
local DEFAULT_ANCHOR_Y = 16
|
||||
local WORLD_ANCHOR_X = 8
|
||||
local WORLD_ANCHOR_Y = 12
|
||||
SpriteRenderer.DEFAULT_FRAME_WIDTH = DEFAULT_FRAME_WIDTH
|
||||
SpriteRenderer.DEFAULT_FRAME_HEIGHT = DEFAULT_FRAME_HEIGHT
|
||||
SpriteRenderer.DEFAULT_ANCHOR_X = DEFAULT_ANCHOR_X
|
||||
SpriteRenderer.DEFAULT_ANCHOR_Y = DEFAULT_ANCHOR_Y
|
||||
|
||||
local function finiteNumber(value)
|
||||
if type(value) ~= "number" or value ~= value
|
||||
or value == math.huge or value == -math.huge then
|
||||
return nil
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
local function positiveInteger(value, fallback)
|
||||
value = finiteNumber(value)
|
||||
if value and value >= 1 then return math.floor(value) end
|
||||
return fallback
|
||||
end
|
||||
|
||||
local function numberOr(value, fallback)
|
||||
return finiteNumber(value) or fallback
|
||||
end
|
||||
|
||||
local function pose(self, facing, walkPhase, stepFlip)
|
||||
if self.frameCount <= 1 then return 0, false end
|
||||
local frame = (self.def.walker and walkPhase == 1)
|
||||
and WALK[facing] or STAND[facing]
|
||||
frame = frame or 0
|
||||
-- Preserve the old fallback for a short custom sheet whose pose table
|
||||
-- names a frame it does not provide.
|
||||
if not self.frames[frame] then frame = 0 end
|
||||
local flip = false
|
||||
if facing == "right" then
|
||||
flip = true
|
||||
elseif (facing == "down" or facing == "up")
|
||||
and walkPhase == 1 and stepFlip then
|
||||
flip = true
|
||||
end
|
||||
return frame, flip
|
||||
end
|
||||
|
||||
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
|
||||
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
|
||||
function SpriteRenderer.new(spriteDef, seed)
|
||||
@@ -83,14 +136,62 @@ function SpriteRenderer.new(spriteDef, seed)
|
||||
self.def = spriteDef
|
||||
self.seed = seed
|
||||
self.image = getImage(spriteDef.image)
|
||||
self.frameCount = positiveInteger(spriteDef.frames, 1)
|
||||
self.frameWidth = positiveInteger(spriteDef.frameWidth, DEFAULT_FRAME_WIDTH)
|
||||
self.frameHeight = positiveInteger(spriteDef.frameHeight, DEFAULT_FRAME_HEIGHT)
|
||||
self.anchorX = numberOr(spriteDef.anchorX, self.frameWidth / 2)
|
||||
self.anchorY = numberOr(spriteDef.anchorY, self.frameHeight)
|
||||
local iw, ih = self.image:getDimensions()
|
||||
self.frames = {}
|
||||
for f = 0, spriteDef.frames - 1 do
|
||||
self.frames[f] = love.graphics.newQuad(0, f * 16, 16, 16, iw, ih)
|
||||
for f = 0, self.frameCount - 1 do
|
||||
self.frames[f] = love.graphics.newQuad(0, f * self.frameHeight,
|
||||
self.frameWidth, self.frameHeight,
|
||||
iw, ih)
|
||||
end
|
||||
return self
|
||||
end
|
||||
|
||||
-- Return the sheet rectangle and top-left-relative anchor for a frame. The
|
||||
-- result is a fresh table so a custom render pipeline may annotate it without
|
||||
-- changing the renderer's shared definition.
|
||||
function SpriteRenderer:getFrameGeometry(frame)
|
||||
frame = math.floor(finiteNumber(frame) or 0)
|
||||
if frame < 0 then frame = 0 end
|
||||
if frame >= self.frameCount then frame = self.frameCount - 1 end
|
||||
return {
|
||||
frame = frame,
|
||||
x = 0,
|
||||
y = frame * self.frameHeight,
|
||||
width = self.frameWidth,
|
||||
height = self.frameHeight,
|
||||
anchorX = self.anchorX,
|
||||
anchorY = self.anchorY,
|
||||
quad = self.frames[frame],
|
||||
}
|
||||
end
|
||||
|
||||
-- Return the frame geometry selected by the ordinary 2D pose rules, plus the
|
||||
-- horizontal mirror state that :draw applies. This is the supported hook for
|
||||
-- custom render pipelines that need to draw actors with the same pose/flip.
|
||||
function SpriteRenderer:getPoseGeometry(facing, walkPhase, stepFlip)
|
||||
local frame, flip = pose(self, facing, walkPhase, stepFlip)
|
||||
local geometry = self:getFrameGeometry(frame)
|
||||
geometry.facing = facing
|
||||
geometry.walkPhase = walkPhase
|
||||
geometry.stepFlip = stepFlip
|
||||
geometry.mirror = flip
|
||||
return geometry
|
||||
end
|
||||
|
||||
-- Screen-space top-left for the actor's current world anchor. World-facing
|
||||
-- effects such as fishing can use this instead of assuming a 16x16 frame.
|
||||
function SpriteRenderer:getScreenOrigin(px, py, camX, camY)
|
||||
local baseX = math.floor(px - camX) + WORLD_ANCHOR_X
|
||||
local baseY = math.floor(py - camY) + WORLD_ANCHOR_Y
|
||||
return math.floor(baseX - self.anchorX),
|
||||
math.floor(baseY - self.anchorY)
|
||||
end
|
||||
|
||||
-- The image this sprite would draw from right now: the plain sheet, or the
|
||||
-- OBP-recolored bake of it. Exposed so a render pipeline can texture its
|
||||
-- own geometry from the very same image -- the geometry carries sheet pixel
|
||||
@@ -125,27 +226,32 @@ end
|
||||
|
||||
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
||||
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
|
||||
local function blitFrame(image, quad, x, y, flip, redraw)
|
||||
local function blitFrame(image, quad, x, y, flip, redraw, frameWidth)
|
||||
frameWidth = frameWidth or DEFAULT_FRAME_WIDTH
|
||||
if flip then
|
||||
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
|
||||
if redraw then PaletteFX.markSpriteRedraw(image, quad, x + 16, y, -1) end
|
||||
love.graphics.draw(image, quad, x + frameWidth, y, 0, -1, 1)
|
||||
if redraw then
|
||||
PaletteFX.markSpriteRedraw(image, quad, x + frameWidth, y, -1)
|
||||
end
|
||||
else
|
||||
love.graphics.draw(image, quad, x, y)
|
||||
if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end
|
||||
end
|
||||
end
|
||||
|
||||
-- topHalf blits only the upper 8 rows of the frame: FishingAnim overwrites the
|
||||
-- bottom tile row of the standing frames with the fishing pose art, which the
|
||||
-- caller then draws itself through :drawTile (Player:draw, #384)
|
||||
-- topHalf blits everything above the bottom 8-pixel tile row: FishingAnim
|
||||
-- overwrites that row of the standing frames with fishing pose art, which the
|
||||
-- caller then draws itself through :drawTile (Player:draw, #384). Vanilla
|
||||
-- frames therefore still draw 8 rows, while taller frames keep their larger
|
||||
-- body and reserve only the overlay row.
|
||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf)
|
||||
local x = math.floor(px - camX)
|
||||
local y = math.floor(py - camY) - 4
|
||||
local x, y = self:getScreenOrigin(px, py, camX, camY)
|
||||
local image = self.image
|
||||
local redraw = false
|
||||
-- full-color art claims its 16x16 cell out of the shade-remap pass
|
||||
-- True-color sheets bypass every palette bake; the screen-space exemption
|
||||
-- is recorded below once the final frame/height is known.
|
||||
if self.def.trueColor then
|
||||
PaletteFX.markTrueColor(x, y, 16, 16)
|
||||
image = self.image
|
||||
elseif PaletteFX.usesGbcPack() then
|
||||
-- RED++: the world canvas is already true-color (TileRenderer bakes
|
||||
-- terrain, this bakes the sprite) and the world pass runs unshaded
|
||||
@@ -177,31 +283,28 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, to
|
||||
-- being colorized by the zone IS the point (#301).
|
||||
image = getObpImage(self.def.image, PaletteFX.dmgObj())
|
||||
end
|
||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||
-- Single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||
-- still 3-frame sprites turn to face (the nurse at her machine,
|
||||
-- facePlayer on STAY NPCs) but never show walk frames
|
||||
if self.def.frames <= 1 then
|
||||
blitFrame(image, self.frames[0], x, y, false, redraw)
|
||||
return
|
||||
end
|
||||
local frame = (self.def.walker and walkPhase == 1)
|
||||
and WALK[facing] or STAND[facing]
|
||||
local flip = false
|
||||
if facing == "right" then
|
||||
flip = true
|
||||
elseif (facing == "down" or facing == "up") and walkPhase == 1 and stepFlip then
|
||||
flip = true
|
||||
end
|
||||
local quad = self.frames[frame] or self.frames[0]
|
||||
if topHalf then
|
||||
-- facePlayer on STAY NPCs) but never show walk frames.
|
||||
local frame, flip = pose(self, facing, walkPhase, stepFlip)
|
||||
local quad = self.frames[frame]
|
||||
local drawHeight = self.frameHeight
|
||||
if topHalf and self.frameCount > 1 then
|
||||
self.halfFrames = self.halfFrames or {}
|
||||
if not self.halfFrames[frame] then
|
||||
local iw, ih = self.image:getDimensions()
|
||||
self.halfFrames[frame] = love.graphics.newQuad(0, frame * 16, 16, 8, iw, ih)
|
||||
local topHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight))
|
||||
self.halfFrames[frame] = love.graphics.newQuad(
|
||||
0, frame * self.frameHeight, self.frameWidth, topHeight, iw, ih)
|
||||
end
|
||||
quad = self.halfFrames[frame]
|
||||
drawHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight))
|
||||
end
|
||||
blitFrame(image, quad, x, y, flip, redraw)
|
||||
-- Full-color art claims exactly the portion of the frame that was drawn.
|
||||
if self.def.trueColor then
|
||||
PaletteFX.markTrueColor(x, y, self.frameWidth, drawHeight)
|
||||
end
|
||||
blitFrame(image, quad, x, y, flip, redraw, self.frameWidth)
|
||||
end
|
||||
|
||||
-- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ
|
||||
@@ -225,7 +328,7 @@ function SpriteRenderer:drawTile(path, x, y, flip)
|
||||
self.tileQuads = self.tileQuads or {}
|
||||
self.tileQuads[path] = self.tileQuads[path]
|
||||
or love.graphics.newQuad(0, 0, iw, ih, iw, ih)
|
||||
blitFrame(image, self.tileQuads[path], x, y, flip, redraw)
|
||||
blitFrame(image, self.tileQuads[path], x, y, flip, redraw, iw)
|
||||
end
|
||||
|
||||
return SpriteRenderer
|
||||
|
||||
@@ -85,8 +85,10 @@ local HEAL_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 1, [3] = 3 }
|
||||
-- above (screen = tile*8 + pixel - 8/16), measured against the player
|
||||
-- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40
|
||||
-- (home/reset_player_sprite.asm), i.e. screen (64,60). So what ports over
|
||||
-- is the delta from the sprite's top-left, which SpriteRenderer:draw puts at
|
||||
-- (px, py - 4). `tile` indexes the three stacked 8x8 tiles of
|
||||
-- is the delta from the sprite's top-left, which the vanilla
|
||||
-- SpriteRenderer:draw puts at (px, py - 4); custom frame anchors move that
|
||||
-- origin while keeping these offsets frame-relative. `tile` indexes the
|
||||
-- three stacked 8x8 tiles of
|
||||
-- assets/generated/fx/fishing_rod.png: FishingRodOAM only ever draws $fd
|
||||
-- (row 0, up/down) and $fe (row 1, left/right), and RIGHT is the LEFT tile
|
||||
-- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a
|
||||
@@ -4819,9 +4821,15 @@ function OverworldState:drawWorld()
|
||||
end
|
||||
end
|
||||
local quad = self.rodQuads[oam.tile]
|
||||
-- the sprite's top-left is 4px above its cell (SpriteRenderer:draw)
|
||||
local rx = p.px - cam.x + oam.dx
|
||||
local ry = p.py - cam.y - 4 + oam.dy
|
||||
-- Place the rod against the active sprite's anchored top-left. The
|
||||
-- vanilla result is still (px-cam, py-cam-4), while custom larger
|
||||
-- sheets keep the rod attached to their feet.
|
||||
-- Fishing always uses the on-foot player sheet; read its fields
|
||||
-- directly so this FX pass does not advance pose-side animation.
|
||||
local sprite, px, py = p.sprite, p.px, p.py
|
||||
local sx, sy = sprite:getScreenOrigin(px, py, cam.x, cam.y)
|
||||
local rx = sx + oam.dx
|
||||
local ry = sy + oam.dy
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if quad and oam.flip then
|
||||
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1)
|
||||
|
||||
@@ -335,8 +335,13 @@ function Player:draw(camX, camY)
|
||||
local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing]
|
||||
if fishTile then
|
||||
sprite:draw(px, py, camX, camY, facing, 0, false, true)
|
||||
sprite:drawTile(fishTile, math.floor(px - camX),
|
||||
math.floor(py - camY) - 4 + 8, facing == "right")
|
||||
-- The fishing pose replaces the bottom 8-pixel tile. Use the sprite's
|
||||
-- actual anchored frame origin so larger/custom sheets keep the pose at
|
||||
-- their feet instead of falling back to the vanilla 16x16 top-left.
|
||||
local sx, sy = sprite:getScreenOrigin(px, py, camX, camY)
|
||||
sprite:drawTile(fishTile, sx,
|
||||
sy + math.max(0, sprite.frameHeight - 8),
|
||||
facing == "right")
|
||||
return
|
||||
end
|
||||
sprite:draw(px, py, camX, camY, facing, phase, flip)
|
||||
|
||||
Reference in New Issue
Block a user