Multi-Image Spritesheet Module

– [[ THIS IS OUTDATED, SEE THE NEWEST VERSION ]] –

For the past week or so now I’ve wanted to add a sprite to my game, while still preserving the image quality (due to roblox downscaling large images.) The solution I came up with was a system that accepted a table of images. After a couple of failed attempts, this module is the result!

Some things to note:

  1. The module is poorly optimized. The module is open source, so feel free to optimize to your heart’s content.
  2. The module uses a slightly hacky method. I’ve found ImageRectOffset and switching the Image properties to be unreliable in the past, so instead I use scaled up images and change their position inside of a ClipsDescendants = true frame, and I change the Visible property to switch between images.

Other than that, its usage is pretty simple. The only method is .new(). There is only one parameter, and that is a table (full of parameters lol.) Here’s an example of some code to help with assembly:

local module = require() -- path to module

local info = {
	Images = {"rbxassetid://15038884804"}, -- these are in index order, make sure the ID is of the image and NOT the decal, and it MUST have this format. Although there's only one image here, I've tested it and it works with multiple.
	Size = Vector2.new(400,400), -- X and Y size (in offset) that YOU want to have (not actual dimensions of the image)
	imgtransparency = 0.5, -- self explanatory
	resample = Enum.ResamplerMode.Default, -- render appearance, you can set to Pixelate instead of Default
	X = 8, -- number of horizontal frames
	Frames = 64, -- number of frames in each spritesheet; this assumes all sheets have the same number
	fps = 30, -- self explanatory
	cycles = -1 -- how many times the animation will play; set it to -1 for infinite
}

local sprite = module.new(info)
-- to change things like the parent and position of the sprite, call sprite.bg to get the container that holds all of the image labels
sprite.bg.Parent = gui

Let me know if you have any feedback (on the module or on how I should properly post on the forum) and you can get the module here.

2 Likes

HI,
Looks cool, do you have a .rbxl with an open source spritesheet to check it out?

Thanks

Sorry for the late reply! Unfortunately I don’t have an open sourced sprite that uses multiple images (I want to keep the one I have private for now) but here is a demo place you can use to play with the module that uses an open source 1 image sprite (you can test your multi image sprites with it:)
SpritesheetDemo.rbxl (52.2 KB)

1 Like

Since @WoozyNate is now using this resource for DIG, I’ve remade the module so it has more functionality and is easier to use.

Hopefully everything here should be self explanatory.

Constructor:

Sprite.new(params: {
images: {string}, -- asset ids in order
imageSize: UDim2,
xFrames: number, -- number of horizontal frames in a single image of the spritesheet
framesPerImage: number,
totalAnimationFrames: number?, -- ignore this unless your spritesheet has empty frames
fps: number?, -- defaults to 24
cycles: number?, -- number of times animation plays, defaults to -1 (infinite)
imageTransparency: number?, -- defaults to 0
resampleMode: Enum.ResamplerMode?, -- defaults to Default
}): SpriteObject

Methods:

SpriteObject:Play() -- can only be called once
SpriteObject:Pause()
SpriteObject:Unpause()
SpriteObject:Destroy() -- if called the SpriteObject cannot be brought back
SpriteObject:GetContainer() -- returns the Frame that holds all the ImageLabels used under the hood

Example usage:

local sprite = require(script:WaitForChild("Sprite"))

local gui = script.Parent

local spriteObject = sprite.new{
	images = {"rbxassetid://108492549606073","rbxassetid://115432300557277","rbxassetid://108107306918025","rbxassetid://123902261605426"},
	imageSize = UDim2.fromOffset(200,200),
	xFrames = 5,
	framesPerImage = 25,
	fps = 100
}

local container = spriteObject:GetContainer()

container.AnchorPoint = Vector2.one
container.Position = UDim2.new(1,-10,1,-10)
container.Parent = gui

spriteObject:Play()

Result:

SOURCE CODE:

local ContentProvider = game:GetService("ContentProvider")
local RunService = game:GetService("RunService")

local Sprite = {}
Sprite.__index = Sprite

type SpriteParameters = {
	images: {string},
	imageSize: UDim2,
	xFrames: number,
	framesPerImage: number,
	totalSpriteFrames: number?,
	fps: number?,
	cycles: number?,
	imageTransparency: number?,
	resampleMode: Enum.ResamplerMode?
}

function Sprite.new(params: SpriteParameters)
	local self = setmetatable({}, Sprite)
	self.images = params.images
	self.imageSize = params.imageSize
	self.xFrames = params.xFrames
	self.framesPerImage = params.framesPerImage
	self.totalSpriteFrames = params.totalSpriteFrames or (#self.images * self.framesPerImage)
	self.fps = params.fps or 24
	self.cycles = params.cycles or -1
	self.imageTransparency = params.imageTransparency or 0
	self.resampleMode = params.resampleMode or Enum.ResamplerMode.Default
	ContentProvider:PreloadAsync(self.images)
	local container = Instance.new("Frame")
	container.Size = self.imageSize
	container.ClipsDescendants = true
	container.BackgroundTransparency = 1
	self.container = container
	self._imageLabels = {}
	for _, imageId in ipairs(self.images) do
		local img = Instance.new("ImageLabel")
		img.Size = UDim2.new(self.xFrames, 0, math.floor(self.framesPerImage / self.xFrames), 0)
		img.Position = UDim2.new(0, 0, 0, 0)
		img.Image = imageId
		img.BackgroundTransparency = 1
		img.ImageTransparency = self.imageTransparency
		if self.resampleMode then
			img.ResampleMode = self.resampleMode
		end
		img.Visible = false
		img.Parent = container
		table.insert(self._imageLabels, img)
	end
	self._isPaused = true
	self._destroyed = false
	self._currentImage = 1
	self._currentCycle = 0
	self._playThread = nil
	self:_preloadImages()
	return self
end

function Sprite:_preloadImages()
	task.spawn(function()
		repeat
			local allLoaded = true
			for _, img in ipairs(self._imageLabels) do
				if not img.IsLoaded then
					allLoaded = false
					break
				end
			end
			if allLoaded then break end
			task.wait()
		until false
		for _, img in ipairs(self._imageLabels) do
			img.Visible = false
		end
	end)
end

function Sprite:Play()
	if self._playThread then return end
	self._isPaused = false
	self._currentCycle = 0
	self._currentImage = 1
	self._playThread = task.spawn(function()
		local counter = 0
		while not self._destroyed and (self.cycles == -1 or self._currentCycle < self.cycles) do
			if self._currentImage == 1 then counter = 0 end
			if self._isPaused then
				RunService.Heartbeat:Wait()
				continue
			end
			local currentImageLabel = self._imageLabels[self._currentImage]
			currentImageLabel.Visible = true
			
			local yFrames = math.ceil(self.framesPerImage / self.xFrames)
			for y = 0, yFrames - 1 do
				for x = 0, self.xFrames - 1 do
					if self._isPaused or self._destroyed then break end
					counter += 1
					if counter > self.totalSpriteFrames then break end
					currentImageLabel.Position = UDim2.new(-x, 0, -y, 0)
					task.wait(1 / self.fps)
				end
				if counter > self.totalSpriteFrames then
					counter = 0
					break
				end
			end
			
			currentImageLabel.Visible = false
			self._currentImage = (self._currentImage % #self._imageLabels) + 1
			self._currentCycle += 1
		end
	end)
end

function Sprite:Pause()
	self._isPaused = true
end

function Sprite:Unpause()
	self._isPaused = false
end

function Sprite:Destroy()
	self._destroyed = true
	if self._playThread then
		task.cancel(self._playThread)
		self._playThread = nil
	end
	for _, img in ipairs(self._imageLabels) do
		img:Destroy()
	end
	if self.container then
		self.container:Destroy()
	end
end

function Sprite:GetContainer()
	return self.container
end

return Sprite

When using this resource, a good practice is to preload any sprite images a couple seconds before they actually get used by the module to prevent flickering when the sprite switches images (since the images aren’t loaded.) If you follow this practice, you can delete the _preloadImages() method and the line where it’s called in the constructor.

It’s worth noting that I haven’t tested every single edge case so there is a chance that some less standard sprites may behave weirdly. I’ve tested the more common use cases and they all work fine and I will edit this post in the event of one of these edge cases being an issue. Enjoy!

4 Likes