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
2019-12-04 17:11:11 +00:00
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 ' )
2019-12-04 17:11:11 +00:00
if success then
error = errorlib ' spritesheet '
error_internal = error.internal
end
end
2018-11-08 15:27:03 +00:00
2020-07-07 10:52:37 +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 ' )
2020-07-07 10:52:37 +00:00
-- 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
2020-07-07 10:52:37 +00:00
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
2020-07-07 10:52:37 +00:00
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
2020-07-07 10:52:37 +00:00
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 )
2020-07-07 10:52:37 +00:00
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 )
2020-07-07 10:52:37 +00:00
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
2020-07-07 10:52:37 +00:00
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
2020-07-07 10:52:37 +00:00
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 )
2019-12-04 17:11:11 +00:00
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
2020-07-07 10:52:37 +00:00
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 ' )
2020-07-07 10:52:37 +00:00
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
2020-07-07 10:52:37 +00:00
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
2020-07-07 10:52:37 +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
2020-07-07 10:52:37 +00:00
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
2020-07-07 10:52:37 +00:00
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
2020-07-07 10:52:37 +00:00
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
2020-07-07 10:52:37 +00:00
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
2020-07-07 10:52:37 +00:00
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
2020-07-07 10:52:37 +00:00
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
2020-07-07 10:52:37 +00:00
local chunk , error_msg
if define_love then
2024-04-26 09:56:27 +00:00
chunk , error_msg = love.filesystem . load ( filename .. filetype )
2020-07-07 10:52:37 +00:00
else
2024-04-26 09:56:27 +00:00
chunk , error_msg = loadfile ( filename .. filetype )
2020-07-07 10:52:37 +00:00
end
2018-11-08 15:27:03 +00:00
if chunk then
local data = setfenv ( chunk , SPRITESHEET_ENV ) ( )
2019-12-04 17:11:11 +00:00
2018-11-08 15:27:03 +00:00
-- Error check
2019-12-04 17:11:11 +00:00
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 )
2019-12-04 17:11:11 +00:00
-- 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.
2020-07-07 10:52:37 +00:00
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
2020-07-07 10:52:37 +00:00
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
2019-12-04 17:11:11 +00:00
-- TODO: Give warning/error due to rounding down.
2020-07-07 10:52:37 +00:00
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 )
2019-12-04 17:11:11 +00:00
2020-07-07 10:52:37 +00:00
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
2019-12-04 17:11:11 +00:00
do
2020-07-07 10:52:37 +00:00
local rem_width = width % self.tile_width
local rem_height = height % self.tile_height
2019-12-04 17:11:11 +00:00
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
2020-07-07 10:52:37 +00:00
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 ' )
2020-07-07 10:52:37 +00:00
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
2020-07-07 10:52:37 +00:00
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
2019-06-10 17:41:59 +00:00
error ( ' Unknown origin mode %s (%s) ' , mode , type ( mode ) )
2018-11-08 15:27:03 +00:00
end
2020-07-07 10:52:37 +00:00
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