1
0
spritesheet/spritesheet.lua

467 lines
16 KiB
Lua
Raw Permalink Normal View History

2024-07-10 19:14:12 +00:00
--[[-- # Spritesheet
Library for managing sprite sheets of textures and animations.
Has support for both individual images in spritesheets and animations. This
can be specified from a lua file placed beside the spritesheet image file.
## Notes
- When drawing an image or animation when a shader is defined the library may
send certain useful constants along, notably `spritesheet_inverse_width` and
`spritesheet_inverse_height`.
--]]
2018-11-08 15:27:03 +00:00
2024-04-26 09:58:22 +00:00
local error_original = error
local error, error_internal do
2024-04-26 09:58:22 +00:00
error, error_internal = error_original, error_original
2024-04-26 09:56:27 +00:00
local success, errorlib = pcall(require, 'errors')
if success then
error = errorlib 'spritesheet'
error_internal = error.internal
end
end
2018-11-08 15:27:03 +00:00
--------------------------------------------------------------------------------
-- Checks that LÖVE is defined; if not, run in information loading
-- mode only.
local define_love = true
if type(love) ~= 'table' then
io.stderr:write '[Spritesheet]: Loaded in non-LÖVE environment. Can still load spritesheet data,\n but image drawing methods will not be defined.\n'
define_love = false
end
2018-11-08 15:27:03 +00:00
--------------------------------------------------------------------------------
-- Util
local function calculate_animation_duration (self, frame_i)
2024-04-26 09:56:27 +00:00
frame_i = frame_i or math.huge
2018-11-08 15:27:03 +00:00
assert(type(self) == 'table')
assert(type(frame_i) == 'number')
-- If time_total is provided, return that.
if self.time_total then
assert(self.time == nil)
self.time = self.time_total/#self
return self.time_total
end
2018-11-08 15:27:03 +00:00
-- Easy if number
if type(self.time) == 'number' then
return math.min(#self, frame_i) * self.time
end
2018-11-08 15:27:03 +00:00
-- Sum if table
local sum = 0
for i = 1, math.min(#self, frame_i) do sum = sum + self.time[i] end
return sum
end
local function get_quad_based_on_time (l, t)
-- TODO: Reimplement as binary search. (Maybe only use the binary search version for very long animations?)
--
assert(type(l) == 'table')
assert(type(t) == 'number')
--
local time = l.time
for i = 1, #time do
if t <= time[i] then
return l[i]
end
end
--
return l[#l]
end
--------------------------------------------------------------------------------
---- Sprite
local Sprite = {}
Sprite.__index = Sprite
function Sprite.new (quad, imagesheet)
return setmetatable({ quad = quad, imagesheet = imagesheet, is_sprite = true }, Sprite)
end
2023-10-22 11:08:52 +00:00
local function set_shader_texture_size(texture)
local currently_active_shader = love.graphics.getShader()
if currently_active_shader ~= nil and currently_active_shader:hasUniform('spritesheet_inverse_width') then
local width, height = texture:getDimensions()
currently_active_shader:send('spritesheet_inverse_width', {1/width, 0})
currently_active_shader:send('spritesheet_inverse_height', { 0, 1/height})
end
end
if define_love then
2018-11-08 15:27:03 +00:00
function Sprite:generateImage ()
local imagesheet, quad = self.imagesheet, self.quad
self.func = function (x, y)
2023-10-22 11:08:52 +00:00
set_shader_texture_size(imagesheet.image)
2018-11-08 15:27:03 +00:00
love.graphics.draw(imagesheet.image, quad, math.floor(x), math.floor(y), 0, 1, 1, imagesheet.origin_x, imagesheet.origin_y)
end
end
function Sprite:getImage ()
if not self.func then self:generateImage() end
return self.func
end
function Sprite:getQuad ()
return self.quad
end
function Sprite:draw(...)
return self:getImage()(...)
end
2019-02-26 21:45:31 +00:00
function Sprite:getSheetDimensions()
return self.imagesheet.image:getDimensions()
end
end
2018-11-08 15:27:03 +00:00
setmetatable(Sprite, {__call = function(_, ...) return Sprite.new(...) end})
--------------------------------------------------------------------------------
---- Animation
local Animation = {}
Animation.__index = Animation
function Animation.new (self)
2024-04-26 09:56:27 +00:00
assert(type(self) == 'table' and self ~= Animation)
assert(type(self.time) == 'table' or type(self.time) == 'number' and self.time > 0 or type(self.time_total) == 'number')
2018-11-08 15:27:03 +00:00
assert(#self > 0)
assert(type(self.time) == 'number' or type(self.time_total) == 'number' or #self == #self.time)
if self.time_total then assert(self.time == nil) end
2018-11-10 14:03:21 +00:00
assert(self.wrap == nil or self.wrap == true or self.wrap == false)
2024-04-26 09:56:27 +00:00
setmetatable(self, Animation)
self.duration = calculate_animation_duration(self)
self.is_animation = true
2018-11-08 15:27:03 +00:00
-- Contact frame?
if self.contact_frame then
self.contact_time = calculate_animation_duration(self, self.contact_frame)
end
--
return self
end
if define_love then
2018-11-08 15:27:03 +00:00
function Animation:generateImage ()
self.func = function (x, y, t)
t = t or 0
assert(type(t) == 'number')
if self.wrap then t = t % self.duration end
local quad = get_quad_based_on_time(self, t)
if not quad then error_internal('Could not determine quad when drawing animation. Time was %f.', t) end
2023-10-22 11:08:52 +00:00
set_shader_texture_size(self.imagesheet.image)
2018-11-08 15:27:03 +00:00
love.graphics.draw(self.imagesheet.image, quad, x, y, 0, 1, 1, self.imagesheet.origin_x, self.imagesheet.origin_y)
end
end
function Animation:getImage ()
if not self.func then self:generateImage() end
return self.func
end
function Animation:getQuad (i)
assert(i == nil or type(i) == 'number' and self[i])
return self[i or 1]
end
function Animation:getDuration ()
return self.duration
end
function Animation:draw(...)
return self:getImage()(...)
end
2019-02-26 21:45:31 +00:00
function Animation:getSheetDimensions()
return self.imagesheet.image:getDimensions()
end
end
2018-11-08 15:27:03 +00:00
setmetatable(Animation, {__call = function(_, ...) return Animation.new(...) end})
--------------------------------------------------------------------------------
-- Constants
local SPRITESHEET_ENV = { Anim = Animation }
local SPRITESHEET_DATA_FILETYPES = { ['.lua'] = true, ['.raw'] = true }
--------------------------------------------------------------------------------
local function calculate_frame_times (time_list, nr_frames)
assert(type(time_list) == 'table' or type(time_list) == 'number')
assert(type(nr_frames) == 'number')
local frame_times = { [0] = -math.huge, orig = not define_love and time_list or nil }
2018-11-08 15:27:03 +00:00
if type(time_list) == 'number' then
for i = 1, nr_frames do
frame_times[i] = i * time_list
end
else
frame_times[1] = time_list[1]
for i = 2, nr_frames do
frame_times[i] = frame_times[i-1] + time_list[i]
end
end
return frame_times
end
local function load_quads (_, _, quad_data, imagesheet)
assert(type(imagesheet) == 'table')
assert(type(imagesheet.tiles_per_row) == 'number')
assert(type(imagesheet.tiles_per_column) == 'number')
2018-11-08 15:27:03 +00:00
local tile_width, tile_height = imagesheet.tile_width, imagesheet.tile_height
local tiles_per_row = imagesheet.tiles_per_row
local max_quad_id = tiles_per_row * imagesheet.tiles_per_column - 1
2018-11-08 15:27:03 +00:00
local quad_cache = {}
local function quad_from_id (id)
-- Error checking
if type(id) ~= 'number' then error('All quad ids must be natural numbers, but one was %s (%s)', id, type(id)) end
if id % 1 ~= 0 then error('All quad ids must be natural numbers, but one was %03.03f (floating point number)', id) end
2018-11-08 15:27:03 +00:00
if not (0 <= id and id <= max_quad_id) then error('All quad ids must - for this spritesheet ("%s") - be natural numbers equal/below %i, but one was %s = 0x%X', imagesheet.filename, max_quad_id, id, id) end
-- Calculate
local quad = quad_cache[id]
if quad == nil then
if define_love then
quad = love.graphics.newQuad((id%tiles_per_row)*tile_width, math.floor(id/tiles_per_row)*tile_height, tile_width, tile_height, imagesheet.width, imagesheet.height)
else
quad = { id = id, (id%tiles_per_row)*tile_width, math.floor(id/tiles_per_row)*tile_height, tile_width, tile_height }
end
quad_cache[id] = quad
2018-11-08 15:27:03 +00:00
end
return quad
2018-11-08 15:27:03 +00:00
end
local function visit_animation (t)
assert(type(t) == 'table' and t.is_animation)
for i = 1, #t do t[i] = quad_from_id(t[i]) end
t.time = calculate_frame_times(t.time, #t)
t.imagesheet = imagesheet
end
local function visit_quad (n)
assert(type(n) == 'number')
return Sprite(quad_from_id(n), imagesheet)
end
local function visit_node (t, already_seen)
2018-11-08 15:27:03 +00:00
assert(type(t) == 'table')
for key, val in pairs(t) do
local val_type = type(val)
if val_type == 'number' then
t[key] = visit_quad(val)
elseif val_type == 'table' then
local visit_func = rawget(val,'is_animation') and visit_animation or visit_node
visit_func(val, already_seen)
2018-11-08 15:27:03 +00:00
end
end
if type(error) == 'table' and define_love then
error.strict_table(t)
end
2018-11-08 15:27:03 +00:00
end
assert(type(quad_data) == 'table')
assert(type(quad_data.tile_names) == 'table')
local visit_func = quad_data.tile_names.is_animation and visit_animation or visit_node
visit_func(quad_data.tile_names)
return quad_data.tile_names
end
local function load_quad_data (filename)
if type(filename) ~= 'string' then error('Bad argument #1, expected string, got %s (%s)', filename, type(filename)) end
-- Attempt to load file
for filetype in pairs(SPRITESHEET_DATA_FILETYPES) do
local chunk, error_msg
if define_love then
2024-04-26 09:56:27 +00:00
chunk, error_msg = love.filesystem.load(filename..filetype)
else
2024-04-26 09:56:27 +00:00
chunk, error_msg = loadfile(filename..filetype)
end
2018-11-08 15:27:03 +00:00
if chunk then
local data = setfenv(chunk, SPRITESHEET_ENV)()
2018-11-08 15:27:03 +00:00
-- Error check
if type(data) ~= 'table' then
error('Bad spritesheet "%s". Must return a table, but returned %s (%s)', filename, data, type(data))
end
local l = {'Bad spritesheet "'.. filename.. '"'}
if data.tiles_per_row ~= nil and data.tile_width ~= nil then
l[#l+1] = 'Root table must not contain both keys "tiles_per_row" and "tile_width"'
elseif data.tiles_per_row == nil and data.tile_width == nil then
l[#l+1] = 'Root table must contain either keys "tiles_per_row" or "tile_width"'
end
if data.tiles_per_column ~= nil and data.tile_height ~= nil then
l[#l+1] = 'Root table must not contain both keys "tiles_per_column" and "tile_height"'
elseif data.tiles_per_column == nil and data.tile_height == nil then
l[#l+1] = 'Root table must contain either keys "tiles_per_column" or "tile_height"'
end
local INTEGER_TILESET_KEYS = {'tile_width', 'tile_height', 'tiles_per_row', 'tiles_per_column'}
for _, integer_key in ipairs(INTEGER_TILESET_KEYS) do
local v = data[integer_key]
if v and (type(v) ~= 'number' or v % 1 ~= 0) then
l[#l+1] = string.format('Key "%s" in root table must map to integer value, but it was %s (%s)', integer_key, v, type(v))
end
end
if not (type(data.tile_names) == 'table' or type(data.tile_names) == 'number') then
l[#l+1] = string.format('Root table must contain key "tile_names", with either a table or a number value, but it was %s (%s)', data.tile_names, type(data.tile_names))
end
if data.tile_origin and type(data.tile_origin) ~= 'table' then
l[#l+1] = string.format('Key "%s" in root table must map to a table value, but it was %s (%s)', 'tile_origin', data.tile_origin, type(data.tile_origin))
end
-- Throw error or return
if #l > 1 then
error(table.concat(l, '\n '))
end
2018-11-08 15:27:03 +00:00
return data
end
print(error_msg)
end
-- Else, give up.
error('Could not find file "%s.lua" or "%s.raw".', filename, filename)
end
--------------------------------------------------------------------------------
local SpriteSheet = {}
SpriteSheet.__index = SpriteSheet
SpriteSheet.is_spritesheet = true
function SpriteSheet.new (filename)
local quad_data = load_quad_data(filename)
-- NOTE: `force_uneven_tile_size` in quad_data can be used to
-- ignore the image size-tile size divisibility check. Edit the
-- spriteimage itself if you can, as it will silently ignore
-- several errors.
local img, width, height
if define_love then
img = love.graphics.newImage(filename..'.png')
width, height = img:getDimensions()
else
img = require 'imlib2'.image.load(filename..'.png')
width = img:get_width()
height = img:get_height()
end
2018-11-08 15:27:03 +00:00
-- Set info
local self = setmetatable({}, SpriteSheet)
self.filename = filename
self.image = img
self.width = width
self.height = height
2018-11-08 15:27:03 +00:00
self.origin_x = 0
self.origin_y = 0
-- TODO: Give warning/error due to rounding down.
self.tiles_per_row = quad_data.tiles_per_row or math.floor(width / quad_data.tile_width)
self.tiles_per_column = quad_data.tiles_per_column or math.floor(height / quad_data.tile_height)
self.tile_width = quad_data.tile_width or math.floor(width / self.tiles_per_row)
self.tile_height = quad_data.tile_height or math.floor(height / self.tiles_per_column)
2018-11-08 15:27:03 +00:00
-- Error checking
do
local rem_width = width % self.tile_width
local rem_height = height % self.tile_height
if not quad_data.force_uneven_tile_size and (rem_width ~= 0 or rem_height ~= 0) then
local s = ('Bad spritesheet "%s". Image size (%i, %i) must be dividable by tile size (%i, %i)')
:format(filename, self.image:getWidth(), self.image:getHeight(), self.tile_width, self.tile_height)
if rem_width ~= 0 then s = s..('\n Width leaves a remainder of %i.'):format(rem_width) end
if rem_height ~= 0 then s = s..('\n Height leaves a remainder of %i.'):format(rem_height) end
error(s)
end
end
2018-11-08 15:27:03 +00:00
-- Set origin
if quad_data.tile_origin then
self:setOrigin(unpack(quad_data.tile_origin))
end
-- Import quads into SpriteSheet
self.quads = load_quads(width, height, quad_data, self)
2018-11-08 15:27:03 +00:00
if rawget(self.quads, 'is_sprite') or rawget(self.quads, 'is_animation') then
self.only_quads = self.quads
else
for key, value in pairs(self.quads) do
assert(not self[key])
self[key] = value
end
end
-- Return
return self
end
function SpriteSheet:setOrigin (ox, oy, mode)
assert(type(ox) == 'number')
assert(type(oy) == 'number')
self.origin_orig = { ox, oy, mode }
2018-11-08 15:27:03 +00:00
if mode == 'absolute' then
self.origin_x = ox
self.origin_y = oy
elseif mode == 'relative' or mode == nil then
self.origin_x = math.floor(self.tile_width * ox)
self.origin_y = math.floor(self.tile_height * oy)
2018-11-08 15:27:03 +00:00
else
error('Unknown origin mode %s (%s)', mode, type(mode))
2018-11-08 15:27:03 +00:00
end
assert(self.origin_x % 1 == 0)
assert(self.origin_y % 1 == 0)
2018-11-08 15:27:03 +00:00
end
function SpriteSheet:getQuadKeys ()
local keys = {}
for key in pairs(self.quads) do keys[#keys+1] = key end
return keys
end
local function assert_self (self_v, is_field)
if type(self_v) ~= 'table' then error('Bad self value, must be indexable, but was %s (%s).', self_v, type(self_v)) end
if is_field and not self_v[is_field] then error('Bad self value, is indexable, but does not possess "%s" field.', is_field) end
return true
end
-- Create redirections
for _, method_name in pairs{ 'getImage', 'getQuad', 'draw', 'getDuration' } do
SpriteSheet[method_name] = function (self, ...)
assert_self (self, 'is_spritesheet')
if not self.only_quads then error('Attempting to call method "%s" on a SpriteSheet ("%s"). This is only allowed when the Spritesheet contains a single sprite or animation.', method_name, self.filename) end
return self.only_quads[method_name](self.only_quads, ...)
end
end
setmetatable(SpriteSheet, {__call = function(_, ...) return SpriteSheet.new(...) end})
--------------------------------------------------------------------------------
-- Return
return SpriteSheet