/** * * @source http://tilde.club/~chmod777/ts/pong.ts * * @license AGPL-3.0-only * @licstart * Copyright (c) 2021 chmod777 * * This program is free software: you can redistribute it and/or modify it under * the terms of the GNU Affero General Public License as published by the Free * Software Foundation, either version 3 of the License. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more * details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * @licend */ class Vec2 { constructor(public x: number, public y: number) {} static zero(): Vec2 { return new Vec2(0, 0); } static one(): Vec2 { return new Vec2(1, 1); } public clone(): Vec2 { return new Vec2(this.x, this.y); } public add(other: Vec2): Vec2 { return new Vec2(this.x + other.x, this.y + other.y); } public sub(other: Vec2): Vec2 { return new Vec2(this.x - other.x, this.y - other.y); } public mul(s: number): Vec2 { return new Vec2(this.x * s, this.y * s); } public div(s: number): Vec2 { return new Vec2(this.x / s, this.y / s); } public cross(other: Vec2): number { return this.x * other.y - this.y * other.x; } public dot(other: Vec2): number { return this.x * other.y + this.y * other.y; } public magnitude2(): number { return this.x * this.x + this.y * this.y; } public magnitude(): number { return Math.sqrt(this.magnitude2()); } public asUnit(): Vec2 { return this.div(this.magnitude()); } static numberAsString(n: number): string { if (n != Math.floor(n)) { n.toFixed(2); } return n.toString(); } public asString(): string { return `${Vec2.numberAsString(this.x)},${Vec2.numberAsString(this.y)} `; } public asAbsolute(): string { return `M${this.asString()}`; } public asLine(): string { return `L${this.asString()}`; } } class Line { constructor(public p1: Vec2, public p2: Vec2) {} public lineLineIntersect(l2: Line): null | Vec2 { const p = this.p1; const r = this.p2.sub(p); const q = l2.p1; const s = l2.p2.sub(q); const r_cross_s = r.cross(s); const q_minus_p = q.sub(p); if (r_cross_s === 0) { return null; // let q_minus_p_cross_r = q_minus_p.cross(r); // if (q_minus_p_cross_r === 0) // return null; // collinear // else // return null; // parallel } else { const t = q_minus_p.cross(s.div(r_cross_s)); const u = q_minus_p.cross(r.div(r_cross_s)); if (0 <= t && t <= 1 && 0 <= u && u <= 1) { if (t === 0) return q.add(s.mul(u)); else return p.add(r.mul(t)); } else { return null; // divergent } } } } class PongElements { public playerPaddle: SVGRectElement; public aiPaddle: SVGRectElement; public playerScoreElement: SVGTextElement; public aiScoreElement: SVGTextElement; public ball: SVGCircleElement; public ballAnimation: SVGAnimateMotionElement; public ballPath: SVGPathElement; public collisionPath: SVGPathElement; public resetButton: HTMLInputElement; public serveButton: HTMLInputElement; public upButton: HTMLInputElement; public stopButton: HTMLInputElement; public downButton: HTMLInputElement; public fps: HTMLSpanElement; constructor(svgContent: Document) { this.playerPaddle = getSVGRectElement(svgContent, 'pong-player-paddle'); this.aiPaddle = getSVGRectElement(svgContent, 'pong-ai-paddle'); this.playerScoreElement = getSVGTextElement(svgContent, 'pong-player-score'); this.aiScoreElement = getSVGTextElement(svgContent, 'pong-ai-score'); this.ball = getSVGCircleElement(svgContent, 'pong-ball'); this.ballAnimation = getSVGAnimateMotionElement(svgContent, 'pong-ball-animation'); this.ballPath = getSVGPathElement(svgContent, 'pong-ball-path'); this.collisionPath = getSVGPathElement(svgContent, 'pong-collision-path'); this.resetButton = getHTMLInput('pong-reset'); this.serveButton = getHTMLInput('pong-serve'); this.upButton = getHTMLInput('pong-paddle-up'); this.stopButton = getHTMLInput('pong-paddle-stop'); this.downButton = getHTMLInput('pong-paddle-down'); this.fps = getHTMLSpan('fps'); const WIDTH_STRING = Pong.PADDLE_WIDTH.toString(); this.aiPaddle.setAttribute('width', WIDTH_STRING); this.playerPaddle.setAttribute('width', WIDTH_STRING); const BALL_RADIUS_STRING = Pong.BALL_RADIUS.toString(); this.ball.setAttribute('r', BALL_RADIUS_STRING); this.resetPaddles(); this.resetBall(); } public resetBall() { this.ballPath.setAttribute('d', Pong.ABSOLUTE_CENTER); this.collisionPath.setAttribute('d', Pong.ABSOLUTE_CENTER); this.ballAnimation.beginElement(); } public resetPaddles() { translateToPosition(this.playerPaddle, Pong.PLAYER_STARTING_POSITION); translateToPosition(this.aiPaddle, Pong.AI_STARTING_POSITION); } public resetScores() { this.playerScoreElement.innerHTML = '0'; this.aiScoreElement.innerHTML = '0'; } } class PongState { public lastAnimationFrame = 0; public playerPosition = Pong.PLAYER_STARTING_POSITION.clone(); public aiPosition = Pong.AI_STARTING_POSITION.clone(); public ballSpeed = Pong.BALL_BASE_SPEED; public ballVelocity = Vec2.zero(); public running = false; public shouldServe = false; public playerServe = false; public time = 0; public lastTime = 0; public serveTime = 0; public dt_ms = 0; public dt = 0; public playerScore = 0; public aiScore = 0; public scoreDuration = 0; public collisionDuration = 0; public collisionPoint = Vec2.zero(); public moveUp = false; public moveDown = false; get timeSinceServe(): number { return (this.time - this.serveTime) / 1000; } public update_dt(time: number) { this.lastTime = this.time; this.time = time; this.dt_ms = this.time - this.lastTime; this.dt = this.dt_ms / 1000; } public resetBall() { this.ballSpeed = Pong.BALL_BASE_SPEED; this.ballVelocity = Vec2.zero(); } public resetPaddles() { this.playerPosition = Pong.PLAYER_STARTING_POSITION.clone(); this.aiPosition = Pong.AI_STARTING_POSITION.clone(); this.moveUp = false; this.moveDown = false; } } class Pong { /* x w y +---------------------+ | | | h | 0 | | | | +---------------------+ */ static readonly WIDTH = 512; static readonly HEIGHT = 256; static readonly HALF_WIDTH = Pong.WIDTH / 2; static readonly HALF_HEIGHT = Pong.HEIGHT / 2; static readonly CENTER = new Vec2(Pong.HALF_WIDTH, Pong.HALF_HEIGHT); static readonly ABSOLUTE_CENTER = Pong.CENTER.asAbsolute(); static readonly PADDLE_WIDTH = 4; static readonly PADDLE_HEIGHT = 28; static readonly PADDLE_HALF_WIDTH = Pong.PADDLE_WIDTH / 2; static readonly PADDLE_HALF_HEIGHT = Pong.PADDLE_HEIGHT / 2; static readonly PADDLE_MAX_POSITION = Pong.HEIGHT - Pong.PADDLE_HEIGHT; static readonly PADDLE_VELOCITY = 150; static readonly BALL_RADIUS = 5; static readonly BALL_SPEED_INCREASE = 10; static readonly BALL_BASE_SPEED = 200; static readonly PADDLE_STARTING_POSITION_Y = Pong.CENTER.y - Pong.PADDLE_HALF_HEIGHT; static readonly PLAYER_STARTING_POSITION = new Vec2(0, Pong.PADDLE_STARTING_POSITION_Y); static readonly AI_STARTING_POSITION = new Vec2(Pong.WIDTH - Pong.PADDLE_WIDTH, Pong.PADDLE_STARTING_POSITION_Y); static readonly RAW_CORNERS: Array = [ // top left new Vec2(0, 0+Pong.BALL_RADIUS), // top right new Vec2(Pong.WIDTH, 0+Pong.BALL_RADIUS), // bottom right new Vec2(Pong.WIDTH, Pong.HEIGHT-Pong.BALL_RADIUS), // bottom left new Vec2(0, Pong.HEIGHT-Pong.BALL_RADIUS) ]; static readonly BOARD_CORNERS: Array = [ // top left new Vec2( Pong.RAW_CORNERS[0].x-Pong.BALL_RADIUS, Pong.RAW_CORNERS[0].y), // top right new Vec2( Pong.RAW_CORNERS[1].x+Pong.BALL_RADIUS, Pong.RAW_CORNERS[1].y), // bottom right new Vec2( Pong.RAW_CORNERS[2].x+Pong.BALL_RADIUS, Pong.RAW_CORNERS[2].y), // bottom left new Vec2( Pong.RAW_CORNERS[3].x-Pong.BALL_RADIUS, Pong.RAW_CORNERS[3].y) ]; static readonly PADDLE_COLLISION_CORNERS: Array = [ // top left new Vec2( Pong.RAW_CORNERS[0].x+Pong.PADDLE_WIDTH+Pong.BALL_RADIUS, Pong.RAW_CORNERS[0].y), // top right new Vec2( (Pong.RAW_CORNERS[1].x-Pong.PADDLE_WIDTH)-Pong.BALL_RADIUS, Pong.RAW_CORNERS[1].y), // bottom right new Vec2( (Pong.RAW_CORNERS[2].x-Pong.PADDLE_WIDTH)-Pong.BALL_RADIUS, Pong.RAW_CORNERS[2].y), // bottom left new Vec2( Pong.RAW_CORNERS[3].x+Pong.PADDLE_WIDTH+Pong.BALL_RADIUS, Pong.RAW_CORNERS[3].y) ]; static readonly BOARD_BOUNDS: Array<[Line, boolean]> = [ // top [new Line(Pong.BOARD_CORNERS[0], Pong.BOARD_CORNERS[1]), false], // left [new Line(Pong.BOARD_CORNERS[0], Pong.BOARD_CORNERS[3]), true], // bottom [new Line(Pong.BOARD_CORNERS[3], Pong.BOARD_CORNERS[2]), false], // right [new Line(Pong.BOARD_CORNERS[1], Pong.BOARD_CORNERS[2]), true] ]; static readonly PADDLE_COLLISION_BOUNDS: Array<[Line, boolean]> = [ // top [new Line(Pong.PADDLE_COLLISION_CORNERS[0], Pong.PADDLE_COLLISION_CORNERS[1]), false], // left [new Line(Pong.PADDLE_COLLISION_CORNERS[0], Pong.PADDLE_COLLISION_CORNERS[3]), true], // bottom [new Line(Pong.PADDLE_COLLISION_CORNERS[3], Pong.PADDLE_COLLISION_CORNERS[2]), false], // right [new Line(Pong.PADDLE_COLLISION_CORNERS[1], Pong.PADDLE_COLLISION_CORNERS[2]), true] ]; private elements: PongElements; private state = new PongState(); constructor(svgContent: Document) { this.elements = new PongElements(svgContent); this.elements.resetButton.addEventListener('click', () => { this.reset(); }); this.elements.serveButton.addEventListener('click', () => { if (!this.state.running) { this.state.running = true; this.state.shouldServe = true; } }); this.elements.upButton.addEventListener('click', () => { this.state.moveUp = true; this.state.moveDown = false; }); this.elements.downButton.addEventListener('click', () => { this.state.moveUp = false; this.state.moveDown = true; }); this.elements.stopButton.addEventListener('click', () => { this.state.moveUp = false; this.state.moveDown = false; }); document.addEventListener('keydown', (event) => { this.handleKeydown(event); }); document.addEventListener('keyup', (event) => { this.handleKeyup(event); }); this.state.lastAnimationFrame = window.requestAnimationFrame((timestamp) => { this.update(timestamp); }); } private serve() { this.state.shouldServe = false; this.state.serveTime = this.state.time; this.state.ballVelocity.x = genRandom(); this.state.ballVelocity.y = genRandom(); this.state.ballVelocity = this.state.ballVelocity.asUnit().mul(this.state.ballSpeed); if (this.state.ballVelocity.x > 0) { this.state.playerServe = true; } this.buildBallPaths(Pong.CENTER); } public reset() { this.state = new PongState(); window.cancelAnimationFrame(this.state.lastAnimationFrame); this.updatePaddles(); this.elements.resetScores(); this.elements.resetBall(); } private resetBall() { this.state.resetBall(); this.elements.resetBall(); } private resetPaddles() { this.state.resetPaddles(); this.updatePaddles(); } private updatePaddles() { translateToPosition(this.elements.playerPaddle, this.state.playerPosition); translateToPosition(this.elements.aiPaddle, this.state.aiPosition); } private update(timestamp: number) { this.state.update_dt(timestamp); const fps = 1 / this.state.dt; this.elements.fps.innerHTML = fps.toString(); if (this.state.shouldServe) { this.serve(); } this.updatePlayer(); if (this.state.running) { this.updateAI(this.elements.aiPaddle, this.state.aiPosition); this.processScore(); if (this.state.running) { this.processHit(); } } this.state.lastAnimationFrame = window.requestAnimationFrame((timestamp) => { this.update(timestamp); }); } private isColliding(paddle: Vec2): boolean { return paddle.y + Pong.PADDLE_HEIGHT > this.state.collisionPoint.y && paddle.y < this.state.collisionPoint.y; } private processScore() { if (this.state.timeSinceServe > this.state.scoreDuration) { if (this.state.playerServe) { this.state.playerScore++; this.elements.playerScoreElement.innerHTML = this.state.playerScore.toString(); } else { this.state.aiScore++; this.elements.aiScoreElement.innerHTML = this.state.aiScore.toString(); } this.state.running = false; this.resetBall(); this.resetPaddles(); } } private processHit() { if (this.state.timeSinceServe > this.state.collisionDuration) { let position: Vec2 = this.state.playerPosition; let collisionOffset: number = 0.1; if (this.state.playerServe) { position = this.state.aiPosition; collisionOffset = -collisionOffset; } if (this.isColliding(position)) { this.state.ballVelocity.x = -this.state.ballVelocity.x; this.state.ballSpeed += Pong.BALL_SPEED_INCREASE; this.state.serveTime = this.state.time; this.state.playerServe = !this.state.playerServe; this.state.collisionPoint.x += collisionOffset; this.buildBallPaths(this.state.collisionPoint); } } } private clampPaddlePosition(paddlePosition: Vec2) { paddlePosition.y = Math.max(Math.min(paddlePosition.y, Pong.PADDLE_MAX_POSITION), 0); } private updatePlayer() { if (this.state.moveUp && this.state.moveDown || !(this.state.moveUp || this.state.moveDown)) { return; } const dy = this.state.dt * Pong.PADDLE_VELOCITY; if (this.state.moveDown) { this.state.playerPosition.y += dy; } else { this.state.playerPosition.y -= dy; } this.clampPaddlePosition(this.state.playerPosition); translateToPosition(this.elements.playerPaddle, this.state.playerPosition); } private updateAI(paddle: SVGRectElement, position: Vec2) { let targetPosition: number; if (this.state.playerServe) { targetPosition = this.state.collisionPoint.y; } else { targetPosition = Pong.HALF_HEIGHT; } targetPosition -= Pong.PADDLE_HALF_HEIGHT; const targetRange = Pong.PADDLE_HALF_HEIGHT / 2; const dy = this.state.dt * Pong.PADDLE_VELOCITY; let shouldMove = false; if (position.y > targetPosition + targetRange) { shouldMove = true; position.y -= dy; } else if (position.y < targetPosition - targetRange) { shouldMove = true; position.y += dy; } if (shouldMove) { this.clampPaddlePosition(position); translateToPosition(paddle, position); } } private buildBallPath(startingPosition: Vec2, dir: Vec2, bounds: [Line, boolean][]): Vec2[] { const path = new Line(startingPosition, startingPosition.add(dir)); const points: Array = []; while (true) { const maybeIntersect = this.findNextCollision(path, bounds); if (maybeIntersect !== null) { const [point, isEnd] = maybeIntersect; if (isEnd) { if (point.x > Pong.HALF_WIDTH) point.x += 0.01; else point.x -= 0.01; } else { if (point.y > Pong.HALF_HEIGHT) point.y -= 0.01; else point.y += 0.01; } points.push(point); if (isEnd) { break; } dir.y = -dir.y; path.p1 = point; path.p2 = point.add(dir); } else { console.error("intersect is null") break; } } return points; } private buildBallPaths(startingPosition: Vec2) { const dir = this.state.ballVelocity.mul(1000); const animationPoints = this.buildBallPath(startingPosition, dir.clone(), Pong.BOARD_BOUNDS); const collisionPoints = this.buildBallPath(startingPosition, dir, Pong.PADDLE_COLLISION_BOUNDS); this.state.collisionPoint = collisionPoints[collisionPoints.length - 1].clone(); this.state.ballVelocity = dir.div(1000); const animationPath = this.buildSVGPathFromPoints(startingPosition, animationPoints); this.elements.ballPath.setAttribute('d', animationPath); this.state.scoreDuration = this.elements.ballPath.getTotalLength() / this.state.ballSpeed; this.elements.ballAnimation.setAttribute('dur', this.state.scoreDuration.toString()); const collisionPath = this.buildSVGPathFromPoints(startingPosition, collisionPoints); this.elements.collisionPath.setAttribute('d', collisionPath); this.state.collisionDuration = this.elements.collisionPath.getTotalLength() / this.state.ballSpeed; this.elements.ballAnimation.beginElement(); } private buildSVGPathFromPoints(start: Vec2, points: Array): string { let directions = start.asAbsolute(); for (const point of points) { directions = directions.concat(point.asLine()); } return directions; } private findNextCollision(path: Line, bounds: [Line, boolean][]): null | [Vec2, boolean] { for (const [line, isEnd] of bounds) { const maybeIntersect = path.lineLineIntersect(line); if (maybeIntersect !== null) { return [maybeIntersect, isEnd]; } } return null; } private handleKeydown(event: KeyboardEvent) { const key = event.key; if (key === ',') { this.state.moveUp = true; } else if (key === '.') { this.state.moveDown = true; } else if (key === 's') { if (!this.state.running) { this.state.running = true; this.state.shouldServe = true; } } else if (key === 'n') { this.reset(); } } private handleKeyup(event: KeyboardEvent) { const key = event.key; if (key === ',') { this.state.moveUp = false; } else if (key === '.') { this.state.moveDown = false; } } } // generate random number in range(-1, 1) function genRandom() { return (Math.random() - 0.5) * 2; } function translateToPosition(element: SVGElement, position: Vec2) { translateTo(element, position.x, position.y); } function translateTo(element: SVGElement, x: number, y: number) { element.setAttribute('transform', `translate(${x}, ${y})`); }