I need to keep the same distance between the balls on the Bézier curve. Now I'm using a simple iteration of possible positions on the curve, adding some step amount for each iteration until position with required distance, greater or equal, is found. It works, but can be resource-intensive when there are a large amount of balls. CodeSandbox
class TestScene extends Phaser.Scene {
create() {
// prettier-ignore
const curve = new Phaser.Curves.QuadraticBezier(
[
55, 310,
40, 0,
250, 310
]
);
const graphicsLayer = this.add.graphics({
lineStyle: {
width: 2,
color: 0x0000ff
}
});
curve.draw(graphicsLayer);
const balls = this.initBalls(6);
this.curve = curve;
this.balls = balls;
}
initBalls(quantity) {
const balls = [];
for (let i = 0; i < quantity; i++) {
const ball = this.add.circle(0, 0, 12, 0x00ff00);
ball.t = 0;
balls.push(ball);
}
return balls;
}
calcNextBallT(previous) {
const ballDiameter = previous.radius * 2;
const curve = this.curve;
const curveLen = curve.getLength();
const previousPos = new Phaser.Math.Vector2(previous);
const previousT = previous.t;
const startingT = (1 / curveLen) * ballDiameter + previousT;
const step = 1 / 1000;
let nextT = startingT;
let nextPos;
let currentDistance = 0;
while (nextT >= 0 && nextT <= 1) {
nextPos = curve.getPointAt(nextT);
currentDistance = previousPos.distance(nextPos);
if (currentDistance >= ballDiameter) {
break;
}
nextT += step;
}
return nextT;
}
update() {
const {
curve,
balls
} = this;
if (!curve || !balls) {
return;
}
const speed = 1;
const curveLen = curve.getLength();
const step = (1 / curveLen) * speed;
balls.forEach((ball, index, array) => {
let nextT = ball.t;
if (index === 0) {
nextT += step;
if (nextT > 1) {
nextT = 0;
}
} else {
const previous = array[index - 1];
nextT = this.calcNextBallT(previous);
}
ball.t = nextT;
ball.copyPosition(curve.getPointAt(nextT));
});
}
}
const game = new Phaser.Game({
width: 320,
height: 320,
powerPreference: 'low-power',
audio: {
noAudio: true
},
scene: [TestScene]
});
<script src="https://unpkg.com/phaser@3.55.2/dist/phaser.min.js"></script>
I think maybe there is a mathematical or algorithmic solution. Something like finding the intersection of a larger circle with a curve, but as I understood it is impossible.
Is there a better way to determine the next point on the Bézier curve based on the straight-line distance from the previous point?
UPDATE 1: A solution using Binary Search, as suggested by @MBo, shows good stability with an average of 5-10 iterations, even with varying curves and ball sizes. CodeSandbox
class TestScene extends Phaser.Scene {
init() {
this.input.on('drag', (pointer, gameObject, dragX, dragY) => {
gameObject.setPosition(dragX, dragY);
});
this.iters = [];
}
create() {
// prettier-ignore
const p0 = this.add.circle(55, 310, 4, 0xffff00),
p1 = this.add.circle(40, 5, 4, 0xffff00),
p2 = this.add.circle(250, 310, 4, 0xffff00);
const curve = new Phaser.Curves.QuadraticBezier(p0, p1, p2);
const graphicsLayer = this.add.graphics({
lineStyle: {
width: 2,
color: 0x0000ff
}
});
curve.draw(graphicsLayer);
const curveDragHandler = () => {
curve.updateArcLengths();
graphicsLayer.clear();
curve.draw(graphicsLayer);
};
[p0, p1, p2].forEach((p) => {
p.setDepth(10).setInteractive();
this.input.setDraggable(p);
p.on('drag', curveDragHandler);
});
const balls = this.initBalls(3, 50);
const text = this.add.text(0, 0, '', {
color: 'yellow'
});
this.curve = curve;
this.balls = balls;
this.text = text;
}
initBalls(quantity, diameter) {
const radius = diameter / 2;
const balls = [];
for (let i = 0; i < quantity; i++) {
const ball = this.add.circle(0, 0, radius, 0x00ff00);
ball.t = 0;
balls.push(ball);
}
return balls;
}
calcNextBallT(previous) {
const ballDiameter = previous.radius * 2;
const curve = this.curve;
const previousPos = new Phaser.Math.Vector2(previous);
const previousT = previous.t;
let nextT = 1;
let lowT = previousT;
let highT = 1;
let iter = 1;
const skip = previousPos.distance(curve.getEndPoint()) <= ballDiameter;
while (lowT <= highT && !skip) {
nextT = lowT + (highT - lowT) / 2;
const nextPos = curve.getPointAt(nextT);
const currentDistance = previousPos.distance(nextPos);
if (fuzzySame(currentDistance, ballDiameter)) {
break;
}
if (currentDistance > ballDiameter) {
highT = nextT;
} else {
lowT = nextT;
}
iter++;
}
if (!skip) {
this.iters.push(iter);
}
return nextT;
}
update() {
const {
curve,
balls
} = this;
if (!curve || !balls) {
return;
}
const speed = 1;
const curveLen = curve.getLength();
const step = (1 / curveLen) * speed;
balls.forEach((ball, index, array) => {
let nextT = ball.t;
if (index === 0) {
nextT += step;
if (nextT > 1) {
const average = findAverage(this.iters).toFixed(2);
const maximum = findMaximum(this.iters);
this.text.setText(`Average: ${average}\nMaximum: ${maximum}`);
this.iters = [];
nextT = 0;
}
} else {
const previous = array[index - 1];
nextT = this.calcNextBallT(previous);
}
ball.t = nextT;
ball.copyPosition(curve.getPointAt(nextT));
});
}
}
const fuzzySame = (num1, num2) => {
const tolerance = 0.2;
return num1 >= num2 - tolerance && num1 <= num2 + tolerance;
};
const findAverage = (array) => {
const len = array.length;
let total = 0;
array.forEach((num) => (total += num));
return total / len;
};
const findMaximum = (array) => {
return [...array].sort((a, b) => b - a)[0];
};
const game = new Phaser.Game({
width: 320,
height: 320,
powerPreference: 'low-power',
audio: {
noAudio: true
},
scene: [TestScene]
});
<script src="https://unpkg.com/phaser@3.55.2/dist/phaser.min.js"></script>