Hello everyone! I’m very sorry for the delay, I had to fix lag issues with the engine.
This is part 2 of How to make Friday Night Funkin’ in Roblox.
If you didn’t see part 1, you can find it here
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 .
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.
All of the code was sourced from the original game .
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 2.
1. Writing the core libraries
Currently, we need 2 core libraries for the game to function properly.
They are:
Conductor and MusicBeatState
The Conductor manages the SongPosition, BPM, Crochet (for calculating beats from milliseconds), StepCrochet (for calculating steps from milliseconds), SafeFrames (for hit detection), and SafeZoneOffset (SafeFrames in milliseconds).
MusicBeatState handles calculating the current step and beat. these two variables are super important in FNF. A beat is roughly 1/4 of a section, and a step is 1/4 of a beat. (a section is 16 steps long, I’ll explain sections later when we get into JSON chart parsing.) If a song has a BPM of 60, one beat will occur every second, and a step will occur ever 250 milliseconds.
Ok, lets start creating the Conductor first.
(Couldn’t show the Haxe one due to character limit)
(This is written in Haxe by the way.)
I actually don’t know Haxe, but it seems pretty straightforward.
Here is my transcription to Lua.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Source = ReplicatedStorage:WaitForChild("Source")
--\\ The Source variable should be used to require anything that you need later if you are adding on to this library. EX: local Util = require(Source.Util)
local Conductor = {}
function Conductor.Initialize()
--\\ Just put all the variables in an initialize function so we can reset the conductor.
Conductor.BPM = 100
Conductor.Crochet = ((60 / Conductor.BPM) * 1000)
Conductor.StepCrochet = (Conductor.Crochet / 4)
Conductor.SongPosition = 0
Conductor.LastSongPosition = 0
Conductor.Offset = 0
Conductor.SafeFrames = 10
Conductor.SafeZoneOffset = ((Conductor.SafeFrames / 60) * 1000)
Conductor.BPMChangeMap = {}
Conductor.SongSpeed = 1 --\\ For song speed
end
function Conductor.MapBPMChanges(SongData)
--\\ We will have SongData later during JSON chart parsing.
Conductor.BPMChangeMap = {}
local CurBPM = SongData.bpm
local TotalSteps = 0
local TotalPos = 0
--\\ Loop through all the sections in the song, and check if that section requires a BPM change.
for i = 1, #SongData.notes, 1 do --\\ The JSON files are named really confusingly.
local CurrentSection = SongData.notes[i]
if CurrentSection.changeBPM and (CurrentSection.bpm ~= CurBPM) then
CurBPM = CurrentSection.bpm
local BPMChangeEvent = { --\\ BPM change event.
StepTime = TotalSteps;
SongTime = TotalPos;
BPM = CurBPM;
}
table.insert(Conductor.BPMChangeMap, BPMChangeEvent) --\\ Add to the BPM change map (table containing of all the changes)
end
--\\ Update the total steps, and total song position.
local DeltaSteps = CurrentSection.lengthInSteps
TotalSteps += DeltaSteps
TotalPos += ((60 / CurBPM) * 1000 / 4) * DeltaSteps
end
print("new BPM map BUDDY", Conductor.BPMChangeMap) --\\ I had to look trace() up lol. It turns out it's kinda like print().
end
function Conductor.ChangeBPM(NewBPM)
Conductor.BPM = NewBPM --\\ Set new bpm
--\\ Recalculate Crochet and StepCrochet using the new BPM.
Conductor.Crochet = ((60 / Conductor.BPM) * 1000)
Conductor.StepCrochet = (Conductor.Crochet / 4)
end
return Conductor
Nice, now that the Conductor is finished, now let’s move on to MusicBeatState.
(Couldn’t show the Haxe one due to character limit)
We will only need the functions that manage the beat and step.
Here is my Lua transcription:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Source = ReplicatedStorage:WaitForChild("Source")
local Conductor = require(Source.lib.Conductor)
local MusicBeatState = {}
MusicBeatState.__index = MusicBeatState
local BeatHitEvents = {}
local StepHitEvents = {}
function MusicBeatState.Initialize(SongData)
--\\ Initialize function like the Conductor.
MusicBeatState.LastBeat = 0
MusicBeatState.LastStep = 0
MusicBeatState.CurStep = 0
MusicBeatState.CurBeat = 0
MusicBeatState.LastChange = {
StepTime = 0,
SongTime = 0,
BPM = SongData.bpm
}
end
function MusicBeatState.OnBeatHit(func)
--\\ kinda like RbxScriptSignal:Connect(function), but I didn't want to use bindable events.
--\\ I know there is probably a better way to do this, so change at will.
local newConnection = {}
setmetatable(newConnection,MusicBeatState)
newConnection.func = func --\\ function
newConnection.t = "beat" --\\ name
newConnection.i = (#BeatHitEvents + 1) --\\ index
table.insert(BeatHitEvents,newConnection.func)
return newConnection
end
function MusicBeatState.OnStepHit(func)
--\\ kinda like RbxScriptSignal:Connect(function), but I didn't want to use bindable events.
--\\ I know there is probably a better way to do this, so change at will.
local newConnection = {}
setmetatable(newConnection,MusicBeatState)
newConnection.func = func --\\ function
newConnection.t = "step" --\\ name
newConnection.i = (#StepHitEvents + 1) --\\ index
table.insert(StepHitEvents,newConnection.func)
return newConnection
end
function MusicBeatState:Destroy()
--\\ Cleanup for the event stuff (why is it not called disconnect? idk)
if self.func then
self.func = nil
end
if self.t == "beat" then
table.remove(BeatHitEvents, self.i)
end
if self.t == "step" then
table.remove(StepHitEvents, self.i)
end
end
function MusicBeatState.UpdateCurStep()
--\\ Updates the current step
for i = 1,#Conductor.BPMChangeMap,1 do
if (Conductor.SongPosition >= Conductor.BPMChangeMap[i].SongTime) then
MusicBeatState.LastChange = Conductor.BPMChangeMap[i]
break
end
end
MusicBeatState.CurStep = MusicBeatState.LastChange.StepTime + math.floor((Conductor.SongPosition - MusicBeatState.LastChange.SongTime) / Conductor.StepCrochet)
end
function MusicBeatState.UpdateBeat()
MusicBeatState.CurBeat = math.floor(MusicBeatState.CurStep / 4)
end
function MusicBeatState.BeatHit()
--\\ instead of "do literally nothing", fire all the beat hit functions
if #BeatHitEvents > 0 then
for i = 1,#BeatHitEvents do
if BeatHitEvents[i] then
BeatHitEvents[i]()
end
end
end
end
function MusicBeatState.StepHit()
--\\ fire all of the step hit functions
if #StepHitEvents > 0 then
for i = 1,#StepHitEvents do
if StepHitEvents[i] then
StepHitEvents[i]()
end
end
end
--\\ fire beat at every fourth step
if (MusicBeatState.CurStep % 4) == 0 then
MusicBeatState.BeatHit()
end
end
function MusicBeatState.Update()
--\\ Main update (to be used with RenderStep)
local oldStep = MusicBeatState.CurStep
MusicBeatState.UpdateCurStep()
MusicBeatState.UpdateBeat()
if oldStep ~= MusicBeatState.CurStep and (MusicBeatState.CurStep > 0) then
MusicBeatState.StepHit()
end
end
return MusicBeatState
Nice! we have tackled the main libraries!
Here is where the new modules should be placed (circled in red):
(Also don’t forget to place the main module too. (I forgot to circle sorry. It’s above Util.))
2. Main game module
This is going to be the longest step yet (I think).
Before we continue, we need to setup the song’s assets.
The JSON chart, and the music.
Because of Roblox’s new audio system, you will have to upload the song on your own.
I will use the song “fresh” from the original game as demonstration. I recommend using original FNF songs, otherwise you will need permissions from the song’s mod creator(s) to use it on Roblox.
Why do you not need permission for original FNF songs? If you read the “do NOT readme.txt” in the original game, it says that you have permission to do anything you want.
Ok, with that permissions stuff out of the way, let’s continue.
There are two ways to upload music.
- Use the Instrumental and the Voices (2 audio files)
- Use them combined (1 audio file)
Warning: if you download from youtube and then upload it, MAKE SURE THAT IT IS AUDIO ONLY! NOTHING EXTRA, OR ELSE IT WILL BE OFF-SYNC.
Example: you find bopeebo on youtube, and at the beginning it is “three two one go!”. that beginning part will mess up the timings.
Example of a good audio:
Notice that it goes right to the music, and it doesn’t have anything extra at the beginning or the end. That is how the audio should be.
If you have the game downloaded, you can just grab the audio from the game directly like so:
(using “fresh” as an example)
Luckily, the audio files are in ogg-vorbis (.ogg) format, so we should have no trouble uploading it.
I uploaded the audios, and made new Songs folder.
Create a Songs folder like I did, and add a “Fresh” folder with the audios that you uploaded.
Nice!
Now, let’s add the charting.
You will need the game downloaded to get the charts, or you can just use the github.
Here is the general path to the charts:
If you are using github, follow this path:
Go to assets,
then open preload,
then open data.
Find the song that you want (I’m using “fresh” for the tutorial)
and you should see this:
(or this for github users)
Open up the difficulty that you want (the one without a difficulty is normal)
I will choose hard difficulty. It doesn’t matter which one you choose, as they are all the same JSON format. I recommend choosing a difficulty that you can play easily, as you will need to playtest it. (although I won’t cover death features so it really doesn’t matter.)
You should see something like this:
Woah! That is some compresed JSON! No worries, as it doesn’t matter, but for reading purposes, I used a JSON beautifier to make it readable. (to see the song data stuff)
Copy it all of it and paste into an empty module inside the song folder.
It should look something like this:
Note on the string [==[string]==]:
We need the equal signs because the JSON has many [] square brackets inside it.
Mine looks like this because of the beautifier:
Cool! We now have all the song assets, we are ready to start coding the main module!
Here is the starting of it:
--\\ Services
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local HttpService = game:GetService("HttpService")
local RunService = game:GetService("RunService")
--\\ Folders
local Assets = ReplicatedStorage:WaitForChild("Assets")
local Source = ReplicatedStorage:WaitForChild("Source")
local Songs = ReplicatedStorage:WaitForChild("Songs")
--\\ Modules
local Util = require(Source.Util)
local XMLSprite = require(Source.class.XMLSprite)
local Conductor = require(Source.lib.Conductor)
local MusicBeatState = require(Source.lib.MusicBeatState)
local GUICreator = require(Source.lib.GUI)
--\\ Core Objects
local Camera = game:GetService("Workspace").CurrentCamera
--\\ Variable setup
--\\ This is in a function so we can reset it on command.
function Initialize()
SongData = nil
SongName = ""
OpponentStrums = {}
PlayerStrums = {}
end
local MAIN = {}
function MAIN.LoadSong(SongNameToLoad)
end
return MAIN
Now that we have the bare module setup, we can start creating the first part of the game!
ARROW SETUP
Before we can create the JSON parser, we need to create the static arrow setup, and the Note class.
Static arrow creation (called “strums”)
local function CreateStrums(PlayerNumber)
--\\ Get note assets
local NoteImageID = Assets.Note.ImageID.Value
local NoteXML = require(Assets.Note.XML)
for i = 1, 4 do --\\ loop 4 times, because 4 notes
local NewStrum = XMLSprite.new(true) --\\ new sprite
NewStrum.Transparency = 0
NewStrum.Size = Vector2.new(0.7, 0.7) --\\ 0.7% size, you saw how big the orignal was lol.
if PlayerNumber == 2 then
NewStrum.Position = Vector2.new(
Util.GetStrumX(i + 3),
StrumYLine - 20
)
else
NewStrum.Position = Vector2.new(
Util.GetStrumX(i - 1),
StrumYLine - 20
)
end
NewStrum.ImageLabel.Parent = GameGUI.Game.Strums
--\\ 1 = left 2 = down 3 = up 4 = right
local Directions = {
[1] = "left";
[2] = "down";
[3] = "up";
[4] = "right";
}
--\\ Only load what we need
NewStrum:AddByPrefix(NoteXML, NoteImageID, 0.5, 24, "static", "arrow"..string.upper(Directions[i]))
NewStrum:AddByPrefix(NoteXML, NoteImageID, 0.5, 24, "press", Directions[i].." press")
NewStrum:AddByPrefix(NoteXML, NoteImageID, 0.5, 24, "confirm", Directions[i].." confirm")
--\\ Play static animation
NewStrum:Play("static", true)
--\\ Insert into tables
--\\ we will need this later.
if PlayerNumber == 1 then
table.insert(OpponentStrums, NewStrum)
end
if PlayerNumber == 2 then
table.insert(PlayerStrums, NewStrum)
end
end
end
Hold up, did you notice this?
Util.GetStrumX(index)
We need to create a function to get an arrow’s position by it’s index.
It’s index is organized like so:
- “Left”
- “Down”
- “Up”
- “Right”
Why is it in order left down up right?
(notice that the order of left down up right is from left to right on the arrow keys)
If it went: left right up down, it would be really confusing to play, as left and right would be right next to each other, so you would want to press the keys close to each other.
Ok, enough of me running my mouth on arrow layout, let’s start coding the utility function.
function Util.GetStrumX(StrumIndex)
StrumIndex += 1 --\\ Adding one because left is equal to 0.
--\\ For strums
if StrumIndex > 4 then --\\ Player2
return (Util.ScreenSize.X - ((5 - (StrumIndex - 4)) * (Util.NoteWidth)))
end
if StrumIndex < 5 then --\\ Player1
return (StrumIndex * (Util.NoteWidth))
end
return 0
end
The reason I am setting this up to be [0, 1, 2, 3] instead of [1, 2, 3, 4] is because the original game starts at 0 for tables, so the note types start at 0 in the charts.
Oh yeah, and for the NoteWidth variable in Util, please update it to this:
NoteWidth = (166 * 0.7); --\\ all notes are scaled down by 0.7, so NoteWidth is the original pixel size of every note scaled down by 0.7.
Util should now look like this:
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 = (166 * 0.7); --\\ all notes are scaled down by 0.7, so NoteWidth is the original pixel size of every note scaled down by 0.7.
--\\ For keybinds
KeybindNames = {
[1] = "Left";
[2] = "Down";
[3] = "Up";
[4] = "Right";
};
--\\ For notes
NoteDataNames = {
[1] = "Left";
[2] = "Down";
[3] = "Up";
[4] = "Right";
};
}
function Util.GetXPositionForNoteData(NoteData, ManualSide, PlayerSide)
NoteData += 1 --\\ Shift it over one for roblox
if PlayerSide == 2 then
--\\ Normal PlayerSide
--\\ Shenanigans for Notes.
NoteData -= 1 --\\ Shift it back over because we are using modulo
if ManualSide == 2 then --\\ Player2
return (Util.ScreenSize.X - ((5 - ((NoteData % 4) + 1)) * (Util.NoteWidth)))
end
if ManualSide == 1 then --\\ Player1
return (((NoteData % 4) + 1) * (Util.NoteWidth))
end
else
--\\ Flip all if PlayerSide is flipped.
--\\ Shenanigans for Notes.
NoteData -= 1 --\\ Shift it back over because we are using modulo
if ManualSide == 1 then --\\ Player2
return (Util.ScreenSize.X - ((5 - ((NoteData % 4) + 1)) * (Util.NoteWidth)))
end
if ManualSide == 2 then --\\ Player1
return (((NoteData % 4) + 1) * (Util.NoteWidth))
end
end
return 0
end
function Util.Lerp(start, goal, t)
return start + (goal - start) * t
end
function Util.GetStrumX(StrumIndex)
StrumIndex += 1
--\\ For strums
if StrumIndex > 4 then --\\ Player2
--\\ get screensize-x and subtract (5-(index-4) * NoteWidth)
--\\ it's easier to understand this after you look at the one for the opponent
return (Util.ScreenSize.X - ((5 - (StrumIndex - 4)) * (Util.NoteWidth)))
end
if StrumIndex < 5 then --\\ Player1
--\\ just the product of the index and the NoteWidth
return (StrumIndex * (Util.NoteWidth))
end
return 0
end
return Util
Before we finish up the arrow stuff, we need to make a class called Note. This class is what each moving arrow will be based off.
Example:
https://streamable.com/bq8xq2
Notice the colored arrows that go up into the strums.
This is what the Note class will be handling.
Here is the Note class:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Source = ReplicatedStorage:WaitForChild("Source")
local Assets = ReplicatedStorage:WaitForChild("Assets")
local Util = require(Source.Util)
local XMLSprite = require(Source.class.XMLSprite)
local Conductor = require(Source.lib.Conductor)
local Directions = {
[1] = "left";
[2] = "down";
[3] = "up";
[4] = "right";
}
local ColorDirections = { --\\ this is here because in the XML data, notes are named by color.
[1] = "purple";
[2] = "blue";
[3] = "green";
[4] = "red";
}
local function GetTargetStrum(NoteData, Side)
if Side == 1 then
return Conductor.OpponentStrums[(NoteData % 4) + 1]
end
if Side == 2 then
return Conductor.PlayerStrums[(NoteData % 4) + 1]
end
return nil
end
local Note = {}
Note.__index = Note
function Note.new(StrumTime, NoteData, MustPress, NoteType, IsHoldNote, IsHoldEnd, PrevNote)
local NewNote = setmetatable({
StrumTime = StrumTime;
NoteData = NoteData;
MustPress = MustPress;
IsHoldNote = IsHoldNote;
IsHoldEnd = IsHoldEnd;
PrevNote = PrevNote;
NoteType = NoteType;
CanBeHeld = false;
CanBeHit = false;
WasGoodHit = false;
TooLate = false;
Sprite = nil;
Destroyed = false;
UpdateConnection = nil;
CanUpdate = true;
Spawned = false;
}, Note)
if NoteType == "InsertNoteTypeHere" then --\\ Adding this here so you can make custom note types EX: Tricky Halo notes.
--\\ do something
else
--\\ Default note
local ImageID = Assets.Note.ImageID.Value
local XML = Assets.Note.XML
local NewSprite = XMLSprite.new(false)
NewSprite.Position = Vector2.new(
Util.GetXPositionForNoteData(NoteData, (MustPress and 2 or 1), 2),
2000 --\\ Make sure it is offscreen
)
NewSprite.Size = Vector2.new(
(0.7),
(0.7)
)
--\\ Only load what we need!
local Name = Directions[((NoteData % 4) + 1)]
local Color = ColorDirections[(NoteData % 4) + 1]
if Name == "left" then --\\ We have to do these shenanigans because of a typo the FNF developers made :|
if IsHoldNote then
if IsHoldEnd then
NewSprite:AddByPrefix(XML, ImageID, 0.5, 24, Name.." end", "pruple end hold")
else
NewSprite:AddByPrefix(XML, ImageID, 0.5, 24, Name.." hold", Color.." hold piece")
end
else
NewSprite:AddByPrefix(XML, ImageID, 0.5, 24, Name, Color)
end
else
if IsHoldNote then
if IsHoldEnd then
NewSprite:AddByPrefix(XML, ImageID, 0.5, 24, Name.." end", Color.." hold end")
else
NewSprite:AddByPrefix(XML, ImageID, 0.5, 24, Name.." hold", Color.." hold piece")
end
else
NewSprite:AddByPrefix(XML, ImageID, 0.5, 24, Name, Color)
end
end
NewNote.Sprite = NewSprite
end
--\\ Create update connection
NewNote.UpdateConnection = RunService.RenderStepped:Connect(function(DeltaTime)
NewNote:Update(DeltaTime)
NewNote.Sprite:Update(DeltaTime)
end)
NewNote.Sprite.ImageLabel.ZIndex = 3
if IsHoldNote then
NewNote.Sprite.ImageLabel.ZIndex = 2
NewNote.Sprite.Transparency = 0.5
if IsHoldEnd then
NewNote.Sprite:Play(Directions[((NoteData % 4) + 1)].." end", true)
else
NewNote.Sprite:Play(Directions[((NoteData % 4) + 1)].." hold", true)
end
else
NewNote.Sprite:Play(Directions[((NoteData % 4) + 1)], true)
end
return NewNote
end
function Note:Update(DeltaTime)
if self.CanUpdate and self.Spawned then
if self.MustPress then
--\\ Check if note is within hit time
if self.StrumTime > (Conductor.SongPosition - (Conductor.SafeZoneOffset * 1)) and self.StrumTime < (Conductor.SongPosition + (Conductor.SafeZoneOffset * 0.5)) then
self.CanBeHit = true
else
self.CanBeHit = false
end
if self.StrumTime <= Conductor.SongPosition then
self.CanBeHeld = true
else
self.CanBeHeld = false
end
--\\ Check if note is not too late
if self.StrumTime < (Conductor.SongPosition - Conductor.SafeZoneOffset) and (not self.WasGoodHit) then
self.TooLate = true
end
else
--\\ Opponent note
self.CanBeHit = false
--\\ Opponent auto hit
if self.StrumTime <= Conductor.SongPosition then
self.WasGoodHit = true
end
end
self:GameUpdate(DeltaTime)
end
end
function Note:GameUpdate(DeltaTime)
--\\ Update note position
--\\ Get note side
local NoteSide
if self.MustPress then
NoteSide = 2
else
NoteSide = 1
end
--\\ Set the sustain size
local FNF_SizeScale = 1
if self.IsHoldNote and (not self.IsHoldEnd) then
FNF_SizeScale = ((Conductor.StepCrochet / Conductor.SongSpeed) / 100 * 1.5 * (Conductor.SongSpeed))
end
if self.IsHoldEnd then
self.Sprite.Size = Vector2.new(
0.7,
1
)
else
self.Sprite.Size = Vector2.new(
0.7,
(0.7 * FNF_SizeScale)
)
end
--\\ Get the strum that the note is heading for.
local TargetStrum = GetTargetStrum(self.NoteData, NoteSide)
--\\ set the Y position
local y = (TargetStrum.Position.Y - (0.45 * (Conductor.SongPosition - self.StrumTime) * Conductor.SongSpeed))
local x = self.Sprite.Position.X --\\ X stays the same
--\\ Update the new positions
self.Sprite.Position = Vector2.new(x, y)
end
function Note:Destroy()
self.CanUpdate = false
self.Destroyed = true
if self.UpdateConnection then
self.UpdateConnection:Disconnect()
end
self.Sprite:Destroy()
end
return Note
Ok, here is where the new Note class goes:
Notice the PlayerStrums and the OpponentStrums are being referenced from the Conductor?
We will have to update the Conductor to contain those variables too. See, the Conductor is also used as a variable storage for other scripts to access.
Updated Conductor:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Source = ReplicatedStorage:WaitForChild("Source")
local Util = require(Source.Util)
local Conductor = {}
function Conductor.Initialize()
--\\ Just put all the variables in an initialize function so we can reset the conductor.
Conductor.BPM = 100
Conductor.Crochet = ((60 / Conductor.BPM) * 1000)
Conductor.StepCrochet = (Conductor.Crochet / 4)
Conductor.SongPosition = 0
Conductor.LastSongPosition = 0
Conductor.Offset = 0
Conductor.SafeFrames = 10
Conductor.SafeZoneOffset = ((Conductor.SafeFrames / 60) * 1000)
Conductor.BPMChangeMap = {}
--\\ The conductor is also used to store game data that can be accessed by all scripts.
Conductor.SongSpeed = 1
Conductor.OpponentStrums = {}
Conductor.PlayerStrums = {}
end
function Conductor.MapBPMChanges(SongData)
Conductor.BPMChangeMap = {}
local CurBPM = SongData.bpm
local TotalSteps = 0
local TotalPos = 0
--\\ Loop through all the sections in the song, and check if that section requires a BPM change.
for i = 1, #SongData.notes, 1 do --\\ The JSON files are named really confusingly.
local CurrentSection = SongData.notes[i]
if CurrentSection.changeBPM and (CurrentSection.bpm ~= CurBPM) then
CurBPM = CurrentSection.bpm
local BPMChangeEvent = { --\\ BPM change event.
StepTime = TotalSteps;
SongTime = TotalPos;
BPM = CurBPM;
}
table.insert(Conductor.BPMChangeMap, BPMChangeEvent) --\\ Add to the BPM change map (table containing of all the changes)
end
--\\ Update the total steps, and total song position.
local DeltaSteps = CurrentSection.lengthInSteps
TotalSteps += DeltaSteps
TotalPos += ((60 / CurBPM) * 1000 / 4) * DeltaSteps
end
print("new BPM map BUDDY", Conductor.BPMChangeMap) --\\ I had to look trace() up lol. It turns out it's kinda like print().
end
function Conductor.ChangeBPM(NewBPM)
Conductor.BPM = NewBPM --\\ Set new bpm
--\\ Recalculate Crochet and StepCrochet.
Conductor.Crochet = ((60 / Conductor.BPM) * 1000)
Conductor.StepCrochet = (Conductor.Crochet / 4)
end
return Conductor
Nice!
Now that we have it all set up for the arrow spawning, we can now create the JSON chart parser!
JSON CHART PARSER
--\\ Services
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local HttpService = game:GetService("HttpService")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
--\\ Folders
local Assets = ReplicatedStorage:WaitForChild("Assets")
local Source = ReplicatedStorage:WaitForChild("Source")
local Songs = ReplicatedStorage:WaitForChild("Songs")
--\\ Modules
local Util = require(Source.Util)
local XMLSprite = require(Source.class.XMLSprite)
local NoteClass = require(Source.class.Note)
local Conductor = require(Source.lib.Conductor)
local MusicBeatState = require(Source.lib.MusicBeatState)
local GUICreator = require(Source.lib.GUI)
--\\ Core Objects
local Camera = game:GetService("Workspace").CurrentCamera
--\\ Constants
local StrumYLine = 100
local Keybinds = { --\\ Change keybinds at will!
[1] = Enum.KeyCode.D;
[2] = Enum.KeyCode.F;
[3] = Enum.KeyCode.J;
[4] = Enum.KeyCode.K;
}
--\\ Variable setup
--\\ This is in a function so we can reset it on command.
local function Initialize()
SongData = nil
SongName = ""
LOADED = false
STARTED_SONG = false
RS_CONNECTION = nil
INST = nil
VOICES = nil
UIScale = 1
FOVScale = 70
GameGUI = nil
JSON_FILE = nil
OpponentStrums = {}
PlayerStrums = {}
UnspawnedNotes = {}
NotesInGame = {}
SolidNotes = {} --\\ Keeps track of player notes
--\\ Input stuff
InputBeganConnection = nil
InputEndedConnection = nil
BindsPressed = {
[1] = false;
[2] = false;
[3] = false;
[4] = false;
}
BindWasChecked = { --\\ This is to prevent people holding down keys and hitting all the notes.
[1] = false;
[2] = false;
[3] = false;
[4] = false;
}
end
local function EndGame() --\\ Put whatever cleanup you want in here
if RS_CONNECTION then
RS_CONNECTION:Disconnect()
end
if InputBeganConnection then
InputBeganConnection:Disconnect()
end
if InputEndedConnection then
InputEndedConnection:Disconnect()
end
end
local function StartCountdown() --\\ This will run when the countdown should be started.
end
local function CreateStrums(PlayerNumber)
--\\ Get note assets
local NoteImageID = Assets.Note.ImageID.Value
local NoteXML = require(Assets.Note.XML)
for i = 1, 4 do --\\ loop 4 times, because 4 notes
local NewStrum = XMLSprite.new(true) --\\ new sprite
NewStrum.Transparency = 0
NewStrum.Size = Vector2.new(0.7, 0.7) --\\ 0.7% size, you saw how big the orignal was lol.
if PlayerNumber == 2 then
NewStrum.Position = Vector2.new(
Util.GetStrumX(i + 3),
StrumYLine - 20
)
else
NewStrum.Position = Vector2.new(
Util.GetStrumX(i - 1),
StrumYLine - 20
)
end
NewStrum.ImageLabel.Parent = GameGUI.Game.Strums
--\\ 1 = left 2 = down 3 = up 4 = right
local Directions = {
[1] = "left";
[2] = "down";
[3] = "up";
[4] = "right";
}
--\\ Only load what we need
NewStrum:AddByPrefix(NoteXML, NoteImageID, 0.5, 24, "static", "arrow"..string.upper(Directions[i]))
NewStrum:AddByPrefix(NoteXML, NoteImageID, 0.5, 24, "press", Directions[i].." press")
NewStrum:AddByPrefix(NoteXML, NoteImageID, 0.5, 24, "confirm", Directions[i].." confirm")
--\\ Play static animation
NewStrum:Play("static", true)
--\\ Insert into tables
--\\ we will need this later.
if PlayerNumber == 1 then
table.insert(OpponentStrums, NewStrum)
end
if PlayerNumber == 2 then
table.insert(PlayerStrums, NewStrum)
end
end
end
local MAIN = {}
function MAIN.LoadSong(SongNameToLoad)
Initialize()
--\\ Get the song folder and assets, don't run if stuff doesn't exist.
local SongFolder = Songs:FindFirstChild(SongNameToLoad)
if not SongFolder then
warn(SongNameToLoad.." was not found in ReplicatedStorage.Songs")
return
end
local InstOnly = false
--\\ If you are using only 1 audio file, just call it Inst and set the variable above to true
INST = SongFolder:FindFirstChild("Inst")
VOICES = SongFolder:FindFirstChild("Voices")
JSON_FILE = SongFolder:FindFirstChild("JSON")
if InstOnly then
if not (INST and JSON_FILE) then
warn(SongNameToLoad.." does not have inst and JSON file!")
return
end
else
if not (INST and VOICES and JSON_FILE) then
warn(SongNameToLoad.." does not have inst, voices, and JSON file!")
return
end
end
--\\ Do I really need this pcall()? Or am I being overly error avoiding?
--\\ This will error if the JSON is malformed or something though.
local success, err = pcall(function()
SongData = HttpService:JSONDecode(require(JSON_FILE))
end)
--\\ Check if it was parsed
if not success then
warn("Unable to parse JSON!")
return
end
SongData = SongData.song
--\\ Get GameGUI
GameGUI = GUICreator.GameGUI(5)
GameGUI.Parent = Players.LocalPlayer:WaitForChild("PlayerGui") --\\ Parent to playergui
--\\ Setup libraries
Conductor.Initialize()
Conductor.ChangeBPM(SongData.bpm) --\\ Set initial bpm
--\\ Create player1 (opponent) and player2 (player) strums
CreateStrums(1)
CreateStrums(2)
--\\ Update Conductor variables
Conductor.OpponentStrums = OpponentStrums
Conductor.PlayerStrums = PlayerStrums
MusicBeatState.Initialize(SongData)
--\\ Variable setup
for i = 1, 4 do
table.insert(SolidNotes, {})
end
--\\ Input handlers (these will be used later)
InputBeganConnection = UserInputService.InputBegan:Connect(function(Input, GameProcessedEvent)
if GameProcessedEvent then return end
for i = 1, #Keybinds do
local Bind = Keybinds[i]
if Input.KeyCode == Bind then
BindsPressed[i] = true
end
end
end)
InputEndedConnection = UserInputService.InputEnded:Connect(function(Input, GameProcessedEvent)
if GameProcessedEvent then return end
for i = 1, #Keybinds do
local Bind = Keybinds[i]
if Input.KeyCode == Bind then
BindsPressed[i] = false
PlayerStrums[i]:Play("static", true)
BindWasChecked[i] = false
end
end
end)
--\\ Load song
local NoteData = SongData.notes
Conductor.SongSpeed = SongData.speed or 1 --\\ Set speed
local NoteLoops = 0
local SectionLoops = 0
local StartTime = os.clock() --\\ This is used to measure how long it takes to load the song.
for i = 1, #NoteData do
local Section = NoteData[i]
if Section.sectionNotes then
for v = 1, #Section.sectionNotes do
local Note = Section.sectionNotes[v]
--\\ StrumTime is the time the note should be hit in (ms)
local StrumTime = Note[1]
local NoteData = Note[2] --\\ Note data is an integer from 0 - 7. It determines which note type it is EX: 0 == "left" etc. etc.
local GottaHitNote = Section.mustHitSection --\\ Self explanatory
if NoteData > 3 then --\\ above 3 means it is the other character's notes. if musthit is false and the notedata is 7, then it is the player's note and vice versa
GottaHitNote = (not Section.mustHitSection)
end
--\\ Used to detect sustains in the original source code. I'm not using it, but I'm keeping it here in case something needs it later on.
local OldNote
if #UnspawnedNotes > 0 then
OldNote = UnspawnedNotes[#UnspawnedNotes - 1]
else
OldNote = nil
end
local NoteType = "" --\\ use this to implement custom note types if you want.
local NewNote = NoteClass.new(StrumTime, NoteData, GottaHitNote, NoteType, false, false, OldNote)
--\\ add to unspawned notes.
table.insert(UnspawnedNotes, NewNote)
--\\ Create hold parts
local SustainLength = Note[3]
SustainLength /= Conductor.StepCrochet
local FloorSustain = (math.floor(SustainLength) * (Conductor.SongSpeed))
if FloorSustain > 0 then
for length = 1, FloorSustain, 1 do
--\\ This is calculating the sustain chunk timings. I don't actually know how this works. I guess that is a question for ninjamuffin99 lol.
local SustainStrumTime = (StrumTime + ((Conductor.StepCrochet / (Conductor.SongSpeed)) * length))
--\\ Get a true/false variable depending on if this is the last one or not.
local IsEnd = ((length == FloorSustain) or (length + 1) > FloorSustain)
--\\ get PrevNote (previous note)
OldNote = UnspawnedNotes[#UnspawnedNotes - 1]
--\\ create new sustain note
local SustainNote = NoteClass.new(SustainStrumTime, NoteData, GottaHitNote, NoteType, true, IsEnd, OldNote)
--\\ add to unspawned notes
table.insert(UnspawnedNotes, SustainNote)
end
end
end
--\\ Yield a little every 8 loops to avoid lag
NoteLoops += 1
if (NoteLoops % 8) == 0 then
RunService.Heartbeat:Wait()
end
end
--\\ Yield a little every 8 loops to avoid lag
SectionLoops += 1
if (SectionLoops % 8) == 0 then
RunService.Heartbeat:Wait()
end
end
--\\ Sort from least StrumTime to greatest StrumTime
--\\ This is super important because the notes in the chart are not in order!
table.sort(UnspawnedNotes, function(a, b)
return a.StrumTime < b.StrumTime
end)
print(("Loading finished! elapsed time: %s"):format(os.clock() - StartTime))
LOADED = true
end
Whew, that was a big chunk. We are almost there to testing!
Let’s start by changing a few things, then we can test the game.
First, let’s change some stuff in the XMLSprite; Specifically line 21.
NewImageLabel.BackgroundTransparency = 0.5 --\\ for debug, it shows the sprite's size.
We need to change this value to 1 like so:
NewImageLabel.BackgroundTransparency = 1 --\\ for debug, it shows the sprite's size.
(Here is updated code in case you can’t find it.)
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 = 1 --\\ 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
Next, we need to change the LocalScript we used for testing to use the Main module.
You can find it chilling in ReplicatedFirst.
Here is the updated code for Game:
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 Main = require(Source.Main)
task.wait(4)
Main.LoadSong("Fresh") --\\ If you are using a different song, change the name to the song that you chose. (case sensitive)
Wow! that was really short! This is because most of the code will reside in the Main module.
One last thing before we test,
Set CharacterAutoLoads to false like so:
This way, we don’t have to deal with our character.
Ok, lets try it out!
https://streamable.com/pt8lac
Yes!! the strums appear!
Alright, now lets create the gameplay.
First, we need one last module.
Yes, I know there are so many in this tutorial, but I assure you this is the last one.
I call it ScoreManager. It basically calculates your rating depending on the millisecond offset of when you hit notes. (Sick, Good, Bad, Trash).
Here it is:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Source = ReplicatedStorage:WaitForChild("Source")
local Conductor = require(Source.lib.Conductor)
local Ratings = {
[1] = {
Name = "Sick";
RatingMod = 1;
RatingWindow = 45; --\\ Milliseconds
Score = 350;
};
[2] = {
Name = "Good";
RatingMod = 0.7;
RatingWindow = 90;--\\ Milliseconds
Score = 200;
};
[3] = {
Name = "Bad";
RatingMod = 0.4;
RatingWindow = 135; --\\ Milliseconds
Score = 100;
};
[4] = {
Name = "Trash";
RatingMod = 0;
RatingWindow = 200; --\\ Milliseconds
Score = 50;
};
}
local ScoreManager = {}
function ScoreManager.GetScoreAndJudgement(TimeDifference)
--\\ Skips the last one ("Trash")
--\\ Loop through all the ratings, and return the first one that is >= to the MS offset.
for i = 1, (#Ratings - 1) do
local Rating = Ratings[i]
local RatingWindow = Rating.RatingWindow
if math.abs(TimeDifference) <= RatingWindow then
return Ratings[i]
end
end
--\\ If no ratings matched, return "Trash".
return Ratings[#Ratings]
end
return ScoreManager
Ok nice! Now, let’s add that to the main module:
--\\ Modules
local Util = require(Source.Util)
local XMLSprite = require(Source.class.XMLSprite)
local NoteClass = require(Source.class.Note)
local Conductor = require(Source.lib.Conductor)
local MusicBeatState = require(Source.lib.MusicBeatState)
local GUICreator = require(Source.lib.GUI)
local ScoreManager = require(Source.lib.ScoreManager) --\\ The new ScoreManager!
Alright, let’s start creating the gameplay.
function MAIN.PlaySong()
local StartedCountdown = false
if not LOADED then
warn("Please load the song first before playing.")
end
--\\ Functions
local function GoodNoteHit(Note, SI)
--\\ SI stands for SolidIndex (index in SolidNotes)
local Rating = ScoreManager.GetScoreAndJudgement(Note.StrumTime - Conductor.SongPosition)
print(Rating.Name) --\\ You can make this a GUI if you want lol.
Note:Destroy()
table.remove(SolidNotes[SI], 1)
PlayerStrums[(Note.NoteData % 4) + 1]:Play("confirm", true)
end
--\\ Pre-song setup
Conductor.SongPosition = -5000 --\\ Set the SongPosition to five seconds before the actual song starts (this way we can spawn notes early and have time for countdown.)
RS_CONNECTION = RunService.RenderStepped:Connect(function(DeltaTime)
--\\ Update MBS and conductor.
MusicBeatState.Update()
Conductor.SongPosition += (DeltaTime * 1000) --\\ Convert DeltaTime to milliseconds
--\\ Lerp UI Zoom and Camera FOV Zoom
local LerpFactor = 0.05
UIScale = Util.Lerp(UIScale, 1, LerpFactor)
FOVScale = Util.Lerp(FOVScale, 70, LerpFactor)
GameGUI.Game.UIScale.Scale = UIScale
Camera.FieldOfView = FOVScale
--\\ Spawn in notes
if #UnspawnedNotes > 0 then
local AmountToRemove = 0
for i = 1, #UnspawnedNotes do
local NewNote = UnspawnedNotes[i]
--\\ Stop when notes are spawned 2000 milliseconds before they have to be hit.
if not ((UnspawnedNotes[i].StrumTime - Conductor.SongPosition) < 2000) then
break
end
NewNote.Spawned = true --\\ Tell the note that it is spawned.
--\\ IMPORTANT: Parent new note to GameFrame, otherwise we won't see it.
NewNote.Sprite.ImageLabel.Parent = GameGUI.Game.Notes
--\\ Add to NotesInGame for us to reference later.
table.insert(NotesInGame, NewNote)
if (not NewNote.IsHoldNote) and NewNote.MustPress then
--\\ If we have to hit this note, and it is NOT a hold note, we add to SolidNotes for hit detection.
table.insert(SolidNotes[(NewNote.NoteData % 4) + 1], NewNote)
end
AmountToRemove += 1
end
--\\ Delete spawned in notes
for i = 1, AmountToRemove do
table.remove(UnspawnedNotes, 1)
end
end
if Conductor.SongPosition >= 0 and (not STARTED_SONG) then
STARTED_SONG = true
--\\ Start at time 0.
--\\ Start music
if INST then
INST:Play()
end
if VOICES then
VOICES:Play()
end
print("Song started!")
end
if Conductor.SongPosition >= -3000 and (not StartedCountdown) then
--\\ Countdown start
StartedCountdown = true
StartCountdown()
end
if Conductor.SongPosition >= ((INST.TimeLength * 1000) + 100) then --\\ end the song at the time of the instrumental plus 0.1 seconds.
EndGame()
print("Song over!")
end
if STARTED_SONG then
--\\ Update Notes
for i = 1, #NotesInGame do
local Note = NotesInGame[i]
if Note then
if not Note.MustPress then --\\ Opponent note auto hit
if Note.WasGoodHit then
Note:Destroy() --\\ Delete the note because the opponent just hit it.
end
end
if Note.MustPress and Note.IsHoldNote and Note.CanBeHeld then
--\\ Only handle hit if we are pressing down on the keybind.
if BindsPressed[(Note.NoteData % 4) + 1] then
Note:Destroy()
PlayerStrums[(Note.NoteData % 4) + 1]:Play("confirm", true)
end
end
if Note.MustPress and Note.TooLate then
--\\ Player note auto-delete
Note:Destroy()
end
end
end
--\\ Clean up any deleted notes
for i = 1, #NotesInGame do
local Note = NotesInGame[i]
if Note then
if Note.Destroyed then
table.remove(NotesInGame, i)
end
end
end
--\\ Clean up deleted notes in SolidNotes.
for i = 1, #SolidNotes do
local NoteList = SolidNotes[i]
for v = 1, #NoteList do
local Note = NoteList[v]
if Note then
if Note.MustPress and Note.TooLate then
--\\ Player note auto-delete
Note:Destroy()
end
if Note.Destroyed then
table.remove(NoteList, v)
end
end
end
end
--\\ hit detection
--\\ Sort first
for i = 1, #SolidNotes do
local NoteList = SolidNotes[i]
table.sort(NoteList, function(a, b)
return a.StrumTime < b.StrumTime
end)
end
--\\ Loop through each note type
for i = 1,4 do
if BindsPressed[i] then --\\ is bind pressed
if (not BindWasChecked[i]) then --\\ bind was not already pressed
BindWasChecked[i] = true
local HittableNotes = {} --\\ get hittable notes
for i,note in pairs(SolidNotes[i]) do
if (not note.TooLate) and (not note.IsSustainNote) and note.CanBeHit and (not note.WasGoodHit) then
table.insert(HittableNotes,note)
end
end
table.sort(HittableNotes,function(a,b) --\\ sort
return (a.StrumTime < b.StrumTime)
end)
if #HittableNotes > 0 then
local TopNote = HittableNotes[1] --\\ get the first note in HittableNotes
--\\ YES! You hit a note!
PlayerStrums[i]:Play("confirm",true)
TopNote.WasGoodHit = true
GoodNoteHit(TopNote, i)
else
--\\ You either missed a note, or you just pressed a key when there were no notes incoming.
PlayerStrums[i]:Play("press",true)
end
end
end
end
end
end)
--\\ Fires every beat
MusicBeatState.OnBeatHit(function()
--\\ Zoom camera every 4 beats but, only after the song starts!
if ((MusicBeatState.CurBeat % 4) == 0) and STARTED_SONG then
UIScale += 0.02
FOVScale -= 0.6
end
end)
end
Cool!, Last thing before testing, we need to update the EndGame() and StartCountdown() functions!
Here is the updated ones:
local function EndGame() --\\ Put whatever cleanup you want in here
if RS_CONNECTION then
RS_CONNECTION:Disconnect()
end
if InputBeganConnection then
InputBeganConnection:Disconnect()
end
if InputEndedConnection then
InputEndedConnection:Disconnect()
end
if GameGUI then
GameGUI:Destroy()
end
end
local function StartCountdown() --\\ This will run when the countdown should be started.
coroutine.wrap(function() --\\ We don't want this to yield!
local Counts = 4 --\\ 3, 2, 1, GO!
task.wait(3 - ((60 / Conductor.BPM) * 4)) --\\ We have to wait 60/bpm 4 times, so we subtract that from 3 to get the starting wait time.
local Countdowns = {
[1] = "3";
[2] = "2";
[3] = "1";
[4] = "Go!";
}
for i = 1, #Countdowns do
local Countdown = Countdowns[i]
print(Countdown) --\\ For the sake of simplicity, I am using the print(). You can implement a GUI.
task.wait((60 / Conductor.BPM)) --\\ wait 60/bpm so it's in beat sync.
end
end)()
end
One last thing, we need to update the Game LocalScript.
We need to add the Main.Play() under the Main.LoadSong(SongName).
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 Main = require(Source.Main)
task.wait(4)
Main.LoadSong("Fresh") --\\ If you are using a different song, change the name to the song that you chose. (case sensitive)
Main.PlaySong() --\\ Play the song!
And we get:
https://streamable.com/unmtvr
Great job not falling asleep to me!
I’m sorry that I didn’t get to the health bar, but it should be easy to implement on your own. As of now you should have a FNF game framework. This is made to be built apon, so you can add features at will! EX: Note splashes, Ghost tapping, Opponent strum light up, etc.
Also, you remember the NoteType variable in the song loader?
That will allow you to create custom notes if you wanted to.
I hope you enjoyed this tutorial.
Please by all means tell me if I missed something.
I’ll try to answer questions as best as I can.
EDIT: Util was missing values, so incase you are missing values, here is the full folder of libraries:
Thanks for reading!