"use strict"; /** * * @source http://tilde.club/~chmod777/ts/base.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 */ let pong; window.onload = function () { pong = new Pong(document); }; function error(id, elementName) { return `Failed to find ${elementName} element with id "${id}"`; } function getHTMLInput(id) { const element = document.getElementById(id); if (element instanceof HTMLInputElement) return element; throw error(id, 'html button'); } function getHTMLSpan(id) { const element = document.getElementById(id); if (element instanceof HTMLSpanElement) return element; throw error(id, 'html span'); } function getSVGRectElement(svgDocument, id) { const element = svgDocument.getElementById(id); if (element instanceof SVGRectElement) return element; throw error(id, 'svg rect'); } function getSVGTextElement(svgDocument, id) { const element = svgDocument.getElementById(id); if (element instanceof SVGTextElement) return element; throw error(id, 'svg text'); } function getSVGCircleElement(svgDocument, id) { const element = svgDocument.getElementById(id); if (element instanceof SVGCircleElement) return element; throw error(id, 'svg circle'); } function getSVGAnimateMotionElement(svgDocument, id) { const element = svgDocument.getElementById(id); if (element instanceof SVGAnimateMotionElement) return element; throw error(id, 'svg animate motion'); } function getSVGPathElement(svgDocument, id) { const element = svgDocument.getElementById(id); if (element instanceof SVGPathElement) return element; throw error(id, 'svg path'); } /** * * @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(x, y) { this.x = x; this.y = y; } static zero() { return new Vec2(0, 0); } static one() { return new Vec2(1, 1); } clone() { return new Vec2(this.x, this.y); } add(other) { return new Vec2(this.x + other.x, this.y + other.y); } sub(other) { return new Vec2(this.x - other.x, this.y - other.y); } mul(s) { return new Vec2(this.x * s, this.y * s); } div(s) { return new Vec2(this.x / s, this.y / s); } cross(other) { return this.x * other.y - this.y * other.x; } dot(other) { return this.x * other.y + this.y * other.y; } magnitude2() { return this.x * this.x + this.y * this.y; } magnitude() { return Math.sqrt(this.magnitude2()); } asUnit() { return this.div(this.magnitude()); } static numberAsString(n) { if (n != Math.floor(n)) { n.toFixed(2); } return n.toString(); } asString() { return `${Vec2.numberAsString(this.x)},${Vec2.numberAsString(this.y)} `; } asAbsolute() { return `M${this.asString()}`; } asLine() { return `L${this.asString()}`; } } class Line { constructor(p1, p2) { this.p1 = p1; this.p2 = p2; } lineLineIntersect(l2) { 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 { constructor(svgContent) { 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(); } resetBall() { this.ballPath.setAttribute('d', Pong.ABSOLUTE_CENTER); this.collisionPath.setAttribute('d', Pong.ABSOLUTE_CENTER); this.ballAnimation.beginElement(); } resetPaddles() { translateToPosition(this.playerPaddle, Pong.PLAYER_STARTING_POSITION); translateToPosition(this.aiPaddle, Pong.AI_STARTING_POSITION); } resetScores() { this.playerScoreElement.innerHTML = '0'; this.aiScoreElement.innerHTML = '0'; } } class PongState { constructor() { this.lastAnimationFrame = 0; this.playerPosition = Pong.PLAYER_STARTING_POSITION.clone(); this.aiPosition = Pong.AI_STARTING_POSITION.clone(); this.ballSpeed = Pong.BALL_BASE_SPEED; this.ballVelocity = Vec2.zero(); this.running = false; this.shouldServe = false; this.playerServe = false; this.time = 0; this.lastTime = 0; this.serveTime = 0; this.dt_ms = 0; this.dt = 0; this.playerScore = 0; this.aiScore = 0; this.scoreDuration = 0; this.collisionDuration = 0; this.collisionPoint = Vec2.zero(); this.moveUp = false; this.moveDown = false; } get timeSinceServe() { return (this.time - this.serveTime) / 1000; } update_dt(time) { this.lastTime = this.time; this.time = time; this.dt_ms = this.time - this.lastTime; this.dt = this.dt_ms / 1000; } resetBall() { this.ballSpeed = Pong.BALL_BASE_SPEED; this.ballVelocity = Vec2.zero(); } resetPaddles() { this.playerPosition = Pong.PLAYER_STARTING_POSITION.clone(); this.aiPosition = Pong.AI_STARTING_POSITION.clone(); this.moveUp = false; this.moveDown = false; } } class Pong { constructor(svgContent) { this.state = new PongState(); 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); }); } 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); } reset() { this.state = new PongState(); window.cancelAnimationFrame(this.state.lastAnimationFrame); this.updatePaddles(); this.elements.resetScores(); this.elements.resetBall(); } resetBall() { this.state.resetBall(); this.elements.resetBall(); } resetPaddles() { this.state.resetPaddles(); this.updatePaddles(); } updatePaddles() { translateToPosition(this.elements.playerPaddle, this.state.playerPosition); translateToPosition(this.elements.aiPaddle, this.state.aiPosition); } update(timestamp) { 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); }); } isColliding(paddle) { return paddle.y + Pong.PADDLE_HEIGHT > this.state.collisionPoint.y && paddle.y < this.state.collisionPoint.y; } 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(); } } processHit() { if (this.state.timeSinceServe > this.state.collisionDuration) { let position = this.state.playerPosition; let collisionOffset = 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); } } } clampPaddlePosition(paddlePosition) { paddlePosition.y = Math.max(Math.min(paddlePosition.y, Pong.PADDLE_MAX_POSITION), 0); } 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); } updateAI(paddle, position) { let targetPosition; 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); } } buildBallPath(startingPosition, dir, bounds) { const path = new Line(startingPosition, startingPosition.add(dir)); const points = []; 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; } buildBallPaths(startingPosition) { 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(); } buildSVGPathFromPoints(start, points) { let directions = start.asAbsolute(); for (const point of points) { directions = directions.concat(point.asLine()); } return directions; } findNextCollision(path, bounds) { for (const [line, isEnd] of bounds) { const maybeIntersect = path.lineLineIntersect(line); if (maybeIntersect !== null) { return [maybeIntersect, isEnd]; } } return null; } handleKeydown(event) { 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(); } } handleKeyup(event) { const key = event.key; if (key === ',') { this.state.moveUp = false; } else if (key === '.') { this.state.moveDown = false; } } } /* x w y +---------------------+ | | | h | 0 | | | | +---------------------+ */ Pong.WIDTH = 512; Pong.HEIGHT = 256; Pong.HALF_WIDTH = Pong.WIDTH / 2; Pong.HALF_HEIGHT = Pong.HEIGHT / 2; Pong.CENTER = new Vec2(Pong.HALF_WIDTH, Pong.HALF_HEIGHT); Pong.ABSOLUTE_CENTER = Pong.CENTER.asAbsolute(); Pong.PADDLE_WIDTH = 4; Pong.PADDLE_HEIGHT = 28; Pong.PADDLE_HALF_WIDTH = Pong.PADDLE_WIDTH / 2; Pong.PADDLE_HALF_HEIGHT = Pong.PADDLE_HEIGHT / 2; Pong.PADDLE_MAX_POSITION = Pong.HEIGHT - Pong.PADDLE_HEIGHT; Pong.PADDLE_VELOCITY = 150; Pong.BALL_RADIUS = 5; Pong.BALL_SPEED_INCREASE = 10; Pong.BALL_BASE_SPEED = 200; Pong.PADDLE_STARTING_POSITION_Y = Pong.CENTER.y - Pong.PADDLE_HALF_HEIGHT; Pong.PLAYER_STARTING_POSITION = new Vec2(0, Pong.PADDLE_STARTING_POSITION_Y); Pong.AI_STARTING_POSITION = new Vec2(Pong.WIDTH - Pong.PADDLE_WIDTH, Pong.PADDLE_STARTING_POSITION_Y); Pong.RAW_CORNERS = [ // 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) ]; Pong.BOARD_CORNERS = [ // 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) ]; Pong.PADDLE_COLLISION_CORNERS = [ // 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) ]; Pong.BOARD_BOUNDS = [ // 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] ]; Pong.PADDLE_COLLISION_BOUNDS = [ // 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] ]; // generate random number in range(-1, 1) function genRandom() { return (Math.random() - 0.5) * 2; } function translateToPosition(element, position) { translateTo(element, position.x, position.y); } function translateTo(element, x, y) { element.setAttribute('transform', `translate(${x}, ${y})`); } //# sourceMappingURL=library.js.map