How to make Friday Night Funkin' In Roblox! PART 1

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.
image_2023-02-07_171555620

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.

image_2023-02-07_164755301

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. :relieved:

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”.

image

image

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.
image

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”
image
open “images”
image
look for “NOTE_assets”
image

Uploading the images

First, go to “Asset Manager” and open “Images”
image

Then, click on the “Bulk Import”
image

Then, select the NOTE_assets file that you downloaded (or already have)
image

If all went well, then your upload should look like this:
image

Right click on the newly added image and select “Copy to clipboard”.
image

Add this new “Assets” folder to ReplicatedStorage.
Inside it, add a “Note” folder and a StringValue, and a ModuleScript.
image

Then, paste the ID into the StringValue.
image

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 [[
	
]]

image

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”
image

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”.
image

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:
image
image
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.
image

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:
image
It corresponds with the XML that has the data for the animations.
image

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! :tada:

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.

37 Likes

wow, thanks so much
I will read this fully soon

Wow, Thank you for the tutorial!

it’s good but why don’t you complete it

I did not complete this in one post, as there is so much code that I have to go over just to get a basic framework done.

1 Like

I can’t wait until the next part! Thank you for this!

to this degree? :exploding_head: :exploding_head: :exploding_head: :exploding_head: But you must continue :+1:

Not to a huge degree, but it would bloat the post a ton, especially because I plan to go over sections of the main code and JSON chart reader. And yes, I definitely will continue this!

1 Like

Its 2023. FNF came out in 2020.

You’re 3 years late.

I didn’t know about FNF it until 2021, and I was busy with other projects until late 2022, and then I started research and work on this in 2023.

In that case
image

lol yes very true
(ignore the line below please)

local ThirtyCharacterPlaceholder = ""