Blade Ball Ball Physics

Wouldn’t changing the network ownership make the ball appear laggy / desynced across clients?

2 Likes

Hence why you would want to layer additional effects, like the one I mentioned (bezier curve), on every client.

The network ownership would be purely for the person the ball is going towards, which is the most important for timings, etc…

2 Likes

uhhhhhhhhhhhhhhhhhhhhhhhhhhhhh ball

2 Likes

I think the devs might be using float curves. This is just a theory and im not pretty sure bout it.

this is a video that i saw, maybe the devs like make 3 points, the 1st point infront of the player who deflected the ball, 2nd point in middle of the deflected player and the target and the 3rd point infront of the targeted player. JUST A THEORY I MIGHT BE WRONG SO DONT JUDGE ME (didnt watch the video completly btw its too complex for my monkey brain)

3 Likes

The devs stated they dont use tweens, and these use tweens

1 Like

The dev probably meant that they don’t use TweenService, not that they don’t use interpolation at all. They could still calculate a point on a curve every frame and set the CFrame of the ball based on that. In the video posted by @fasmandom , TweenService:GetValue() was used for calculating a time number for calculating the point on the curve. TweenService is not needed for that, and it’s probably useless if the developers want to have precise control of the speed of the ball over time.

I don’t know whether the developers of Blade Ball calculate points on a parametric curve, and if they do, what kind of curve it is. However, after seeing this post I thought it’d be interesting to try making something similar (a ball following a character) using quadratic bezier curves (which are parametric curves).

Calculating curve control points
The function that creates the ball object (which is not an instance but can contain references to instances used for visualization) has parameters for the initial position and movement direction of the ball, and for the initial position of the target. It calculates an initial curve using these.

When using parametric curves for following a moving target, it is necessary to update the curve when the target moves. Thus, every frame, my code computes a new curve and then moves the ball along the new curve. P0 (start point) of the new curve is the position calculated for the ball on last frame. It’s tangent (derivative) direction at P0 is the same as the tangent direction of the last frame’s curve at the t value (curve parameter value between 0 and 1) of the ball position calculated last frame. P2 of the new curve is the new target position. P1 of the new curve is calculated by picking a point on the tangent line of P0 such that the point is in the derivative direction from P0 (this ensures G1 continuity i.e. that there is no sudden change of tangent direction at the point where two consecutive curves meet) and the lengths of P1 - P0 and P2 - P1 have a spesific ratio calculated in an arbitrary way that I thought would give good results but wasn’t actually as good as I thought. Anyways, the way I calculate P1 ensures that the curve will stay the same until the target moves (without explicitly checking whether it moves).

I’m not entirely happy with the results I get with the way I calculate the control point P1 of the curve. The direction changes sometimes feel too fast and it’s also possible for the path to go inside the ground. P1 could be calculated in many different ways as long as it is in the correct direction from P0. Also, calculating just a single quadratic bezier curve is limiting. Forming the curve calculated on a spesific frame from two quadratic curves or using a cubic curve would give more freedom for improving the curve shape but it can be difficult to decide a good way to use this freedom (how should the distances and directions between control points be calculated since there are so many possible ways?).

Calculating the t value for the new position
Every frame, a new ball position is calculated. For this, it is necessary to find a t value that corresponds to a sensible point on the curve (a sensible arc length from the last position of the ball). The arc length that the part should move along the curve should depend on its desired velocity and the time between frames. So, after calculating how much the part should move (a scalar), we need to find the t value that corresponds to this amount of movement (arc length between t = 0 and this t value is the amount of movement).

It might feel logical to just calculate the t value as t = (amount of movement) / (curve length) and plug this into the bezier curve formula. However, there’s a problem. Normally, when calculating points along a bezier curve with constant difference between consecutive t values (for example t0 = 0, t1 = 0.1, t2 = 0.2, t3 = 0.3 etc.), the points are denser near the middle of the curve (the arc length between consecutive points is not constant). So when moving something along the curve by linearly changing t, it will first move quickly, then slow down near the middle of the length of the curve, and finally speed up again when approaching the other end point of the curve.

Arc length parameterization
When we want to control the speed at which something moves along the curve, arc length parameterization is needed. We need a function that, given a t value that represents the arc length between p0 and the desired point, calculates the regular t value that corresponds to this arc length t. With such a function, if we wanted to calculate such a point P that arc length between P0 and P is a quarter (0.25) of the length of the curve, we can just give 0.25 to the aforementioned function and plug the returned t value to the bezier curve point formula to get P.

The way I went about implementing arc length parameterization was by using the instantaneous speed of change of the point on the curve with respect to t. This speed is the magnitude of the derivative vector. First, I calculate points on the curve normally without arc length parameterization with constant t differences (for example the aforementioned 0, 0.1, 0.2, 0.3 etc.). As mentioned before, the arc length between such points is not constant. For each pair of consecutive such points, I create a QuadraticBezierInterval object that stores these points, their t values and the euclidean (straight/shortest) distance between them. The sum of these euclidean distances is set as curve.length. The length calculated this way is always an underestimation because the actual arc length between two points is greater than the straight (shortest) distance between them but with enough points its close to the correct length. When calculating the regular t value corresponding to an arc length t value, I first calculate the approximate desired arc length between t = 0 and this regular t by multiplying curve.length with the given arc length t. After that I sum BezierInterval euclidean distances until I find the interval in which the desired t value is by checking when the sum exceeds the desired arc length. Then I calculate the aproximate arc length from interval.point0 to the desired point.

local approximateArcLengthFromIntervalPoint0 = approximateArcLengthBeforeT - sumOfLengthsOfWholeIntervalsBeforeT

I derived the way I calculate the regular t value from this arc length from the equation for travelled distance s in uniformly accelerating motion. s in this case is approximateArcLengthFromIntervalPoint0 . The scalar acceleration (rate of change of speed) is not actually constant in the case of a bezier curve but this still seems to work pretty well. Here’s the equation.

s = v0 * Δt + 1/2 * a * Δt^2

v0 in the equation is the speed at interval.t0 and Δt = interval.t1 - interval.t0. The average acceleration is (v - v0) / Δt where v is the speed at interval.t1. By substituting this into the equation, we get the following:

s = v0 * Δt + 1/2 * (v - v0) / Δt * Δt^2
-- after simplifying:
s = 1/2 * (v0 + v) * Δt

Then we can easily solve Δt from the equation and calculate the approximate desired regular t value.

Δt = 2*s / (v0 + v)

-- this is the t value that the function returns
t = interval.t0 + Δt = interval.t0 + 2*s / (v0 + v)

Now, finding a point and a derivative vector with an arc length t value can be done in the following way.

local regularT = self:getNormalTFromArcLengthParameterizationT(arcLengthT)
local arcLengthParameterizationPoint = self:getPointNormally(regularT)
local arcLengthParameterizationDerivativeVector = self:getTangentVectorNormally(regularT)

The way I calculate the arc length that the ball should move is based on the same equation, but this time I’ll just use the equation s = 1/2 * (v0 + v) * Δt instead of solving Δt from it, and in this case, Δt is time between position updates, v0 is the ball speed at the moment of the earlier position update and v is the ball speed at the moment of the new position update. After that, arc length t is calculated using the aforementioned formula t = (amount of movement) / (curve length).

local curveArclengthParamaterizationT = amountToMove / newCurve.length

Other
The instances are meant to be created on the clients. They are only used for visualization.

The movement should be smooth because the client calculates the ball position every frame instead of it being sent from the server which means there won’t be sudden teleporting. However, the paths may be more or less different (depending on internet connection) between clients which can cause the distance from ball to target along the curve to be different between clients. Perhaps the desync could be decreased by sending the distance from ball to the target along the server curve to the clients and having the clients adjust the speed of the ball based on how much closer or further that client’s ball is from the target than it should be. I’m not sure how exactly this speed adjusting should work, though.

22 Likes

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
17 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)