Merge pull request #1020 from ArmstrongThomas/agent/variable-size-overworld-sprites

Support variable-size anchored overworld sprites
This commit is contained in:
bryanthaboi
2026-08-10 14:09:13 -04:00
committed by GitHub
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 | | | `src/core/SaveData.lua` | Lua-serialized save in the LÖVE save dir |
| render | `src/render/Renderer.lua` | 160x144 canvas, integer nearest scaling | | 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/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/Font.lua` | glyph rendering via charmap (greedy longest match) |
| | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page | | | `src/render/TextBox.lua` | dialogue box: typewriter, `\n` line, `\v` scroll, `\f` page |
| | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades | | | `src/render/Camera.lua`, `Transition.lua` | follow camera, warp fades |
+38
View File
@@ -110,6 +110,44 @@ Three rules worth knowing:
Returning `nil` from `drawWorld` is a normal answer meaning "not this Returning `nil` from `drawWorld` is a normal answer meaning "not this
frame"; the engine draws the vanilla world instead. 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 ## Battle sprite scaling
The enemy's front pic draws at 1x and the player's back pic at 2x, the way 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, image = f.path,
frames = f.int(1), frames = f.int(1),
walker = f.opt(f.bool), 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), trueColor = f.opt(f.bool),
-- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ -- Mod art can opt into an existing ROM sprite's Advanced-mode OBJ
-- palette assignment without claiming that the image itself came from -- 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 -- Overworld character sprites. The vanilla 12-tile sheet (16x96 PNG) holds
-- frames: stand down/up/left, walk down/up/left (data/sprites/facings.asm). -- 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. -- 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 Assets = require("src.render.Assets")
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
@@ -76,6 +77,58 @@ local WALK = { down = 3, up = 4, left = 5, right = 5 }
SpriteRenderer.STAND = STAND SpriteRenderer.STAND = STAND
SpriteRenderer.WALK = WALK 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 -- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp) -- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
function SpriteRenderer.new(spriteDef, seed) function SpriteRenderer.new(spriteDef, seed)
@@ -83,14 +136,62 @@ function SpriteRenderer.new(spriteDef, seed)
self.def = spriteDef self.def = spriteDef
self.seed = seed self.seed = seed
self.image = getImage(spriteDef.image) 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() local iw, ih = self.image:getDimensions()
self.frames = {} self.frames = {}
for f = 0, spriteDef.frames - 1 do for f = 0, self.frameCount - 1 do
self.frames[f] = love.graphics.newQuad(0, f * 16, 16, 16, iw, ih) self.frames[f] = love.graphics.newQuad(0, f * self.frameHeight,
self.frameWidth, self.frameHeight,
iw, ih)
end end
return self return self
end 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 -- 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 -- 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 -- 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 -- 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). -- 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 if flip then
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1) love.graphics.draw(image, quad, x + frameWidth, y, 0, -1, 1)
if redraw then PaletteFX.markSpriteRedraw(image, quad, x + 16, y, -1) end if redraw then
PaletteFX.markSpriteRedraw(image, quad, x + frameWidth, y, -1)
end
else else
love.graphics.draw(image, quad, x, y) love.graphics.draw(image, quad, x, y)
if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end if redraw then PaletteFX.markSpriteRedraw(image, quad, x, y, 1) end
end end
end end
-- topHalf blits only the upper 8 rows of the frame: FishingAnim overwrites the -- topHalf blits everything above the bottom 8-pixel tile row: FishingAnim
-- bottom tile row of the standing frames with the fishing pose art, which the -- overwrites that row of the standing frames with fishing pose art, which the
-- caller then draws itself through :drawTile (Player:draw, #384) -- 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) function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf)
local x = math.floor(px - camX) local x, y = self:getScreenOrigin(px, py, camX, camY)
local y = math.floor(py - camY) - 4
local image = self.image local image = self.image
local redraw = false 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 if self.def.trueColor then
PaletteFX.markTrueColor(x, y, 16, 16) image = self.image
elseif PaletteFX.usesGbcPack() then elseif PaletteFX.usesGbcPack() then
-- RED++: the world canvas is already true-color (TileRenderer bakes -- RED++: the world canvas is already true-color (TileRenderer bakes
-- terrain, this bakes the sprite) and the world pass runs unshaded -- 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). -- being colorized by the zone IS the point (#301).
image = getObpImage(self.def.image, PaletteFX.dmgObj()) image = getObpImage(self.def.image, PaletteFX.dmgObj())
end 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, -- still 3-frame sprites turn to face (the nurse at her machine,
-- facePlayer on STAY NPCs) but never show walk frames -- facePlayer on STAY NPCs) but never show walk frames.
if self.def.frames <= 1 then local frame, flip = pose(self, facing, walkPhase, stepFlip)
blitFrame(image, self.frames[0], x, y, false, redraw) local quad = self.frames[frame]
return local drawHeight = self.frameHeight
end if topHalf and self.frameCount > 1 then
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
self.halfFrames = self.halfFrames or {} self.halfFrames = self.halfFrames or {}
if not self.halfFrames[frame] then if not self.halfFrames[frame] then
local iw, ih = self.image:getDimensions() 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 end
quad = self.halfFrames[frame] quad = self.halfFrames[frame]
drawHeight = math.max(1, self.frameHeight - math.min(8, self.frameHeight))
end 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 end
-- Blit a loose 16-wide fx tile at screen (x, y) wearing THIS sprite's OBJ -- 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 = self.tileQuads or {}
self.tileQuads[path] = self.tileQuads[path] self.tileQuads[path] = self.tileQuads[path]
or love.graphics.newQuad(0, 0, iw, ih, iw, ih) 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 end
return SpriteRenderer return SpriteRenderer
+13 -5
View File
@@ -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 -- above (screen = tile*8 + pixel - 8/16), measured against the player
-- sprite's fixed screen spot: ResetPlayerSpriteData parks it at $3c/$40 -- 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 -- (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 -- is the delta from the sprite's top-left, which the vanilla
-- (px, py - 4). `tile` indexes the three stacked 8x8 tiles of -- 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 -- 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 -- (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 -- x-flipped. Blitting the whole 8x24 sheet is what drew the rod as a
@@ -4819,9 +4821,15 @@ function OverworldState:drawWorld()
end end
end end
local quad = self.rodQuads[oam.tile] local quad = self.rodQuads[oam.tile]
-- the sprite's top-left is 4px above its cell (SpriteRenderer:draw) -- Place the rod against the active sprite's anchored top-left. The
local rx = p.px - cam.x + oam.dx -- vanilla result is still (px-cam, py-cam-4), while custom larger
local ry = p.py - cam.y - 4 + oam.dy -- 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) love.graphics.setColor(1, 1, 1, 1)
if quad and oam.flip then if quad and oam.flip then
love.graphics.draw(self.rodImg, quad, rx + 8, ry, 0, -1, 1) 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] local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing]
if fishTile then if fishTile then
sprite:draw(px, py, camX, camY, facing, 0, false, true) sprite:draw(px, py, camX, camY, facing, 0, false, true)
sprite:drawTile(fishTile, math.floor(px - camX), -- The fishing pose replaces the bottom 8-pixel tile. Use the sprite's
math.floor(py - camY) - 4 + 8, facing == "right") -- 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 return
end end
sprite:draw(px, py, camX, camY, facing, phase, flip) 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", spriteReg:register("SPRITE_TITLE_LOGO",
{ image = "mods/logo/logo.png", frames = 1, { image = "mods/logo/logo.png", frames = 1,
trueColor = true }, "logo_mod") 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") local logoDef = spriteReg:get("SPRITE_TITLE_LOGO")
check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_TITLE_LOGO", check(Schemas.check(Schemas.REGISTRIES.sprites, "sprites", "SPRITE_TITLE_LOGO",
logoDef, "register"), logoDef, "register"),
"a trueColor sprites record validates against the catalog schema") "a trueColor sprites record validates against the catalog schema")
check(logoDef.trueColor == true, "and keeps the flag through the merge") 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() Renderer:init()
local plainSprite = SpriteRenderer.new( local plainSprite = SpriteRenderer.new(
{ image = "assets/generated/sprites/red.png", frames = 1 }) { image = "assets/generated/sprites/red.png", frames = 1 })
local litSprite = SpriteRenderer.new(logoDef) 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) Renderer:beginFrame(true)
check(#PaletteFX.trueColorRects("ui") == 0 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, check(worldDrawn[1].shader and worldDrawn[2].shader == false,
"the colorized pass runs first, then the sprite's rect with no shader") "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 -- the same path on the UI canvas, which is where a full-color title logo
-- or menu portrait lands -- or menu portrait lands
Renderer:beginFrame(false) Renderer:beginFrame(false)