my_blog/ts/src/pong.ts

604 lines
18 KiB
TypeScript

/**
*
* @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 <https://www.gnu.org/licenses/>.
* @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<Vec2> = [
// 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<Vec2> = [
// 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<Vec2> = [
// 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<Vec2> = [];
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<Vec2>): 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})`);
}