mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
Merge pull request #1020 from ArmstrongThomas/agent/variable-size-overworld-sprites
Support variable-size anchored overworld sprites
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
renderer’s 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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user