EDIT: i might end up just open sourcing a future engine because creating tutorials is incredibly cumbersome.
THIS IS PART 1! MORE PARTS WILL BE COMING OUT SOON!
Hello!
This is my first post, so I apologize if there are any mistakes.
Why did I make this tutorial?
So, about a month ago, I wanted to make a FNF game.
There were no tutorials that I could follow, so I made an engine from the game’s source code. (It’s open source!) You can find it at this github repository.
What will this tutorial teach?
I will teach you to create a Roblox FNF game.
I might update this to include multiplayer setup or stages, but for now, the game will be a basic outline of the game Friday Night Funkin’. There will only be the arrows, music, and health bar. You could implement the characters and background if you wanted to.
Any other info?
I recommend having at least some experience in Lua and Roblox Studio. It would be good if you knew the basics and can understand code. I will explain the code and the math to some degree with comments, but I won’t go super in-depth.
I will try to thoroughly explain any concepts like the image scaling, using original size and stuff like that though.
Fun fact: all of the code was sourced from the original game. This is just a Roblox recreation that I made for fun.
Credits
Thanks to ninjamuffin99, PhantomArcade3K, Kawaisprite, Evilsk8r, and the rest of the Friday Night Funkin’ team for the original game!
If you plan on making a game with this, please credit them.
I take zero credit for anything here. All I did was porting to Roblox.
This is going to be a pretty long tutorial, so I split it into parts. This is part 1.
1. Setting up
Let’s setup for creating the game. Open a new baseplate in studio.
Next, make sure that you can see the Explorer, Properties, Output, Asset Manager, and Toolbox.
Also, add these folders and modulescripts to ReplicatedStorage.
Add these folders, and the two ModuleScripts.
Code for the “GUI” module:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Source = ReplicatedStorage:WaitForChild("Source")
local Util = require(Source.Util)
local GUI = {}
function GUI.GameGUI(DisplayOrder)
--\\ Construct the game GUI
local ScreenGUI = Instance.new("ScreenGui")
ScreenGUI.Enabled = true
ScreenGUI.ResetOnSpawn = false
ScreenGUI.IgnoreGuiInset = true
ScreenGUI.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
ScreenGUI.DisplayOrder = DisplayOrder
ScreenGUI.Name = "FNF_GAME_GUI"
--\\ GameFrame holds the game GUI. (Arrows, health, etc.)
local GameFrame = Instance.new("Frame")
GameFrame.BackgroundTransparency = 1
GameFrame.Size = UDim2.new(1, 0, 1, 0)
GameFrame.Position = UDim2.new(0.5, 0, 0.5, 0)
GameFrame.AnchorPoint = Vector2.new(0.5, 0.5)
GameFrame.Name = "Game"
GameFrame.Parent = ScreenGUI
--\\ Scale the GameFrame with the AspectRatio of the ScreenSize.
--\\ This is crucial because we need the XMLSprites to scale and position properly.
--\\ This maintains the AspectRatio of 16:9. VERY IMPORTANT!
local GameScaler = Instance.new("UIAspectRatioConstraint")
GameScaler.AspectRatio = (Util.ScreenSize.X / Util.ScreenSize.Y)
GameScaler.AspectType = Enum.AspectType.FitWithinMaxSize
GameScaler.DominantAxis = Enum.DominantAxis.Width
GameScaler.Parent = GameFrame
--\\ This is for UI zooming.
local GameScale = Instance.new("UIScale")
GameScale.Scale = 1
GameScale.Parent = GameFrame
--\\ This is for debugging to show the true screen size. Comment this out when you don't need it.
local BoundaryBox = Instance.new("UIStroke")
BoundaryBox.Enabled = true
BoundaryBox.Color = Color3.new(1, 0, 0)
BoundaryBox.Thickness = 1
BoundaryBox.Parent = GameFrame
--\\ Health Bar
local HealthBG = Instance.new("Frame")
HealthBG.AnchorPoint = Vector2.new(0.5, 0.5)
HealthBG.BackgroundColor3 = Color3.new(0, 0, 0)
HealthBG.BorderSizePixel = 0
HealthBG.Position = UDim2.fromScale(0.5, 0.85)
HealthBG.Size = UDim2.fromScale(0.563, 0.025)
HealthBG.ZIndex = 5
HealthBG.Name = "HealthBar"
HealthBG.Parent = GameFrame
--\\ Player 2 health overlay
local HealthP2 = Instance.new("Frame")
HealthP2.AnchorPoint = Vector2.new(0.5, 0.5)
HealthP2.BackgroundColor3 = Color3.new(1, 0, 0)
HealthP2.BorderSizePixel = 0
HealthP2.Position = UDim2.fromScale(0.5, 0.5)
HealthP2.Size = UDim2.fromScale(0.99, 0.6)
HealthP2.Name = "Player1"
HealthP2.Parent = HealthBG
--\\ Player 1 health underlay
local HealthP1 = Instance.new("Frame")
HealthP1.AnchorPoint = Vector2.new(1, 0.5)
HealthP1.BackgroundColor3 = Color3.new(0, 1, 0)
HealthP1.BorderSizePixel = 0
HealthP1.Position = UDim2.fromScale(1, 0.5)
HealthP1.Size = UDim2.fromScale(0.5, 1)
HealthP1.Name = "Player2"
HealthP1.Parent = HealthP2
--\\ Containers
local StrumsFolder = Instance.new("Folder") --\\ "Strums" refers to the grey arrows at the top.
StrumsFolder.Name = "Strums"
StrumsFolder.Parent = GameFrame
local NotesFolder = Instance.new("Folder") --\\ "Notes" refers to the colored arrows that come up to the grey ones.
NotesFolder.Name = "Notes"
NotesFolder.Parent = GameFrame
return ScreenGUI
end
return GUI
This code above, is a module creates the game’s GUI. Note the UIAspectRatioConstraint that is used to maintain a 16:9 AspectRatio so everything inside the UI will be scaled accordingly to the real game.
Code for the “Util” module:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Source = ReplicatedStorage:WaitForChild("Source")
local Util = {
--\\ FNF Screen size: https://github.com/FunkinCrew/Funkin/blob/master/Project.xml
ScreenSize = Vector2.new(1280, 720); --\\ FNF is in 720p lol
--\\ AspectRatio = 16:9
NoteWidth = 100; --\\ In pixels
--\\ For keybinds
KeybindNames = {
[1] = "Left";
[2] = "Down";
[3] = "Up";
[4] = "Right";
};
--\\ For notes
NoteDataNames = {
[1] = "Left";
[2] = "Down";
[3] = "Up";
[4] = "Right";
};
}
return Util
The code above, is a utility module that we will expand on later.
Explanation for the ScreenSize variable: We need FNF’s screen size so we can scale the spritesheets accordingly. Luckily, it is provided in the source code here. It will now be stored forever in a Vector2 for later usage.
Awesome! You are all set to start creating a FNF game!
2. Creating the XML Image Animations.
First off, we need to create the arrows.
There are many ways to do this, but I will use XML data and Spritesheets to create the arrows and their animations. The arrows will be created programmatically because of this.
Using spritesheets is also good for loading times, as you don’t need to load 30 different images. And the animations will be overall smoother. Also, this is the way the original game does it.
Let’s start by getting a understanding of what we are dealing with.
First we have the spritesheet. (2048 x 1024)
Then, we have the XML data.
NOTE_assets.xml (4.9 KB)
Wait a minute, How are we supposed to get a single arrow from a spritesheet?!!
Well, for that we have ImageRectOffset and ImageRectSize!
ImageRectOffset is a pixel position (x, y) that determines the pixel offset from the top left of the image to be displayed in an ImageLabel.
ImageRectSize is a size in pixels (x, y) that determines the pixel size to be displayed in an ImageLabel.
Ok cool, but how are we going to get that data?
We will use the XML data. There are XML string parsers written in Lua that can convert the XML data into a Lua table.
Here is a good one by jonathanpoelen on github!
This took me ages to find, as the other XML parsers that I found just refused to work with Roblox Lua.
Also, this is the most lightweight one, as it is only one module.
Uh oh! Notice that in the code they use “io”.
We will have to format the code for Roblox Lua, as we do not have “io”.
(formatted code):
-- from https://github.com/jonathanpoelen/lua-xmlparser
--\\ I removed all usages of "io" as it is not a global in Roblox Lua, and we don't need to open actual files.
-- http://lua-users.org/wiki/StringTrim
local trim = function(s)
local from = s:match"^%s*()"
return from > #s and "" or s:match(".*%S", from)
end
local slashchar = string.byte('/', 1)
local E = string.byte('E', 1)
--! Return the default entity table.
--! @return table
local function defaultEntityTable()
return { quot='"', apos='\'', lt='<', gt='>', amp='&', tab='\t', nbsp=' ', }
end
--! @param[in] s string
--! @param[in] entities table : with entity name as key and value as replacement
--! @return string
local function replaceEntities(s, entities)
return s:gsub('&([^;]+);', entities)
end
--! Add entities to resultEntities then return it.
--! Create new table when resultEntities is nul.
--! Create an entity table from the document entity table.
--! @param[in] docEntities table
--! @param[in,out] resultEntities table|nil
--! @return table
local function createEntityTable(docEntities, resultEntities)
entities = resultEntities or defaultEntityTable()
for _,e in pairs(docEntities) do
e.value = replaceEntities(e.value, entities)
entities[e.name] = e.value
end
return entities
end
--! Return a document `table`.
--! @code
--! document = {
--! children = {
--! { text=string } or
--! { tag=string,
--! attrs={ [name]=value ... },
--! orderedattrs={ { name=string, value=string }, ... },
--! children={ ... }
--! },
--! ...
--! },
--! entities = { { name=string, value=string }, ... },
--! tentities = { name=value, ... } -- only if evalEntities = true
--! }
--! @endcode
--! If `evalEntities` is `true`, the entities are replaced and
--! a `tentity` member is added to the document `table`.
--! @param[in] s string : xml data
--! @param[in] evalEntities boolean
--! @return table
local function parse(s, evalEntities)
-- remove comments
s = s:gsub('<!%-%-(.-)%-%->', '')
local entities, tentities = {}
if evalEntities then
local pos = s:find('<[_%w]')
if pos then
s:sub(1, pos):gsub('<!ENTITY%s+([_%w]+)%s+(.)(.-)%2', function(name, q, entity)
entities[#entities+1] = {name=name, value=entity}
end)
tentities = createEntityTable(entities)
s = replaceEntities(s:sub(pos), tentities)
end
end
local t, l = {}, {}
local addtext = function(txt)
txt = txt:match'^%s*(.*%S)' or ''
if #txt ~= 0 then
t[#t+1] = {text=txt}
end
end
s:gsub('<([?!/]?)([-:_%w]+)%s*(/?>?)([^<]*)', function(type, name, closed, txt)
-- open
if #type == 0 then
local attrs, orderedattrs = {}, {}
if #closed == 0 then
local len = 0
for all,aname,_,value,starttxt in string.gmatch(txt, "(.-([-_%w]+)%s*=%s*(.)(.-)%3%s*(/?>?))") do
len = len + #all
attrs[aname] = value
orderedattrs[#orderedattrs+1] = {name=aname, value=value}
if #starttxt ~= 0 then
txt = txt:sub(len+1)
closed = starttxt
break
end
end
end
t[#t+1] = {tag=name, attrs=attrs, children={}, orderedattrs=orderedattrs}
if closed:byte(1) ~= slashchar then
l[#l+1] = t
t = t[#t].children
end
addtext(txt)
-- close
elseif '/' == type then
t = l[#l]
l[#l] = nil
addtext(txt)
-- ENTITY
elseif '!' == type then
if E == name:byte(1) then
txt:gsub('([_%w]+)%s+(.)(.-)%2', function(name, q, entity)
entities[#entities+1] = {name=name, value=entity}
end, 1)
end
-- elseif '?' == type then
-- print('? ' .. name .. ' // ' .. attrs .. '$$')
-- elseif '-' == type then
-- print('comment ' .. name .. ' // ' .. attrs .. '$$')
-- else
-- print('o ' .. #p .. ' // ' .. name .. ' // ' .. attrs .. '$$')
end
end)
return {children=t, entities=entities, tentities=tentities}
end
-- Return a tuple `document table, error file`.
-- @param filename[in] string
-- @param evalEntities[in] boolean : see \c parse()
-- @return table : see parse
return {
parse = parse,
defaultEntityTable = defaultEntityTable,
replaceEntities = replaceEntities,
createEntityTable = createEntityTable,
}
Now let’s put this in ReplicatedStorage.
Cool!
Now let’s upload the assets for the arrows!
I’ll have the assets availible to download here at mediafire.
SKIP TO “Uploading the images” IF YOU DOWNLOADED FROM MEDIAFIRE!
If you don’t want to use mediafire links, or you can’t download it, then you can download Friday Night Funkin’ from ninja-muffin24.itch.io. You can find the note image assets by going into the game’s files and opening these folders:
open “assets”
open “images”
look for “NOTE_assets”
Uploading the images
First, go to “Asset Manager” and open “Images”
Then, click on the “Bulk Import”
Then, select the NOTE_assets file that you downloaded (or already have)
If all went well, then your upload should look like this:
Right click on the newly added image and select “Copy to clipboard”.
Add this new “Assets” folder to ReplicatedStorage.
Inside it, add a “Note” folder and a StringValue, and a ModuleScript.
Then, paste the ID into the StringValue.
Download this XML File. it contains the XML data for the note spritesheet.
NOTE_assets.xml (4.9 KB)
Open in notepad (Or whatever editing program you like.) and copy all of the text.
Put this into the XML ModuleScript that we created earlier.
return [[
]]
And paste!
Nice!
Now that we have tackled the XML and Image importing, we are ready to start coding the XMLSprite class!
Add a new ModuleScript to the “class” folder. Call it “AnimationManager”
Here is the code:
--\\ Manages animations for XMLSprite!
--Basically everything here is derived from the following Haxeflixel libraries:
-- https://github.com/HaxeFlixel/flixel/blob/dev/flixel/animation/FlxAnimation.hx
-- https://github.com/HaxeFlixel/flixel/blob/dev/flixel/animation/FlxAnimationController.hx
-- https://github.com/HaxeFlixel/flixel/blob/dev/flixel/FlxSprite.hx
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Source = ReplicatedStorage:WaitForChild("Source")
local Util = require(Source.Util)
local XMLParser = require(Source.lib.XMLParser)
local AnimationManager = {}
AnimationManager.__index = AnimationManager
function AnimationManager.new(XMLSprite)
local new = setmetatable({
XMLSprite = XMLSprite;
Animations = {};
AnimationCompleted = true;
CurrentFrame = 0;
AnimationTimer = 0;
CurrentAnimationName = "";
CurrentAnimation = nil;
Size = Vector2.new(1, 1);
Position = Vector2.new(0, 0);
}, AnimationManager)
return new
end
function AnimationManager:AddByPrefix(XML, ImageID, Framerate, AnimationName, AnimationPrefix)
assert((type(XML) == "string") or (type(XML) == "userdata"), "Invalid argument #1 to XMLSprite:AddByPrefix(). Expected string or ModuleScript. Got '"..type(XML).."'")
--\\ Avoid duplicate animations
if self.Animations[AnimationName] then
warn("'"..AnimationName.."' has already been used as an animation name.")
return
end
--\\ If we gave a modulescript, then require it.
if type(XML) == "userdata" then
XML = require(XML)
end
local NewAnimation = {
Frames = {};
Framerate = Framerate or 60;
ImageID = ImageID;
}
--\\ Parse the XML
local ParsedXML = XMLParser.parse(XML, true)
--print(ParsedXML)
local XMLData = ParsedXML.children[1].children
for i = 1, #XMLData, 1 do
local Frame = XMLData[i].attrs
local FramePrefix = string.sub(Frame.name, 1, string.len(AnimationPrefix)) --\\ Get prefix of Frame.name
local FrameNumber = string.sub(Frame.name, (string.len(AnimationPrefix) + 1), string.len(Frame.name)) --\\ Get the suffix of the prefix (which is assumed to be a number) of Frame.name
if (FramePrefix == AnimationPrefix) and tonumber(FrameNumber) then --\\ Make sure that the suffix is actually a number. Also, make sure that the prefix is the same as the prefix that we want!
--\\ XML data is in starling format or something like that.
--\\ in Starling Framework, one point is equal to one pixel.
--\\ x, y, width, height are all in points.
local ImageRectOffset = Vector2.new(Frame.x, Frame.y)
local ImageRectSize = Vector2.new(Frame.width, Frame.height)
--\\ ImageSize is the actual size (in pixels) of the ImageLabel.
local ImageSize = Vector2.new(Frame.width, Frame.height)
--\\ Create new AnimationFrame
local AnimationFrame = {
ImageRectOffset = ImageRectOffset;
ImageRectSize = ImageRectSize;
ImageSize = ImageSize;
Name = Frame.name;
Prefix = AnimationPrefix;
}
table.insert(NewAnimation.Frames, AnimationFrame)
end
end
self.Animations[AnimationName] = NewAnimation
end
function AnimationManager:Play(ImageLabel, AnimationName, Override)
if self.AnimationCompleted or Override then
self.AnimationCompleted = true
self.CurrentFrame = 1
self.AnimationTimer = 0
self.CurrentAnimationName = AnimationName
self.CurrentAnimation = self.Animations[AnimationName]
--\\ Load Image
ImageLabel.Image = self.Animations[AnimationName].ImageID
self.AnimationCompleted = false
self:UpdateImageLabel(ImageLabel)
end
end
function AnimationManager:StopAll()
self.AnimationCompleted = true
self.CurrentFrame = 1
self.AnimationTimer = 0
self.CurrentAnimation = nil
self.CurrentAnimationName = ""
end
function AnimationManager:UpdateImageLabel(ImageLabel)
if self.CurrentAnimationName ~= "" then
local CurrentFrame = self.CurrentAnimation.Frames[self.CurrentFrame]
if CurrentFrame then
ImageLabel.ImageRectOffset = CurrentFrame.ImageRectOffset
ImageLabel.ImageRectSize = CurrentFrame.ImageRectSize
--\\ https://devforum.roblox.com/t/how-can-you-convert-offset-size-to-scale v-size/793784
--\\ Explanation: Offset is a pixel value. Scale is a percentage of the screen size.
--\\ So, to get scale from offset you divide the offset value by the screen size.
--\\ EX: (OffsetValue.X / ViewportSize.X) == ScaleValue.X
--\\ This is using FNF's screen size (1280x720) so that the XMLSprite scales correctly.
ImageLabel.Size = UDim2.fromScale(
((CurrentFrame.ImageSize.X * self.Size.X) / Util.ScreenSize.X),
((CurrentFrame.ImageSize.Y * self.Size.Y) / Util.ScreenSize.Y)
)
ImageLabel.Position = UDim2.fromScale(
(self.Position.X) / Util.ScreenSize.X,
(self.Position.Y) / Util.ScreenSize.Y
)
end
end
end
function AnimationManager:Update(DeltaTime, ImageLabel)
--\\ Animation Update
if not self.AnimationCompleted then
--\\ Update timer
self.AnimationTimer += DeltaTime
local FrameCount = #self.CurrentAnimation.Frames
local AnimationDelta = (1 / self.CurrentAnimation.Framerate)
--\\ To calculate seconds between frames, you divide 1 by the framerate.
--\\ EX: FrameRate == 10, AnimationDelta == 0.1
if self.AnimationTimer > AnimationDelta then
self.AnimationTimer = 0 --\\ Reset timer
self.CurrentFrame += 1 --\\ Increment CurrentFrame
if self.CurrentFrame >= FrameCount then --\\ Animation ended
if self.CurrentFrame == FrameCount then
self:UpdateImageLabel(ImageLabel) --\\ Last update!
end
--\\ Let the script know we are done.
self.AnimationCompleted = true
--\\ Just in case we somehow went over the framecount.
self.CurrentFrame = FrameCount
end
end
end
self:UpdateImageLabel(ImageLabel)
end
return AnimationManager
This keeps track of animations for the XMLSprite. This also parses XML data and handles it.
Now, let’s create the XMLSprite class!
Add a new ModuleScript to the “class” folder. Call it “XMLSprite”.
Here is the code:
--Basically everything here is derived from the following Haxeflixel libraries:
-- https://github.com/HaxeFlixel/flixel/blob/dev/flixel/animation/FlxAnimation.hx
-- https://github.com/HaxeFlixel/flixel/blob/dev/flixel/animation/FlxAnimationController.hx
-- https://github.com/HaxeFlixel/flixel/blob/dev/flixel/FlxSprite.hx
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Source = ReplicatedStorage:WaitForChild("Source")
local Util = require(Source.Util)
local AnimationManager = require(script.AnimationManager)
local XMLSprite = {}
XMLSprite.__index = XMLSprite
function XMLSprite.new(AutomaticUpdate)
--\\ Create new ImageLabel for the sprite to use
local NewImageLabel = Instance.new("ImageLabel")
NewImageLabel.AnchorPoint = Vector2.new(0.5, 0.5)
NewImageLabel.Position = UDim2.fromScale(0.5, 0.5)
NewImageLabel.Size = UDim2.fromOffset(100, 100)
NewImageLabel.ScaleType = Enum.ScaleType.Stretch
NewImageLabel.Name = "XMLSprite_ImageLabel"
NewImageLabel.BackgroundTransparency = 0.5 --\\ for debug, it shows the sprite's size.
--\\ Create new sprite object
local NewSprite = setmetatable({
Position = Vector2.new(0, 0);
Size = Vector2.new(1, 1);
AnimationManager = AnimationManager.new();
ImageLabel = NewImageLabel;
Destroyed = false;
CanUpdate = true;
UpdateConnection = nil;
}, XMLSprite)
--\\ Update the sprite
if AutomaticUpdate then
NewSprite.UpdateConnection = RunService.RenderStepped:Connect(function(DeltaTime)
NewSprite:Update(DeltaTime)
end)
end
return NewSprite
end
function XMLSprite:AddByPrefix(XML, ImageID, Framerate, AnimationName, AnimationPrefix)
self.AnimationManager:AddByPrefix(XML, ImageID, Framerate, AnimationName, AnimationPrefix)
end
function XMLSprite:Play(AnimationName, Override)
self.AnimationManager:Play(self.ImageLabel, AnimationName, Override)
end
function XMLSprite:StopAll()
self.AnimationManager:StopAll()
end
function XMLSprite:Update(DeltaTime)
if self.CanUpdate then
self.AnimationManager.Position = self.Position
self.AnimationManager.Size = self.Size
self.AnimationManager:Update(DeltaTime, self.ImageLabel)
end
end
function XMLSprite:Destroy()
self.CanUpdate = false
self.Destroyed = true
if self.UpdateConnection then
self.UpdateConnection:Disconnect()
end
self.ImageLabel:Destroy()
end
return XMLSprite
Here is the Starling Framework references that I used to make sure that the measurements were correct:
Why Starling Framework?
It was used in the original FNF game.
Now, let’s put everything together! (not really lol)
Add a LocalScript into ReplicatedFirst.
Put this code in it:
if not game:IsLoaded() then
game.Loaded:Wait()
end
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local UserInputService = game:GetService("UserInputService")
local Players = game:GetService("Players")
local Source = ReplicatedStorage:WaitForChild("Source")
local Assets = ReplicatedStorage:WaitForChild("Assets")
local Util = require(Source.Util)
local XMLSprite = require(Source.class.XMLSprite)
local GUI = require(Source.lib.GUI)
local GameGUI = GUI.GameGUI(10)
GameGUI.Parent = Players.LocalPlayer:WaitForChild("PlayerGui")
task.wait(1)
local TestSprite = XMLSprite.new(true)
TestSprite.ImageLabel.Parent = GameGUI.Game
TestSprite.Position = Vector2.new(
Util.ScreenSize.X / 2,
Util.ScreenSize.Y / 2
)
--\\ Setup animations
local XML = Assets.Note.XML
local ImageID = Assets.Note.ImageID.Value
TestSprite:AddByPrefix(XML, ImageID, 24, "grey_arrow", "arrowLEFT")
TestSprite:AddByPrefix(XML, ImageID, 24, "arrow_hit", "left confirm")
TestSprite:AddByPrefix(XML, ImageID, 24, "arrow_press", "left press")
TestSprite:Play("grey_arrow", true)
UserInputService.InputBegan:Connect(function(input, GameProcessedEvent)
if GameProcessedEvent then return end
if (input.KeyCode == Enum.KeyCode.E) then
TestSprite:Play("arrow_hit", true)
elseif(input.KeyCode == Enum.KeyCode.R) then
TestSprite:Play("arrow_press", true)
end
end)
UserInputService.InputEnded:Connect(function(input, GameProcessedEvent)
if GameProcessedEvent then return end
if (input.KeyCode == Enum.KeyCode.E) or (input.KeyCode == Enum.KeyCode.R) then
TestSprite:Play("grey_arrow", true)
end
end)
This code is currently going to test the XMLSprite. Pressing “E” should make the hit animation play. Pressing “R” should make the press animation play.
Little note on this:
It corresponds with the XML that has the data for the animations.
Notice the prefix of the SubTexture name corresponds with the last argument on AddByPrefix().
This is how it reads the XML. You can change the prefix to get different animations. Try looking through the XML data and changing the animations. Just make sure that it corresponds with the XML SubTexture name (Don’t include the numbers. EX: “arrowLEFT”).
Let’s test it out!
Now for the moment of truth…
https://streamable.com/316w6p
Umm… That’s not what we wanted.
Don’t panic, there is an explanation for this!
Remember this?
This image’s resolution is (2048x1024). That may not seem like a reason this doesn’t work, but Roblox’s maximum resolution is (1024x1024) If an image is larger than that, It will be downscaled to match.
So, the image was downscaled by Roblox to (1024x512) (I assumed this, by scaling the x down to 1024, and scaling the y along with it.).
If we divide the new resolution (1024x512) by the original resolution (2048x1024), we get 0.5!
This is great, as this tells us how we can multiply the pixel values by the number that we just got. We can just adjust the code to have an input parameter of scale. It will be called ScaleFactor.
New code for the “XMLSprite” ModuleScript:
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Source = ReplicatedStorage:WaitForChild("Source")
local Util = require(Source.Util)
local AnimationManager = require(Source.class.AnimationManager)
local XMLSprite = {}
XMLSprite.__index = XMLSprite
function XMLSprite.new(AutomaticUpdate)
--\\ Create new ImageLabel for the sprite to use
local NewImageLabel = Instance.new("ImageLabel")
NewImageLabel.AnchorPoint = Vector2.new(0.5, 0.5)
NewImageLabel.Position = UDim2.fromScale(0.5, 0.5)
NewImageLabel.Size = UDim2.fromOffset(100, 100)
NewImageLabel.ScaleType = Enum.ScaleType.Stretch
NewImageLabel.Name = "XMLSprite_ImageLabel"
NewImageLabel.BackgroundTransparency = 0.5 --\\ for debug, it shows the sprite's size.
--\\ Create new sprite object
local NewSprite = setmetatable({
Position = Vector2.new(0, 0);
Size = Vector2.new(1, 1);
AnimationManager = AnimationManager.new();
ImageLabel = NewImageLabel;
Destroyed = false;
CanUpdate = true;
UpdateConnection = nil;
}, XMLSprite)
--\\ Update the sprite
if AutomaticUpdate then
NewSprite.UpdateConnection = RunService.RenderStepped:Connect(function(DeltaTime)
NewSprite:Update(DeltaTime)
end)
end
return NewSprite
end
function XMLSprite:AddByPrefix(XML, ImageID, ScaleFactor, Framerate, AnimationName, AnimationPrefix) --\\ ScaleFactor added!
self.AnimationManager:AddByPrefix(XML, ImageID, ScaleFactor, Framerate, AnimationName, AnimationPrefix)
end
function XMLSprite:Play(AnimationName, Override)
self.AnimationManager:Play(self.ImageLabel, AnimationName, Override)
end
function XMLSprite:StopAll()
self.AnimationManager:StopAll()
end
function XMLSprite:Update(DeltaTime)
if self.CanUpdate then
self.AnimationManager.Position = self.Position
self.AnimationManager.Size = self.Size
self.AnimationManager:Update(DeltaTime, self.ImageLabel)
end
end
function XMLSprite:Destroy()
self.CanUpdate = false
self.Destroyed = true
if self.UpdateConnection then
self.UpdateConnection:Disconnect()
end
self.ImageLabel:Destroy()
end
return XMLSprite
New code for the “AnimationManager” ModuleScript:
--\\ Manages animations for XMLSprite!
local RunService = game:GetService("RunService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Source = ReplicatedStorage:WaitForChild("Source")
local Util = require(Source.Util)
local XMLParser = require(Source.lib.XMLParser)
local AnimationManager = {}
AnimationManager.__index = AnimationManager
function AnimationManager.new(XMLSprite)
local new = setmetatable({
XMLSprite = XMLSprite;
Animations = {};
AnimationCompleted = true;
CurrentFrame = 0;
AnimationTimer = 0;
CurrentAnimationName = "";
CurrentAnimation = nil;
Size = Vector2.new(1, 1);
Position = Vector2.new(0, 0);
}, AnimationManager)
return new
end
function AnimationManager:AddByPrefix(XML, ImageID, ScaleFactor, Framerate, AnimationName, AnimationPrefix)
assert((type(XML) == "string") or (type(XML) == "userdata"), "Invalid argument #1 to XMLSprite:AddByPrefix(). Expected string or ModuleScript. Got '"..type(XML).."'")
--\\ Avoid duplicate animations
if self.Animations[AnimationName] then
warn("'"..AnimationName.."' has already been used as an animation name.")
return
end
--\\ If we gave a modulescript, then require it.
if type(XML) == "userdata" then
XML = require(XML)
end
local NewAnimation = {
Frames = {};
Framerate = Framerate or 60;
ImageID = ImageID;
}
--\\ Parse the XML
local ParsedXML = XMLParser.parse(XML, true)
--print(ParsedXML)
local XMLData = ParsedXML.children[1].children
for i = 1, #XMLData, 1 do
local Frame = XMLData[i].attrs
local FramePrefix = string.sub(Frame.name, 1, string.len(AnimationPrefix)) --\\ Get prefix of Frame.name
local FrameNumber = string.sub(Frame.name, (string.len(AnimationPrefix) + 1), string.len(Frame.name)) --\\ Get the suffix of the prefix (which is assumed to be a number) of Frame.name
if (FramePrefix == AnimationPrefix) and tonumber(FrameNumber) then --\\ Make sure that the suffix is actually a number. Also, make sure that the prefix is the same as the prefix that we want!
--\\ XML data is in starling format or something like that.
--\\ in Starling Framework, one point is equal to one pixel.
--\\ x, y, width, height are all in points.
local ImageRectOffset = Vector2.new(
Frame.x,
Frame.y
) * Vector2.new(ScaleFactor, ScaleFactor) --\\ Scale the RectOffset by ScaleFactor
local ImageRectSize = Vector2.new(
Frame.width,
Frame.height
) * Vector2.new(ScaleFactor, ScaleFactor) --\\ Scale the RectSize by ScaleFactor
--\\ ImageSize is the actual size (in pixels) of the ImageLabel.
local ImageSize = Vector2.new(
Frame.width,
Frame.height
) --\\ Don't scale ImageSize, as we want the original size to be maintained.
--\\ Create new AnimationFrame
local AnimationFrame = {
ImageRectOffset = ImageRectOffset;
ImageRectSize = ImageRectSize;
ImageSize = ImageSize;
Name = Frame.name;
Prefix = AnimationPrefix;
}
table.insert(NewAnimation.Frames, AnimationFrame)
end
end
self.Animations[AnimationName] = NewAnimation
end
function AnimationManager:Play(ImageLabel, AnimationName, Override)
if self.AnimationCompleted or Override then
self.AnimationCompleted = true
self.CurrentFrame = 1
self.AnimationTimer = 0
self.CurrentAnimationName = AnimationName
self.CurrentAnimation = self.Animations[AnimationName]
--\\ Load Image
ImageLabel.Image = self.Animations[AnimationName].ImageID
self.AnimationCompleted = false
self:UpdateImageLabel(ImageLabel)
end
end
function AnimationManager:StopAll()
self.AnimationCompleted = true
self.CurrentFrame = 1
self.AnimationTimer = 0
self.CurrentAnimation = nil
self.CurrentAnimationName = ""
end
function AnimationManager:UpdateImageLabel(ImageLabel)
if self.CurrentAnimationName ~= "" then
local CurrentFrame = self.CurrentAnimation.Frames[self.CurrentFrame]
if CurrentFrame then
ImageLabel.ImageRectOffset = CurrentFrame.ImageRectOffset
ImageLabel.ImageRectSize = CurrentFrame.ImageRectSize
--\\ https://devforum.roblox.com/t/how-can-you-convert-offset-size-to-scale v-size/793784
--\\ Explanation: Offset is a pixel value. Scale is a percentage of the screen size.
--\\ So, to get scale from offset you divide the offset value by the screen size.
--\\ EX: (OffsetValue.X / ViewportSize.X) == ScaleValue.X
--\\ This is using FNF's screen size (1280x720) so that the XMLSprite scales correctly.
ImageLabel.Size = UDim2.fromScale(
((CurrentFrame.ImageSize.X * self.Size.X) / Util.ScreenSize.X),
((CurrentFrame.ImageSize.Y * self.Size.Y) / Util.ScreenSize.Y)
)
ImageLabel.Position = UDim2.fromScale(
(self.Position.X) / Util.ScreenSize.X,
(self.Position.Y) / Util.ScreenSize.Y
)
end
end
end
function AnimationManager:Update(DeltaTime, ImageLabel)
--\\ Animation Update
if not self.AnimationCompleted then
--\\ Update timer
self.AnimationTimer += DeltaTime
local FrameCount = #self.CurrentAnimation.Frames
local AnimationDelta = (1 / self.CurrentAnimation.Framerate)
--\\ To calculate seconds between frames, you divide 1 by the framerate.
--\\ EX: FrameRate == 10, AnimationDelta == 0.1
if self.AnimationTimer > AnimationDelta then
self.AnimationTimer = 0 --\\ Reset timer
self.CurrentFrame += 1 --\\ Increment CurrentFrame
if self.CurrentFrame >= FrameCount then --\\ Animation ended
if self.CurrentFrame == FrameCount then
self:UpdateImageLabel(ImageLabel) --\\ Last update!
end
--\\ Let the script know we are done.
self.AnimationCompleted = true
--\\ Just in case we somehow went over the framecount.
self.CurrentFrame = FrameCount
end
end
end
self:UpdateImageLabel(ImageLabel)
end
return AnimationManager
New code for the “Game” LocalScript:
if not game:IsLoaded() then
game.Loaded:Wait()
end
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local UserInputService = game:GetService("UserInputService")
local Players = game:GetService("Players")
local Source = ReplicatedStorage:WaitForChild("Source")
local Assets = ReplicatedStorage:WaitForChild("Assets")
local Util = require(Source.Util)
local XMLSprite = require(Source.class.XMLSprite)
local GUI = require(Source.lib.GUI)
local GameGUI = GUI.GameGUI(10)
GameGUI.Parent = Players.LocalPlayer:WaitForChild("PlayerGui")
task.wait(1)
local TestSprite = XMLSprite.new(true)
TestSprite.ImageLabel.Parent = GameGUI.Game
TestSprite.Position = Vector2.new(
Util.ScreenSize.X / 2,
Util.ScreenSize.Y / 2
)
--\\ Setup animations
local XML = Assets.Note.XML
local ImageID = Assets.Note.ImageID.Value
--\\ Added ScaleFactor!
TestSprite:AddByPrefix(XML, ImageID, 0.5, 24, "grey_arrow", "arrowLEFT")
TestSprite:AddByPrefix(XML, ImageID, 0.5, 24, "arrow_hit", "left confirm")
TestSprite:AddByPrefix(XML, ImageID, 0.5, 24, "arrow_press", "left press")
TestSprite:Play("grey_arrow", true)
UserInputService.InputBegan:Connect(function(input, GameProcessedEvent)
if GameProcessedEvent then return end
if (input.KeyCode == Enum.KeyCode.E) then
TestSprite:Play("arrow_hit", true)
elseif(input.KeyCode == Enum.KeyCode.R) then
TestSprite:Play("arrow_press", true)
end
end)
UserInputService.InputEnded:Connect(function(input, GameProcessedEvent)
if GameProcessedEvent then return end
if (input.KeyCode == Enum.KeyCode.E) or (input.KeyCode == Enum.KeyCode.R) then
TestSprite:Play("grey_arrow", true)
end
end)
Now for the REAL moment of truth…
https://streamable.com/1jowxm
Hooray!
This concludes Part 1 of this tutorial. I hope you enjoyed!
The next part will cover:
Processing JSON charts,
The basic game.
Constructive criticism will be appreciated!
EDIT: Sorry everyone, but part 2 will be delayed for a little while as I work out some stuff.
EDIT 2: Part 2 is out! You can find it here.