1
0
spritesheet/spritesheet.lua
Jon Michael Aanes bc3752da1b Added several convenience features.
Including allowing specifying number of tiles for each dimension, if for
example it is known that the image will contain 16x16 sprites, but the
size of the individual sprite is unknown.
2019-12-04 18:11:13 +01:00

386 lines
14 KiB
Lua

local error_orig = error
local error, error_internal do
error, error_internal = error_orig, error_orig
error_orig = nil
local success, errorlib = pcall(require,'errors')
if success then
error = errorlib 'spritesheet'
error_internal = error.internal
end
end
--------------------------------------------------------------------------------
-- Util
local function calculate_animation_duration (self, frame_i)
local frame_i = frame_i or math.huge
assert(type(self) == 'table')
assert(type(frame_i) == 'number')
-- Easy if number
if type(self.time) == 'number' then return math.min(#self, frame_i) * self.time end
-- 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
function Sprite:generateImage ()
local imagesheet, quad = self.imagesheet, self.quad
self.func = function (x, y)
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
function Sprite:getSheetDimensions()
return self.imagesheet.image:getDimensions()
end
setmetatable(Sprite, {__call = function(_, ...) return Sprite.new(...) end})
--------------------------------------------------------------------------------
---- Animation
local Animation = {}
Animation.__index = Animation
function Animation.new (self)
assert(type(self) == 'table')
assert(type(self.time) == 'table' or type(self.time) == 'number' and self.time > 0)
assert(#self > 0)
assert(type(self.time) == 'number' or #self == #self.time)
assert(self.wrap == nil or self.wrap == true or self.wrap == false)
local self = setmetatable(self, Animation)
self.duration = calculate_animation_duration(self)
self.is_animation = true
-- Contact frame?
if self.contact_frame then
-- why was this minus one? It screwed up the audio.
--self.contact_time = calculate_animation_duration(self, self.contact_frame - 1)
self.contact_time = calculate_animation_duration(self, self.contact_frame)
end
--
return self
end
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
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
function Animation:getSheetDimensions()
return self.imagesheet.image:getDimensions()
end
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 }
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 (image, quad_data, imagesheet)
assert(image.typeOf and image:typeOf('Image'))
assert(type(quad_data) == 'table')
local image_width, image_height = image:getDimensions()
local tile_width, tile_height = quad_data.tile_width, quad_data.tile_height
local tiles_per_row = image_width/tile_width
local max_quad_id = tiles_per_row * (image_height/tile_height) - 1
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 ~= math.floor(id) then error('All quad ids must be natural numbers, but one was %03.03f (floating point number)', id) end
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
if not quad_cache[id] then
quad_cache[id] = love.graphics.newQuad((id%tiles_per_row)*tile_width, math.floor(id/tiles_per_row)*tile_height, tile_width, tile_height, image_width, image_height)
end
return quad_cache[id]
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)
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 = val.is_animation and visit_animation or visit_node
visit_func(val)
end
end
error.strict_table(t)
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 = love.filesystem.load(filename..'.lua')
if chunk then
local data = setfenv(chunk, SPRITESHEET_ENV)()
-- 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
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.
-- Set info
local self = setmetatable({}, SpriteSheet)
self.filename = filename
self.image = love.graphics.newImage(filename..'.png')
self.origin_x = 0
self.origin_y = 0
-- TODO: Give warning/error due to rounding down.
quad_data.tile_width = quad_data.tile_width or math.floor(self.image:getWidth() / quad_data.tiles_per_row)
quad_data.tile_height = quad_data.tile_height or math.floor(self.image:getHeight() / quad_data.tiles_per_column)
print(self.tile_width, self.tile_height)
self.tile_width = quad_data.tile_width
self.tile_height = quad_data.tile_height
-- Error checking
do
local rem_width = self.image:getWidth() % self.tile_width
local rem_height = self.image:getHeight() % 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
-- Set origin
if quad_data.tile_origin then
self:setOrigin(unpack(quad_data.tile_origin))
end
-- Import quads into SpriteSheet
self.quads = load_quads(self.image, quad_data, self)
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')
if mode == 'absolute' then
self.origin_x = ox
self.origin_y = oy
elseif mode == 'relative' or mode == nil then
self.origin_x = self.tile_width * ox
self.origin_y = self.tile_height * oy
else
error('Unknown origin mode %s (%s)', mode, type(mode))
end
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