From 81f18e244d8b6dc84b68e6d3f3bed01d18a39663 Mon Sep 17 00:00:00 2001 From: Thomas Armstrong <12937683+ArmstrongThomas@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:10:39 -0400 Subject: [PATCH] Support variable-size anchored overworld sprites --- docs/architecture.md | 2 +- docs/modding.md | 38 +++++++ src/mods/Schemas.lua | 7 ++ src/render/SpriteRenderer.lua | 171 ++++++++++++++++++++++++------ src/world/OverworldController.lua | 18 +++- src/world/Player.lua | 9 +- tests/mod_graphics_tests.lua | 47 ++++++++ 7 files changed, 250 insertions(+), 42 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a1d58df6..09bbc60d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/docs/modding.md b/docs/modding.md index 8bbb7dde..369479ad 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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 +renderer’s 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 diff --git a/src/mods/Schemas.lua b/src/mods/Schemas.lua index c834ce41..3c033bbe 100644 --- a/src/mods/Schemas.lua +++ b/src/mods/Schemas.lua @@ -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 diff --git a/src/render/SpriteRenderer.lua b/src/render/SpriteRenderer.lua index 3f39f1d9..36ebb36e 100644 --- a/src/render/SpriteRenderer.lua +++ b/src/render/SpriteRenderer.lua @@ -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 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 1ac89816..ce3e7bf2 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -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) diff --git a/src/world/Player.lua b/src/world/Player.lua index 5fb257e8..65406b85 100644 --- a/src/world/Player.lua +++ b/src/world/Player.lua @@ -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) diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index b2b543a2..8e310e63 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -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)