diff --git a/.changeset/gentle-colts-hunt.md b/.changeset/gentle-colts-hunt.md new file mode 100644 index 00000000..21f3be3d --- /dev/null +++ b/.changeset/gentle-colts-hunt.md @@ -0,0 +1,5 @@ +--- +"planck": minor +--- + +Add DataDriver (experimental for demo use-case) diff --git a/example/8-Ball.ts b/example/8-Ball.ts index 4817cf2a..9c736a29 100644 --- a/example/8-Ball.ts +++ b/example/8-Ball.ts @@ -1,14 +1,14 @@ -import { World, Circle, Settings, Polygon, Testbed, Vec2Value, Contact, Body } from "planck"; - -const POCKET = "pocket"; -const BALL = "ball"; -const RAIL = "rail"; - -const TABLE_WIDTH = 8.0; -const TABLE_HEIGHT = 4.0; - -const BALL_RADIUS = 0.12; -const POCKET_RADIUS = 0.2; +import { + World, + Circle, + Settings, + Polygon, + Testbed, + Vec2Value, + Contact, + DataDriver, + Body, +} from "planck"; const BLACK = "black"; const WHITE = "white"; @@ -29,6 +29,115 @@ const COLORS = [ "blue-stripe", ]; +class EightBallGame { + // physics simulation + physics = new BilliardPhysics(); + + // user input and output + terminal = new TestbedTerminal(); + + // table geometry + table = new BilliardTableData(); + + // game data + balls: BallData[]; + rails: RailData[]; + pockets: PocketData[]; + + // setup everything together + setup() { + this.physics.setup(this); + this.terminal.setup(this); + } + + // inform the physics and the terminal about the changes + update() { + this.physics.update(this); + this.terminal.update(this); + } + + // start a new game + start() { + this.rails = this.table.getRails(); + this.pockets = this.table.getPockets(); + this.balls = this.table.rackBalls(); + + this.update(); + } + + // reset the cue ball + resetCueBall() { + this.balls.push(this.table.cueBall()); + + this.update(); + } + + // sink event listener + onBallInPocket(ball: BallData, pocket: PocketData) { + const index = this.balls.indexOf(ball); + if (index !== -1) this.balls.splice(index, 1); + + if (ball.color === BLACK) { + this.balls = []; + setTimeout(this.start.bind(this), 400); + } else if (ball.color === WHITE) { + setTimeout(this.resetCueBall.bind(this), 400); + } + + this.update(); + } +} + +// we use testbed here to implement user input and output +class TestbedTerminal { + testbed: Testbed; + + setup(game: EightBallGame) { + if (this.testbed) return; + + Settings.velocityThreshold = 0; + + this.testbed = Testbed.mount(); + this.testbed.x = 0; + this.testbed.y = 0; + this.testbed.width = game.table.tableWidth * 1.2; + this.testbed.height = game.table.tableHeight * 1.2; + this.testbed.mouseForce = -20; + this.testbed.start(game.physics.world); + } + + update(game: EightBallGame) {} +} + +interface BallData { + type: "ball"; + key: string; + position: { + x: number; + y: number; + }; + radius: number; + color: string; +} + +interface RailData { + type: "rail"; + key: string; + vertices: Vec2Value[] | undefined; +} + +interface PocketData { + type: "pocket"; + key: string; + position: { + x: number; + y: number; + }; + radius: number; +} + +type UserData = BallData | RailData | PocketData; + const STYLES = { "black": { fill: "#000000", stroke: "#ffffff" }, "white": { fill: "#ffffff", stroke: "#000000" }, @@ -48,217 +157,264 @@ const STYLES = { "blue-stripe": { fill: "#0077ff", stroke: "#ffffff" }, }; -Settings.velocityThreshold = 0; - -interface BallData { - x: number; - y: number; - color?: string; -} - -interface BilliardPhysicsClientInterface { - onBallInPocket(ball: Body, pocket: Body): void; +interface BilliardPhysicsListener { + onBallInPocket(ball: BallData, pocket: PocketData): void; } class BilliardPhysics { - client?: BilliardPhysicsClientInterface; - + listener: BilliardPhysicsListener; world: World; - balls: Body[] = []; - constructor(client?: BilliardPhysicsClientInterface) { - this.client = client; + // physics driver bridges the game data and the physics world + driver = new DataDriver((data) => data.key, { + enter: (data: UserData) => { + if (data.type === "ball") return this.createBall(data); + if (data.type === "rail") return this.createRail(data); + if (data.type === "pocket") return this.createPocket(data); + return null; + }, + update: (data, body) => {}, + exit: (data, body) => { + this.world.destroyBody(body); + }, + }); + + setup(listener: BilliardPhysicsListener) { + this.listener = listener; + this.world = new World(); + this.world.on("begin-contact", this.collide); } - setup() { - if (this.world) return; + update(game: EightBallGame) { + this.driver.update([...game.balls, ...game.rails, ...game.pockets]); + } - this.world = new World(); - this.world.on("post-solve", this.collide.bind(this)); + createBall(data: BallData) { + const body = this.world.createBody({ + type: "dynamic", + bullet: true, + position: data.position, + linearDamping: 1.5, + angularDamping: 1, + userData: data, + }); + const color = data.color; + const style = color && STYLES[color]; + body.createFixture({ + shape: new Circle(data.radius), + friction: 0.1, + restitution: 0.99, + density: 1, + userData: data, + style, + }); + return body; + } - this.createTable(); + createRail(data: RailData) { + const body = this.world.createBody({ + type: "static", + userData: data, + }); + const fixture = body.createFixture({ + shape: new Polygon(data.vertices), + friction: 0.1, + restitution: 0.9, + userData: data, + }); + return body; } - start(balls: BallData[]) { - this.createBalls(balls); + createPocket(data: PocketData) { + const body = this.world.createBody({ + type: "static", + position: data.position, + userData: data, + }); + const fixture = body.createFixture({ + shape: new Circle(data.radius), + userData: data, + isSensor: true, + }); + return body; } - createBalls(ballsData: BallData[]) { - for (let i = 0; i < this.balls.length; i++) { - const ball = this.balls[i]; - this.world.destroyBody(ball); - } + collide = (contact: Contact) => { + const fA = contact.getFixtureA(); + const bA = fA.getBody(); + const fB = contact.getFixtureB(); + const bB = fB.getBody(); - for (let i = 0; i < ballsData.length; i++) { - const ball = this.world.createBody({ - type: "dynamic", - bullet: true, - position: ballsData[i], - linearDamping: 1.5, - angularDamping: 1, - }); - this.balls.push(ball); - const color = ballsData[i].color; - const style = color && STYLES[color]; - ball.createFixture({ - shape: new Circle(BALL_RADIUS), - friction: 0.1, - restitution: 0.99, - density: 1, - userData: BALL, - style, + const dataA = bA.getUserData() as UserData; + const dataB = bB.getUserData() as UserData; + + if (!dataA || !dataB) return; + + const ball = dataA.type === "ball" ? bA : dataB.type === "ball" ? bB : null; + const pocket = dataA.type === "pocket" ? bA : dataB.type === "pocket" ? bB : null; + + if (ball && pocket) { + // do not change world immediately + this.world.queueUpdate(() => { + this.listener.onBallInPocket( + ball.getUserData() as BallData, + pocket.getUserData() as PocketData, + ); }); } - } + }; +} + +// table data +class BilliardTableData { + tableWidth = 8.0; + tableHeight = 4.0; + + ballRadius = 0.12; + pocketRadius = 0.2; - createTable() { + getRails(): RailData[] { const SPI4 = Math.sin(Math.PI / 4); const topLeftRail = [ { - x: POCKET_RADIUS, - y: TABLE_HEIGHT * 0.5, + x: this.pocketRadius, + y: this.tableHeight * 0.5, }, { - x: POCKET_RADIUS, - y: TABLE_HEIGHT * 0.5 + POCKET_RADIUS, + x: this.pocketRadius, + y: this.tableHeight * 0.5 + this.pocketRadius, }, { - x: TABLE_WIDTH * 0.5 - POCKET_RADIUS / SPI4 + POCKET_RADIUS, - y: TABLE_HEIGHT * 0.5 + POCKET_RADIUS, + x: this.tableWidth * 0.5 - this.pocketRadius / SPI4 + this.pocketRadius, + y: this.tableHeight * 0.5 + this.pocketRadius, }, { - x: TABLE_WIDTH * 0.5 - POCKET_RADIUS / SPI4, - y: TABLE_HEIGHT * 0.5, + x: this.tableWidth * 0.5 - this.pocketRadius / SPI4, + y: this.tableHeight * 0.5, }, ]; const leftRail = [ { - x: TABLE_WIDTH * 0.5, - y: -(TABLE_HEIGHT * 0.5 - POCKET_RADIUS / SPI4), + x: this.tableWidth * 0.5, + y: -(this.tableHeight * 0.5 - this.pocketRadius / SPI4), }, { - x: TABLE_WIDTH * 0.5 + POCKET_RADIUS, - y: -(TABLE_HEIGHT * 0.5 - POCKET_RADIUS / SPI4 + POCKET_RADIUS), + x: this.tableWidth * 0.5 + this.pocketRadius, + y: -(this.tableHeight * 0.5 - this.pocketRadius / SPI4 + this.pocketRadius), }, { - x: TABLE_WIDTH * 0.5 + POCKET_RADIUS, - y: TABLE_HEIGHT * 0.5 - POCKET_RADIUS / SPI4 + POCKET_RADIUS, + x: this.tableWidth * 0.5 + this.pocketRadius, + y: this.tableHeight * 0.5 - this.pocketRadius / SPI4 + this.pocketRadius, }, { - x: TABLE_WIDTH * 0.5, - y: TABLE_HEIGHT * 0.5 - POCKET_RADIUS / SPI4, + x: this.tableWidth * 0.5, + y: this.tableHeight * 0.5 - this.pocketRadius / SPI4, + }, + ]; + return [ + { + type: "rail", + key: "rail-1", + vertices: leftRail, + }, + { + type: "rail", + key: "rail-2", + vertices: leftRail.map((v) => ({ x: -v.x, y: +v.y })), + }, + { + type: "rail", + key: "rail-3", + vertices: topLeftRail, + }, + { + type: "rail", + key: "rail-4", + vertices: topLeftRail.map((v) => ({ x: -v.x, y: +v.y })), + }, + { + type: "rail", + key: "rail-5", + vertices: topLeftRail.map((v) => ({ x: +v.x, y: -v.y })), + }, + { + type: "rail", + key: "rail-6", + vertices: topLeftRail.map((v) => ({ x: -v.x, y: -v.y })), }, ]; - - const rails: Vec2Value[][] = []; - - rails.push(leftRail); - rails.push(leftRail.map((v) => ({ x: -v.x, y: +v.y }))); - - rails.push(topLeftRail); - rails.push(topLeftRail.map((v) => ({ x: -v.x, y: +v.y }))); - rails.push(topLeftRail.map((v) => ({ x: +v.x, y: -v.y }))); - rails.push(topLeftRail.map((v) => ({ x: -v.x, y: -v.y }))); - - for (let i = 0; i < rails.length; i++) { - const body = this.world.createBody({ - type: "static", - }); - const fixture = body.createFixture({ - shape: new Polygon(rails[i]), - friction: 0.1, - restitution: 0.9, - userData: RAIL, - }); - } - - const pockets: Vec2Value[] = []; - pockets.push({ - x: 0, - y: -TABLE_HEIGHT * 0.5 - POCKET_RADIUS * 1.5, - }); - pockets.push({ - x: 0, - y: +TABLE_HEIGHT * 0.5 + POCKET_RADIUS * 1.5, - }); - pockets.push({ - x: +TABLE_WIDTH * 0.5 + POCKET_RADIUS * 0.7, - y: +TABLE_HEIGHT * 0.5 + POCKET_RADIUS * 0.7, - }); - pockets.push({ - x: -TABLE_WIDTH * 0.5 - POCKET_RADIUS * 0.7, - y: +TABLE_HEIGHT * 0.5 + POCKET_RADIUS * 0.7, - }); - pockets.push({ - x: +TABLE_WIDTH * 0.5 + POCKET_RADIUS * 0.7, - y: -TABLE_HEIGHT * 0.5 - POCKET_RADIUS * 0.7, - }); - pockets.push({ - x: -TABLE_WIDTH * 0.5 - POCKET_RADIUS * 0.7, - y: -TABLE_HEIGHT * 0.5 - POCKET_RADIUS * 0.7, - }); - - for (let i = 0; i < pockets.length; i++) { - const body = this.world.createBody({ - type: "static", - position: pockets[i], - }); - const fixture = body.createFixture({ - shape: new Circle(POCKET_RADIUS), - userData: POCKET, - }); - } - } - - collide = (contact: Contact) => { - const fA = contact.getFixtureA(); - const bA = fA.getBody(); - const fB = contact.getFixtureB(); - const bB = fB.getBody(); - - const ball = fA.getUserData() === BALL ? bA : fB.getUserData() === BALL ? bB : null; - const pocket = fA.getUserData() === POCKET ? bA : fB.getUserData() === POCKET ? bB : null; - - if (ball && pocket) { - // do not change world immediately - this.world.queueUpdate(() => { - this.world.destroyBody(ball); - this.client?.onBallInPocket(ball, pocket); - }); - } - }; -} - -class EightBallGame { - terminal: EightballTerminalInterface; - physics: BilliardPhysics; - - setup(terminal: EightballTerminalInterface) { - this.terminal = terminal; - this.physics = new BilliardPhysics(this); - - this.physics.setup(); - this.terminal.setup(this); - } - - onBallInPocket(ball: Body, pocket: Body) { - // todo } - start() { - this.physics.start(this.rackBalls()); - this.terminal.start(this); + getPockets(): PocketData[] { + return [ + { + type: "pocket", + key: "pocket-1", + radius: this.pocketRadius, + position: { + x: 0, + y: -this.tableHeight * 0.5 - this.pocketRadius * 1.5, + }, + }, + { + type: "pocket", + key: "pocket-2", + radius: this.pocketRadius, + position: { + x: 0, + y: +this.tableHeight * 0.5 + this.pocketRadius * 1.5, + }, + }, + { + type: "pocket", + key: "pocket-3", + radius: this.pocketRadius, + position: { + x: +this.tableWidth * 0.5 + this.pocketRadius * 0.7, + y: +this.tableHeight * 0.5 + this.pocketRadius * 0.7, + }, + }, + { + type: "pocket", + key: "pocket-4", + radius: this.pocketRadius, + position: { + x: -this.tableWidth * 0.5 - this.pocketRadius * 0.7, + y: +this.tableHeight * 0.5 + this.pocketRadius * 0.7, + }, + }, + { + type: "pocket", + key: "pocket-5", + radius: this.pocketRadius, + position: { + x: +this.tableWidth * 0.5 + this.pocketRadius * 0.7, + y: -this.tableHeight * 0.5 - this.pocketRadius * 0.7, + }, + }, + { + type: "pocket", + key: "pocket-6", + radius: this.pocketRadius, + position: { + x: -this.tableWidth * 0.5 - this.pocketRadius * 0.7, + y: -this.tableHeight * 0.5 - this.pocketRadius * 0.7, + }, + }, + ]; } rackBalls() { - const r = BALL_RADIUS; - const cx = TABLE_WIDTH / 4; + const r = this.ballRadius; + const cx = this.tableWidth / 4; const cy = 0; const SPI3 = Math.sin(Math.PI / 3); + Util.shuffleArray(COLORS); + const n = 5; const balls: BallData[] = []; const d = r * 2; @@ -266,62 +422,56 @@ class EightBallGame { for (let i = 0; i < n; i++) { for (let j = 0; j <= i; j++) { balls.push({ - x: cx + i * l /*- (n - 1) * 0.5 * l*/ + Math.random() * r * 0.02, - y: cy + (j - i * 0.5) * d + Math.random() * r * 0.02, + type: "ball", + key: "ball-" + Math.random(), + position: { + x: cx + i * l /*- (n - 1) * 0.5 * l*/ + Math.random() * r * 0.02, + y: cy + (j - i * 0.5) * d + Math.random() * r * 0.02, + }, + radius: this.ballRadius, + color: COLORS[balls.length], }); } } - shuffleArray(COLORS); - - for (let i = 0; i < COLORS.length; i++) { - balls[i].color = COLORS[i]; - } balls[14].color = balls[4].color; balls[4].color = BLACK; - balls.push({ x: -TABLE_WIDTH / 4, y: 0, color: WHITE }); + balls.push(this.cueBall()); return balls; } -} - -interface EightballTerminalInterface { - setup(game: EightBallGame): void; - start(game: EightBallGame): void; -} -class TestbedTerminal implements EightballTerminalInterface { - testbed: Testbed; + cueBall(): BallData { + return { + type: "ball", + key: "ball-" + Math.random(), + position: { + x: -this.tableWidth / 4, + y: 0, + }, - setup(game: EightBallGame) { - if (this.testbed) return; - this.testbed = Testbed.mount(); - this.testbed.x = 0; - this.testbed.y = 0; - this.testbed.width = TABLE_WIDTH * 1.2; - this.testbed.height = TABLE_HEIGHT * 1.2; - this.testbed.mouseForce = -20; - this.testbed.start(game.physics.world); + radius: this.ballRadius, + color: WHITE, + }; } +} - start(game: EightBallGame) {} +class Util { + static shuffleArray(array: T[]) { + // http://stackoverflow.com/a/12646864/483728 + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } + return array; + } } { - const terminal = new TestbedTerminal(); const game = new EightBallGame(); - game.setup(terminal); + game.setup(); game.start(); } - -function shuffleArray(array: T[]) { - // http://stackoverflow.com/a/12646864/483728 - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - const temp = array[i]; - array[i] = array[j]; - array[j] = temp; - } - return array; -} diff --git a/example/Asteroid.ts b/example/Asteroid.ts index 2af30bf9..f7385760 100644 --- a/example/Asteroid.ts +++ b/example/Asteroid.ts @@ -1,4 +1,4 @@ -import { World, Circle, Polygon, Testbed, Body, Contact, Vec2Value } from "planck"; +import { World, Circle, Polygon, Testbed, Body, Contact, Vec2Value, DataDriver } from "planck"; const SPACE_WIDTH = 16; const SPACE_HEIGHT = 9; @@ -9,185 +9,148 @@ const BULLET_LIFE_TIME = 1000; const ASTEROID_RADIUS = 0.2; const ASTEROID_SPEED = 2; -const SHIP_BITS = 2; -const BULLET_BITS = 4; -const ASTEROID_BITS = 4; +interface ShipData { + key: string; + type: "ship"; + x: number; + y: number; + left?: boolean; + right?: boolean; + forward?: boolean; +} -interface UserData { - type: string; - size?: number; +interface BulletData { + key: string; + type: "bullet"; bulletTime?: number; + ship: ShipData; } -interface AsteroidPhysicsClientInterface { - collideShipAsteroid(ship: Body, asteroid: Body): void; - collideBulletAsteroid(asteroidBody: Body, bulletBody: Body): void; +interface AsteroidData { + key: string; + type: "asteroid"; + size?: number; + x: number; + y: number; } -class AsteroidPhysics { - client?: AsteroidPhysicsClientInterface; +type UserData = ShipData | BulletData | AsteroidData; - world: World; - asteroids: Body[] = []; - bullets: Body[] = []; - ship: Body | null; +class AsteroidGame { + terminal = new TestbedTerminal(); + physics = new AsteroidPhysics(); - constructor(client?: AsteroidPhysicsClientInterface) { - this.client = client; - } + level: number; + lives: number; + gameover: boolean; - setup() { - if (this.world) return; + globalTime = 0; + allowCrashTime = 0; + allowFireTime = 0; - this.world = new World(); - this.world.on("pre-solve", this.collidePhysics.bind(this)); - } + bullets: BulletData[] = []; + asteroids: AsteroidData[] = []; + ship: ShipData | null = null; - start() { - this.createShip(); - this.createAsteroids(4); + setup() { + this.physics.setup(this); + this.terminal.setup(this); } - end() {} + update() { + this.physics.update(this); + this.terminal.update(this); + } - step = (dt: number) => { - if (this.ship) { - this.wrapBody(this.ship); - } + start() { + this.gameover = false; + this.level = 1; + this.lives = 3; - for (let i = 0; i !== this.bullets.length; i++) { - this.wrapBody(this.bullets[i]); - } + this.setupShip(); + this.initAsteroids(4); - for (let i = 0; i !== this.asteroids.length; i++) { - this.wrapBody(this.asteroids[i]); - } - }; + this.update(); + } - createShip() { - this.ship = this.world.createBody({ - type: "dynamic", - angularDamping: 2.0, - linearDamping: 0.5, - position: { x: 0, y: 0 }, - userData: { - type: "ship", - }, - }); + end() { + this.gameover = true; - this.ship.createFixture({ - shape: new Polygon([ - { x: -0.15, y: -0.15 }, - { x: 0, y: -0.1 }, - { x: 0.15, y: -0.15 }, - { x: 0, y: 0.2 }, - ]), - density: 1000, - filterCategoryBits: SHIP_BITS, - filterMaskBits: ASTEROID_BITS, - }); + this.update(); } - steerLeft() { - if (!this.ship) return false; - this.ship.applyAngularImpulse(0.1, true); - return true; + setupShip() { + this.ship = { + key: "ship", + type: "ship", + x: 0, + y: 0, + }; + this.allowCrashTime = this.globalTime + 2000; + this.update(); } - steerRight() { - if (!this.ship) return false; - this.ship.applyAngularImpulse(-0.1, true); - return true; - } + step = (dt: number) => { + this.globalTime += dt; - thrustForward() { - if (!this.ship) return false; - const f = this.ship.getWorldVector({ x: 0.0, y: 1.0 }); - const p = this.ship.getWorldPoint({ x: 0.0, y: 2.0 }); - this.ship.applyLinearImpulse(f, p, true); - return true; - } + if (this.ship) { + this.ship.left = this.terminal.activeKeys.left && !this.terminal.activeKeys.right; + this.ship.right = this.terminal.activeKeys.right && !this.terminal.activeKeys.left; + this.ship.forward = this.terminal.activeKeys.up; + if (this.terminal.activeKeys.fire) { + this.fireBullet(this.ship); + } + } - fireBullet(speed = 5): Body | null { - if (!this.ship) return null; + for (let i = this.bullets.length - 1; i >= 0; i--) { + const bullet = this.bullets[i]; + if ((bullet.bulletTime ?? 0) <= this.globalTime) { + this.deleteBullet(bullet); + } + } - const body = this.world.createBody({ - type: "dynamic", - // mass : 0.05, - position: this.ship.getWorldPoint({ x: 0, y: 0 }), - linearVelocity: this.ship.getWorldVector({ x: 0, y: speed }), - bullet: true, - userData: { - type: "bullet", - }, - }); + this.update(); + this.physics.step(dt); + }; - body.createFixture({ - shape: new Circle(0.05), - filterCategoryBits: BULLET_BITS, - filterMaskBits: ASTEROID_BITS, + fireBullet(ship: ShipData) { + if (this.allowFireTime > this.globalTime || !this.ship) { + return false; + } + this.allowFireTime = this.globalTime + FIRE_RELOAD_TIME; + this.bullets.push({ + key: "bullet-" + Math.random(), + type: "bullet", + bulletTime: this.globalTime + BULLET_LIFE_TIME, + + ship: this.ship, }); - this.bullets.push(body); - return body; + this.update(); } - createAsteroids(count: number) { - while (this.asteroids.length) { - const asteroidBody = this.asteroids.shift(); - this.world.destroyBody(asteroidBody!); - } + initAsteroids(count: number) { + this.asteroids.length = 0; for (let i = 0; i < count; i++) { const x = Calc.random(SPACE_WIDTH); const y = Calc.random(SPACE_HEIGHT); - const vx = Calc.random(ASTEROID_SPEED); - const vy = Calc.random(ASTEROID_SPEED); - const va = Calc.random(ASTEROID_SPEED); - - this.makeAsteroidBody(x, y, vx, vy, va, 4); - } - } - - makeAsteroidBody(x: number, y: number, vx: number, vy: number, va: number, size: number) { - const radius = size * ASTEROID_RADIUS; - - const n = 8; - const path: Vec2Value[] = []; - for (let i = 0; i < n; i++) { - const a = (i * 2 * Math.PI) / n; - const x = radius * (Math.sin(a) + Calc.random(0.3)); - const y = radius * (Math.cos(a) + Calc.random(0.3)); - path.push({ x: x, y: y }); - } - - const asteroidBody = this.world.createBody({ - // mass : 10, - type: "kinematic", - position: { x: x, y: y }, - linearVelocity: { x: vx, y: vy }, - angularVelocity: va, - userData: { + this.asteroids.push({ + key: "asteroid-" + Math.random(), type: "asteroid", - size: size, - }, - }); - this.asteroids.push(asteroidBody); - - asteroidBody.createFixture({ - shape: new Polygon(path), - filterCategoryBits: ASTEROID_BITS, - filterMaskBits: BULLET_BITS | SHIP_BITS, - }); + size: 4, + x: x, + y: y, + }); + } - return asteroidBody; + this.update(); } - splitAsteroid(parent: Body) { - const parentData = parent.getUserData() as UserData; - const currentSize = parentData?.size ?? 4; - const splitSize = currentSize - 1; + splitAsteroid(parentData: AsteroidData, parentBody: Body) { + const parentSize = parentData.size ?? 4; + const splitSize = parentSize - 1; if (splitSize == 0) { return; } @@ -201,201 +164,86 @@ class AsteroidPhysics { x: radius * Math.cos(angle), y: radius * Math.sin(angle), }; - const sp = parent.getWorldPoint(d); - - const vx = Calc.random(ASTEROID_SPEED); - const vy = Calc.random(ASTEROID_SPEED); - const va = Calc.random(ASTEROID_SPEED); - - const child = this.makeAsteroidBody(sp.x, sp.y, vx, vy, va, splitSize); - child.setAngle(Calc.random() * Math.PI); - } - } - - collidePhysics(contact: Contact) { - const fixtureA = contact.getFixtureA(); - const fixtureB = contact.getFixtureB(); - - const bodyA = fixtureA.getBody(); - const bodyB = fixtureB.getBody(); - - const dataA = bodyA.getUserData() as UserData; - const dataB = bodyB.getUserData() as UserData; - - const ship = dataA?.type == "ship" ? bodyA : dataB?.type == "ship" ? bodyB : null; - - const bullet = dataA?.type == "bullet" ? bodyA : dataB?.type == "bullet" ? bodyB : null; - - const asteroid = dataA?.type == "asteroid" ? bodyA : dataB?.type == "asteroid" ? bodyB : null; + const sp = parentBody.getWorldPoint(d); - if (ship && asteroid) { - // do not change world immediately - this.world.queueUpdate(() => { - this.client?.collideShipAsteroid(ship, asteroid); + this.asteroids.push({ + key: "asteroid-" + Math.random(), + type: "asteroid", + size: splitSize, + x: sp.x, + y: sp.y, }); } - if (bullet && asteroid) { - // do not change world immediately - this.world.queueUpdate(() => { - this.client?.collideBulletAsteroid(bullet, asteroid); - }); - } + this.update(); } deleteShip(): boolean { if (!this.ship) return false; - - this.world.destroyBody(this.ship); this.ship = null; + + this.update(); return true; } - deleteBullet(bullet: Body): boolean { - const index = this.bullets.indexOf(bullet); + deleteBullet(data: BulletData): boolean { + const index = this.bullets.indexOf(data); if (index != -1) { - this.world.destroyBody(bullet); this.bullets.splice(index, 1); + this.update(); return true; } + return false; } - deleteAsteroid(asteroid: Body): boolean { - const index = this.asteroids.indexOf(asteroid); + deleteAsteroid(data: AsteroidData): boolean { + const index = this.asteroids.indexOf(data); if (index != -1) { - this.world.destroyBody(asteroid); this.asteroids.splice(index, 1); + this.update(); return true; } - return false; - } - - wrapBody(body: Body) { - const p = body.getPosition(); - p.x = Calc.wrap(p.x, -SPACE_WIDTH / 2, SPACE_WIDTH / 2); - p.y = Calc.wrap(p.y, -SPACE_HEIGHT / 2, SPACE_HEIGHT / 2); - body.setPosition(p); - } -} - -class AsteroidGame { - terminal: AsteroidTerminalInterface; - physics: AsteroidPhysics; - - globalTime = 0; - - level: number; - lives: number; - gameover: boolean; - - allowCrashTime = 0; - allowFireTime = 0; - - setup(terminal: AsteroidTerminalInterface) { - this.terminal = terminal; - this.physics = new AsteroidPhysics(this); - - this.physics.setup(); - this.terminal.setup(this); - } - - start() { - this.gameover = false; - this.level = 1; - this.lives = 3; - this.physics.start(); - this.terminal.start(this); - } - - end() { - this.gameover = true; - this.terminal.end(this); - } - - setupShip() { - this.physics.createShip(); - this.allowCrashTime = this.globalTime + 2000; + return false; } - step = (dt: number) => { - this.globalTime += dt; - - if (this.terminal.activeKeys.left && !this.terminal.activeKeys.right) { - this.physics.steerLeft(); - } else if (this.terminal.activeKeys.right && !this.terminal.activeKeys.left) { - this.physics.steerRight(); - } - - if (this.terminal.activeKeys.up) { - this.physics.thrustForward(); - } - - if (this.terminal.activeKeys.fire && this.globalTime > this.allowFireTime) { - const bullet = this.physics.fireBullet(); - if (bullet) { - this.allowFireTime = this.globalTime + FIRE_RELOAD_TIME; - const data = bullet.getUserData() as UserData; - data.bulletTime = this.globalTime + BULLET_LIFE_TIME; - } - } - - for (let i = this.physics.bullets.length - 1; i >= 0; i--) { - const bullet = this.physics.bullets[i]; - const data = bullet.getUserData() as UserData; - if ((data.bulletTime ?? 0) <= this.globalTime) { - this.physics.deleteBullet(bullet); - } - } - - this.physics.step(dt); - }; - - collideShipAsteroid(ship: Body, asteroid: Body) { + collideShipAsteroid() { if (this.allowCrashTime > this.globalTime) { return; } this.lives--; - this.terminal.status(this); - this.physics.deleteShip(); + this.deleteShip(); if (this.lives <= 0) { this.end(); - return; + } else { + setTimeout(() => { + this.setupShip(); + }, 1000); } - setTimeout(() => { - this.setupShip(); - }, 1000); + + this.update(); } collideBulletAsteroid(bullet: Body, asteroid: Body) { - const deletedAsteroid = this.physics.deleteAsteroid(asteroid); - const deletedBullet = this.physics.deleteBullet(bullet); + const deletedAsteroid = this.deleteAsteroid(asteroid.getUserData() as AsteroidData); + const deletedBullet = this.deleteBullet(bullet.getUserData() as BulletData); if (deletedAsteroid && deletedBullet) { - this.physics.splitAsteroid(asteroid); + this.splitAsteroid(asteroid.getUserData() as AsteroidData, asteroid); } - if (this.physics.asteroids.length == 0) { + if (this.asteroids.length == 0) { this.level++; - this.terminal.status(this); - this.physics.createAsteroids(this.level); + this.initAsteroids(this.level); } } } -interface AsteroidTerminalInterface { - activeKeys: { [key: string]: boolean }; - setup(game: AsteroidGame): void; - start(game: AsteroidGame): void; - end(game: AsteroidGame): void; - status(game: AsteroidGame): void; -} - -class TestbedTerminal implements AsteroidTerminalInterface { +class TestbedTerminal { testbed: Testbed; get activeKeys() { @@ -423,17 +271,195 @@ class TestbedTerminal implements AsteroidTerminalInterface { this.testbed.start(game.physics.world); } - start(game: AsteroidGame) { - this.status(game); + update(game: AsteroidGame) { + if (game.lives > 0) { + this.testbed.status(""); + } else { + this.testbed.status("Game Over!"); + } + this.testbed.status("Level", game.level); + this.testbed.status("Lives", game.lives); } +} - end(game: AsteroidGame) { - this.testbed.status("Game Over!"); +interface AsteroidPhysicsListener { + collideShipAsteroid(ship: Body, asteroid: Body): void; + collideBulletAsteroid(asteroidBody: Body, bulletBody: Body): void; +} + +class AsteroidPhysics { + + static SHIP_BITS = 2; + static BULLET_BITS = 4; + static ASTEROID_BITS = 4; + + + listener: AsteroidPhysicsListener; + + world: World; + + driver = new DataDriver((data: UserData) => data.key, { + enter: (data: UserData) => { + if (data.type == "ship") return this.createShip(data); + if (data.type == "asteroid") return this.createAsteroid(data); + if (data.type == "bullet") return this.createBullet(data); + return null; + }, + update: (data: UserData, body: Body) => { + if (data.type == "ship" && body) { + if (data.left) { + body.applyAngularImpulse(0.1, true); + } + if (data.right) { + body.applyAngularImpulse(-0.1, true); + } + if (data.forward) { + const f = body.getWorldVector({ x: 0.0, y: 1.0 }); + const p = body.getWorldPoint({ x: 0.0, y: 2.0 }); + body.applyLinearImpulse(f, p, true); + } + } + }, + exit: (data: UserData, body: Body) => { + this.world.destroyBody(body); + }, + }); + + setup(listener: AsteroidPhysicsListener) { + this.listener = listener; + + if (this.world) return; + this.world = new World(); + this.world.on("pre-solve", this.collide.bind(this)); } - status(game: AsteroidGame) { - this.testbed.status("Level", game.level); - this.testbed.status("Lives", game.lives); + update(game: AsteroidGame) { + this.driver.update([...game.asteroids, ...game.bullets, game.ship]); + } + + step = (dt: number) => { + // wrap objects around the screen + let body = this.world.getBodyList(); + while (body) { + if (body.getType() !== "static") { + const p = body.getPosition(); + p.x = Calc.wrap(p.x, -SPACE_WIDTH / 2, SPACE_WIDTH / 2); + p.y = Calc.wrap(p.y, -SPACE_HEIGHT / 2, SPACE_HEIGHT / 2); + body.setPosition(p); + } + body = body.getNext(); + } + }; + + createShip(data: ShipData) { + const body = this.world.createBody({ + type: "dynamic", + angularDamping: 2.0, + linearDamping: 0.5, + position: { x: 0, y: 0 }, + userData: data, + }); + + body.createFixture({ + shape: new Polygon([ + { x: -0.15, y: -0.15 }, + { x: 0, y: -0.1 }, + { x: 0.15, y: -0.15 }, + { x: 0, y: 0.2 }, + ]), + density: 1000, + filterCategoryBits: AsteroidPhysics.SHIP_BITS, + filterMaskBits: AsteroidPhysics.ASTEROID_BITS, + }); + + return body; + } + + createBullet(data: BulletData): Body | null { + const speed = 5; + const ship = this.driver.ref(data.ship); + if (!ship) return null; + const body = this.world.createBody({ + type: "dynamic", + // mass : 0.05, + position: ship.getWorldPoint({ x: 0, y: 0 }), + linearVelocity: ship.getWorldVector({ x: 0, y: speed }), + bullet: true, + userData: data, + }); + + body.createFixture({ + shape: new Circle(0.05), + filterCategoryBits: AsteroidPhysics.BULLET_BITS, + filterMaskBits: AsteroidPhysics.ASTEROID_BITS, + }); + + return body; + } + + createAsteroid(data: AsteroidData) { + const size = data.size ?? 4; + const radius = size * ASTEROID_RADIUS; + + const n = 8; + const path: Vec2Value[] = []; + for (let i = 0; i < n; i++) { + const a = (i * 2 * Math.PI) / n; + const x = radius * (Math.sin(a) + Calc.random(0.3)); + const y = radius * (Math.cos(a) + Calc.random(0.3)); + path.push({ x: x, y: y }); + } + const vx = Calc.random(ASTEROID_SPEED); + const vy = Calc.random(ASTEROID_SPEED); + const va = Calc.random(ASTEROID_SPEED); + const body = this.world.createBody({ + // mass : 10, + type: "kinematic", + position: { x: data.x, y: data.y }, + angle: Calc.random() * Math.PI, + linearVelocity: { x: vx, y: vy }, + angularVelocity: va, + userData: data, + }); + + body.createFixture({ + shape: new Polygon(path), + filterCategoryBits: AsteroidPhysics.ASTEROID_BITS, + filterMaskBits: AsteroidPhysics.BULLET_BITS | AsteroidPhysics.SHIP_BITS, + }); + + return body; + } + + collide(contact: Contact) { + const fixtureA = contact.getFixtureA(); + const fixtureB = contact.getFixtureB(); + + const bodyA = fixtureA.getBody(); + const bodyB = fixtureB.getBody(); + + const dataA = bodyA.getUserData() as UserData; + const dataB = bodyB.getUserData() as UserData; + + if (!dataA || !dataB) return; + + const ship = dataA.type == "ship" ? bodyA : dataB.type == "ship" ? bodyB : null; + const bullet = dataA.type == "bullet" ? bodyA : dataB.type == "bullet" ? bodyB : null; + const asteroid = dataA.type == "asteroid" ? bodyA : dataB.type == "asteroid" ? bodyB : null; + + if (ship && asteroid) { + // do not change world immediately + this.world.queueUpdate(() => { + this.listener.collideShipAsteroid(ship, asteroid); + }); + } + + if (bullet && asteroid) { + // do not change world immediately + this.world.queueUpdate(() => { + this.listener.collideBulletAsteroid(bullet, asteroid); + }); + } } } @@ -461,8 +487,7 @@ class Calc { } { - const terminal = new TestbedTerminal(); const game = new AsteroidGame(); - game.setup(terminal); + game.setup(); game.start(); } diff --git a/example/Breakout.ts b/example/Breakout.ts index a7b75757..5ea3219c 100644 --- a/example/Breakout.ts +++ b/example/Breakout.ts @@ -1,76 +1,379 @@ import { World, - Vec2, CircleShape, BoxShape, EdgeShape, PolygonShape, Testbed, - Shape, Body, Contact, + DataDriver, + Vec2Value, } from "planck"; -class ObjectData { - type: "ball" | "brick" | "drop" | "paddle" | "bottom"; - subtype: string; +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; - body: Body; } -class BallData extends ObjectData { - type = "ball" as const; - constructor(speed: number) { - super(); - this.speed = speed; - } +interface PaddleData { + key: string; + type: "paddle"; + size: "mini" | "full"; + speed: number; + position: Vec2Value; } -class BrickData extends ObjectData { - type = "brick" as const; - constructor(type: string, i: number, j: number) { - super(); - this.subtype = type; - this.i = i; - this.j = j; - } +interface WallData { + key: string; + type: "wall"; + floor?: boolean; } -class DropData extends ObjectData { - type = "drop" as const; - constructor(type: string, i: number, j: number, speed: number) { - super(); - this.subtype = type; - this.i = i; - this.j = j; - this.speed = speed; +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; } -} -class PaddleData extends ObjectData { - type = "paddle" as const; - subtype: "mini" | "full"; - constructor(type: "mini" | "full") { - super(); - this.subtype = type; + getDropSpeed() { + return -6; } -} -class WallData extends ObjectData { - type = "bottom" as const; - constructor() { - super(); + 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(); } } -interface BreakoutPhysicsClientInterface { - collideBallBrick(ball: BallData, brick: BrickData): void; - collideBallPaddle(ball: BallData): void; - collideBallBottom(ball: BallData): void; - collideDropPaddle(drop: DropData): void; - collideDropBottom(drop: DropData): void; +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; @@ -105,7 +408,7 @@ const dropFix = { 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 miniPaddleShape = new PolygonShape([ +const fullPaddleShape = new PolygonShape([ { x: 1.7, y: -0.2 }, { x: 1.8, y: -0.1 }, { x: 1.8, y: 0.1 }, @@ -119,7 +422,7 @@ const miniPaddleShape = new PolygonShape([ { x: -1.8, y: -0.1 }, { x: -1.7, y: -0.2 }, ]); -const fullPaddleShape = new PolygonShape([ +const miniPaddleShape = new PolygonShape([ { x: 1.2, y: -0.1 }, { x: 1.2, y: 0.1 }, { x: 0.9, y: 0.4 }, @@ -135,103 +438,66 @@ const paddleShapes = { 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 { - client?: BreakoutPhysicsClientInterface; + listener: BreakoutPhysicsListener; world: World; - bottomWall: Body; - paddle: Body; - balls: Body[] = []; - bricks: Body[] = []; - drops: Body[] = []; - constructor(client?: BreakoutPhysicsClientInterface) { - this.client = client; - } + driver = new DataDriver((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); + }, + }); - setupPhysics() { - if (this.world) return; + setup(listener: BreakoutPhysicsListener) { + this.listener = listener; + if (this.world) return; this.world = new World(); this.world.on("pre-solve", this.collidePhysics); - - this.createBoardPhysics(); - } - - resetPhysics() { - for (let i = 0; i < this.balls.length; i++) { - this.world.destroyBody(this.balls[i]); - } - for (let i = 0; i < this.bricks.length; i++) { - this.world.destroyBody(this.bricks[i]); - } - for (let i = 0; i < this.drops.length; i++) { - this.world.destroyBody(this.drops[i]); - } } - endPhysics() { - this.world.destroyBody(this.paddle); - } - - startPhysics() { - const ball = this.balls[0]; - const a = Math.PI * Math.random() * 0.4 - 0.2; - const speed = 10; - ball.setLinearVelocity({ x: speed * Math.sin(a), y: speed * Math.cos(a) }); - } - - collidePhysics = (contact: Contact) => { - const fixtureA = contact.getFixtureA(); - const bodyA = fixtureA.getBody(); - const fixtureB = contact.getFixtureB(); - const bodyB = fixtureB.getBody(); - - const dataA = bodyA.getUserData() as ObjectData; - const dataB = bodyB.getUserData() as ObjectData; - - if (!dataA || !dataB) { - return; - } - - const typeA = dataA.type; - const typeB = dataB.type; - - const ball = typeA === "ball" ? dataA : typeB === "ball" ? dataB : null; - const brick = typeA === "brick" ? dataA : typeB === "brick" ? dataB : null; - const bottom = typeA === "bottom" ? dataA : typeB === "bottom" ? dataB : null; - const paddle = typeA === "paddle" ? dataA : typeB === "paddle" ? dataB : null; - const drop = typeA === "drop" ? dataA : typeB === "drop" ? dataB : null; - - // do not change world immediately - if (ball && brick) { - this.world.queueUpdate(() => { - this.client?.collideBallBrick(ball as BallData, brick as BrickData); - }); - } else if (ball && bottom) { - this.world.queueUpdate(() => { - this.client?.collideBallBottom(ball as BallData); - }); - } else if (ball && paddle) { - this.world.queueUpdate(() => { - this.client?.collideBallPaddle(ball as BallData); - }); - } else if (drop && paddle) { - this.world.queueUpdate(() => { - this.client?.collideDropPaddle(drop as DropData); - }); - } else if (drop && bottom) { - this.world.queueUpdate(() => { - this.client?.collideDropBottom(drop as DropData); - }); - } - }; + update(game: BreakoutGame) { + this.driver.update([...game.balls, ...game.bricks, ...game.drops, game.paddle, game.board]); + } - createBoardPhysics() { + 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 }), @@ -242,6 +508,7 @@ class BreakoutPhysics { 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 }), @@ -252,6 +519,7 @@ class BreakoutPhysics { 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 }), @@ -262,6 +530,7 @@ class BreakoutPhysics { 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 }), @@ -272,6 +541,7 @@ class BreakoutPhysics { 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 }), @@ -282,102 +552,97 @@ class BreakoutPhysics { const wall = this.world.createBody({ type: "static", position: { x: 0, y: -13 }, - userData: new WallData(), + userData: { + ...data, + floor: true, + }, }); wall.createFixture({ shape: new EdgeShape({ x: -9, y: 0 }, { x: +9, y: 0 }), ...wallFix, }); - - this.bottomWall = wall; } + + return null; } - setPaddlePhysics(data: PaddleData) { + createPaddle(data: PaddleData) { const body = this.world.createBody({ type: "kinematic", - position: { x: 0, y: -10.5 }, + position: data.position, userData: data, }); - const shape = paddleShapes[data.subtype] || fullPaddleShape; + const shape = paddleShapes[data.size] || fullPaddleShape; body.createFixture({ shape: shape, ...paddleFix, }); - if (this.paddle) { - const pInit = this.paddle.getPosition(); - const vInit = this.paddle.getLinearVelocity(); - body.setPosition(pInit); - body.setLinearVelocity(vInit); - this.world.destroyBody(this.paddle); - } + return body; + } + + updatePaddle(data: PaddleData, body: Body) { + if (!body) return; - data.body = body; - this.paddle = body; + data.position = body.getPosition(); + + body.setLinearVelocity({ + x: data.speed, + y: 0, + }); } - addBallPhysics(data: BallData) { + 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, }); - const oldBall = this.balls[0]; - if (oldBall) { - body.setPosition(oldBall.getPosition()); - body.setLinearVelocity(Vec2.neg(oldBall.getLinearVelocity())); - } else { - body.setPosition({ x: 0, y: -5 }); - } - - body.setUserData(data); - data.body = body; - this.balls.push(body); + return body; } - removeBallPhysics(ball: BallData) { - const body = ball.body; - if (!removeFromArray(this.balls, body)) return; - this.world.destroyBody(body); + updateBall(data: BallData, body: Body) { + if (!body) return; + + data.position = body.getPosition(); + data.velocity = body.getLinearVelocity(); } - addBrickPhysics(data: BrickData) { - const shape = data.subtype == "small" ? smallBrickShape : normalBrickShape; + 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, }); - body.setUserData(data); - data.body = body; - this.bricks.push(body); + return body; } - updateBrickPhysics(data: BrickData) { - const body = data.body; - body.setPosition({ x: (data.i - 3) * 2, y: 9 - data.j * 2 }); - } - - removeBrickPhysics(data: BrickData) { - const body = data.body; - if (!removeFromArray(this.bricks, body)) return; - this.world.destroyBody(body); + updateBrick(data: BrickData, body: Body) { + body.setPosition({ + x: (data.i - 3) * 2, + y: 9 - data.j * 2, + }); } - addDropPhysics(drop: DropData) { + createDrop(drop: DropData) { const body = this.world.createBody({ type: "dynamic", position: { @@ -390,7 +655,8 @@ class BreakoutPhysics { }, userData: drop, }); - if (drop.subtype == "+") { + + if (drop.value == "+") { body.createFixture({ shape: new BoxShape(0.08, 0.32), ...dropFix, @@ -399,7 +665,7 @@ class BreakoutPhysics { shape: new BoxShape(0.32, 0.08), ...dropFix, }); - } else if (drop.subtype == "-") { + } else if (drop.value == "-") { body.createFixture({ shape: new BoxShape(0.3, 0.1), ...dropFix, @@ -411,324 +677,67 @@ class BreakoutPhysics { }); } - drop.body = body; - this.drops.push(body); - } - - removeDropPhysics(drop: DropData) { - const body = drop.body; - if (!removeFromArray(this.drops, body)) return; - this.world.destroyBody(body); - } - - movePaddlePhysics(dir: number) { - const from = this.paddle.getPosition(); - const to = { x: dir + from.x, y: 0 + from.y }; - const data = this.paddle.getUserData() as PaddleData; - const paddleWidth = data.subtype == "mini" ? 2.4 : 3.6; - const maxX = 9 - paddleWidth / 2; - to.x = Math.min(maxX, Math.max(-maxX, to.x)); - this.paddle.setPosition(to); - } -} - -class BreakoutGame { - WIDTH = 20; - HEIGHT = 26; - - ROWS = 10; - COLUMNS = 7; - - physics: BreakoutPhysics; - terminal: BreakoutTerminalInterface; - - state: string; - - score = 0; - combo = 1; - - passedTime = 0; - nextRowTime = 0; - resetPaddleTime = 0; - - balls: BallData[] = []; - bricks: BrickData[] = []; - drops: DropData[] = []; - - 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(terminal: BreakoutTerminalInterface) { - this.terminal = terminal; - - this.physics = new BreakoutPhysics(this); - - this.physics.setupPhysics(); - this.terminal.setup(this); - - this.resetBoard(); - } - - resetBoard() { - if (this.state == "ready") return; - this.state = "ready"; - this.score = 0; - this.combo = 1; - this.nextRowTime = 0; - this.resetPaddleTime = 0; - this.physics.resetPhysics(); - this.setPaddle("full"); - this.addBall(); - this.addRow(); - this.addRow(); - this.addRow(); - this.updateStatus(); - } - - startGame() { - this.resetBoard(); - this.state = "playing"; - this.physics.startPhysics(); - } - - endGame() { - this.state = "gameover"; - this.updateStatus(); - this.physics.endPhysics(); - } - - setPaddle(size: "mini" | "full") { - const paddle = new PaddleData(size); - - this.physics.setPaddlePhysics(paddle); - } - - addBall() { - const ball = new BallData(this.getBallSpeed()); - this.balls.push(ball); - - this.physics.addBallPhysics(ball); - } - - addDrop(i: number, j: number) { - const type = Math.random() < 0.6 ? "+" : "-"; - const drop = new DropData(type, i, j, this.getDropSpeed()); - this.drops.push(drop); - - this.physics.addDropPhysics(drop); - } - - addBrick(type: string, i: number, j: number) { - const brick = new BrickData(type, i, j); - this.bricks.push(brick); - - this.physics.addBrickPhysics(brick); - } - - updateBrick(brick: BrickData) { - this.physics.updateBrickPhysics(brick); - } - - addRow() { - this.nextRowTime = this.passedTime + this.getNextRowTime(); - - for (let i = 0; i < this.bricks.length; i++) { - const brick = this.bricks[i]; - brick.j++; - this.updateBrick(brick); - } - - for (let i = 0; i < this.COLUMNS; 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.ROWS) { - this.endGame(); - continue; - } - } - } - - movePaddle(dir: number) { - this.physics.movePaddlePhysics(dir); + return body; } - step(dt: number) { - dt = Math.min(dt, 50); - this.passedTime += dt; - - const isPlaying = this.state === "playing"; - const isReady = this.state === "ready"; - - if (isPlaying && isReady) { - return; - } + collidePhysics = (contact: Contact) => { + const fixtureA = contact.getFixtureA(); + const bodyA = fixtureA.getBody(); + const fixtureB = contact.getFixtureB(); + const bodyB = fixtureB.getBody(); - const isLeftPressed = this.terminal.activeKeys.left; - const isRightPressed = this.terminal.activeKeys.right; - if (isLeftPressed && !isRightPressed) { - this.movePaddle((-this.getPaddleSpeed() * dt) / 1000); - } else if (isRightPressed && !isLeftPressed) { - this.movePaddle((+this.getPaddleSpeed() * dt) / 1000); - } + const dA = bodyA.getUserData() as UserData; + const dB = bodyB.getUserData() as UserData; - if (isPlaying) { + if (!dA || !dB) { return; } - if (this.nextRowTime && this.passedTime > this.nextRowTime) { - this.nextRowTime = 0; - this.addRow(); - } - - if (this.resetPaddleTime && this.passedTime > this.resetPaddleTime) { - this.resetPaddleTime = 0; - this.setPaddle("full"); - } - } - - collideBallBrick(ball: BallData, brick: BrickData) { - if (!removeFromArray(this.bricks, brick)) return; - this.physics.removeBrickPhysics(brick); - - if (!this.bricks.length) { - this.addRow(); - } - this.score += this.combo; - // this.combo++; - this.updateStatus(); - this.addDrop(brick.i, brick.j); - } - - collideBallPaddle(ball: BallData) { - // this.combo = 1; - } - - collideBallBottom(ball: BallData) { - if (!removeFromArray(this.balls, ball)) return; - this.physics.removeBallPhysics(ball); - - if (!this.balls.length) { - this.endGame(); - } - } - - collideDropPaddle(drop: DropData) { - if (!removeFromArray(this.drops, drop)) return; - this.physics.removeDropPhysics(drop); + 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; - if (drop.subtype == "+") { - this.addBall(); - } else if (drop.subtype == "-") { - this.setPaddle("mini"); + // 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); + }); } - } - - collideDropBottom(drop: DropData) { - if (!removeFromArray(this.drops, drop)) return; - this.physics.removeDropPhysics(drop); - } - - updateStatus() { - this.terminal.updateState(this); - } -} - -interface BreakoutTerminalInterface { - setup(game: BreakoutGame): void; - activeKeys: Record; - updateState(game: BreakoutGame): void; + }; } -class TestbedTerminal implements BreakoutTerminalInterface { - testbed: Testbed; - - get activeKeys() { - return this.testbed.activeKeys; - } - - updateState(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); +class Util { + static removeFromArray(array: T[], item: T) { + const i = array.indexOf(item); + if (i == -1) { + return false; } else { - this.testbed.status(""); - this.testbed.status("Score", game.score); + array.splice(i, 1); + return true; } } - - setup(game: BreakoutGame) { - if (this.testbed) return; - - this.testbed = Testbed.mount(); - this.testbed.width = game.WIDTH; - this.testbed.height = game.HEIGHT * 1.12; - this.testbed.y = 0; - - this.testbed.keydown = () => { - if (this.testbed.activeKeys.fire) { - if (game.state == "gameover") { - game.resetBoard(); - } else if (game.state == "ready") { - game.startGame(); - } - } - }; - - this.testbed.step = (dt) => { - game.step(dt); - }; - - this.testbed.start(game.physics.world); - } } { - const terminal = new TestbedTerminal(); const game = new BreakoutGame(); - game.setup(terminal); -} - -function removeFromArray(array: T[], item: T) { - const i = array.indexOf(item); - if (i == -1) { - return false; - } else { - array.splice(i, 1); - return true; - } + game.setup(); + game.play(); } diff --git a/example/Shuffle.ts b/example/Shuffle.ts index 99f6c3ab..ed8f3907 100644 --- a/example/Shuffle.ts +++ b/example/Shuffle.ts @@ -1,117 +1,241 @@ -import { World, Vec2Value, Circle, Chain, Settings, Testbed } from "planck"; +import { + World, + Vec2Value, + Circle, + Chain, + Settings, + Testbed, + Contact, + Body, + DataDriver, +} from "planck"; + +type Color = "red" | "blue"; + +interface PuckData { + key: string; + type: "puck"; + color?: Color; + x?: number; + y?: number; + radius?: number; +} -const width = 10.0; -const height = 10.0; +interface WallData { + key: string; + type: "wall"; +} -const BALL_R = 0.3; -const BALL_D = 1; +type UserData = PuckData | WallData; -Settings.velocityThreshold = 0; +class ShuffleGame { + physics = new ShufflePhysics(); + terminal = new TestbedTerminal(); -const world = new World(); + redPucks: PuckData[] = []; + bluePucks: PuckData[] = []; -const testbed = Testbed.mount(); -testbed.x = 0; -testbed.y = 0; -testbed.width = width * 1.5; -testbed.height = height * 1.5; -testbed.mouseForce = -100; -testbed.start(world); + width = 10.0; + height = 10.0; -const walls = [ - { x: -width * 0.5, y: -height * 0.5 }, - { x: -width * 0.5, y: +height * 0.5 }, - { x: +width * 0.5, y: +height * 0.5 }, - { x: +width * 0.5, y: -height * 0.5 }, -]; + puckRadius = 0.3; + puckSpace = 1; -const wallFixDef = { - userData: "wall", -}; + setup() { + this.physics.setup(this); + this.terminal.setup(this); + } -const ballFixDef = { - friction: 0.1, - restitution: 0.98, - density: 0.8, - userData: "ball", -}; + update() { + this.physics.update(this); + this.terminal.update(this); + } + + start() { + this.physics.start(); + + this.physics.createBoard(this.width, this.height); + + this.redPucks = this.team(1, 8, this.puckRadius, this.puckSpace).map((v) => ({ + key: "red-puck-" + v.x + "-" + v.y, + x: v.x + this.height * 0.4, + y: v.y + 0, + color: "red", + type: "puck", + radius: this.puckRadius, + })); + + this.bluePucks = this.team(1, 8, this.puckRadius, this.puckSpace).map((v) => ({ + key: "blue-puck-" + v.x + "-" + v.y, + x: v.x + -this.height * 0.4, + y: v.y + 0, + color: "blue", + type: "puck", + radius: this.puckRadius, + })); + + this.update(); + } + + team(n: number, m: number, r: number, l: number) { + const pucks: Vec2Value[] = []; + for (let i = 0; i < n; i++) { + for (let j = 0; j < m; j++) { + pucks.push({ + x: i * l - (n - 1) * 0.5 * l + Math.random() * r * 0.02, + y: j * l - (m - 1) * 0.5 * l + Math.random() * r * 0.02, + }); + } + } + return pucks; + } + + onPuckOut(data: PuckData) { + if (data.color == "red") { + const index = this.redPucks.indexOf(data); + if (index >= 0) this.redPucks.splice(index, 1); + } else if (data.color == "blue") { + const index = this.bluePucks.indexOf(data); + if (index >= 0) this.bluePucks.splice(index, 1); + } + + this.update(); + } +} + +class TestbedTerminal { + testbed: Testbed; + + setup(game: ShuffleGame) { + this.testbed = Testbed.mount(); + this.testbed.x = 0; + this.testbed.y = 0; + this.testbed.width = game.width * 1.5; + this.testbed.height = game.height * 1.5; + this.testbed.mouseForce = -100; + this.testbed.start(game.physics.world); + } + + update(game: ShuffleGame) { + this.testbed.status("Red", game.redPucks.length); + this.testbed.status("Blue", game.bluePucks.length); + } +} -const ballBodyDef = { - type: "dynamic" as const, - bullet: true, - linearDamping: 1.6, - angularDamping: 1.6, +const STYLES = { + red: { fill: "#ff411a", stroke: "black" }, + blue: { fill: "#0077ff", stroke: "black" }, }; -world - .createBody({ - type: "static", - }) - .createFixture({ - shape: new Chain(walls, true), - ...wallFixDef, +interface ShufflePhysicsListener { + onPuckOut(data: PuckData, body: Body): void; +} + +class ShufflePhysics { + listener: ShufflePhysicsListener; + + world: World; + + driver = new DataDriver((data: UserData) => data.key, { + enter: (data: UserData) => { + if (data.type === "puck") return this.createPuck(data); + return null; + }, + update: (data: UserData, body: Body) => {}, + exit: (data: UserData, body: Body) => { + if (body) { + this.world.destroyBody(body); + } + }, }); -row(1, 8, BALL_R, BALL_D) - .map((v) => ({ x: v.x + height * 0.4, y: v.y + 0 })) - .forEach(function (p) { - const ball = world.createBody(ballBodyDef); - ball.setPosition(p); - ball.setAngle(Math.PI); - ball.createFixture({ - shape: new Circle(BALL_R), - ...ballFixDef, + setup(client: ShufflePhysicsListener) { + this.listener = client; + Settings.velocityThreshold = 0; + this.world = new World(); + this.world.on("begin-contact", this.handleBeginContact.bind(this)); + } + + update(game: ShuffleGame) { + this.driver.update([...game.redPucks, ...game.bluePucks]); + } + + start() {} + + createBoard(width: number, height: number) { + const userData = { + type: "wall", + }; + + const shape = new Chain( + [ + { x: -width * 0.5, y: -height * 0.5 }, + { x: -width * 0.5, y: +height * 0.5 }, + { x: +width * 0.5, y: +height * 0.5 }, + { x: +width * 0.5, y: -height * 0.5 }, + ], + true, + ); + + const body = this.world.createBody({ + type: "static", + userData, }); - ball.style = { fill: "#ff411a", stroke: "black" }; - }); -row(1, 8, BALL_R, BALL_D) - .map((v) => ({ x: v.x + -height * 0.4, y: v.y + 0 })) - .forEach(function (p) { - const ball = world.createBody(ballBodyDef); - ball.setPosition(p); - ball.createFixture({ - shape: new Circle(BALL_R), - ...ballFixDef, + body.createFixture({ + shape: shape, + isSensor: true, + userData, }); - ball.style = { fill: "#0077ff", stroke: "black" }; - }); + } -world.on("post-solve", function (contact) { - const fA = contact.getFixtureA(); - const bA = fA.getBody(); - const fB = contact.getFixtureB(); - const bB = fB.getBody(); - - const wall = - fA.getUserData() === wallFixDef.userData - ? bA - : fB.getUserData() === wallFixDef.userData - ? bB - : null; - const ball = - fA.getUserData() === ballFixDef.userData - ? bA - : fB.getUserData() === ballFixDef.userData - ? bB - : null; - - if (ball && wall) { - world.queueUpdate(() => { - world.destroyBody(ball); + createPuck(data: PuckData) { + const style = data.color ? STYLES[data.color] : undefined; + const body = this.world.createBody({ + type: "dynamic", + bullet: true, + position: data as Vec2Value, + linearDamping: 1.6, + angularDamping: 1.6, + userData: data, + style, }); + body.createFixture({ + shape: new Circle(data.radius), + friction: 0.1, + restitution: 0.98, + density: 0.8, + userData: data, + style, + }); + + return body; } -}); - -function row(n: number, m: number, r: number, l: number) { - const balls: Vec2Value[] = []; - for (let i = 0; i < n; i++) { - for (let j = 0; j < m; j++) { - balls.push({ - x: i * l - (n - 1) * 0.5 * l + Math.random() * r * 0.02, - y: j * l - (m - 1) * 0.5 * l + Math.random() * r * 0.02, + + handleBeginContact = (contact: Contact) => { + const fA = contact.getFixtureA(); + const bA = fA.getBody(); + const fB = contact.getFixtureB(); + const bB = fB.getBody(); + + const dataA = fA.getUserData() as UserData; + const dataB = fB.getUserData() as UserData; + + if (!dataA || !dataB) return; + + const wall = dataA.type === "wall" ? bA : dataB.type === "wall" ? bB : null; + const puck = dataA.type === "puck" ? bA : dataB.type === "puck" ? bB : null; + + if (puck && wall) { + this.world.queueUpdate(() => { + this.listener.onPuckOut(puck.getUserData() as PuckData, puck); }); } - } - return balls; + }; +} + +{ + const game = new ShuffleGame(); + game.setup(); + game.start(); } diff --git a/example/Soccer.ts b/example/Soccer.ts index f5e24a39..3ae94cc4 100644 --- a/example/Soccer.ts +++ b/example/Soccer.ts @@ -1,6 +1,14 @@ -import { World, Circle, Chain, Settings, Testbed } from "planck"; - -const testbed = Testbed.mount(); +import { + World, + Circle, + Chain, + Settings, + Testbed, + Contact, + Vec2Value, + DataDriver, + Body, +} from "planck"; const width = 10.0; const height = 6.0; @@ -8,186 +16,298 @@ const height = 6.0; const PLAYER_R = 0.35; const BALL_R = 0.23; -testbed.x = 0; -testbed.y = 0; -testbed.width = width * 1.6; -testbed.height = height * 1.6; -testbed.mouseForce = -120; - -Settings.velocityThreshold = 0; - -const world = new World(); -testbed.start(world); - -const goal = [ - { x: 0, y: -height * 0.2 }, - { x: 0, y: +height * 0.2 }, -]; - -const wallFixDef = { - friction: 0, - restitution: 0, - userData: "wall", -}; -const goalFixDef = { - friction: 0, - restitution: 1, - userData: "goal", -}; - -const ballFixDef = { - friction: 0.2, - restitution: 0.99, - density: 0.5, - userData: "ball", -}; -const ballBodyDef = { - type: "dynamic" as const, - bullet: true, - linearDamping: 3.5, - angularDamping: 1.6, -}; - -const playerFixDef = { - friction: 0.1, - restitution: 0.99, - density: 0.8, - userData: "player", -}; -const playerBodyDef = { - type: "dynamic" as const, - bullet: true, - linearDamping: 4, - angularDamping: 1.6, -}; - -world - .createBody({ - type: "static", - }) - .createFixture({ - shape: new Chain(walls(), true), - ...wallFixDef, - }); +interface PlayerData { + type: "player"; + key: string; + color: string; + position: Vec2Value; +} -{ - // goal left - const body = world.createBody({ - type: "static", - position: { x: -width * 0.5 - BALL_R, y: 0 }, - }); - const fixture = body.createFixture({ - shape: new Chain(goal), - ...goalFixDef, - }); +interface BallData { + type: "ball"; + key: string; } -{ - // goal right - const body = world.createBody({ - type: "static", - position: { x: +width * 0.5 + BALL_R, y: 0 }, - }); - const fixture = body.createFixture({ - shape: new Chain(goal), - ...goalFixDef, - }); +interface WallData { + type: "wall"; + key: string; +} + +interface GoalData { + type: "goal"; + key: string; +} + +type UserData = PlayerData | BallData | WallData | GoalData; + +class SoccerGame { + terminal = new TestbedTerminal(); + physics = new SoccerPhysics(); + + wall: WallData; + rightGoal: GoalData; + leftGoal: GoalData; + ball: BallData | null = null; + + playersRed: PlayerData[] = []; + playersBlue: PlayerData[] = []; + + setup() { + this.physics.setup(this); + this.terminal.setup(this); + } + + update() { + this.physics.update(this); + this.terminal.update(this); + } + + start() { + this.wall = { + key: "wall", + type: "wall", + }; + + this.ball = { + key: "ball-" + Math.random(), + type: "ball", + }; + + this.leftGoal = { + key: "left", + type: "goal", + }; + + this.rightGoal = { + key: "right", + type: "goal", + }; + + this.playersRed = this.team().map((v) => ({ + type: "player", + key: "player-" + Math.random(), + position: v, + color: "red", + })); + + this.playersBlue = this.team(true).map((v) => ({ + type: "player", + key: "player-" + Math.random(), + position: v, + color: "blue", + })); + + this.update(); + } + + onGoal() { + this.ball = null; + setTimeout(() => { + this.ball = { + key: "ball-" + Math.random(), + type: "ball", + }; + this.update(); + }, 500); + + this.update(); + } + + team(reverse: boolean = false) { + const positions = [ + { x: -width * 0.45, y: 0 }, + { x: -width * 0.3, y: -height * 0.2 }, + { x: -width * 0.3, y: +height * 0.2 }, + { x: -width * 0.1, y: -height * 0.1 }, + { x: -width * 0.1, y: +height * 0.1 }, + ]; + if (!reverse) { + return positions; + } else { + return positions.map((v) => ({ x: -v.x, y: v.y })); + } + } +} + +class TestbedTerminal { + setup(game: SoccerGame) { + const testbed = Testbed.mount(); + testbed.x = 0; + testbed.y = 0; + testbed.width = width * 1.6; + testbed.height = height * 1.6; + testbed.mouseForce = -120; + testbed.start(game.physics.world); + } + + update(game: SoccerGame) {} } -const ball = world.createBody(ballBodyDef); -ball.createFixture({ - shape: new Circle(BALL_R), - ...ballFixDef, -}); -ball.style = { fill: "white", stroke: "black" }; - -team().forEach(function (p) { - const player = world.createBody(playerBodyDef); - player.setPosition(p); - player.createFixture({ - shape: new Circle(PLAYER_R), - ...playerFixDef, +interface SoccerPhysicsListener { + onGoal(): void; +} + +class SoccerPhysics { + listener: SoccerPhysicsListener; + + world: World; + + driver = new DataDriver((data) => data.key, { + enter: (data: UserData) => { + if (data.type === "player") return this.createPlayer(data); + if (data.type === "ball") return this.createBall(data); + if (data.type === "wall") return this.createWall(data); + if (data.type === "goal") return this.createGoal(data); + return null; + }, + update: (data, body) => {}, + exit: (data, body) => { + this.world.destroyBody(body); + }, }); - player.style = { fill: "#0077ff", stroke: "black" }; -}); - -team() - .map((v) => ({ x: -v.x, y: v.y })) - .forEach(function (p) { - const player = world.createBody(playerBodyDef); - player.setPosition(p); - player.setAngle(Math.PI); - player.createFixture({ - shape: new Circle(PLAYER_R), - ...playerFixDef, + + setup(listener: SoccerPhysicsListener) { + this.listener = listener; + this.world = new World(); + this.world.on("post-solve", this.collide.bind(this)); + + Settings.velocityThreshold = 0; + } + + update(game: SoccerGame) { + this.driver.update([ + game.wall, + game.leftGoal, + game.rightGoal, + game.ball, + ...game.playersRed, + ...game.playersBlue, + ]); + } + + createWall(data: WallData) { + const vertices = [ + { x: -width * 0.5 + 0.2, y: -height * 0.5 }, + { x: -width * 0.5, y: -height * 0.5 + 0.2 }, + { x: -width * 0.5, y: -height * 0.2 }, + { x: -width * 0.6, y: -height * 0.2 }, + { x: -width * 0.6, y: +height * 0.2 }, + { x: -width * 0.5, y: +height * 0.2 }, + { x: -width * 0.5, y: +height * 0.5 - 0.2 }, + { x: -width * 0.5 + 0.2, y: +height * 0.5 }, + { x: +width * 0.5 - 0.2, y: +height * 0.5 }, + { x: +width * 0.5, y: +height * 0.5 - 0.2 }, + { x: +width * 0.5, y: +height * 0.2 }, + { x: +width * 0.6, y: +height * 0.2 }, + { x: +width * 0.6, y: -height * 0.2 }, + { x: +width * 0.5, y: -height * 0.2 }, + { x: +width * 0.5, y: -height * 0.5 + 0.2 }, + { x: +width * 0.5 - 0.2, y: -height * 0.5 }, + ]; + + const body = this.world.createBody({ + type: "static", + userData: data, }); - player.style = { fill: "#ff411a", stroke: "black" }; - }); + body.createFixture({ + shape: new Chain(vertices, true), + friction: 0, + restitution: 0, + userData: data, + }); + + return body; + } -world.on("post-solve", function (contact) { - const fA = contact.getFixtureA(); - const bA = fA.getBody(); - const fB = contact.getFixtureB(); - const bB = fB.getBody(); - - const wall = - fA.getUserData() === wallFixDef.userData - ? bA - : fB.getUserData() === wallFixDef.userData - ? bB - : null; - const ball = - fA.getUserData() === ballFixDef.userData - ? bA - : fB.getUserData() === ballFixDef.userData - ? bB - : null; - const goal = - fA.getUserData() === goalFixDef.userData - ? bA - : fB.getUserData() === goalFixDef.userData - ? bB - : null; - - if (ball && goal) { - // do not change world immediately - world.queueUpdate(function () { - ball.setPosition({ x: 0, y: 0 }); - ball.setLinearVelocity({ x: 0, y: 0 }); - // world.destroyBody(ball); + createGoal(data: GoalData) { + const body = this.world.createBody({ + type: "static", + position: { + x: data.key === "left" ? -width * 0.5 : +width * 0.5, + y: 0, + }, + userData: data, }); + body.createFixture({ + shape: new Chain([ + { x: 0, y: -height * 0.2 }, + { x: 0, y: +height * 0.2 }, + ]), + friction: 0, + restitution: 1, + userData: data, + }); + return body; + } + + createBall(data: BallData) { + const body = this.world.createBody({ + type: "dynamic", + bullet: true, + linearDamping: 3.5, + angularDamping: 1.6, + userData: data, + }); + body.createFixture({ + shape: new Circle(BALL_R), + friction: 0.2, + restitution: 0.99, + density: 0.5, + userData: data, + }); + body.style = { fill: "white", stroke: "black" }; + return body; + } + + createPlayer(data: PlayerData) { + const body = this.world.createBody({ + type: "dynamic", + bullet: true, + linearDamping: 4, + angularDamping: 1.6, + position: data.position, + userData: data, + }); + body.createFixture({ + shape: new Circle(PLAYER_R), + friction: 0.1, + restitution: 0.99, + density: 0.8, + userData: data, + }); + if (data.color === "red") { + body.style = { fill: "#ff411a", stroke: "black" }; + } else if (data.color === "blue") { + body.style = { fill: "#0077ff", stroke: "black" }; + } + return body; + } + + collide(contact: Contact) { + const fA = contact.getFixtureA(); + const bA = fA.getBody(); + const fB = contact.getFixtureB(); + const bB = fB.getBody(); + + const dataA = bA.getUserData() as UserData; + const dataB = bB.getUserData() as UserData; + + if (!dataA || !dataB) return; + + const ball = dataA.type === "ball" ? bA : dataB.type === "ball" ? bB : null; + const goal = dataA.type === "goal" ? bA : dataB.type === "goal" ? bB : null; + + if (ball && goal) { + // do not change world immediately + this.world.queueUpdate(() => { + this.listener.onGoal(); + }); + } } -}); - -function team() { - const positions = [ - { x: -width * 0.45, y: 0 }, - { x: -width * 0.3, y: -height * 0.2 }, - { x: -width * 0.3, y: +height * 0.2 }, - { x: -width * 0.1, y: -height * 0.1 }, - { x: -width * 0.1, y: +height * 0.1 }, - ]; - return positions; } -function walls() { - const chain = [ - { x: -width * 0.5 + 0.2, y: -height * 0.5 }, - { x: -width * 0.5, y: -height * 0.5 + 0.2 }, - { x: -width * 0.5, y: -height * 0.2 }, - { x: -width * 0.6, y: -height * 0.2 }, - { x: -width * 0.6, y: +height * 0.2 }, - { x: -width * 0.5, y: +height * 0.2 }, - { x: -width * 0.5, y: +height * 0.5 - 0.2 }, - { x: -width * 0.5 + 0.2, y: +height * 0.5 }, - { x: +width * 0.5 - 0.2, y: +height * 0.5 }, - { x: +width * 0.5, y: +height * 0.5 - 0.2 }, - { x: +width * 0.5, y: +height * 0.2 }, - { x: +width * 0.6, y: +height * 0.2 }, - { x: +width * 0.6, y: -height * 0.2 }, - { x: +width * 0.5, y: -height * 0.2 }, - { x: +width * 0.5, y: -height * 0.5 + 0.2 }, - { x: +width * 0.5 - 0.2, y: -height * 0.5 }, - ]; - return chain; +{ + const game = new SoccerGame(); + game.setup(); + game.start(); } diff --git a/src/index.ts b/src/index.ts index 8e669981..381682d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,4 +55,6 @@ export * from "./collision/TimeOfImpact"; export * from "./collision/DynamicTree"; export * from "./util/stats"; -export * from "./internal"; \ No newline at end of file +export * from "./internal"; + +export * from "./util/DataDriver"; diff --git a/src/util/DataDriver.ts b/src/util/DataDriver.ts new file mode 100644 index 00000000..c120dcca --- /dev/null +++ b/src/util/DataDriver.ts @@ -0,0 +1,102 @@ +/* + * Planck.js + * + * Copyright (c) Ali Shakiba + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export interface DataDriverListener { + enter: (d: D) => R | null; + exit: (d: D, ref: R) => void; + update: (d: D, ref: R) => void; +} + +/** + * @experimental + * + * DataDriver is used it to create, update and destroy physics entities based on game objects. + */ +export class DataDriver { + /** @internal */ private _refMap: Record = {}; + /** @internal */ private _listener: DataDriverListener; + /** @internal */ private _key: (d: D) => string; + + constructor(key: (d: D) => string, listener: DataDriverListener) { + this._key = key; + this._listener = listener; + } + + /** @internal */ private _map: Record = {}; + + // just for reuse + /** @internal */ private _xmap: Record = {}; + /** @internal */ private _data: D[] = []; + /** @internal */ private _entered: D[] = []; + /** @internal */ private _exited: D[] = []; + + update(data: (D | null)[]) { + // todo: use diff-match-patch instead of map? + if (!Array.isArray(data)) throw "Invalid data: " + data; + + this._entered.length = 0; + this._exited.length = 0; + this._data.length = data.length; + + for (let i = 0; i < data.length; i++) { + if (typeof data[i] !== "object" || data[i] === null) continue; + const d = data[i]; + const id = this._key(d); + if (!this._map[id]) { + this._entered.push(d); + } else { + delete this._map[id]; + } + this._data[i] = d; + this._xmap[id] = d; + } + + for (const id in this._map) { + this._exited.push(this._map[id]); + delete this._map[id]; + } + + const temp = this._map; + this._map = this._xmap; + this._xmap = temp; + + for (let i = 0; i < this._exited.length; i++) { + const d = this._exited[i]; + const key = this._key(d); + const ref = this._refMap[key]; + this._listener.exit(d, ref); + delete this._refMap[key]; + } + + for (let i = 0; i < this._entered.length; i++) { + const d = this._entered[i]; + const key = this._key(d); + const ref = this._listener.enter(d); + if (ref) { + this._refMap[key] = ref; + } + } + + for (let i = 0; i < this._data.length; i++) { + if (typeof data[i] !== "object" || data[i] === null) continue; + const d = this._data[i]; + const key = this._key(d); + const ref = this._refMap[key]; + this._listener.update(d, ref); + } + + this._entered.length = 0; + this._exited.length = 0; + this._data.length = 0; + } + + ref(d: D): R { + return this._refMap[this._key(d)]; + } +}