Support variable-size anchored overworld sprites

This commit is contained in:
Thomas Armstrong
2026-08-09 04:10:39 -04:00
parent 943ba5dcbf
commit 81f18e244d
7 changed files with 250 additions and 42 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ the same core data and graphics into the source tree for verification.
| | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir |
| render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling |
| | `src/render/TileRenderer.lua` | one SpriteBatch per map (8x8 quads) + border-block ring |
| | `src/render/SpriteRenderer.lua` | 6-frame walker sheets, flipped right facing |
| | `src/render/SpriteRenderer.lua` | variable-size anchored sprite sheets, 6-frame walkers and flipped right facing |
| | `src/render/Font.lua` | glyph rendering via charmap (greedy longest match) |
| | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page |
| | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades |
+38
View File
@@ -100,6 +100,44 @@ Three rules worth knowing:
Returning `nil` from `drawWorld` is a normal answer meaning "not this
frame"; the engine draws the vanilla world instead.
## Variable-size overworld sprites
The `sprites` registry keeps the vanilla 16x16 grounded walker as its default,
but a mod can describe any frame rectangle and anchor for player characters,
NPCs, followers, mounts, vehicles, bosses, or other field actors:
```lua
mod.content.sprites:register("SPRITE_COMPANION", {
image = "mods/example/companion.png", -- one frame per row
frames = 6,
walker = true,
frameWidth = 32,
frameHeight = 32,
anchorX = 16, -- frame-relative bottom-center anchor
anchorY = 32,
})
```
`frameWidth` and `frameHeight` are sheet pixels. `anchorX` and `anchorY` are
measured from each frame's top-left; when omitted they default to the frame's
horizontal center and bottom edge, so a larger sprite grows upward while its
feet stay on the same world cell. Omitting all four fields is exactly the
vanilla 16x16 placement. The normal player/NPC/follower draw paths consume
these values automatically, including horizontal flips and the fishing pose.
Custom render pipelines can use the same geometry without reproducing the
pose rules:
```lua
local geometry = sprite:getPoseGeometry(facing, walkPhase, stepFlip)
-- geometry.quad, .x/.y/.width/.height, .anchorX/.anchorY, .mirror
local originX, originY = sprite:getScreenOrigin(px, py, camX, camY)
```
`getFrameGeometry(frame)` is the corresponding accessor for a specific
zero-based sheet frame. Both accessors return fresh tables and share the
renderers frame selection and mirror conventions.
## Battle sprite scaling
The enemy's front pic draws at 1x and the player's back pic at 2x, the way
+7
View File
@@ -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
View File
@@ -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
+13 -5
View File
@@ -81,8 +81,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
@@ -4799,9 +4801,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)
+7 -2
View File
@@ -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)
+47
View File
@@ -428,16 +428,42 @@ local spriteReg = Registry.new("sprites", Schemas.REGISTRIES.sprites)
spriteReg:register("SPRITE_TITLE_LOGO",
{ image = "mods/logo/logo.png", frames = 1,
trueColor = true }, "logo_mod")
spriteReg:register("SPRITE_LARGE_ACTOR",
{ image = "mods/actor/actor.png", frames = 6,
walker = true, frameWidth = 32, frameHeight = 24,
anchorX = 16, anchorY = 24, trueColor = true },
"actor_mod")
local logoDef = spriteReg:get("SPRITE_TITLE_LOGO")
check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_TITLE_LOGO",
logoDef, "register"),
"a trueColor sprites record validates against the catalog schema")
check(logoDef.trueColor == true, "and keeps the flag through the merge")
local largeDef = spriteReg:get("SPRITE_LARGE_ACTOR")
check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_LARGE_ACTOR",
largeDef, "register"),
"a variable-size sprites record validates against the catalog schema")
Renderer:init()
local plainSprite = SpriteRenderer.new(
{ image = "assets/generated/sprites/red.png", frames = 1 })
local litSprite = SpriteRenderer.new(logoDef)
local largeSprite = SpriteRenderer.new(largeDef)
check(plainSprite.frameWidth == 16 and plainSprite.frameHeight == 16
and plainSprite.anchorX == 8 and plainSprite.anchorY == 16,
"legacy sprite definitions keep the vanilla frame geometry")
local frameGeometry = largeSprite:getFrameGeometry(5)
check(frameGeometry.frame == 5 and frameGeometry.x == 0
and frameGeometry.y == 120 and frameGeometry.width == 32
and frameGeometry.height == 24 and frameGeometry.anchorX == 16
and frameGeometry.anchorY == 24,
"frame geometry exposes a larger sheet rectangle and anchor")
local poseGeometry = largeSprite:getPoseGeometry("right", 1, true)
check(poseGeometry.frame == 5 and poseGeometry.mirror == true
and poseGeometry.quad == largeSprite.frames[5],
"pose geometry follows walker frame selection and right mirroring")
local originX, originY = largeSprite:getScreenOrigin(32, 32, 0, 0)
check(originX == 24 and originY == 20,
"a custom anchor keeps a larger sprite grounded at its cell")
Renderer:beginFrame(true)
check(#PaletteFX.trueColorRects("ui") == 0
@@ -471,6 +497,27 @@ check(#worldDrawn == 2, "the reported zone joins the world list endFrame blits")
check(worldDrawn[1].shader and worldDrawn[2].shader == false,
"the colorized pass runs first, then the sprite's rect with no shader")
-- Larger true-color frames claim their actual extent, and fishing's top-half
-- path reserves only the bottom 8-pixel tile for the overlay.
Renderer:beginFrame(true)
Renderer:beginWorldPass()
largeSprite:draw(32, 32, 0, 0, "down", 0, false)
local largeRects = PaletteFX.trueColorRects("world")
check(#largeRects == 1 and largeRects[1].x == 24 and largeRects[1].y == 20
and largeRects[1].w == 32 and largeRects[1].h == 24,
"a larger trueColor sprite reports its full anchored extent")
Renderer:endWorldPass()
Renderer:beginFrame(true)
Renderer:beginWorldPass()
largeSprite:draw(32, 32, 0, 0, "down", 0, false, true)
local topRects = PaletteFX.trueColorRects("world")
check(#topRects == 1 and topRects[1].h == 16
and largeSprite.halfFrames[0].y == 0
and largeSprite.halfFrames[0].h == 16,
"the fishing overlay keeps a larger frame's bottom tile clear")
Renderer:endWorldPass()
-- the same path on the UI canvas, which is where a full-color title logo
-- or menu portrait lands
Renderer:beginFrame(false)