Jon Michael Aanes
bc3752da1b
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.
386 lines
14 KiB
Lua
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
|
|
|