Wouldn’t changing the network ownership make the ball appear laggy / desynced across clients?
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…
uhhhhhhhhhhhhhhhhhhhhhhhhhhhhh ball
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)
The devs stated they dont use tweens, and these use tweens
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.
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
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
--]]
- Ur code is so long, can u tell me what u used to move the object
- Can u send a clip or something of it working
- 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.
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
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.
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)