Skip to content

Instantly share code, notes, and snippets.

@vasturiano
Last active February 11, 2023 04:03
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save vasturiano/94107e18d438942f92b217809eb3e7ba to your computer and use it in GitHub Desktop.
Save vasturiano/94107e18d438942f92b217809eb3e7ba to your computer and use it in GitHub Desktop.
Quad Pong

A quadrilateral self-version of the Pong classic game.

Uses the d3-force simulation engine and custom forces to handle the collision and bounce between balls (d3.forceBounce) and with the paddles (d3.forceSurface).

Click-drag to control the paddles' horizontal and vertical positions. Each time a ball bounces on a paddle you get 1 point. Each time it goes off screen you lose 2 points.

Use the top-left +/- buttons to add/remove balls from the system.

class Ball {
constructor(radius) {
this.r = radius;
}
resetMotion(cx=0, cy=0, velocityRange=[0.5, 1]) {
this.x = cx;
this.y = cy;
const angle = Math.random() * 2 * Math.PI,
velocity = Math.random() * (velocityRange[1] - velocityRange[0]) + velocityRange[0];
this.vx = Math.cos(angle) * velocity;
this.vy = Math.sin(angle) * velocity;
}
getBbox() {
return {
t: this.y - this.r,
b: this.y + this.r,
l: this.x - this.r,
r: this.x + this.r
}
}
isWithin(x, y, width, height) {
const p = this.getBbox();
return p.l > x && p.r < x + width && p.t > y && p.b < y + height;
}
}
<head>
<script src="//cdnjs.cloudflare.com/ajax/libs/d3/4.8.0/d3.min.js"></script>
<script src="//unpkg.com/d3-force-surface"></script>
<script src="//unpkg.com/d3-force-bounce"></script>
<script src="paddle.js"></script>
<script src="ball.js"></script>
<link rel="stylesheet" media="screen" href="https://fontlibrary.org/face/8bit-wonder" type="text/css"/>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="num-balls">
Balls: <span></span>
<button onClick="addBall()">+</button>
<button onClick="removeBall()">-</button>
</div>
<div class="score">
Score: <span class="large">0</span>
</div>
<div class="info-panel">
<div class="large">Ready?</div>
<div class="blink">click-drag to start</div>
</div>
<svg id="canvas"></svg>
<script src="index.js"></script>
</body>
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);
}
class Paddle {
constructor(pos='b', length=20, thickness=5, initX=0, initY=0) {
this.pos = pos;
this.length = length;
this.thickness = thickness;
this.x = initX;
this.y = initY;
}
isVertical() {
return this.pos === 'l' || this.pos === 'r';
}
getWidth() {
return this.isVertical() ? this.thickness : this.length
}
getHeight() {
return this.isVertical() ? this.length : this.thickness
}
getEdgeLine() {
const edge = this.thickness/2 * (this.pos === 't' || this.pos === 'l' ? 1 : -1),
edges = [edge, edge],
sides = [-this.length/ 2, this.length/2],
vertical = this.isVertical(),
xOffset = vertical?edges:sides,
yOffset = vertical?sides:edges;
if (this.pos === 't' || this.pos === 'r') {
sides.reverse(); // Reverse line direction for flipped axis paddles (t, r)
}
return {
from: {
x: this.x + xOffset[0],
y: this.y + yOffset[0]
},
to: {
x: this.x + xOffset[1],
y: this.y + yOffset[1]
}
}
}
getEdgeFrom() { return this.getEdgeLine().from; }
getEdgeTo() { return this.getEdgeLine().to; }
}
body {
margin: 0;
text-align: center;
font-family: '8BITWONDERNominal';
font-size: 9px;
color: white;
}
.large {
font-size: 20px;
}
.blink {
animation: blinker 0.9s step-start infinite;
}
@keyframes blinker {
50% { visibility: hidden; }
}
.num-balls, .score, .info-panel {
position: absolute;
top: 20px;
user-select: none;
z-index: 1000;
}
.num-balls {
left: 20px;
}
.num-balls button {
padding: 0px 4px 2px;
cursor: pointer;
}
.score {
right: 20px;
}
.info-panel {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
line-height: 300%;
}
#canvas {
background: black;
cursor: move;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment