UNPKG

planck-js

Version:

2D JavaScript/TypeScript physics engine for cross-platform HTML5 game development

744 lines (637 loc) 16.6 kB
import { World, CircleShape, BoxShape, EdgeShape, PolygonShape, Testbed, Body, Contact, DataDriver, Vec2Value, } from "planck"; interface BallData { key: string; type: "ball"; speed: number; position: { x: number; y: number }; velocity: { x: number; y: number }; } interface BrickData { key: string; type: "brick"; size: "normal" | "small"; i: number; j: number; } interface DropData { key: string; type: "drop"; value: "+" | "-"; i: number; j: number; speed: number; } interface PaddleData { key: string; type: "paddle"; size: "mini" | "full"; speed: number; position: Vec2Value; } interface WallData { key: string; type: "wall"; floor?: boolean; } type UserData = BallData | BrickData | DropData | PaddleData | WallData; class BreakoutGame { physics = new BreakoutPhysics(); terminal = new TestbedTerminal(); boardWidth = 20; boardHeight = 26; boardRows = 10; boardColumns = 7; state: string; score = 0; combo = 1; globalTime = 0; nextRowTime = 0; resetPaddleTime = 0; balls: BallData[] = []; bricks: BrickData[] = []; drops: DropData[] = []; paddle: PaddleData | null = null; board: WallData = { key: "board", type: "wall" }; getPaddleSpeed() { return 18; } getDropSpeed() { return -6; } getBallSpeed() { return (13 + this.score * 0.05) * 0.7; } getNextRowTime() { return Math.max(8000 - 20 * this.score, 1000); } getResetPaddleTime() { return 7500; } setup() { this.physics.setup(this); this.terminal.setup(this); } update() { this.physics.update(this); this.terminal.update(this); } ready() { if (this.state == "ready") return; this.state = "ready"; this.score = 0; this.combo = 1; this.nextRowTime = 0; this.resetPaddleTime = 0; this.bricks.length = 0; this.balls.length = 0; this.drops.length = 0; this.setPaddle("full"); this.addBall(); this.addRow(); this.addRow(); this.addRow(); this.update(); } play() { this.ready(); this.state = "playing"; this.update(); } end() { this.state = "gameover"; this.paddle = null; this.update(); } keydown(activeKeys: { left?: boolean; right?: boolean; fire?: boolean }) { if (activeKeys.fire) { if (this.state == "gameover") { this.ready(); } else if (this.state == "ready") { this.play(); } } } step(dt: number) { dt = Math.min(dt, 50); const isPlaying = this.state === "playing"; if (isPlaying) { this.globalTime += dt; if (this.nextRowTime && this.globalTime > this.nextRowTime) { this.nextRowTime = 0; this.addRow(); } if (this.resetPaddleTime && this.globalTime > this.resetPaddleTime) { this.resetPaddleTime = 0; this.setPaddle("full"); } } this.movePaddle(); this.update(); } movePaddle() { const isPlaying = this.state === "playing"; const isReady = this.state === "ready"; if (!isPlaying && !isReady) return; if (!this.paddle) return; const isLeftPressed = this.terminal.activeKeys.left; const isRightPressed = this.terminal.activeKeys.right; if (isLeftPressed && !isRightPressed) { this.paddle.speed = -this.getPaddleSpeed(); } else if (isRightPressed && !isLeftPressed) { this.paddle.speed = +this.getPaddleSpeed(); } else { this.paddle.speed = 0; } } setPaddle(size: "mini" | "full") { const position = this.paddle?.position ?? { x: 0, y: -10.5 }; const speed = this.paddle?.speed ?? 0; this.paddle = { key: "paddle-" + performance.now(), type: "paddle", size: size, speed: speed, position: position, }; if (size == "mini") { this.resetPaddleTime = this.globalTime + this.getResetPaddleTime(); } this.update(); } addBall() { const speed = this.getBallSpeed(); const ball = this.balls[this.balls.length - 1]; const position = ball?.position ?? { x: 0, y: -5 }; let velocity = ball?.velocity; if (velocity) { velocity = { x: -velocity.x, y: -velocity.y }; } else { const a = Math.PI * Math.random() * 0.4 - 0.2; velocity = { x: speed * Math.sin(a), y: speed * Math.cos(a) }; } this.balls.push({ key: "ball-" + Math.random(), type: "ball", speed: this.getBallSpeed(), position: position, velocity: velocity, }); this.update(); } addDrop(i: number, j: number) { const type = Math.random() < 0.6 ? "+" : "-"; this.drops.push({ key: "drop-" + Math.random(), type: "drop", value: type, i, j, speed: this.getDropSpeed(), }); this.update(); } addBrick(type: "normal" | "small", i: number, j: number) { this.bricks.push({ key: "brick-" + Math.random(), type: "brick", size: type, i, j, }); this.update(); } addRow() { this.nextRowTime = this.globalTime + this.getNextRowTime(); for (let i = 0; i < this.bricks.length; i++) { const brick = this.bricks[i]; brick.j++; } for (let i = 0; i < this.boardColumns; i++) { if (Math.random() < 0.1) { continue; } const oneChance = this.score + 1; const fourChance = Math.max(0, this.score * 1.1 - 60); if (Math.random() < oneChance / (fourChance + oneChance)) { this.addBrick("normal", i, 0); } else { this.addBrick("small", i - 0.25, -0.25); this.addBrick("small", i + 0.25, -0.25); this.addBrick("small", i - 0.25, +0.25); this.addBrick("small", i + 0.25, +0.25); } } for (let i = 0; i < this.bricks.length; i++) { const brick = this.bricks[i]; if (brick.j >= this.boardRows) { this.end(); continue; } } } collideBallBrick(ball: BallData, brick: BrickData) { if (!Util.removeFromArray(this.bricks, brick)) return; if (!this.bricks.length) { this.addRow(); } this.addDrop(brick.i, brick.j); this.score += this.combo; this.combo++; this.update(); } collideBallPaddle(ball: BallData) { this.combo = 1; } collideBallBottom(ball: BallData) { if (!Util.removeFromArray(this.balls, ball)) return; if (!this.balls.length) { this.end(); } this.update(); } collideDropPaddle(drop: DropData) { if (!Util.removeFromArray(this.drops, drop)) return; if (drop.value == "+") { this.addBall(); } else if (drop.value == "-") { this.setPaddle("mini"); } this.update(); } collideDropBottom(drop: DropData) { if (!Util.removeFromArray(this.drops, drop)) return; this.update(); } } class TestbedTerminal { testbed: Testbed; get activeKeys() { return this.testbed.activeKeys; } setup(game: BreakoutGame) { if (this.testbed) return; this.testbed = Testbed.mount(); this.testbed.width = game.boardWidth; this.testbed.height = game.boardHeight * 1.12; this.testbed.y = 0; this.testbed.keydown = () => { game.keydown(this.testbed.activeKeys); }; this.testbed.step = (dt) => { game.step(dt); }; this.testbed.start(game.physics.world); } update(game: BreakoutGame) { if (game.state == "gameover") { this.testbed.status("Gameover!"); this.testbed.status("Score", game.score); } else if (game.state == "ready") { this.testbed.status("Ready!"); this.testbed.status("Score", game.score); } else { this.testbed.status(""); this.testbed.status("Score", game.score); } } } const BALL_BITS = 1; const WALL_BITS = 2; const BRICK_BITS = 4; const DROP_BITS = 8; const PADDLE_BITS = 16; const ballFix = { friction: 0.0, restitution: 1.0, filterCategoryBits: BALL_BITS, filterMaskBits: PADDLE_BITS | WALL_BITS | BRICK_BITS, }; const paddleFix = { filterCategoryBits: PADDLE_BITS, filterMaskBits: BALL_BITS | DROP_BITS, }; const wallFix = { filterCategoryBits: WALL_BITS, filterMaskBits: BALL_BITS | DROP_BITS, }; const brickFix = { filterCategoryBits: BRICK_BITS, filterMaskBits: BALL_BITS, }; const dropFix = { filterCategoryBits: DROP_BITS, filterMaskBits: PADDLE_BITS | WALL_BITS, }; const ballShape = new CircleShape(0.5); const normalBrickShape = new BoxShape(1.9 / 2, 1.9 / 2); const smallBrickShape = new BoxShape(0.9 / 2, 0.9 / 2); const fullPaddleShape = new PolygonShape([ { x: 1.7, y: -0.2 }, { x: 1.8, y: -0.1 }, { x: 1.8, y: 0.1 }, { x: 1.7, y: 0.2 }, { x: 1.2, y: 0.4 }, { x: 0.4, y: 0.6 }, { x: -0.4, y: 0.6 }, { x: -1.2, y: 0.4 }, { x: -1.7, y: 0.2 }, { x: -1.8, y: 0.1 }, { x: -1.8, y: -0.1 }, { x: -1.7, y: -0.2 }, ]); const miniPaddleShape = new PolygonShape([ { x: 1.2, y: -0.1 }, { x: 1.2, y: 0.1 }, { x: 0.9, y: 0.4 }, { x: 0.2, y: 0.6 }, { x: -0.2, y: 0.6 }, { x: -0.9, y: 0.4 }, { x: -1.2, y: 0.1 }, { x: -1.2, y: -0.1 }, ]); const paddleShapes = { mini: miniPaddleShape, full: fullPaddleShape, }; interface BreakoutPhysicsListener { collideBallBrick(ball: BallData, brick: BrickData): void; collideBallPaddle(ball: BallData): void; collideBallBottom(ball: BallData): void; collideDropPaddle(drop: DropData): void; collideDropBottom(drop: DropData): void; } class BreakoutPhysics { listener: BreakoutPhysicsListener; world: World; driver = new DataDriver<UserData, Body>((data) => data.key, { enter: (data) => { if (data.type === "ball") { return this.createBall(data); } else if (data.type === "brick") { return this.createBrick(data); } else if (data.type === "drop") { return this.createDrop(data); } else if (data.type === "paddle") { return this.createPaddle(data); } else if (data.type === "wall") { return this.createBoard(data); } return null; }, update: (data, body) => { if (data.type === "brick") { this.updateBrick(data, body); } else if (data.type === "ball") { this.updateBall(data, body); } else if (data.type === "paddle") { this.updatePaddle(data, body); } }, exit: (data, body) => { this.world.destroyBody(body); }, }); setup(listener: BreakoutPhysicsListener) { this.listener = listener; if (this.world) return; this.world = new World(); this.world.on("pre-solve", this.collidePhysics); } update(game: BreakoutGame) { this.driver.update([...game.balls, ...game.bricks, ...game.drops, game.paddle, game.board]); } createBoard(data: WallData) { { const wall = this.world.createBody({ type: "static", position: { x: +9, y: -0.5 }, userData: data, }); wall.createFixture({ shape: new EdgeShape({ x: 0, y: -12.5 }, { x: 0, y: +11.5 }), ...wallFix, }); } { const wall = this.world.createBody({ type: "static", position: { x: -9, y: -0.5 }, userData: data, }); wall.createFixture({ shape: new EdgeShape({ x: 0, y: -12.5 }, { x: 0, y: +11.5 }), ...wallFix, }); } { const wall = this.world.createBody({ type: "static", position: { x: 0, y: +12 }, userData: data, }); wall.createFixture({ shape: new EdgeShape({ x: -8, y: 0 }, { x: +8, y: 0 }), ...wallFix, }); } { const wall = this.world.createBody({ type: "static", position: { x: 9, y: 12 }, userData: data, }); wall.createFixture({ shape: new EdgeShape({ x: -1, y: 0 }, { x: 0, y: -1 }), ...wallFix, }); } { const wall = this.world.createBody({ type: "static", position: { x: -9, y: 12 }, userData: data, }); wall.createFixture({ shape: new EdgeShape({ x: 1, y: 0 }, { x: 0, y: -1 }), ...wallFix, }); } { const wall = this.world.createBody({ type: "static", position: { x: 0, y: -13 }, userData: { ...data, floor: true, }, }); wall.createFixture({ shape: new EdgeShape({ x: -9, y: 0 }, { x: +9, y: 0 }), ...wallFix, }); } return null; } createPaddle(data: PaddleData) { const body = this.world.createBody({ type: "kinematic", position: data.position, userData: data, }); const shape = paddleShapes[data.size] || fullPaddleShape; body.createFixture({ shape: shape, ...paddleFix, }); return body; } updatePaddle(data: PaddleData, body: Body) { if (!body) return; data.position = body.getPosition(); body.setLinearVelocity({ x: data.speed, y: 0, }); } createBall(data: BallData) { const body = this.world.createBody({ type: "dynamic", bullet: true, position: data.position, linearVelocity: data.velocity, angle: Math.random() * Math.PI * 2, fixedRotation: true, userData: data, }); body.createFixture({ shape: ballShape, ...ballFix, }); return body; } updateBall(data: BallData, body: Body) { if (!body) return; data.position = body.getPosition(); data.velocity = body.getLinearVelocity(); } createBrick(data: BrickData) { const shape = data.size == "small" ? smallBrickShape : normalBrickShape; const pos = { x: (data.i - 3) * 2, y: 9 - data.j * 2 }; const body = this.world.createBody({ type: "static", position: pos, userData: data, }); body.createFixture({ shape: shape, ...brickFix, }); return body; } updateBrick(data: BrickData, body: Body) { body.setPosition({ x: (data.i - 3) * 2, y: 9 - data.j * 2, }); } createDrop(drop: DropData) { const body = this.world.createBody({ type: "dynamic", position: { x: (drop.i - 3) * 2, y: 9 - drop.j * 2, }, linearVelocity: { x: 0, y: drop.speed, }, userData: drop, }); if (drop.value == "+") { body.createFixture({ shape: new BoxShape(0.08, 0.32), ...dropFix, }); body.createFixture({ shape: new BoxShape(0.32, 0.08), ...dropFix, }); } else if (drop.value == "-") { body.createFixture({ shape: new BoxShape(0.3, 0.1), ...dropFix, }); } else { body.createFixture({ shape: new CircleShape(0.3), ...dropFix, }); } return body; } collidePhysics = (contact: Contact) => { const fixtureA = contact.getFixtureA(); const bodyA = fixtureA.getBody(); const fixtureB = contact.getFixtureB(); const bodyB = fixtureB.getBody(); const dA = bodyA.getUserData() as UserData; const dB = bodyB.getUserData() as UserData; if (!dA || !dB) { return; } const ball = dA.type === "ball" ? dA : dB.type === "ball" ? dB : null; const brick = dA.type === "brick" ? dA : dB.type === "brick" ? dB : null; const bottom = dA.type === "wall" && dA.floor ? dA : dB.type === "wall" && dB.floor ? dB : null; const paddle = dA.type === "paddle" ? dA : dB.type === "paddle" ? dB : null; const drop = dA.type === "drop" ? dA : dB.type === "drop" ? dB : null; // do not change world immediately if (ball && brick) { this.world.queueUpdate(() => { this.listener.collideBallBrick(ball as BallData, brick as BrickData); }); } else if (ball && bottom) { this.world.queueUpdate(() => { this.listener.collideBallBottom(ball as BallData); }); } else if (ball && paddle) { this.world.queueUpdate(() => { this.listener.collideBallPaddle(ball as BallData); }); } else if (drop && paddle) { this.world.queueUpdate(() => { this.listener.collideDropPaddle(drop as DropData); }); } else if (drop && bottom) { this.world.queueUpdate(() => { this.listener.collideDropBottom(drop as DropData); }); } }; } class Util { static removeFromArray<T>(array: T[], item: T) { const i = array.indexOf(item); if (i == -1) { return false; } else { array.splice(i, 1); return true; } } } { const game = new BreakoutGame(); game.setup(); game.play(); }