|
const PADDLE_LENGTH = 100, |
|
PADDLE_THICKNESS = 10, |
|
PADDLE_MARGIN = 20, |
|
BALL_RADIUS = 8, |
|
INIT_NUM_BALLS = 3, |
|
INIT_BALL_VELOCITY_RANGE = [0.8, 1.5], |
|
BOUNCE_ACCELERATION = 0.14, |
|
OFFSIDE_POINTS_PENALTY = 2, |
|
MOUSE_SENSITIVITY = 3; // Paddle movement/Mouse movement (per px) |
|
|
|
const canvasWidth = window.innerWidth, |
|
canvasHeight = window.innerHeight; |
|
|
|
// DOM nodes |
|
const svgCanvas = d3.select('svg#canvas') |
|
.attr('width', canvasWidth) |
|
.attr('height', canvasHeight), |
|
paddlesG = svgCanvas.append('g'), |
|
ballsG = svgCanvas.append('g'); |
|
|
|
let numOffsides = 0, numBounces = 0; |
|
|
|
const paddles = [ |
|
new Paddle('t', PADDLE_LENGTH, PADDLE_THICKNESS, canvasWidth / 2, PADDLE_MARGIN - PADDLE_THICKNESS/2), |
|
new Paddle('b', PADDLE_LENGTH, PADDLE_THICKNESS, canvasWidth / 2, canvasHeight - PADDLE_MARGIN + PADDLE_THICKNESS/2), |
|
new Paddle('l', PADDLE_LENGTH, PADDLE_THICKNESS, PADDLE_MARGIN - PADDLE_THICKNESS/2, canvasHeight / 2), |
|
new Paddle('r', PADDLE_LENGTH, PADDLE_THICKNESS, canvasWidth - PADDLE_MARGIN + PADDLE_THICKNESS/2, canvasHeight / 2) |
|
], |
|
balls = []; |
|
|
|
d3.range(INIT_NUM_BALLS).forEach(addBall); |
|
|
|
paddleDigest(); |
|
addPaddleMouseControls(svgCanvas); |
|
|
|
// Setup bouncing forces |
|
const forceSim = d3.forceSimulation() |
|
.alphaDecay(0) |
|
.velocityDecay(0) |
|
.stop() |
|
.nodes(balls) |
|
.force('paddle-bounce', d3.forceSurface() |
|
.surfaces(paddles) |
|
.elasticity(1 + BOUNCE_ACCELERATION) |
|
.radius(node => node.r) |
|
.from(paddle => paddle.getEdgeFrom()) |
|
.to(paddle => paddle.getEdgeTo()) |
|
.oneWay(true) |
|
.onImpact((ball, paddle) => { |
|
ball.impacted = paddle.impacted = true; |
|
numBounces++; |
|
updScore(); |
|
}) |
|
) |
|
.force('bounce', d3.forceBounce() |
|
.radius(node => node.r) |
|
.onImpact((ball1, ball2) => { ball1.impacted = ball2.impacted = true; }) |
|
) |
|
.force('off-side', () => { |
|
balls.forEach(ball => { |
|
if (!ball.isWithin(0, 0, canvasWidth, canvasHeight)) { |
|
resetBallMotion(ball); |
|
|
|
numOffsides++; |
|
|
|
flash(svgCanvas); |
|
updScore(); |
|
} |
|
}); |
|
}) |
|
.on('tick', () => { impactDigest(); ballDigest(); }); |
|
|
|
// Event handlers |
|
function addBall() { balls.push(resetBallMotion()); updNumBalls(); } |
|
function removeBall() { balls.pop(); updNumBalls(); } |
|
|
|
// |
|
|
|
function addPaddleMouseControls(canvas) { |
|
let prevMouseCoords; |
|
|
|
canvas.call(d3.drag() |
|
.on('start', () => { |
|
// Hide cursor |
|
canvas.style('cursor', 'none'); |
|
|
|
// Hide start msg |
|
d3.select('.info-panel').style('display', 'none'); |
|
|
|
// (Re-)start simulation |
|
forceSim.restart(); |
|
|
|
prevMouseCoords = [d3.event.x, d3.event.y]; |
|
}) |
|
.on('drag', () => { |
|
const coords = [d3.event.x, d3.event.y], |
|
deltas = coords.map((coord, idx) => (coord - prevMouseCoords[idx]) * MOUSE_SENSITIVITY); |
|
|
|
prevMouseCoords = coords; |
|
|
|
paddles.forEach(d => { |
|
const vertical = d.isVertical(), |
|
dim = vertical?'y':'x', |
|
delta = deltas[vertical?1:0], |
|
min = PADDLE_MARGIN + PADDLE_LENGTH/ 2, |
|
max = ( vertical ? canvasHeight : canvasWidth ) - PADDLE_LENGTH/2 - PADDLE_MARGIN; |
|
|
|
d[dim] = Math.max(min, Math.min(max, d[dim] + delta)); |
|
}); |
|
|
|
paddleDigest(); |
|
}) |
|
.on('end', () => { |
|
// Reset cursor |
|
canvas.style('cursor', null); |
|
}) |
|
); |
|
} |
|
|
|
function resetBallMotion(ball) { |
|
ball = ball || new Ball(BALL_RADIUS); |
|
ball.resetMotion(canvasWidth / 2, canvasHeight / 2, INIT_BALL_VELOCITY_RANGE); |
|
return ball; |
|
} |
|
|
|
function updScore() { |
|
d3.select('.score span').text(numBounces - numOffsides * OFFSIDE_POINTS_PENALTY); |
|
} |
|
|
|
function updNumBalls() { |
|
try { forceSim.nodes(balls); } catch(e) {} // Refresh nodes in force sim if it exists |
|
d3.select('.num-balls span').text(balls.length); |
|
} |
|
|
|
function flash(sel) { |
|
sel.style('filter', 'invert(100%)') |
|
.transition().duration(0).delay(40) |
|
.style('filter', null); |
|
} |
|
|
|
function paddleDigest() { |
|
let paddle = paddlesG.selectAll('rect.paddle').data(paddles); |
|
|
|
paddle.exit().remove(); |
|
|
|
paddle.merge( |
|
paddle.enter().append('rect') |
|
.classed('paddle', true) |
|
.attr('width', d => d.getWidth()) |
|
.attr('height', d => d.getHeight()) |
|
.attr('transform', d => `translate(-${d.getWidth()/2},-${d.getHeight()/2})`) |
|
.attr('rx', 4) |
|
.attr('ry', 4) |
|
.attr('fill', 'white') |
|
.attr('stroke', 'white') |
|
.attr('stroke-width', 0) |
|
) |
|
.attr('x', d => d.x) |
|
.attr('y', d => d.y); |
|
} |
|
|
|
function ballDigest() { |
|
let ball = ballsG.selectAll('circle.ball').data(balls); |
|
|
|
ball.exit().remove(); |
|
|
|
ball.merge( |
|
ball.enter().append('circle') |
|
.classed('ball', true) |
|
.attr('fill', 'white') |
|
.attr('stroke', 'white') |
|
.attr('stroke-width', 0) |
|
) |
|
.attr('r', d => d.r) |
|
.attr('cx', d => d.x) |
|
.attr('cy', d => d.y); |
|
} |
|
|
|
function impactDigest() { |
|
d3.selectAll('.ball, .paddle') |
|
.filter(d => d.impacted) |
|
.each(d => d.impacted = false) |
|
.attr('stroke-width', 3) |
|
.transition().duration(0).delay(100) |
|
.attr('stroke-width', 0); |
|
} |