Chris Sprance

Character Technical Director

Breakout With SVGame (WIP)

Move Left:

A

Move Right:

D

Shoot Ball:

Spacebar

Pause:

Esc

Game Code

main.ts
import { BreakoutActions } from "~/helpers/breakout/actions";
import { Ball } from "~/helpers/breakout/entities/Ball";
import { BlockSpawner } from "~/helpers/breakout/entities/BlockSpawner";
import { Paddle } from "~/helpers/breakout/entities/Paddle";
import { PauseScreen } from "~/helpers/breakout/entities/PauseScreen";
import { ScoreText } from "~/helpers/breakout/entities/ScoreText";
import { ScreenFPS } from "~/helpers/breakout/entities/ScreenFPS";
import { GameEngine, SVGD3Selection } from "~/helpers/svgame/Game";

export const makeBreakoutGame = (svg: SVGD3Selection) => {
  const game = new GameEngine({ svg: svg });
  game.input.registerAction(BreakoutActions.ShootBall, " ");
  game.input.registerAction(BreakoutActions.MoveRight, "d");
  game.input.registerAction(BreakoutActions.MoveLeft, "a");
  game.input.registerAction(BreakoutActions.Reset, "r");

  game.addEntities([
    new PauseScreen(game),

    new ScreenFPS(game),
    new Paddle(game, "paddle"),
    new BlockSpawner(game, "block-spawner"),
    new Ball(game, "ball"),
    new ScoreText(game),
  ]);
  return game;
};
events.ts
export enum BreakoutEvents {
  BallShot = "BALL_SHOT",
  BlockBroken = "BLOCK_BROKEN",
  BlockHit = "BLOCK_HIT",
  PaddleHit = "PADDLE_HIT",
  Death = "DEATH",
  Lose = "LOSE",
  Win = "WIN",
}
actions.ts
export enum BreakoutActions {
  MoveLeft = "MOVE_LEFT",
  MoveRight = "MOVE_RIGHT",
  Pause = "PAUSE",
  ShootBall = "SHOOT_BALL",
  Reset = "RESET",
}

Entities

entities/Ball.ts
import { Vec2 } from "gl-matrix/dist/esm";
import { BreakoutActions } from "~/helpers/breakout/actions";
import { Block } from "~/helpers/breakout/entities/Block";
import { Paddle } from "~/helpers/breakout/entities/Paddle";
import { BreakoutEvents } from "~/helpers/breakout/events";
import { Entity } from "~/helpers/svgame";
import { drawCircles } from "~/helpers/svgame/lib/drawing";

export class Ball extends Entity {
  position = new Vec2(250, 250);
  radius = 15;
  velocity = new Vec2();
  attached = true;
  speed = 10;

  setup() {
    // Ball Shooting Event
    this.game.eventBus.on(BreakoutEvents.BallShot, this.shoot);
    this.game.eventBus.on(BreakoutEvents.BlockHit, this.blockHit);
    this.game.eventBus.on(BreakoutEvents.PaddleHit, this.paddleHit);
  }

  begin(timestamp: number, delta: number) {
    // Handle Reset Action
    if (this.game.input.isActionJustPressed(BreakoutActions.Reset)) {
      this.reset();
    }
  }

  update = (delta: number) => {
    // Bounce Back if hit top
    if (this.position.y <= this.radius) {
      this.wallHit();
    }
    // Bounce back if hit walls
    if (
      this.position.x <= this.radius ||
      this.position.x >= this.game.resolution.x
    ) {
      this.wallHit();
    }

    // Reset if hits bottom
    if (this.position.y >= this.game.resolution.y) this.reset();
    // If we're attached update the ball positions
    if (this.attached) {
      this.updateBallPosition();
    }
    this.velocity.normalize().scale(this.speed);
    // Add velocity always each frame
    this.position.add(this.velocity);
  };

  draw(interpolationPercentage: number) {
    drawCircles({
      className: "ball",
      fill: "#fff",
      radius: this.radius,
      svg: this.game.svg,
      positions: [this.position],
    });
  }

  // reset it to its default state
  reset = () => {
    this.game.eventBus.emit(BreakoutEvents.Death);
    this.attached = true;
    this.velocity.set([0, 0]);
  };

  // Shoot the ball up transferring the velocity of the paddle
  private shoot = (paddleVelocity: Vec2) => {
    if (this.attached) {
      this.velocity.add([0, -1 * this.speed]).add(paddleVelocity);
    }
    this.attached = false;
  };

  // Update the position of the ball, so it's on the paddle
  private updateBallPosition = () => {
    this.position = new Vec2(
      this.game.getEntity<Paddle>("paddle")!.position,
    ).sub([0, 30]);
  };

  private blockHit = (block: Block) => {
    this.velocity.negate();
  };

  private paddleHit = (paddle: Paddle) => {
    // This makes sure we don't trigger collision too much!
    this.position.add([0, -1]);
    this.velocity.negate().add(paddle.velocity);
  };

  private wallHit = () => {
    // If the ball hits the left or right wall
    if (
      this.position.x <= this.radius ||
      this.position.x >= this.game.resolution.x - this.radius
    ) {
      this.velocity.x = -this.velocity.x; // Reflect the horizontal component
    }

    // If the ball hits the roof
    if (this.position.y <= this.radius) {
      this.velocity.y = -this.velocity.y; // Reflect the vertical component
    }
  };
}
entities/Block.ts
import { Vec2, Vec3 } from "gl-matrix/dist/esm";
import { Ball } from "~/helpers/breakout/entities/Ball";
import { BreakoutEvents } from "~/helpers/breakout/events";
import { Entity } from "~/helpers/svgame";
import { BoxCollision } from "~/helpers/svgame/lib/BoxCollision";
import { createSquare, drawPolygons } from "~/helpers/svgame/lib/drawing";

export class Block extends Entity {
  ball?: Ball;
  vertices: Vec2[] = [];
  position = new Vec2(250, 60);
  size = new Vec2(50, 20);
  color = new Vec3(1, 1, 1);
  collision?: BoxCollision;
  health = 1;

  setup = () => {
    this.vertices = createSquare(this.position, this.size);
    this.ball = this.game.getEntity<Ball>("ball");
    this.collision = new BoxCollision(
      this.position,
      this.size,
      this.handleCollision,
    );
  };

  update = (delta: number) => {
    if (this.ball) {
      this.collision?.checkCollision(this.ball?.position);
    }
  };

  draw(interpolationPercentage: number) {
    drawPolygons({
      svg: this.game.svg,
      vertices: [this.vertices],
      fill: `rgb(${this.color.x * 255}, ${this.color.y * 255}, ${
        this.color.z * 255
      })`,
      className: this.name,
    });
  }

  private handleCollision = () => {
    this.health -= 1;
    this.game.eventBus.emit(BreakoutEvents.BlockHit, this);
    if (this.health <= 0) {
      this.game.eventBus.emit(BreakoutEvents.BlockBroken);
      this.delete();
    }
  };
}
entities/BlockSpawner.ts
import { Vec2, Vec3 } from "gl-matrix/dist/esm";
import { Block } from "~/helpers/breakout/entities/Block";
import { Entity } from "~/helpers/svgame";

export class BlockSpawner extends Entity {
  blocks: Block[] = [];
  // Define the number of rows and columns for the blocks
  private readonly rows: number = 5;
  private readonly columns: number = 10;

  // Block size
  private readonly blockSize: Vec2 = Vec2.fromValues(60, 20); // Example size, adjust as needed

  setup() {
    this.reset();
  }

  reset = () => {
    for (const block of this.blocks) {
      block.delete();
    }
    this.blocks = [];

    // Calculate the starting position and gap between blocks
    const gap = 10; // Gap between blocks
    const startX =
      (this.game.resolution.x -
        (this.columns * this.blockSize[0] + (this.columns - 1) * gap)) /
      2;
    const startY = this.game.resolution.y / 4; // Start from 1/4th of the screen height

    // Create blocks in a grid pattern
    for (let row = 0; row < this.rows; row++) {
      for (let col = 0; col < this.columns; col++) {
        // Calculate position for each block
        const x = startX + col * (this.blockSize[0] + gap);
        const y = startY + row * (this.blockSize[1] + gap);
        const position = Vec2.fromValues(x, y);

        // Create new block at the calculated position
        const block = new Block(this.game, `block-${row * this.columns + col}`);
        block.position = position;
        block.color = new Vec3(Math.random(), Math.random(), Math.random());
        this.blocks.push(block);
      }
    }

    // Add all blocks to the game
    this.game.addEntities(this.blocks);
  };
}
entities/Paddle.ts
import { Vec2, Vec3 } from "gl-matrix/dist/esm";
import { BreakoutActions } from "~/helpers/breakout/actions";
import { Ball } from "~/helpers/breakout/entities/Ball";
import { BreakoutEvents } from "~/helpers/breakout/events";
import { Entity } from "~/helpers/svgame";
import { BoxCollision } from "~/helpers/svgame/lib/BoxCollision";
import { createSquare, drawPolygons } from "~/helpers/svgame/lib/drawing";

export class Paddle extends Entity {
  ball?: Ball;
  name = "paddle";
  vertices: Vec2[] = [];
  position = new Vec2(this.game.resolution.x / 2, this.game.resolution.y - 30);
  size = new Vec2(300, 20);
  velocity = new Vec2();
  speed = 2;
  color = new Vec3(255, 255, 255);
  collision?: BoxCollision;

  setup() {
    this.game.eventBus.on(BreakoutEvents.PaddleHit, this.paddleHit);
    this.ball = this.game.getEntity<Ball>("ball");
    this.collision = new BoxCollision(
      this.position,
      this.size,
      this.handleCollision,
    );
  }

  begin(timestamp: number, delta: number) {
    // Handle input
    // Move Left
    if (this.game.input.isActionPressed(BreakoutActions.MoveLeft)) {
      this.velocity.add(new Vec2([-this.speed, 0]));
    }
    // Move Right
    if (this.game.input.isActionPressed(BreakoutActions.MoveRight)) {
      this.velocity.add(new Vec2([this.speed, 0]));
    }
    // Shoot the ball transferring velocity
    if (this.game.input.isActionPressed(BreakoutActions.ShootBall)) {
      this.game.eventBus.emit(BreakoutEvents.BallShot, this.velocity);
    }
  }

  update(delta: number) {
    if (this.ball) {
      this.collision?.checkCollision(this.ball?.position);
    }
    this.velocity.scale(0.9);
    this.position.add(this.velocity);
    // Bound to play space
    this.position.x = Math.max(
      this.size.x / 2,
      Math.min(this.position.x, this.game.resolution.x - this.size.x / 2),
    );
    this.vertices = createSquare(this.position, this.size);
    this.color.x = (Math.sin(this.game.time * 0.001) + 2) * 0.5;
    this.color.y = (Math.cos(this.game.time * 0.001) + 2) * 0.5;
    this.color.z = (Math.sin(-this.game.time * 0.001) + 2) * 0.5;
  }

  draw(interpolationPercentage: number) {
    drawPolygons({
      svg: this.game.svg,
      vertices: [this.vertices],
      fill: `rgb(${this.color.x * 255}, ${this.color.y * 255}, ${
        this.color.z * 255
      })`,
      className: this.name,
    });
  }

  private paddleHit = () => {};

  private handleCollision = () => {
    this.game.eventBus.emit(BreakoutEvents.PaddleHit, this);
  };
}
entities/PauseScreen.ts
import { Vec2 } from "gl-matrix/dist/esm";
import { BreakoutActions } from "~/helpers/breakout/actions";
import { Entity } from "~/helpers/svgame";
import { drawText } from "~/helpers/svgame/lib/drawing";

export class PauseScreen extends Entity {
  setup() {
    this.game.input.registerAction(BreakoutActions.Pause, "Escape");
  }

  begin(timestamp: number, delta: number) {
    // Kinda wierd to put logic in draw but draw always runs
    if (this.game.input.isActionJustPressed(BreakoutActions.Pause)) {
      this.game.paused = !this.game.paused;
    }
  }

  draw(interpolationPercentage: number) {
    if (this.game.paused) {
      drawText({
        positions: [
          new Vec2(
            Number(this.game.svg.attr("width")),
            Number(this.game.svg.attr("height")),
          ).scale(0.5),
        ],
        className: "pause-text",
        fill: "#fff",
        fontSize: "38px",
        svg: this.game.svg,
        texts: [`PAUSED`],
      }).attr("text-anchor", "middle");
      drawText({
        positions: [
          new Vec2(
            Number(this.game.svg.attr("width")),
            Number(this.game.svg.attr("height")),
          )
            .scale(0.5)
            .sub([0, -50]),
        ],
        className: "pause-instructions-text",
        fill: "#fff",
        fontSize: "18px",
        svg: this.game.svg,
        texts: [`Press Esc to unpause`],
      }).attr("text-anchor", "middle");
    }
  }
}
entities/ScoreText.ts
import { Vec2 } from "gl-matrix/dist/esm";
import { BlockSpawner } from "~/helpers/breakout/entities/BlockSpawner";
import { BreakoutEvents } from "~/helpers/breakout/events";
import { Entity } from "~/helpers/svgame";
import { drawText } from "~/helpers/svgame/lib/drawing";

export class ScoreText extends Entity {
  score = 0;
  lives = 3;
  setup() {
    const blockSpawner = this.game.getEntity<BlockSpawner>("block-spawner")!;
    this.game.eventBus.on(BreakoutEvents.BlockBroken, () => {
      this.score++;
      if (this.score >= blockSpawner.blocks.length) {
        this.game.eventBus.emit(BreakoutEvents.Win);
        this.game.paused = true;
      }
    });
    this.game.eventBus.on(BreakoutEvents.Death, () => {
      this.lives--;
      if (this.lives <= 0) {
        this.game.eventBus.emit(BreakoutEvents.Lose, this.score);
        this.score = 0;
        this.lives = 3;
        blockSpawner.reset();
      }
    });
  }

  draw(interpolationPercentage: number) {
    drawText({
      positions: [new Vec2(100, 40)],
      className: "lives-text",
      fill: "#fff",
      fontSize: "16px",
      svg: this.game.svg,
      texts: [`Lives: ${this.lives}`],
    }).attr("text-anchor", "middle");
    drawText({
      positions: [new Vec2(100, 20)],
      className: "score-text",
      fill: "#fff",
      fontSize: "16px",
      svg: this.game.svg,
      texts: [`Score: ${this.score}`],
    }).attr("text-anchor", "middle");
  }
}
entities/ScreenFPS.ts
import { Vec2 } from "gl-matrix/dist/esm";
import { Entity } from "~/helpers/svgame";
import { drawText } from "~/helpers/svgame/lib/drawing";

export class ScreenFPS extends Entity {
  draw(interpolationPercentage: number) {
    drawText({
      positions: [new Vec2(1100, 20)],
      className: "debug-text",
      fill: "#fff",
      fontSize: "16px",
      svg: this.game.svg,
      texts: [`FPS: ${this.game.loop.getFPS().toFixed(1)}`],
    }).attr("text-anchor", "middle");
  }
}