diff --git a/nodejs-tools/nodejs-apps/vighnesh153-astro-svelte/src/components/projects/graphics/brick-breaker-game/ProjectRoot.svelte b/nodejs-tools/nodejs-apps/vighnesh153-astro-svelte/src/components/projects/graphics/brick-breaker-game/ProjectRoot.svelte new file mode 100644 index 00000000..ec57868f --- /dev/null +++ b/nodejs-tools/nodejs-apps/vighnesh153-astro-svelte/src/components/projects/graphics/brick-breaker-game/ProjectRoot.svelte @@ -0,0 +1,46 @@ + + + + Sorry your browser doesn't support the canvas element + diff --git a/nodejs-tools/nodejs-apps/vighnesh153-astro-svelte/src/components/projects/graphics/pong-game/ProjectRoot.svelte b/nodejs-tools/nodejs-apps/vighnesh153-astro-svelte/src/components/projects/graphics/pong-game/ProjectRoot.svelte index b52d6deb..e0bf55b2 100644 --- a/nodejs-tools/nodejs-apps/vighnesh153-astro-svelte/src/components/projects/graphics/pong-game/ProjectRoot.svelte +++ b/nodejs-tools/nodejs-apps/vighnesh153-astro-svelte/src/components/projects/graphics/pong-game/ProjectRoot.svelte @@ -9,7 +9,7 @@ let game: PongGame; function handleMouseMove(e: MouseEvent) { - game.handleMouseMove(e); + game.handleMouseMove(e, document.documentElement.scrollTop); } function newGame() { diff --git a/nodejs-tools/nodejs-apps/vighnesh153-astro-svelte/src/pages/projects/graphics/brick-breaker-game.astro b/nodejs-tools/nodejs-apps/vighnesh153-astro-svelte/src/pages/projects/graphics/brick-breaker-game.astro new file mode 100644 index 00000000..416ced71 --- /dev/null +++ b/nodejs-tools/nodejs-apps/vighnesh153-astro-svelte/src/pages/projects/graphics/brick-breaker-game.astro @@ -0,0 +1,11 @@ +--- +import { graphicsProjectsMap } from '@vighnesh153/graphics-programming'; +import GraphicsProjectLayout from '@/layouts/GraphicsProjectLayout.astro'; +import ProjectRoot from '@/components/projects/graphics/brick-breaker-game/ProjectRoot.svelte'; + +const project = graphicsProjectsMap.brickBreakerGame; +--- + + + + diff --git a/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/Ball.ts b/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/Ball.ts new file mode 100644 index 00000000..fbe1ca45 --- /dev/null +++ b/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/Ball.ts @@ -0,0 +1,61 @@ +import { CanvasWrapper } from '@/canvas-wrapper'; + +interface BallOptions { + readonly size?: number; + readonly color?: string; + readonly coordinate: Coordinate; + readonly velocity?: Velocity; +} + +interface Coordinate { + x: number; + y: number; +} + +interface Velocity { + dx: number; + dy: number; +} + +export class Ball { + readonly size: number; + readonly color: string; + readonly coordinate: Coordinate; + readonly velocity: Velocity; + + constructor( + private readonly canvasWrapper: CanvasWrapper, + options: BallOptions + ) { + this.coordinate = options.coordinate; + this.size = options.size ?? 5; + this.color = options.color ?? 'black'; + this.velocity = options.velocity ?? { + dx: 5, + dy: -5, + }; + } + + draw(): void { + const { + canvasWrapper, + coordinate: { x, y }, + color, + size, + } = this; + canvasWrapper.drawFilledRect(x, y, size, size, color); + } + + update(): void { + this.coordinate.x += this.velocity.dx; + this.coordinate.y += this.velocity.dy; + } + + flipVelocityX(): void { + this.velocity.dx *= -1; + } + + flipVelocityY(): void { + this.velocity.dy *= -1; + } +} diff --git a/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/Brick.ts b/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/Brick.ts new file mode 100644 index 00000000..40a64481 --- /dev/null +++ b/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/Brick.ts @@ -0,0 +1,57 @@ +import { CanvasWrapper } from '@/canvas-wrapper'; +import { not } from '@vighnesh153/utils'; + +interface BrickOptions { + readonly row: number; + readonly column: number; + readonly width: number; + + readonly height?: number; + readonly color?: string; + readonly endPadding?: number; + + readonly visible?: boolean; +} + +export class Brick { + readonly row: number; + readonly column: number; + + readonly width: number; + readonly height: number; + readonly color: string; + readonly endPadding: number; + + private visible: boolean; + + get isVisible(): boolean { + return this.visible; + } + + constructor( + private readonly canvasWrapper: CanvasWrapper, + options: BrickOptions + ) { + this.row = options.row; + this.column = options.column; + + this.width = options.width; + this.height = options.height ?? 20; + this.color = options.color ?? 'blue'; + this.endPadding = options.endPadding ?? 1; + this.visible = options.visible ?? true; + } + + draw(): void { + if (not(this.isVisible)) { + return; + } + + const { canvasWrapper, row, column, width, height, color, endPadding } = this; + canvasWrapper.drawFilledRect(column * width, row * height, width - endPadding, height - endPadding, color); + } + + destroy(): void { + this.visible = false; + } +} diff --git a/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/Game.ts b/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/Game.ts new file mode 100644 index 00000000..7f17ca9e --- /dev/null +++ b/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/Game.ts @@ -0,0 +1,250 @@ +import { CanvasWrapper } from '@/canvas-wrapper'; +import { getCanvasBgColor } from '@/getCanvasBgColor'; +import { Paddle } from './Paddle'; +import { Ball } from './Ball'; +import { ScoreTracker } from './ScoreTracker'; +import { Brick } from './Brick'; +import { createBricksGrid } from './createBricksGrid'; +import { not } from '@vighnesh153/utils'; + +interface BrickBreakerGameOptions { + readonly bgColor?: string; + readonly scoreColor?: string; + readonly scoreFontSize?: number; + readonly rows?: number; + readonly placeholderBrickRows?: number; + readonly columns?: number; + + onGameOver(): void; +} + +export class BrickBreakerGame { + readonly canvasWrapper: CanvasWrapper; + readonly bgColor: string; + readonly scoreColor: string; + readonly scoreFontSize: number; + readonly rows: number; + readonly placeholderBrickRows: number; + readonly columns: number; + private readonly onGameOver: () => void; + + private paddle!: Paddle; + private ball!: Ball; + + private readonly scoreTracker = new ScoreTracker(); + private bricksGrid!: readonly (readonly Brick[])[]; + + private isRunning = false; + + private previousBrickRow: number | null = null; + private previousBrickCol: number | null = null; + + constructor(canvasWrapper: CanvasWrapper, options: BrickBreakerGameOptions) { + this.canvasWrapper = canvasWrapper; + + this.bgColor = options.bgColor ?? getCanvasBgColor(canvasWrapper); + this.scoreColor = options.scoreColor ?? 'black'; + this.scoreFontSize = options.scoreFontSize ?? 15; + this.rows = options.rows ?? 15; + this.placeholderBrickRows = options.placeholderBrickRows ?? 3; + this.columns = options.columns ?? 20; + this.onGameOver = options.onGameOver; + + this.reset(); + } + + private reset(): void { + const { canvasWrapper } = this; + + // reset score + this.scoreTracker.reset(); + + // reset ball + this.ball = new Ball(canvasWrapper, { + coordinate: { + x: canvasWrapper.width / 2, + y: (canvasWrapper.height * 5) / 6, + }, + }); + + // reset paddle + this.paddle = new Paddle(canvasWrapper, { + y: (canvasWrapper.height * 6) / 7, + }); + + // create bricks grid + this.bricksGrid = createBricksGrid( + this.rows, + this.columns, + (row, column) => + new Brick(canvasWrapper, { + row, + column, + width: canvasWrapper.width / 20, + visible: row >= this.placeholderBrickRows && row < this.rows - this.placeholderBrickRows, + }) + ); + } + + *start() { + this.isRunning = true; + while (this.isRunning) { + this.draw(); + this.update(); + yield; + } + } + + stop() { + this.isRunning = false; + } + + private draw(): void { + this.clear(); + this.paddle.draw(); + this.ball.draw(); + this.writeScore(); + this.drawBricks(); + } + + private update(): void { + this.ball.update(); + this.handleCollisions(); + this.handleGameOver(); + } + + handleMouseMove(e: MouseEvent, scrollLeft: number) { + const { rect } = this.canvasWrapper; + const mouseX = e.clientX - rect.left - scrollLeft; + + // we subtract width/2 to align the mouse position with pad's center + const paddleX = mouseX - this.paddle.width / 2; + + this.paddle.updateX(paddleX); + } + + clear() { + const rect = this.canvasWrapper.getBoundingClientRect(); + const canvasWidth = rect.width; + const canvasHeight = rect.height; + this.canvasWrapper.drawFilledRect(0, 0, canvasWidth, canvasHeight, this.bgColor); + } + + private writeScore(): void { + const cw = this.canvasWrapper; + const color = this.scoreColor; + const fontSize = this.scoreFontSize; + + const scoreText = `Your score: ${this.scoreTracker.getScore()}`; + + cw.writeText(scoreText, 20, 20, color, fontSize); + } + + private drawBricks(): void { + for (const bricksRow of this.bricksGrid) { + for (const brick of bricksRow) { + brick.draw(); + } + } + } + + private handleGameOver(): void { + const maxScore = this.rows * this.columns; + if (this.scoreTracker.getScore() === maxScore) { + this.onGameOver(); + } + } + + private handleCollisions(): void { + this.handleCollisionWithLeftWall(); + this.handleCollisionWithRightWall(); + this.handleCollisionWithTopWall(); + this.handleCollisionWithBottomWall(); + this.handleCollisionWithPaddle(); + this.handleBrickCollision(); + } + + private handleCollisionWithLeftWall(): void { + if (this.ball.coordinate.x <= 0) { + this.ball.flipVelocityX(); + } + } + + private handleCollisionWithRightWall(): void { + if (this.ball.coordinate.x + this.ball.size >= this.canvasWrapper.width) { + this.ball.flipVelocityX(); + } + } + + private handleCollisionWithTopWall(): void { + if (this.ball.coordinate.y <= 0) { + this.ball.flipVelocityY(); + } + } + + private handleCollisionWithBottomWall(): void { + if (this.ball.coordinate.y + this.ball.size < this.canvasWrapper.height) { + return; + } + + this.reset(); + } + + private handleCollisionWithPaddle(): void { + const { ball, paddle } = this; + + const ballLeft = ball.coordinate.x; + const ballRight = ballLeft + ball.size; + const ballTop = ball.coordinate.y; + const ballBottom = ballTop + ball.size; + + const paddleLeft = paddle.x; + const paddleRight = paddleLeft + paddle.width; + const paddleTop = paddle.y; + const paddleBottom = paddleTop + paddle.height; + + if (ballRight < paddleLeft) return; + if (ballLeft > paddleRight) return; + if (ballBottom < paddleTop) return; + if (ballTop > paddleBottom) return; + + // collided with paddle + ball.flipVelocityY(); + } + + private handleBrickCollision(): void { + const { previousBrickRow, previousBrickCol } = this; + const [[randomBrick]] = this.bricksGrid; + const currentBrickRow = Math.floor(this.ball.coordinate.y / randomBrick.height); + const currentBrickCol = Math.floor(this.ball.coordinate.x / randomBrick.width); + const currentBrick = this.bricksGrid[currentBrickRow]?.[currentBrickCol] ?? null; + + if ( + previousBrickCol === null || + previousBrickRow === null || + currentBrick === null || + not(currentBrick.isVisible) + ) { + this.previousBrickRow = currentBrickRow; + this.previousBrickCol = currentBrickCol; + return; + } + + if (currentBrickRow !== previousBrickRow && currentBrickCol !== previousBrickCol) { + // ball is travelling diagonally + this.ball.flipVelocityX(); + this.ball.flipVelocityY(); + } else if (currentBrickRow !== previousBrickRow) { + // ball is travelling more vertically + this.ball.flipVelocityY(); + } else if (currentBrickCol !== previousBrickCol) { + // ball is travelling more horzontally + this.ball.flipVelocityX(); + } + + currentBrick.destroy(); + this.scoreTracker.increment(); + this.previousBrickRow = currentBrickRow; + this.previousBrickCol = currentBrickCol; + } +} diff --git a/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/Paddle.ts b/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/Paddle.ts new file mode 100644 index 00000000..c2e258f9 --- /dev/null +++ b/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/Paddle.ts @@ -0,0 +1,47 @@ +import { CanvasWrapper } from '@/canvas-wrapper'; + +interface PaddleOptions { + readonly width?: number; + readonly height?: number; + readonly color?: string; + + readonly initialX?: number; + readonly y: number; +} + +export class Paddle { + readonly width: number; + readonly height: number; + readonly color: string; + readonly y: number; + + x: number; + + constructor( + private readonly canvasWrapper: CanvasWrapper, + options: PaddleOptions + ) { + this.width = options.width ?? canvasWrapper.width / 5; + this.height = options.height ?? 3; + this.color = options.color ?? 'black'; + + this.x = options.initialX ?? (canvasWrapper.width - this.width) / 2; + this.y = options.y; + } + + draw(): void { + const { width, height, color, x, y, canvasWrapper } = this; + + canvasWrapper.drawFilledRect(x, y, width, height, color); + } + + updateX(x: number): void { + this.x = x; + this.coerceX(); + } + + private coerceX(): void { + this.x = Math.max(0, this.x); + this.x = Math.min(this.canvasWrapper.width - this.width, this.x); + } +} diff --git a/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/ScoreTracker.ts b/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/ScoreTracker.ts new file mode 100644 index 00000000..69aeb67e --- /dev/null +++ b/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/ScoreTracker.ts @@ -0,0 +1,15 @@ +export class ScoreTracker { + private score = 0; + + getScore(): number { + return this.score; + } + + increment(): void { + this.score++; + } + + reset(): void { + this.score = 0; + } +} diff --git a/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/createBricksGrid.ts b/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/createBricksGrid.ts new file mode 100644 index 00000000..c8a7f6c4 --- /dev/null +++ b/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/createBricksGrid.ts @@ -0,0 +1,17 @@ +import { repeat } from '@vighnesh153/utils'; +import { Brick } from './Brick'; + +type BrickCreator = (row: number, column: number) => Brick; + +export function createBricksGrid(rows: number, columns: number, brickCreator: BrickCreator): Brick[][] { + const grid = new Array(rows); + repeat(rows, (rowCount) => { + const row = rowCount - 1; + grid[row] = new Array(columns); + repeat(columns, (colCount) => { + const col = colCount - 1; + grid[row][col] = brickCreator(row, col); + }); + }); + return grid; +} diff --git a/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/index.ts b/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/index.ts new file mode 100644 index 00000000..e171380c --- /dev/null +++ b/nodejs-tools/nodejs-lib/graphics-programming/src/brick-breaker-game/index.ts @@ -0,0 +1 @@ +export { BrickBreakerGame } from './Game'; diff --git a/nodejs-tools/nodejs-lib/graphics-programming/src/collection.ts b/nodejs-tools/nodejs-lib/graphics-programming/src/collection.ts index 70cc6d77..70733c35 100644 --- a/nodejs-tools/nodejs-lib/graphics-programming/src/collection.ts +++ b/nodejs-tools/nodejs-lib/graphics-programming/src/collection.ts @@ -13,7 +13,7 @@ const barnsleysFern: GraphicsProject = { }; const brickBreakerGame: GraphicsProject = { - imageLink: 'https://i.imgur.com/C0N3twn.png', + imageLink: 'https://i.imgur.com/yauRTBv.png', title: 'Brick Breaker Game', id: 'brick-breaker-game', description: 'Brick breaker is a arcade game emulation, which destroys the bricks by shooting a ball at them', diff --git a/nodejs-tools/nodejs-lib/graphics-programming/src/index.ts b/nodejs-tools/nodejs-lib/graphics-programming/src/index.ts index b90afa7d..95886b76 100644 --- a/nodejs-tools/nodejs-lib/graphics-programming/src/index.ts +++ b/nodejs-tools/nodejs-lib/graphics-programming/src/index.ts @@ -1,5 +1,6 @@ export * from './barnsleys-fern'; export * from './bonding-particles'; +export * from './brick-breaker-game'; export * from './flappy-block-game'; export * from './grid-path-finder'; export * from './pong-game'; diff --git a/nodejs-tools/nodejs-lib/graphics-programming/src/pong-game/Arena.ts b/nodejs-tools/nodejs-lib/graphics-programming/src/pong-game/Arena.ts index 444049fa..11998a17 100644 --- a/nodejs-tools/nodejs-lib/graphics-programming/src/pong-game/Arena.ts +++ b/nodejs-tools/nodejs-lib/graphics-programming/src/pong-game/Arena.ts @@ -150,11 +150,11 @@ export class Arena { } } - handleMouseMove(e: MouseEvent) { + handleMouseMove(e: MouseEvent, scrollTop: number) { const { rect } = this.#canvasWrapper; - const mouseY = e.clientY - rect.top - document.documentElement.scrollTop; + const mouseY = e.clientY - rect.top - scrollTop; - // we subtract y to align the mouse position with pad's center + // we subtract height/2 to align the mouse position with pad's center const playerPadY = mouseY - this.#playerPad.height / 2; this.#playerPad.setPosition(playerPadY); diff --git a/nodejs-tools/nodejs-lib/graphics-programming/src/pong-game/Game.ts b/nodejs-tools/nodejs-lib/graphics-programming/src/pong-game/Game.ts index dd0372e2..b1725485 100644 --- a/nodejs-tools/nodejs-lib/graphics-programming/src/pong-game/Game.ts +++ b/nodejs-tools/nodejs-lib/graphics-programming/src/pong-game/Game.ts @@ -64,7 +64,7 @@ export class PongGame { this.#canvasWrapper.drawFilledRect(0, 0, canvasWidth, canvasHeight, this.#bgColor); } - handleMouseMove(e: MouseEvent) { - this.#arena.handleMouseMove(e); + handleMouseMove(e: MouseEvent, scrollTop: number) { + this.#arena.handleMouseMove(e, scrollTop); } }