Blade Ball Ball Physics

Here’s the code. I couldn’t put everything in one reply because there were 59381 characters and there’s a 50000 character limit.

Approximate comparison, debugging

NumberComparison
local NumberComparison = {}

local equalityThreshold = 1e-8

-- A better floating point number comparison function would account for the magnitude of the numbers being compared
-- so that with bigger numbers, more difference would be tolerated as floating point numbers get less when going farther from zero.
-- This is just a simple comparison function.
function NumberComparison.areApproximatelyEqual(a: number, b: number)
	return math.abs(a - b) < equalityThreshold
end

return NumberComparison
VectorComparison
local comparisonModulesFolder = script.Parent
local NumberComparison = require(comparisonModulesFolder.NumberComparison)

local VectorComparison = {}

-- I'm not sure if distance (u-v).Magnitude would be a better way to compare than comparing individual components.
function VectorComparison.areApproximatelyEqual(u: Vector3 | Vector2, v: Vector3 | Vector2)
	if typeof(u) ~= typeof(v) then
		error("Only comparison of vectors of same type is supported (at least for now).")
	end
	if typeof(u) == "Vector3" then
		return NumberComparison.areApproximatelyEqual(u.X, v.X) and NumberComparison.areApproximatelyEqual(u.Y, v.Y) and NumberComparison.areApproximatelyEqual(u.Z, v.Z)
	end
	return NumberComparison.areApproximatelyEqual(u.X, v.X) and NumberComparison.areApproximatelyEqual(u.Y, v.Y)
end

return VectorComparison
DebugUtility
local DebugUtility = {}

function DebugUtility.getVectorString(v: Vector3 | Vector2, precision: number)
	if precision == nil then
		precision = 3
	end
	local numberFormatString = "%." .. precision .. "f"
	if typeof(v) == "Vector3" then
		local vectorFormatString = string.format("(%s, %s, %s)", numberFormatString, numberFormatString, numberFormatString)
		return string.format(vectorFormatString, v.X, v.Y, v.Z)
	end
	local vectorFormatString = string.format("(%s, %s)", numberFormatString, numberFormatString)
	return string.format("(%.2f, %.2f)", v.X, v.Y)
end

local function convertIntegerToBitArray(n, numberOfBits)
	if math.floor(n) ~= n then
		error("given number is not an integer")
	end
	if n < 0 then
		error("This currently only supports unsigned integers.")
	end
	local bitArray = table.create(numberOfBits)
	for exponent = numberOfBits - 1, 0, -1 do
		--print(string.format("n: %i; exponent: %i; 2 ^ exponent: %i", n, exponent, 2 ^ exponent))
		bitArray[numberOfBits - exponent] = n >= 2 ^ exponent
		n = n % (2 ^ exponent)
	end
	return bitArray
end

-- I later realised I could have just padded the number with zeros instead but didn't bother switching to that.
function DebugUtility.convertIntegerToStringWithCorrectAlphabeticalOrder(n, numberOfBits)
	local str = ""
	for i, bit in convertIntegerToBitArray(n, numberOfBits) do
		str ..= if bit then "B" else "A"
	end
	return str
end

function DebugUtility.getNameFromFullName(fullName: string)
	return fullName:reverse():split(".")[1]:reverse()
end

return DebugUtility

General bezier curve code

QuadraticBezier
local bladeBallMovementModulesContainer = script.Parent.Parent.Parent
local DebugUtility = require(bladeBallMovementModulesContainer.Debugging.DebugUtility)

local quadraticBezierMathModulesContainer = script.Parent
local QuadraticBezierInterval = require(quadraticBezierMathModulesContainer.QuadraticBezierInterval)

local QuadraticBezier = {}
QuadraticBezier.__index = QuadraticBezier

local maxAverageEuclideanDistanceBetweenNormalCalculatedPoints = .1
local maxEuclideanDistanceBetweenArcLengthParameterizationPoints = .1

local function getCurveLengthAndIntervals(self)
	local numberOfPoints = 2
	local sumOfDistancesBetweenConsecutivePoints = (self.p2 - self.p0).Magnitude
	--print(string.format("(initial) sumOfDistancesBetweenConsecutivePoints: %.2f", sumOfDistancesBetweenConsecutivePoints))
	local intervals = {QuadraticBezierInterval.new(self, 0, 1)}
	--print(string.format("sumOfDistancesBetweenConsecutivePoints / (numberOfPoints - 1): %.2f", sumOfDistancesBetweenConsecutivePoints / (numberOfPoints - 1)))
	while sumOfDistancesBetweenConsecutivePoints / (numberOfPoints - 1) > maxAverageEuclideanDistanceBetweenNormalCalculatedPoints do
		numberOfPoints *= 2
		sumOfDistancesBetweenConsecutivePoints = 0
		intervals = {}
		for segmentStartIndex = 0, numberOfPoints - 2 do
			-- dividing by (numberOfPoints - 1 because it is the number of segments)
			-- segmentStartIndex initial value and final value are chosen such that first segmentStartPoint is p0
			-- and last segmentEndPoint is p2.
			local segmentStartT, segmentEndT = segmentStartIndex / (numberOfPoints - 1), (segmentStartIndex + 1) / (numberOfPoints - 1)
			local interval = QuadraticBezierInterval.new(self, segmentStartT, segmentEndT)
			intervals[segmentStartIndex + 1] = interval
			
			local distanceBetweenConsecutivePoints = interval.euclideanDistance
			--print(string.format("interval.euclideanDistance: %.2f", interval.euclideanDistance))
			sumOfDistancesBetweenConsecutivePoints += distanceBetweenConsecutivePoints
			--print(string.format("(in loop) sumOfDistancesBetweenConsecutivePoints: %.2f", sumOfDistancesBetweenConsecutivePoints))
		end
	end
	--print(string.format("#intervals: %i", #intervals))
	return sumOfDistancesBetweenConsecutivePoints, intervals
end

function QuadraticBezier.new(p0: Vector3, p1: Vector3, p2: Vector3)
	local self = setmetatable({}, QuadraticBezier)
	self.p0 = p0
	self.p1 = p1
	self.p2 = p2
	self.length, self.intervals = getCurveLengthAndIntervals(self)
	return self
end

local function validateT(t)
	if t < 0 or t > 1 or t ~= t then
		error(string.format("invalid t: %.2f", t))
	end
end

function QuadraticBezier:getPointNormally(t: number): Vector3
	validateT(t)
	--print(string.format("getPointNormally was called; t: %.2f", t))
	return (1 - t)^2 * self.p0 + 2 * (1 - t) * t * self.p1 + t^2 * self.p2
end

function QuadraticBezier:getTangentVectorNormally(t: number): Vector3
	validateT(t)
	return 2 * (1 - t) * (self.p1 - self.p0) + 2 * t * (self.p2 - self.p1)
end

function QuadraticBezier:getNormalTFromArcLengthParameterizationT(t: number): number
	validateT(t)
	--print(string.format("%.2f", t))
	if self.length == 0 then
		--print("length is exactly 0")
		return 0
	end
	if t == 1 then
		--return self.p2
		return 1
	end
	
	local sumsOfLengthsOfIntervals: {number} = table.create(#self.intervals)
	local currentSum = 0
	for i, interval in self.intervals do
		currentSum += interval.euclideanDistance
		sumsOfLengthsOfIntervals[i] = currentSum
		--print(string.format("i: %i; currentSum: %.2f", i, currentSum))
	end
	--print(string.format("%.2f", sumsOfLengthsOfIntervals[#self.intervals]), string.format("%.2f", self.length))
	
	local approximateArcLengthBeforeT = t * self.length
	--print(string.format("approximateArcLengthBeforeT: %.2f", approximateArcLengthBeforeT))
	
	local indexOfIntervalInWhichTIs = 1
	local sumOfLengthsOfWholeIntervalsBeforeT = 0
	for i, sum in sumsOfLengthsOfIntervals do
		--print(string.format("sum: %s; sum == 0: %s", tostring(sum), tostring(sum == 0)))
		if sum > approximateArcLengthBeforeT then
			--print(string.format("sum (%.2f) > approximateArcLengthBeforeT(%.2f)", sum, approximateArcLengthBeforeT))
			break
		end
		--print(string.format("i: %.2f; sum: %.2f", i, sum))
		indexOfIntervalInWhichTIs = i + 1
		sumOfLengthsOfWholeIntervalsBeforeT = sum
		--print(string.format("i: %.2f; sum: %.2f", i, sum))
	end
	--print(string.format("indexOfIntervalInWhichTIs: %i; #self.intervals: %i; t: %.2f", indexOfIntervalInWhichTIs, #self.intervals, t))
	
	local interval = self.intervals[indexOfIntervalInWhichTIs]
	if interval == nil then
		error(string.format("interval is nil; t: %s; indexOfIntervalInWhichTIs: %i", tostring(t), indexOfIntervalInWhichTIs))
	end
	local approximateArcLengthFromIntervalPoint0 = approximateArcLengthBeforeT - sumOfLengthsOfWholeIntervalsBeforeT
	--local percent = (approximateArcLengthBeforeT - sumOfLengthsOfWholeIntervalsBeforeT) / interval.euclideanDistance
	--local point, tangentVector = interval:getApproximatePointAndTangentVector(percent)
	--return point, tangentVector
	return interval:getApproximateNormalT(approximateArcLengthFromIntervalPoint0)
end

function QuadraticBezier:getPointAndTangentVectorWithArcLengthParameterization(t: number): (Vector3, Vector3)
	local normalT = self:getNormalTFromArcLengthParameterizationT(t)
	return self:getPointNormally(normalT), self:getTangentVectorNormally(normalT)
end

-- Normal t means not arc length parameterization t.
function QuadraticBezier:getSubCurveFromNormalTs(normalT0: number, normalT1: number)
	if normalT1 < normalT0 then
		error("normalT1 < normalT0")
	end
	if normalT1 == 0 then
		--print("normalT1 == 0")
		return QuadraticBezier.new(self.p0, self.p0, self.p0)
	end
	--print(string.format("normalT0: %.2f, normalT1: %.2f", normalT0, normalT1))
	--print(debug.traceback(nil, 2))
	
	-- De Casteljau's algorithm
	-- Calculating the control points of the new curves formed when splitting self at normalT1
	-- (the first has self.p0 as its p0 and the second one has self.p2 as its p2 so those don't need to be calculated).
	-- T1 in the variable names means normalT1.
	local subCurve0ToT1P1 = self.p0:Lerp(self.p1, normalT1)
	local subCurveT1To1P1 = self.p1:Lerp(self.p2, normalT1)
	local subCurve0ToT1P2AndSubCurveT1To1P0 = subCurve0ToT1P1:Lerp(subCurveT1To1P1, normalT1)
	
	-- Calculating the t value in the first one of the aforementioned curves (sub-curve from 0 to normalT1) that corresponds to
	-- the same point as normalT1 in self.
	-- (not sure if this is the correct way to calculate it)
	local TOfSubCurve0ToT1CorrespondingToT1InSelf = normalT0 / normalT1
	
	-- Calculating the control points of the new curves formed when splitting self at normalT1
	-- subCurve0ToT1P2AndSubCurveT1To1P0 is also p2 of the desired sub curve.
	local subCurveBeforeDesiredSubCurveP1 = self.p0:Lerp(subCurve0ToT1P1, TOfSubCurve0ToT1CorrespondingToT1InSelf)
	local desiredSubCurveP1 = subCurve0ToT1P1:Lerp(subCurve0ToT1P2AndSubCurveT1To1P0, TOfSubCurve0ToT1CorrespondingToT1InSelf)
	local subCurveBeforeDesiredSubCurveP2AndDesiredSubCurveP0 = subCurveBeforeDesiredSubCurveP1:Lerp(desiredSubCurveP1, TOfSubCurve0ToT1CorrespondingToT1InSelf)
	
	--print(string.format("desired sub-curve: %s", QuadraticBezier.new(subCurveBeforeDesiredSubCurveP2AndDesiredSubCurveP0, desiredSubCurveP1, subCurve0ToT1P2AndSubCurveT1To1P0):getControlPointsString()))
	return QuadraticBezier.new(subCurveBeforeDesiredSubCurveP2AndDesiredSubCurveP0, desiredSubCurveP1, subCurve0ToT1P2AndSubCurveT1To1P0)
end

function QuadraticBezier:getSubCurveFromArcLengthParamTs(arcLengthParamT0: number, arcLengthParamT1: number)
	--print(string.format("normal ts: %.2f, %.2f", self:getNormalTFromArcLengthParameterizationT(arcLengthParamT0), self:getNormalTFromArcLengthParameterizationT(arcLengthParamT1)))
	return self:getSubCurveFromNormalTs(self:getNormalTFromArcLengthParameterizationT(arcLengthParamT0), self:getNormalTFromArcLengthParameterizationT(arcLengthParamT1))
end

function QuadraticBezier:getControlPointsString()
	return string.format("p0: %s; p1: %s; p2: %s", DebugUtility.getVectorString(self.p0), DebugUtility.getVectorString(self.p1), DebugUtility.getVectorString(self.p2))
end

function QuadraticBezier:printControlPoints()
	print(self:getControlPointsString())
end

return QuadraticBezier
QuadraticBezierInterval
local QuadraticBezierInterval = {}
QuadraticBezierInterval.__index = QuadraticBezierInterval

function QuadraticBezierInterval.new(curve, t0: number, t1: number)
	local self = {}
	self.curve = curve
	self.t0 = t0
	self.t1 = t1
	self.point0 = curve:getPointNormally(t0)
	self.point1 = curve:getPointNormally(t1)
	self.pointSpeed0 = curve:getTangentVectorNormally(t0).Magnitude
	self.pointSpeed1 = curve:getTangentVectorNormally(t1).Magnitude
	self.euclideanDistance = (self.point1 - self.point0).Magnitude
	--print(t0, t1)
	--print(self.point0, self.point1)
	
	return setmetatable(self, QuadraticBezierInterval)
end

local function validatePercent(percent)
	if percent < 0 or percent > 1 then
		error("Interval percent must be between 0 and 1.")
	end
end

function QuadraticBezierInterval:getApproximateNormalT(approximateArcLengthFromIntervalPoint0: number)
	--validatePercent(percent)
	--local approximateDistFromIntervalPoint0 = percent * self.euclideanDistance
	return self.t0 + 2 * approximateArcLengthFromIntervalPoint0 / (self.pointSpeed0 + self.pointSpeed1)
end

return QuadraticBezierInterval

Ball movement code

PathBall
local bladeBallMovementModules = script.Parent
local BallMovementSettings = require(bladeBallMovementModules.BallMovementSettings)
local QuadraticBezier = require(bladeBallMovementModules.Math.QuadraticBezierMath.QuadraticBezier)
local CurveControlPointCalculation = require(bladeBallMovementModules.CurveControlPointCalculation)
local BallMovementCalculation = require(bladeBallMovementModules.BallMovementCalculation)
local BallMovementVisualization = require(bladeBallMovementModules.Visuals.BallMovementVisualization)
local DebugUtility = require(bladeBallMovementModules.Debugging.DebugUtility)
local NumberComparison = require(bladeBallMovementModules.Math.Comparison.NumberComparison)

local PathBall = {}
PathBall.__index = PathBall

function PathBall.new(startPos, startDir, initialTargetPos)
	local self = setmetatable({}, PathBall)
	local moment = os.clock()
	self.speedResetMoment = moment
	self.latestStateChangeMoment = moment
	self.latestMovementCurve = CurveControlPointCalculation.createInitialCurve(startPos, startDir, initialTargetPos)
	self.arclengthParameterizationTOnLatestMovementCurve = 0
	self.latestCalculatedPosition = self.latestMovementCurve.P0
	self.latestCalculatedSpeed = self:getSpeed()
	
	-- fields related to visualization
	self.initialVisualizationInstancesHaveBeenCreated = false
	self.visualizationInstancesFolder = nil
	self.ballInstance = nil
	self.curvesAlreadyTravelled = {} -- ordered from newest to oldest
	self.predictedCurve = self.latestMovementCurve
	self.predictedPathBeam = nil
	self.travelledPathBeams = {}
	self.lengthOfRenderedTravelledPath = 0 -- this is always approximately in the range [0, maxLengthOfTravelledPathToRender].
	
	return self
end

function PathBall:createInitialVisualizationInstances()
	-- containers
	local visualizationInstancesFolder = Instance.new("Folder")
	visualizationInstancesFolder.Name = "VisualizationInstancesFolder"
	visualizationInstancesFolder.Parent = BallMovementVisualization.visualizationInstancesFolder
	self.visualizationInstancesFolder = visualizationInstancesFolder
	
	local travelledPathBeamsFolder = Instance.new("Folder")
	travelledPathBeamsFolder.Name = "TravelledPathBeams"
	travelledPathBeamsFolder.Parent = visualizationInstancesFolder
	self.travelledPathBeamsFolder = travelledPathBeamsFolder

	local visualizationInstanceAttachmentParentPart = Instance.new("Part")
	visualizationInstanceAttachmentParentPart.Name = "AttachmentParentPart"
	visualizationInstanceAttachmentParentPart.Transparency = 1
	visualizationInstanceAttachmentParentPart.Anchored = true
	visualizationInstanceAttachmentParentPart.CanCollide = false
	visualizationInstanceAttachmentParentPart.Parent = visualizationInstancesFolder
	self.visualizationInstanceAttachmentParentPart = visualizationInstanceAttachmentParentPart
	
	-- other
	self.ballInstance = BallMovementVisualization.createBallInstance(self)
	self.predictedPathBeam = BallMovementVisualization.createPredictedPathBeam(self)
	
	self.initialVisualizationInstancesHaveBeenCreated = true
end

function PathBall:getSpeed()
	local timeSinceSpeedReset = os.clock() - self.speedResetMoment
	--print(BallMovementSettings.speedFunction(timeSinceSpeedReset))
	return BallMovementSettings.speedFunction(timeSinceSpeedReset)
end

local function resetSpeed(self, moment)
	self.speedResetMoment = moment
	self.latestStateChangeMoment = moment
	self.latestCalculatedSpeed = self:getSpeed()
end

local function setPositionAndDirection(self, moment, newPos, newDir)
	self.latestStateChangeMoment = moment
	self.newPositionSetBeforeUpdate = newPos
	self.newDirectionSetBeforeUpdate = newDir
	self.latestCalculatedSpeed = self:getSpeed()
end

function PathBall:resetSpeed()
	resetSpeed(self, os.clock())
end

function PathBall:setPositionAndDirection(newPos, newDir)
	setPositionAndDirection(self, os.clock(), newPos, newDir)
end

function PathBall:resetMovement(newPos, newDir)
	local moment = os.clock()
	setPositionAndDirection(self, moment, newPos, newDir)
	resetSpeed(self, moment)
end

local function getCurveInWhichMaxLengthExceedingPartInBeginningIsRemoved(curve)
	if NumberComparison.areApproximatelyEqual(curve.length, 0) then
		return curve
	end
	local startArclengthParameterizationT = math.max(1 - BallMovementSettings.beamInstanceSettings.maxLengthOfTravelledPathToRender / curve.length, 0)
	--local startNormalT = curve:getNormalTFromArcLengthParameterizationT(startArclengthParameterizationT)
	--print(string.format("curve.length: %s; startArclengthParameterizationT: %s", tostring(curve.length), tostring(startArclengthParameterizationT)))
	return curve:getSubCurveFromArcLengthParamTs(startArclengthParameterizationT, 1)
end

-- newMovementCurve is the curve that was created in :update() and along which :update() moved the ball
-- but it can continue longer than the amount of movement. That's why this function first cuts off the end of the curve
-- (the part in which the ball hasn't moved yet). The result of cutting the end is movementAmountCurve.

-- After that, if the length of movementAmountCurve exeeds the maximum length to render (if movement amount in update
-- is greater than the max length to render), then the exceeding part from the beginning of movementAmountCurve is removed.
-- Then, the result of this second removal is added to curvesAlreadyTravelled as the newest curve.
local function updateAlreadyTravelledPath(self, newMovementCurve, arcLengthParameterizationTOfNewPositionOnNewMovementCurve)
	--print("arcLengthParameterizationTOfNewPositionOnNewMovementCurve: " .. arcLengthParameterizationTOfNewPositionOnNewMovementCurve)
	local curvesAlreadyTravelled = self.curvesAlreadyTravelled
	--local movementEndNormalT = newMovementCurve:getNormalTFromArcLengthParameterizationT(arcLengthParameterizationTOfNewPositionOnNewMovementCurve)
	local movementAmountCurve = newMovementCurve:getSubCurveFromArcLengthParamTs(0, arcLengthParameterizationTOfNewPositionOnNewMovementCurve)
	--print(string.format("arcLengthParameterizationTOfNewPositionOnNewMovementCurve: %s", tostring(arcLengthParameterizationTOfNewPositionOnNewMovementCurve)))
	table.insert(curvesAlreadyTravelled, 1, getCurveInWhichMaxLengthExceedingPartInBeginningIsRemoved(movementAmountCurve))
	
	--print(string.format("number of travelled curves before merging: %i", #curvesAlreadyTravelled))
	local i = 1
	while i <= #curvesAlreadyTravelled do
		local curve = curvesAlreadyTravelled[i]
		--local lengthOfCurveCombination = curve.length
		local finalCurve = curve
		while finalCurve.length < BallMovementSettings.beamInstanceSettings.minLengthOfRenderedCurve and i <= #curvesAlreadyTravelled - 1 do
			
			local nextCurve = curvesAlreadyTravelled[i + 1]
			--print(string.format("nextCurve.length: %s", tostring(nextCurve.length)))
			if nextCurve.length >= BallMovementSettings.beamInstanceSettings.minLengthOfRenderedCurve then
				-- Without this, while the ball is moving slowly enough (hasn't accelerated much yet),
				-- there would only be one rendered curve and its first control point would be the control point
				-- of the movementAmountCurve on the first frame the ball moved
				-- (which means it's very close to the initial position of the ball).
				-- This means that the rendered path would be a rotating almost straigth beam between the initial position and the
				-- latest position of the ball.
				
				-- With this break, only groups of consecutive curves that are all shorter than minLengthOfRenderedCurve
				-- are merged. Thus, when there is only one such curve after a curve that is long enough, it is accepted as a curve to
				-- render despite it being shorter than minLengthOfRenderedCurve. When more short curves are added, they are merged together
				-- until they form a long enough curve after which this process repeats for the next added curves.
				break
			end
			
			table.remove(curvesAlreadyTravelled, i)
			--lengthOfCurveCombination += nextCurve.Length
			
			-- nextCurve.p1 is chosen as p1 because the earlier curve is known to be a short curve
			-- but nextCurve can be a long curve and it's logical to use the p1 of a long curve when such a curve is encountered.
			-- When both curves are very short, the choice of p1 doesn't really matter (no significant difference in end result)
			-- but when one of them is a long curve, it's better that the shape of the final curve is mainly defined by the long curve so
			-- that it better approximates the shape of the original set of curves.
			--print(i, #curvesAlreadyTravelled, curvesAlreadyTravelled[i])
			finalCurve = QuadraticBezier.new(nextCurve.p0, nextCurve.p1, curve.p2)
		end
		curvesAlreadyTravelled[i] = finalCurve
		i += 1
	end
	--print(string.format("number of travelled after merging: %i", #curvesAlreadyTravelled))
	
	local lengthSum = 0
	for i, curve in curvesAlreadyTravelled do
		lengthSum += curve.length
		if lengthSum >= BallMovementSettings.beamInstanceSettings.maxLengthOfTravelledPathToRender then
			curvesAlreadyTravelled[i] = getCurveInWhichMaxLengthExceedingPartInBeginningIsRemoved(curvesAlreadyTravelled[i])
			for iToRemove = #curvesAlreadyTravelled, i + 1, -1 do
				table.remove(curvesAlreadyTravelled, iToRemove)
			end
			break
		end
	end
	
	local newLengthOfRenderedTravelledPath = 0
	for i, curve in curvesAlreadyTravelled do
		newLengthOfRenderedTravelledPath += curve.length
	end
	self.lengthOfRenderedTravelledPath = newLengthOfRenderedTravelledPath
end

local function updatePredictedCurve(self, newMovementCurve, arcLengthParameterizationTOfNewPositionOnNewMovementCurve)	
	self.predictedCurve = newMovementCurve:getSubCurveFromArcLengthParamTs(arcLengthParameterizationTOfNewPositionOnNewMovementCurve, 1)
end

-- This should be called on the client after calling the :update() method.
function PathBall:updateVisuals()
	if not self.initialVisualizationInstancesHaveBeenCreated then
		self:createInitialVisualizationInstances()
	end
	self.ballInstance.Position = self.latestCalculatedPosition
	
	BallMovementVisualization.setBeamControlPointsAndTransparencies(self.predictedPathBeam, self.predictedCurve, 0, 0)
	
	local curvesAlreadyTravelled = self.curvesAlreadyTravelled
	local travelledPathBeams = self.travelledPathBeams
	local lengthSumSoFar = 0
	for i, travelledCurve in curvesAlreadyTravelled do
		if travelledPathBeams[i] == nil then
			travelledPathBeams[i] = BallMovementVisualization.createTravelledPathBeam(self, i)
		end
		-- the sum begins from the end of the path (the position of the ball) so endFadeAlpha
		-- is calculated with the earlier sum and startFadeAlpha with the latter sum.
		local endFadeAlpha = lengthSumSoFar / self.lengthOfRenderedTravelledPath
		lengthSumSoFar += travelledCurve.length
		local startFadeAlpha = lengthSumSoFar / self.lengthOfRenderedTravelledPath
		travelledPathBeams[i].Enabled = true
		BallMovementVisualization.setBeamControlPointsAndTransparencies(travelledPathBeams[i], travelledCurve, startFadeAlpha, endFadeAlpha)
	end
	for i = #curvesAlreadyTravelled + 1, #travelledPathBeams do
		travelledPathBeams[i].Enabled = false
	end
	--print(string.format("#curvesAlreadyTravelled: %i", #curvesAlreadyTravelled))
end

function PathBall:update(currentTargetPos)
	--print(string.format("currentTargetPos: %s", DebugUtility.getVectorString(currentTargetPos)))
	local updateMoment = os.clock()
	local timeBetweenStateChanges = updateMoment - self.latestStateChangeMoment
	local newMovementCurve
	if self.newPositionSetBeforeUpdate == nil then
		newMovementCurve = CurveControlPointCalculation.createNewCurve(self.latestMovementCurve, self.arclengthParameterizationTOnLatestMovementCurve, currentTargetPos)
	else
		print("The ball was teleported between last update call and this update call.")
		newMovementCurve = CurveControlPointCalculation.createInitialCurve(self.newPositionSetBeforeUpdate, self.newDirectionSetBeforeUpdate, currentTargetPos)
		self.newPositionSetBeforeUpdate = nil
		self.newDirectionSetBeforeUpdate = nil
	end
	--print(string.format("newMovementCurve: %s", newMovementCurve:getControlPointsString()))
	
	local newSpeed = self:getSpeed()
	local newPosition, newArcLengthParamT = BallMovementCalculation.getNewPositionAndCorrespondingArcLengthParamaterizationT(self.latestCalculatedSpeed, newSpeed, newMovementCurve, timeBetweenStateChanges)
	
	updateAlreadyTravelledPath(self, newMovementCurve, newArcLengthParamT)
	updatePredictedCurve(self, newMovementCurve, newArcLengthParamT)
	
	self.latestStateChangeMoment = updateMoment
	self.latestMovementCurve = newMovementCurve
	self.arclengthParameterizationTOnLatestMovementCurve = newArcLengthParamT
	self.latestCalculatedPosition = newPosition
	self.latestCalculatedSpeed = newSpeed
end

function PathBall:destroy()
	if self.initialVisualizationInstancesHaveBeenCreated then
		self.visualizationInstancesFolder:Destroy()
	end
end

return PathBall
BallMovementSettings
local BallMovementSettings = {}

-- path and speed
local initialSpeed = 2
local acceleration = 2

BallMovementSettings.initialRatioOfP0P1DistAndP1P2Dist = 1/3
BallMovementSettings.targetPointChangeSignificanceMeasurementEasingFunction = function(rawValue: number)
	return rawValue ^ 30
end

BallMovementSettings.speedFunction = function(timeSinceVelocityReset: number)
	return initialSpeed + acceleration * timeSinceVelocityReset
end

-- visuals
BallMovementSettings.ballInstanceSettings = {
	diameter = 5,
	color = Color3.new(1, 0, 0),
	material = Enum.Material.SmoothPlastic
}

BallMovementSettings.beamInstanceSettings = {
	averageStudsPerSegment = 1,
	width = 2,
	travelledPathColor = Color3.new(1, 1, 1),
	predictedPathColor = Color3.new(0, .5, 1),
	maxLengthOfTravelledPathToRender = 100,
	minLengthOfRenderedCurve = .1
}

return BallMovementSettings
CurveControlPointCalculation
local bladeBallMovementModules = script.Parent
local BallMovementSettings = require(bladeBallMovementModules.BallMovementSettings)
local QuadraticBezier = require(bladeBallMovementModules.Math.QuadraticBezierMath.QuadraticBezier)
local DebugUtility = require(bladeBallMovementModules.Debugging.DebugUtility)
local NumberComparison = require(bladeBallMovementModules.Math.Comparison.NumberComparison)
local VectorComparison = require(bladeBallMovementModules.Math.Comparison.VectorComparison)

local CurveControlPointCalculation = {}

local function createCurveThatSatisfiesRatioByMovingFromP0InP0TangentDirection(p0, p0TangentDirection, p2, ratio)
	--local callingModuleName = DebugUtility.getNameFromFullName(debug.info(2, "s"))
	--print(string.format("p0: %s; p0TangentDirection: %s; p2: %s; ratio: %.2f; calling function: %s", DebugUtility.getVectorString(p0), DebugUtility.getVectorString(p0TangentDirection), DebugUtility.getVectorString(p2), ratio, debug.info(2, "n")))
	if VectorComparison.areApproximatelyEqual(p0TangentDirection, Vector3.zero) then
		return QuadraticBezier.new(p0, (p0 + p2) / 2, p2)
	end
	if NumberComparison.areApproximatelyEqual(ratio, 1) then
		--print("ratio is approximately 1.")
		-- second degree equation formula won't work in this case because a (ratio^2 - 1) will be 0.
		local p0ToP2 = p2 - p0
		local p0TangentDirectionLengthInP0ToP2Direction = p0ToP2.Unit:Dot(p0TangentDirection)
		local controlPoint = p0TangentDirection * p0ToP2.Magnitude * .5 / p0TangentDirectionLengthInP0ToP2Direction
		return QuadraticBezier.new(p0, controlPoint, p2)
	end
	
	-- This needs to be a unit vector because when writing the equation, I assumed the line parameter that is being calculated
	-- (t in N-Spire file, chosenT in this code) to be the length of the vector between p0 and the control point that
	-- is being calculated.
	p0TangentDirection = p0TangentDirection.Unit
	
	-- second degree equation formula
	local a = ratio^2 - 1
	local b = 2 * ratio^2 * (p0:Dot(p0TangentDirection) - p2:Dot(p0TangentDirection))
	local c = ratio^2 * (p0 - p2):Dot(p0 - p2)

	local sqRoot = math.sqrt(b^2 - 4 * a * c)
	--print(string.format("sqRoot: %.2f; rootable: %.2f; a: %.2f; b: %.2f; c: %.2f", sqRoot, b^2 - 4 * a * c, a, b, c))
	local tOption0, tOption1 = (-b + sqRoot) / (2 * a), (-b - sqRoot) / (2 * a)

	local chosenT
	if tOption0 < 0 then
		if tOption1 < 0 then
			error("Something is wrong with the calculations (both options are negative).")
		end
		chosenT = tOption1
		--print(string.format("tOption1 chosen; chosenT: %.2f", chosenT))
	else
		if tOption1 >= 0 then
			local controlPointOption0 = p0 + p0TangentDirection * tOption0
			local controlPointOption1 = p0 + p0TangentDirection * tOption1
			--local resultRatioWithOption0 = (controlPointOption0 - p0).Magnitude / (p2 - controlPointOption0).Magnitude
			--local resultRatioWithOption1 = (controlPointOption1 - p0).Magnitude / (p2 - controlPointOption1).Magnitude
			--error(string.format("Something is wrong with the calculations (both options are positive); tOption0: %s; tOption1: %s; result ratio with option0: %.4f; resultRation with option1: %.4f; desired ratio: %.3f", tostring(tOption0), tostring(tOption1), resultRatioWithOption0, resultRatioWithOption1, ratio))
			
			-- Actually, at least for ratios greater than 1, there can be more than 1 valid option so error is not the right approach.
			-- Instead, I choose the smaller t (which means I get the smaller one of the two valid curves).
			chosenT = math.min(tOption0, tOption1)
			--print(string.format("two valid ts, tOption%i chosen because it is smaller (tOption0: %.4f, tOption1: %.4f).", if tOption0 <= tOption1 then 0 else 1, tOption0, tOption1))
		end
		chosenT = tOption0
		--print(string.format("tOption0 chosen; chosenT: %.2f", chosenT))
	end

	local controlPoint = p0 + p0TangentDirection * chosenT
	--print((controlPoint - p0).Magnitude / (p2 - controlPoint).Magnitude)
	return QuadraticBezier.new(p0, controlPoint, p2)
end

function CurveControlPointCalculation.createInitialCurve(startPos: Vector3, startDir: Vector3, initialTargetPos: Vector3)
	startDir = startDir.Unit
	return createCurveThatSatisfiesRatioByMovingFromP0InP0TangentDirection(startPos, startDir, initialTargetPos, BallMovementSettings.initialRatioOfP0P1DistAndP1P2Dist)
end

local function createSubCurveOfLastFrameFinalCurve(lastFrameFinalCurve, ballPosArcLengthParamTOnLastFrameFinalCurve)
	--print(string.format("ballPosArcLengthParamTOnLastFrameFinalCurve: %.2f", ballPosArcLengthParamTOnLastFrameFinalCurve))
	return lastFrameFinalCurve:getSubCurveFromArcLengthParamTs(ballPosArcLengthParamTOnLastFrameFinalCurve, 1)
end

local function interpolateNumbers(a: number, b: number, alpha: number)
	return a + (b - a) * alpha
end

function CurveControlPointCalculation.createNewCurve(lastFrameFinalCurve, ballPosArcLengthParamTOnLastFrameFinalCurve: number, newTargetPos: Vector3)
	if NumberComparison.areApproximatelyEqual(lastFrameFinalCurve.length, 0) and not VectorComparison.areApproximatelyEqual(lastFrameFinalCurve.p0, newTargetPos) then
		return CurveControlPointCalculation.createInitialCurve(lastFrameFinalCurve.p0, (newTargetPos - lastFrameFinalCurve.p0).Unit, newTargetPos)
	end
	
	local lastFrameCurveSubCurve = createSubCurveOfLastFrameFinalCurve(lastFrameFinalCurve, ballPosArcLengthParamTOnLastFrameFinalCurve)
	local subCurveP0: Vector3, subCurveP1: Vector3, subCurveP2: Vector3 = lastFrameCurveSubCurve.p0, lastFrameCurveSubCurve.p1, lastFrameCurveSubCurve.p2
	--print(string.format("lastFrameCurveSubCurve: %s", lastFrameCurveSubCurve:getControlPointsString()))
	
	--[
	local l0 = (subCurveP1 - subCurveP0).Magnitude
	local l1SubCurve = (subCurveP2 - subCurveP1).Magnitude
	local l1NewTarget = (newTargetPos - subCurveP1).Magnitude
	
	--[[
	local totalSub, totalNewTarget = l0 + l1SubCurve, l0 + l1NewTarget
	
	local targetPointChangeSignificanceMeasurement = math.min(totalSub, totalNewTarget) / math.max(totalSub, totalNewTarget)
	--]]
	
	if NumberComparison.areApproximatelyEqual(math.max(l1SubCurve, l1NewTarget), 0) then
		return lastFrameCurveSubCurve
	end
	-- a number between 0 and 1; the closer this is to 0, the more significant the target point change is considered
	-- and the more the control point is moved.
	-- Calculating the significance and ratio this way is meant to avoid curves that have a sharp turn and a long almost straigth part
	-- and to keep the path almost same as earlier path when the target position hasn't changed much
	-- (and same when it hasn't changed at all).
	-- My code currently doesn't satisfy the aforementioned goal of avoiding
	-- sharp turns and long almost straight paths very well, though.
	local targetPointChangeSignificanceRawMeasurement = math.min(l1SubCurve, l1NewTarget) / math.max(l1SubCurve, l1NewTarget)
	local targetPointChangeSignificanceMeasurement = BallMovementSettings.targetPointChangeSignificanceMeasurementEasingFunction(targetPointChangeSignificanceRawMeasurement)
	--print(string.format("targetPointChangeSignificanceMeasurement: %.2f; l1SubCurve: %.2f; l1NewTarget: %.2f", targetPointChangeSignificanceMeasurement, l1SubCurve, l1NewTarget))
	
	local newCurveRatio = interpolateNumbers(BallMovementSettings.initialRatioOfP0P1DistAndP1P2Dist, l0 / l1NewTarget, targetPointChangeSignificanceMeasurement)
	
	local subCurveP0TangentDirection = if not VectorComparison.areApproximatelyEqual(subCurveP0, subCurveP1) then (subCurveP1 - subCurveP0).Unit else (if not VectorComparison.areApproximatelyEqual(subCurveP1, subCurveP2) then (subCurveP2 - subCurveP0).Unit else Vector3.zero)
	--print(VectorComparison.areApproximatelyEqual(subCurveP0, subCurveP1), VectorComparison.areApproximatelyEqual(subCurveP0, subCurveP2))
	return createCurveThatSatisfiesRatioByMovingFromP0InP0TangentDirection(subCurveP0, subCurveP0TangentDirection, newTargetPos, newCurveRatio)
end

return CurveControlPointCalculation
BallMovementCalculation
local bladeBallMovementModulesFolder = script.Parent
local NumberComparison = require(bladeBallMovementModulesFolder.Math.Comparison.NumberComparison)

local BallMovementCalculation = {}

local function getAmountOfStudsToMoveAlongCurve(lastUpdateSpeed, currentSpeed, timeBetweenUpdates)
	-- = v0 * t + 0.5 * (v - v0) / t * t^2
	-- accurate if scalar acceleration is constant
	--print(lastUpdateSpeed, currentSpeed)
	return 0.5 * (lastUpdateSpeed + currentSpeed) * timeBetweenUpdates
end

function BallMovementCalculation.getNewPositionAndCorrespondingArcLengthParamaterizationT(lastUpdateSpeed, currentSpeed, newCurve, timeBetweenUpdates: number): (Vector3, number)
	local amountToMove = getAmountOfStudsToMoveAlongCurve(lastUpdateSpeed, currentSpeed, timeBetweenUpdates)
	local curveArclengthParamaterizationT = amountToMove / newCurve.length
	--print(string.format("newCurve.length: %.2f", newCurve.length))
	if NumberComparison.areApproximatelyEqual(newCurve.length, 0) then
		curveArclengthParamaterizationT = 0
	end
	if curveArclengthParamaterizationT >= 1 then
		curveArclengthParamaterizationT = 1
		--return newCurve.p2, (newCurve.p2 - newCurve.p1).Unit
	end
	local point, _ = newCurve:getPointAndTangentVectorWithArcLengthParameterization(curveArclengthParamaterizationT)
	return point, curveArclengthParamaterizationT
end

return BallMovementCalculation
BallMovementVisualization
local ballMovementModulesContainer = script.Parent.Parent
local BallMovementSettings = require(ballMovementModulesContainer.BallMovementSettings)
--local BallMovementQuadraticBezier = require(ballMovementModulesContainer.BallMovementQuadraticBezier)
local DebugUtility = require(ballMovementModulesContainer.Debugging.DebugUtility)

local BallMovementVisualization = {}

local visualizationInstancesFolder = Instance.new("Folder")
visualizationInstancesFolder.Name = "BallMovementVisualization"
visualizationInstancesFolder.Parent = workspace
BallMovementVisualization.visualizationInstancesFolder = visualizationInstancesFolder

--local attachmentParent = workspace.Terrain

local function getCubicBezierControlPointsFromQuadraticBezierControllPoints(p0: Vector3, p1: Vector3, p2: Vector3): (Vector3, Vector3, Vector3, Vector3)
	-- https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Degree_elevation
	return p0, 1/3 * (p0 + 2 * p1), 1/3 * (2 * p1 + p2), p2
end

function BallMovementVisualization.createBallInstance(pathBall): Part
	local ballInstanceSettings = BallMovementSettings.ballInstanceSettings
	local ball = Instance.new("Part")
	
	ball.Color = ballInstanceSettings.color
	ball.Material = ballInstanceSettings.material
	ball.Size = ballInstanceSettings.diameter * Vector3.new(1, 1, 1)
	
	ball.Name = "Ball"
	ball.Shape = Enum.PartType.Ball
	ball.CanCollide = false
	ball.Anchored = true
	ball.Parent = pathBall.visualizationInstancesFolder
	
	return ball
end

local function createBeam(pathBall, color: Color3, name: string, parent: Instance)
	local beamInstanceSettings = BallMovementSettings.beamInstanceSettings
	
	local attachment0, attachment1 = Instance.new("Attachment"), Instance.new("Attachment")
	attachment0.Name = name .. "_Attachment0"
	attachment1.Name = name .. "_Attachment1"
	
	local beam = Instance.new("Beam")
	beam.Name = name
	--beam.Width0, beam.Width1 = beamInstanceSettings.width, beamInstanceSettings.width
	beam.Color = ColorSequence.new({ColorSequenceKeypoint.new(0, color), ColorSequenceKeypoint.new(1, color)})
	beam.Attachment0, beam.Attachment1 = attachment0, attachment1
	
	attachment0.Parent, attachment1.Parent = pathBall.visualizationInstanceAttachmentParentPart, pathBall.visualizationInstanceAttachmentParentPart
	beam.Parent = parent
	
	return beam
end

function BallMovementVisualization.createPredictedPathBeam(pathBall)
	return createBeam(pathBall, BallMovementSettings.beamInstanceSettings.predictedPathColor, "PredictedPathBeam", pathBall.visualizationInstancesFolder)
end

function BallMovementVisualization.createTravelledPathBeam(pathBall, index: number)
	local orderingString = DebugUtility.convertIntegerToStringWithCorrectAlphabeticalOrder(index, 10)
	return createBeam(pathBall, BallMovementSettings.beamInstanceSettings.travelledPathColor, "TravelledPathBeam" .. orderingString .. "_" .. index, pathBall.travelledPathBeamsFolder)
end

function BallMovementVisualization.setBeamControlPointsAndTransparencies(beam: Beam, quadraticBezier, startFadeAlpha: number, endFadeAlpha: number)
	local qP0: Vector3, qP1: Vector3, qP2: Vector3 = quadraticBezier.p0, quadraticBezier.p1, quadraticBezier.p2
	local cP0, cP1, cP2, cP3 = getCubicBezierControlPointsFromQuadraticBezierControllPoints(qP0, qP1, qP2)
	beam.CurveSize0, beam.CurveSize1 = (cP1 - cP0).Magnitude, (cP3 - cP2).Magnitude
	local attachment0RightVector, attachment1RightVector = (cP1 - cP0).Unit, (cP3 - cP2).Unit
	beam.Attachment0.CFrame = CFrame.lookAt(cP0, cP0 + attachment0RightVector) * CFrame.Angles(0, math.pi / 2, 0)
	beam.Attachment1.CFrame = CFrame.lookAt(cP3, cP3 + attachment1RightVector) * CFrame.Angles(0, math.pi / 2, 0)
	
	local beamInstanceSettings = BallMovementSettings.beamInstanceSettings
	beam.Segments = quadraticBezier.length / beamInstanceSettings.averageStudsPerSegment
	
	beam.Transparency = NumberSequence.new(startFadeAlpha, endFadeAlpha)
	beam.Width0, beam.Width1 = (1 - startFadeAlpha) * beamInstanceSettings.width, (1 - endFadeAlpha) * beamInstanceSettings.width
end

return BallMovementVisualization
a LocalScript in StarterPlayerScripts using PathBall
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Players = game:GetService("Players")

local bladeBallMovementModulesFolder = ReplicatedStorage.Modules.BladeBallMovement
local PathBall = require(bladeBallMovementModulesFolder.PathBall)

local initialPos = Vector3.new(100, 5, 0)
local initialDirection = Vector3.new(0, 0, 1)

local walkSpeed = 50
local jumpHeight = 100

local ball = nil


local function getBallTargetPos()
	return Players.LocalPlayer.Character.HumanoidRootPart.Position
end

local function onRunServiveEvent()
	local char = Players.LocalPlayer.Character
	if char == nil or char:FindFirstChild("HumanoidRootPart") == nil then
		return
	end
	
	ball:update(getBallTargetPos())
	ball:updateVisuals()
end

local function createBall()
	ball = PathBall.new(initialPos, initialDirection, getBallTargetPos())
end

local function removeBall()
	ball:destroy()
	ball = nil
end

Players.LocalPlayer.CharacterAdded:Connect(function(character)
	local hrp = character:WaitForChild("HumanoidRootPart")
	local humanoid: Humanoid = character:WaitForChild("Humanoid")
	humanoid.WalkSpeed = walkSpeed
	humanoid.UseJumpPower = false
	humanoid.JumpHeight = jumpHeight
end)

local firstChar = Players.LocalPlayer.CharacterAdded:Wait()
firstChar:WaitForChild("HumanoidRootPart")
createBall()

while true do
	--[[
	if shouldBallBeUpdated then
		onRunServiveEvent()
	end
	--]]
	onRunServiveEvent()
	RunService.Heartbeat:Wait()
end
20 Likes

Code for drawing bezier curves and points in two dimensions.

BezierPoints2DCanvas
local Players = game:GetService("Players")

local BezierPoints2DCanvas = {}
BezierPoints2DCanvas.__index = BezierPoints2DCanvas

local canvasFrameSize = 250

local pointFrameSize = 3
local pointDefaultColor = Color3.new(0, 0, 0)

local curveThickness = 1
local numberOfCurvePointsToCalculateForDrawingCurve = 200
local curveDefaultColor = Color3.new(0, 1, 0)

local tangentThickness = 1
local drawnTangentLengthMultiplier = 0.2
local tangentDefaultColor = Color3.new(0, 0, 0)

local spacing = 50
local nextXPosition = spacing
local howManyCreated = 0

--local canvasMinCornerCoords: Vector2, canvasMaxCornerCoords: Vector2

--local canvasScreenGui: ScreenGui
--local canvasFrame: Frame

function BezierPoints2DCanvas.new(minCornerCoords: Vector2, maxCornerCoords: Vector2)
	local self = setmetatable({}, BezierPoints2DCanvas)
	
	howManyCreated += 1
	
	self.canvasMinCornerCoords = minCornerCoords
	self.canvasMaxCornerCoords = maxCornerCoords
	
	self.frameArraysForCurves = {}
	
	local canvasScreenGui = Instance.new("ScreenGui")
	canvasScreenGui.Name = "BezierPointVisualizationScreenGui" .. howManyCreated
	canvasScreenGui.ResetOnSpawn = false
	
	local canvasFrame = Instance.new("Frame")
	canvasFrame.Name = "CanvasFrame"
	canvasFrame.BackgroundColor3 = Color3.new(1, 1, 1)
	canvasFrame.BorderSizePixel = 0
	canvasFrame.ClipsDescendants = true
	canvasFrame.Size = UDim2.fromOffset(canvasFrameSize, canvasFrameSize)
	canvasFrame.Position = UDim2.fromOffset(nextXPosition, spacing)
	nextXPosition += canvasFrameSize + spacing
	canvasFrame.Parent = canvasScreenGui
	
	canvasScreenGui.Parent = Players.LocalPlayer.PlayerGui
	
	self.canvasScreenGui = canvasScreenGui
	self.canvasFrame = canvasFrame
	
	return self
end

local function converToCanvasScaleCoords(self, coords: Vector2)
	return Vector2.new(
		(coords.X - self.canvasMinCornerCoords.X) / (self.canvasMaxCornerCoords.X - self.canvasMinCornerCoords.X),
		1 - (coords.Y - self.canvasMinCornerCoords.Y) / (self.canvasMaxCornerCoords.Y - self.canvasMinCornerCoords.Y)
	)
end

local function convertToPixelCoords(self, coords: Vector2)
	local scaleCoords = converToCanvasScaleCoords(self, coords)
	return Vector2.new(scaleCoords.X * self.canvasFrame.AbsoluteSize.X, scaleCoords.Y * self.canvasFrame.AbsoluteSize.Y)
end

local function createSegmentFrame(self, coords0: Vector2, coords1: Vector2, color: Color3, thickness: number, zIndex: number, name: string)
	local scaleCoords0, scaleCoords1 = converToCanvasScaleCoords(self, coords0), converToCanvasScaleCoords(self, coords1)
	local averageScaleCoords = (scaleCoords0 + scaleCoords1) / 2
	
	local pixelCoords0, pixelCoords1 = convertToPixelCoords(self, coords0), convertToPixelCoords(self, coords1)
	local pixelVectorFromCoords0ToCoords1 = pixelCoords1 - pixelCoords0
	local pixelDistance = pixelVectorFromCoords0ToCoords1.Magnitude
	
	local segmentFrame = Instance.new("Frame")
	segmentFrame.Name = name
	segmentFrame.ZIndex = zIndex
	segmentFrame.BorderSizePixel = 0
	segmentFrame.BackgroundColor3 = if color ~= nil then color else curveDefaultColor
	--segmentFrame.Size = UDim2.fromOffset(math.ceil(pixelDistance), curveThickness)
	segmentFrame.Size = UDim2.new((scaleCoords1 - scaleCoords0).Magnitude, 0, 0, curveThickness)
	segmentFrame.AnchorPoint = Vector2.new(.5, .5)
	segmentFrame.Position = UDim2.fromScale(averageScaleCoords.X, averageScaleCoords.Y)
	segmentFrame.Rotation = math.deg(math.atan2(pixelVectorFromCoords0ToCoords1.Y, pixelVectorFromCoords0ToCoords1.X))
	segmentFrame.Parent = self.canvasFrame
	return segmentFrame
end

local function createPointFrame(self, coords: Vector2, color: Color3, diameter: number, zIndex: number, name: string)
	local canvasMinCornerCoords, canvasMaxCornerCoords = self.canvasMinCornerCoords, self.canvasMaxCornerCoords
	local scaleCoords = converToCanvasScaleCoords(self, coords)

	local pointFrame = Instance.new("Frame")
	pointFrame.Name = name
	pointFrame.ZIndex = zIndex
	pointFrame.BorderSizePixel = 0
	pointFrame.BackgroundColor3 = color
	--pointFrame.BackgroundColor3 = Color3.new(1, 0, 0)
	pointFrame.Size = UDim2.fromOffset(diameter, diameter)
	pointFrame.AnchorPoint = Vector2.new(.5, .5)
	pointFrame.Position = UDim2.fromScale(scaleCoords.X, scaleCoords.Y)

	local uiCorner = Instance.new("UICorner")
	uiCorner.CornerRadius = UDim.new(.5, 0)
	uiCorner.Parent = pointFrame

	pointFrame.Parent = self.canvasFrame
	return pointFrame
end

function BezierPoints2DCanvas:drawPoint(coords: Vector2, color: Color3)
	return createPointFrame(self, coords, if color then color else pointDefaultColor, pointFrameSize, 3, "Point")
end

function BezierPoints2DCanvas:drawCurve(curve, color: Color3)
	local pointFramesAndSegmentFrames = {}
	self.frameArraysForCurves[curve] = pointFramesAndSegmentFrames
	for i = 1, numberOfCurvePointsToCalculateForDrawingCurve do
		--wait(.25)
		local t = (i - 1) / (numberOfCurvePointsToCalculateForDrawingCurve - 1)
		local vector3Point = curve:getPointNormally(t)
		local vector2Point = Vector2.new(vector3Point.X, vector3Point.Y)
		--print(vector2Point)
		table.insert(pointFramesAndSegmentFrames, createPointFrame(self, vector2Point, if color then color else curveDefaultColor, curveThickness, 1, "CurvePoint" .. i))
		if i < numberOfCurvePointsToCalculateForDrawingCurve then
			local nextT = i / (numberOfCurvePointsToCalculateForDrawingCurve - 1)
			local nextVector3Point = curve:getPointNormally(nextT)
			local nextVector2Point = Vector2.new(nextVector3Point.X, nextVector3Point.Y)
			table.insert(pointFramesAndSegmentFrames, createSegmentFrame(self, vector2Point, nextVector2Point, if color then color else curveDefaultColor, curveThickness, 2, "Segment" .. i))
			--print(string.format("t: %.2f; nextT: %.2f", t, nextT))
		end
	end
	return pointFramesAndSegmentFrames
end

function BezierPoints2DCanvas:eraseDrawnCurve(curve)
	if self.frameArraysForCurves[curve] == nil then
		warn("This curve has not been drawn.")
	end
	for _, pointFrame in self.frameArraysForCurves[curve] do
		pointFrame:Destroy()
	end
end

-- normal means not arc length parameterization
function BezierPoints2DCanvas:drawTangentAtNormalT(curve, normalT: number, color: Color3)
	local point = curve:getPointNormally(normalT)
	local tangentVector = curve:getTangentVectorNormally(normalT)
	local vector2Point = Vector2.new(point.X, point.Y)
	local vector2TangentVector = Vector2.new(tangentVector.X, tangentVector.Y)
	--print("tangent length: " .. vector2TangentVector.Magnitude)
	--print(string.format("vector2Point: (%.2f; %.2f)", vector2Point.X, vector2Point.Y))
	
	return createSegmentFrame(self, vector2Point, vector2Point + vector2TangentVector * drawnTangentLengthMultiplier, if color then color else tangentDefaultColor, tangentThickness, 2, "Tangent")
end

return BezierPoints2DCanvas
a LocalScript in StarterPlayerScripts using BezierPoints2DCanvas
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local bladeBallMovementModules = ReplicatedStorage.Modules.BladeBallMovement
local QuadraticBezier = require(bladeBallMovementModules.Math.QuadraticBezierMath.QuadraticBezier)
local BezierPoints2DCanvas = require(bladeBallMovementModules.Visuals.BezierPoints2DCanvas)

-- arbitrary values
local numberOfPointsToDraw = 11
local timeOfDrawingAllPoints = 2

local minCornerCoords = Vector2.new(0, 0)
local maxCornerCoords = Vector2.new(10, 10)

local p0 = Vector2.new(1, 2)
local p1 = Vector2.new(4, 15)
local p2 = Vector2.new(9, 4)

-- other code
local p0V3 = Vector3.new(p0.X, p0.Y, 0)
local p1V3 = Vector3.new(p1.X, p1.Y, 0)
local p2V3 = Vector3.new(p2.X, p2.Y, 0)

local curve = QuadraticBezier.new(p0V3, p1V3, p2V3)

local function converToVector2(vector3Point)
	return Vector2.new(vector3Point.X, vector3Point.Y)
end


local firstCanvas = BezierPoints2DCanvas.new(minCornerCoords, maxCornerCoords)
--firstCanvas:drawCurve(curve)

for i = 0, numberOfPointsToDraw - 1 do
	local t = i/(numberOfPointsToDraw - 1)
	local pointOnCurve = converToVector2(curve:getPointNormally(t))
	local visualPoint = firstCanvas:drawPoint(pointOnCurve, Color3.new(1, 0, 0))
end

task.wait(2)
local secondCanvas = BezierPoints2DCanvas.new(minCornerCoords, maxCornerCoords)
--secondCanvas:drawCurve(curve)
secondCanvas:drawCurve(curve:getSubCurveFromArcLengthParamTs(.2, .5), Color3.new(1, .5, 0))

--[
for i = 0, numberOfPointsToDraw - 1 do
	task.wait(timeOfDrawingAllPoints / numberOfPointsToDraw)
	local t = i/(numberOfPointsToDraw - 1)
	local pointOnCurve = converToVector2(curve:getPointAndTangentVectorWithArcLengthParameterization(t))
	local visualPoint = secondCanvas:drawPoint(pointOnCurve, Color3.new(0, .5, 1))
	--local visualTangent = secondCanvas:drawTangentAtNormalT(curve, curve:getNormalTFromArcLengthParameterizationT(t), nil)
end
--]]
9 Likes
  1. Ur code is so long, can u tell me what u used to move the object
  2. Can u send a clip or something of it working
  3. How long have u been coding for cuase this is rlly complex imo

Thanks a lot for this resource!

Should the ball run on the server to sync it with all players?

If you mean how I move the ball Part, I just set its position every frame. This line of code is in PathBall:updateVisuals().

self.ballInstance.Position = self.latestCalculatedPosition

self.latestCalculatedPosition is set in PathBall:update(). The speed increases linearly over time, as you can see by looking at BallMovementSettings.speedFunction. If you want the speed to be constant or change non-linearly, just change the code in this function.

I think I started coding a little more than four years ago (in the summer or autumn of 2019).

Reply to @Electrizing
The visual ball is supposed to be created on the clients. However, the server should also calculate the positions for validating the blocking and syncing the arc length between the ball and the target on different clients. However, I’m not sure if it’s good idea to sync the target client using the server distance. Perhaps they should just do the movement without caring about the server movement and the server should then just validate that the block time is close enough to the time that the server considers correct time for blocking.

I haven’t implemented blocking, target choosing or syncing in this. My system only makes the ball move towards a given target position and I don’t know how exactly the syncing could be done, but in my earlier post above I wrote a vague overview of how it could possibly be done.

5 Likes

No. Tweens wouldn’t be/shouldn’t be used in anything that’s not Ui or an obby, it’s probably a form of lerping, or the easier method of just using a force in the server and let roblox do all the replication heavy lifting

Thanks for the help, I’ve managed to replicate the ball to all clients and a kind of functional target system.
The only problem I have is actually knowing where the ball should be located on the server
image

I’m not sure if I understand your problem. When you have a reference to the PathBall object created by PathBall.new(), you can get its position by writing pathBallObject.latestCalculatedPosition. Every time you call the :update() method of the object, latestCalculatedPosition is updated.

Just in case it was unclear, a PathBall object should be created on both clients and server. Also, :update() should be called every frame on both clients and server. The difference between clients and server is that server is not meant to call :updateVisuals(). :updateVisuals() is the function that, in addition to updating the visual instances, creates them if they don’t exist yet. So by not calling this on the server, you get position updates without the undesired instances.

Also, it looks like there’s a predicted path beam (blue beam) that ends at nothing in your picture. Did the code break or is the target position in the middle of the world for some reason?

And that’s a client screenshot, right? Because there’s a ball instance and beam instances.

I’m personnaly doing it using a mix of tweens, bezier curves and waypoints in client side synchronized with the server to be able to create different shots effects.


3 Likes

the co-owner and dev of blade ball said it was not lerp or physics constraints

LMAO You made AI for this??? how…?

No, i made the system by myself lol
Edit: Oh yeah, if you’re talking about the NPC then yes, i made a “Fake AI” to test the system ^^

u must be pretty damn experienced

yes its on the client
and i forgot why that blue line was there
1 problem i had was that sometime a script exhaustion thingy would happen (the quadratic module would cause a crash)

and also should hit detection be entirely client sided? (if the ball is targetting the localplayer and it reaches my local character we send a remote to server to kill us)

Script timeout
There’s probably some kind of a special case causing the function createCurveThatSatisfiesRatioByMovingFromP0InP0TangentDirection in CurveControlPointCalculation to calculate such a control point that the resulting curve is very long. Because number of points calculated and interval objects created for the arc length parameterization is proportional to curve length, this can cause a script timeout. Perhaps increasing the allowed difference in NumberComparison.areApproximatelyEqual() could help.

Hit detection
I would recommend having the server and the target client both do hit detection. When the target player detects a hit (by detecting the ball being close enough), the target informs the server and the server kills them. Having hit detection primarily on the client gives mercy to players with bad connection.

However, not too much mercy should be given. The server should also do hit detection. After the server detects a hit, it waits a little. If the target didn’t inform about blocking the ball during that time, the server kills the target even if the target didn’t inform about a hit. So basically, the target would be given a little extra time for blocking to account for desync between target and server.

I’m not experienced in hit detection and server validation, though, so my advice is not necessarily good.

Then either there doing it wrong, or the co-owner doesn’t know what he’s talking about

I’m the co-owner. I wrote the ball code. The statement is correct.

You’re able to use the delta time symbol in your code? That’s interesting.

Also nice explanation (nice is an understatement tbh).

I’m hoping you mean tweening under the guidelines of lerping, and not using TweenService, since tweenservice has performance issues when used too frequently (eg if you are tweening a ball thats target is constantly moving) If I where you I would avoid utilising.

They probably use CustomPhysicalProperties to make the ball lighter, or make it completely defy gravity by enabling the Massless property.

Then, I think they just use the AssemblyLinearVelocity property to move the part, so this is all physics-based.

If it’s just teleportation, they definitely used random positioning and then tweening to that position. Who knows?