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