diff --git a/src/config.js b/src/config.js index 20c039a..5265679 100644 --- a/src/config.js +++ b/src/config.js @@ -2,14 +2,29 @@ export const CONTAINER_WIDTH = 800; export const CONTAINER_HEIGHT = 600; export const PADDLE_WIDTH = 50; +export const PADDLE_WIDE_WIDTH = 100; export const PADDLE_HEIGHT = 10; +export const MIN_PADDLE_REFLECTION_ANGLE = 20; +export const MAX_PADDLE_REFLECTION_ANGLE = 160; +export const PADDLE_SECTOR_AMOUNT = 20; export const BALL_RADIUS = 10; export const BALL_SPEED = 3; -export const BALL_INITIAL_ANGLE = 180; +export const BALL_SPEED_LEVEL_STEP = 2; +export const BALL_SPEED_TIME_STEP = 0.002; +export const BALL_INITIAL_ANGLE = 0; +export const BALL_SPLIT_ANGLE = 20; export const BRICK_WIDTH = 40; export const BRICK_HEIGHT = 10; export const BRICK_ROW_AMOUNT = 5; export const BRICK_COLUMN_AMOUNT = 20; + +export const PERK_WIDTH = 16; +export const PERK_HEIGHT = 16; +export const PERK_FALL_SPEED = 3; +export const PERK_DROP_CHANCE = 0.3; +export const PERK_BALL_SPEED_DECREASE = 2; +export const PERK_TYPES = ['slow', 'wide', 'life', 'clone']; +export const PERK_DURATION_SEC = 20; diff --git a/src/const/levels.js b/src/const/levels.js new file mode 100644 index 0000000..9d9ca20 --- /dev/null +++ b/src/const/levels.js @@ -0,0 +1,33 @@ +/** + * Список уровней игры в форме массива + * 0 - пустое пространство + * 1 - обычный блок + * 2 - блок с несколькими жизнями + * 3 - неразрушимый блок + */ +export const LEVELS = [ + [ + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0], + ], + [ + [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], + [1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2], + [2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + [0, 2, 1, 2, 0, 0, 1, 2, 1, 0, 0, 2, 1, 2, 1, 0, 1, 2, 1, 0], + [0, 0, 2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1, 0, 0], + ], + [ + [1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2], + [2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 1], + [1, 2, 1, 2, 1, 2, 1, 2, 3, 3, 3, 3, 1, 2, 1, 2, 1, 2, 1, 2], + [2, 1, 2, 1, 2, 1, 2, 3, 3, 3, 3, 3, 3, 1, 2, 1, 2, 1, 2, 1], + [1, 2, 0, 2, 1, 2, 1, 0, 3, 3, 3, 3, 0, 2, 1, 2, 1, 0, 1, 2], + [2, 0, 0, 0, 2, 1, 0, 0, 0, 1, 2, 0, 0, 0, 2, 1, 0, 0, 0, 1], + ], +]; diff --git a/src/entities/ball/ball.js b/src/entities/ball/ball.js index 0a0c3d4..a265bc2 100644 --- a/src/entities/ball/ball.js +++ b/src/entities/ball/ball.js @@ -1,3 +1,5 @@ +import { toRadians } from '../../lib/toRadians/toRadians'; + /** * Класс сущности "мяч" * @class @@ -7,13 +9,23 @@ export class Ball { * @param {number} x координата положения мяча по оси X * @param {number} y координата положения мяча по оси Y * @param {number} radius положительное числовое значение радиуса мяча + * @param {number} speed положительное числовое значение скорости мяча + * @param {number} angle угол направления движения мяча в градусах */ - constructor(x, y, radius) { + constructor(x, y, radius, speed = 0, angle = 0) { this.x = x; this.y = y; - this.horizontalSpeed = 0; - this.verticalSpeed = 0; this.radius = radius; + this.speed = speed; + this.angle = angle; + this.verticalSpeed = speed * Math.cos(toRadians(angle)); + this.horizontalSpeed = -1 * speed * Math.sin(toRadians(angle)); + this.status = 'idle'; + + this.defaultX = x; + this.defaultY = y; + this.defaultVerticalSpeed = this.verticalSpeed; + this.defaultHorizontalSpeed = this.horizontalSpeed; } /** @@ -24,4 +36,42 @@ export class Ball { this.x += this.horizontalSpeed * deltaTime; this.y += this.verticalSpeed * deltaTime; } + + /** + * Возвращает значения к дефолтным + */ + reset(x = this.defaultX, y = this.defaultY) { + this.x = x; + this.y = y; + this.verticalSpeed = this.defaultVerticalSpeed; + this.horizontalSpeed = this.defaultHorizontalSpeed; + this.status = 'idle'; + } + + /** + * Запускает мяч с ракетки - переводит из ожидания в игру + */ + launch() { + if (this.status === 'idle') { + this.status = 'moving'; + } + } + + /** + * Увеличивает скорость мяча не меняя направление + * @param {number} step положительное числовое значение прибавки к скорости + */ + increaseSpeed(step) { + if (this.speed <= 0) { + return; + } + + const magnifyingCoeficient = (this.speed + step) / this.speed; + this.speed += step; + + this.verticalSpeed *= magnifyingCoeficient; + this.horizontalSpeed *= magnifyingCoeficient; + this.defaultVerticalSpeed *= magnifyingCoeficient; + this.defaultHorizontalSpeed *= magnifyingCoeficient; + } } diff --git a/src/entities/ball/clone/clone.js b/src/entities/ball/clone/clone.js new file mode 100644 index 0000000..058afd8 --- /dev/null +++ b/src/entities/ball/clone/clone.js @@ -0,0 +1,36 @@ +import { toRadians } from '../../../lib/toRadians/toRadians'; +import { Ball } from '../ball'; + +/** + * Создает копию мяча в той же точке с направлением, повернутым на заданный угол + * @param {Ball} ball исходный мяч + * @param {number} angle угол поворота направления в градусах + * @returns {Ball | null} новый мяч с повернутой скоростью + */ +export function clone(ball, angle) { + try { + if (!(ball instanceof Ball)) { + throw new Error('Аргумент ball должен быть экземпляром класса мяча'); + } + + if (typeof angle !== 'number') { + throw new Error('Угол поворота должен являться числом'); + } + + const clonedBall = new Ball(ball.x, ball.y, ball.radius, ball.speed); + const angleInRadians = toRadians(angle); + const cos = Math.cos(angleInRadians); + const sin = Math.sin(angleInRadians); + + clonedBall.status = ball.status; + clonedBall.horizontalSpeed = ball.horizontalSpeed * cos - ball.verticalSpeed * sin; + clonedBall.verticalSpeed = ball.horizontalSpeed * sin + ball.verticalSpeed * cos; + clonedBall.defaultHorizontalSpeed = clonedBall.horizontalSpeed; + clonedBall.defaultVerticalSpeed = clonedBall.verticalSpeed; + + return clonedBall; + } catch (err) { + console.error(err); + return null; + } +} diff --git a/src/entities/ball/clone/clone.test.js b/src/entities/ball/clone/clone.test.js new file mode 100644 index 0000000..61451f4 --- /dev/null +++ b/src/entities/ball/clone/clone.test.js @@ -0,0 +1,26 @@ +import { Ball } from '../ball'; +import { clone } from './clone'; + +describe('clone', () => { + it('Возвращает корректное значение при неверных типах аргументов', () => { + expect(clone(null, 20)).toBeNull(); + expect(clone(new Ball(0, 0, 10, 3), '20')).toBeNull(); + }); + + it('Клонирует мяч и задает корректное значение скоростей по осям X и Y', () => { + const ball = new Ball(100, 200, 10, 90); + ball.status = 'moving'; + ball.horizontalSpeed = 3; + ball.verticalSpeed = 4; + + const clonedBall = clone(ball, 90); + + expect(clonedBall).toBeInstanceOf(Ball); + expect(clonedBall).not.toEqual(ball); + expect(clonedBall.x).toBe(100); + expect(clonedBall.y).toBe(200); + expect(clonedBall.status).toBe('moving'); + expect(clonedBall.horizontalSpeed).toBeCloseTo(-4); + expect(clonedBall.verticalSpeed).toBeCloseTo(3); + }); +}); diff --git a/src/entities/brick/brick.js b/src/entities/brick/brick.js index 8151678..bf770e5 100644 --- a/src/entities/brick/brick.js +++ b/src/entities/brick/brick.js @@ -9,16 +9,28 @@ export class Brick { * @param {number} y координата положения кирпича по оси Y * @param {number} width положительное числовое значение ширины кирпича * @param {number} height положительное числовое значение высоты кирпича + * @param {number} type тип блока. 1 - обычный, 2 - больше 1 жизни, 3 - неразрушаемый */ - constructor(x, y, width, height) { + constructor(x, y, width, height, type) { this.x = x; this.y = y; this.width = width; this.height = height; + this.type = type; this.alive = true; + this.livesAmount = this.type; } + /** + * В зависимости от типа и оставшихся жизней убивает кирпич + */ kill() { - this.alive = false; + if (this.type !== 3) { + this.livesAmount -= 1; + + if (this.livesAmount === 0) { + this.alive = false; + } + } } } diff --git a/src/entities/brick/layBricks/layBricks.js b/src/entities/brick/layBricks/layBricks.js index 9dc722b..1a26e50 100644 --- a/src/entities/brick/layBricks/layBricks.js +++ b/src/entities/brick/layBricks/layBricks.js @@ -1,37 +1,35 @@ import { Brick } from '../brick'; /** - * Ложит кирпичи по заданым размерам - * @param {number} columnAmount количество кирпичей по оси X, неотрицателное, целое число - * @param {number} rowAmount количество кирпичей по оси Y, неотрицателное, целое число + * Создает массив кирпичей проинициализированных значениями в зависимости от расположения на карте уровня + * @param {number[][]} levelMap карта уровня в формате массива * @param {number} brickWidth ширина кирпича, неотрицателное число * @param {number} brickHeight длина кирпича, неотрицателное число - * @returns {Brick[][]} массив с массивами кирпичей + * @returns {Brick[]} массив кирпичей */ -export function layBricks(columnAmount, rowAmount, brickWidth, brickHeight) { +export function layBricks(levelMap, brickWidth, brickHeight) { try { - const someArgsArentNumbers = [columnAmount, rowAmount, brickWidth, brickHeight].some( - (arg) => typeof arg !== 'number', - ); - if (someArgsArentNumbers) { - throw new Error('Параметры кладки кирпичей должны являться числами'); + if (!(Array.isArray(levelMap) && levelMap.every(Array.isArray))) { + throw new Error('Значение карты уровня должно являться вложенным масивом чисел глубины 2'); } - const someArgsLessThanZero = [columnAmount, rowAmount, brickWidth, brickHeight].some((arg) => arg < 0); - if (someArgsLessThanZero) { - throw new Error('Параметры кладки кирпичей должны являться положительными целыми числами'); + if (!(typeof brickWidth === 'number' && typeof brickHeight === 'number')) { + throw new Error('Значения ширины и высоты кирпича должны являться числами'); } - const rowsOrColumnsAreFloat = [columnAmount, rowAmount].some((amount) => amount % 1 !== 0); - if (rowsOrColumnsAreFloat) { - throw new Error('Размеры рядов и колонок должны являться целыми числами'); + if (brickWidth < 0 || brickHeight < 0) { + throw new Error('Значения ширины и высоты кирпича должны являться положительными целыми числами'); } - const bricks = Array.from({ length: rowAmount }).map((_, rowIndex) => - Array.from({ length: columnAmount }).map( - (_, columnIndex) => new Brick(columnIndex * brickWidth, rowIndex * brickHeight, brickWidth, brickHeight), - ), - ); + const bricks = []; + + for (let i = 0; i < levelMap.length; i++) { + for (let j = 0; j < levelMap[i].length; j++) { + if (levelMap[i][j] !== 0) { + bricks.push(new Brick(j * brickWidth, i * brickHeight, brickWidth, brickHeight, levelMap[i][j])); + } + } + } return bricks; } catch (err) { diff --git a/src/entities/brick/layBricks/layBricks.test.js b/src/entities/brick/layBricks/layBricks.test.js index 7e665bb..70fe366 100644 --- a/src/entities/brick/layBricks/layBricks.test.js +++ b/src/entities/brick/layBricks/layBricks.test.js @@ -5,54 +5,85 @@ describe('layBricks', () => { it('Возвращает корректное значение в случае неверного типа параметра', () => { const wrongType = 'wrong'; - expect(layBricks(wrongType, 1, 1, 1)).toBeNull(); - expect(layBricks(1, wrongType, 1, 1)).toBeNull(); - expect(layBricks(1, 1, wrongType, 1)).toBeNull(); - expect(layBricks(1, 1, 1, wrongType)).toBeNull(); + expect(layBricks(wrongType, 1, 1)).toBeNull(); + expect(layBricks(1, wrongType, 1)).toBeNull(); + expect(layBricks(1, 1, wrongType)).toBeNull(); }); it('Возвращает корректное значение в случае неверных размеров кирпича', () => { const wrongWidth = -1; const wrongHeight = -1; - expect(layBricks(1, 1, wrongWidth, 1)).toBeNull(); - expect(layBricks(1, 1, 1, wrongHeight)).toBeNull(); + expect(layBricks(1, wrongWidth, 1)).toBeNull(); + expect(layBricks(1, 1, wrongHeight)).toBeNull(); }); - it('Возвращает корректное значение в случае неверных значений рядов и колонок', () => { - const negativeRow = -1; - const negativeColumn = -1; - const decimalRow = 1.5; - const decimalColumn = 1.5; + it('Возвращает корректное значение в случае некоректной карты уровня', () => { + const levelMap = ['brick', 'brick']; - expect(layBricks(negativeRow, 1, 1, 1)).toBeNull(); - expect(layBricks(1, negativeColumn, 1, 1)).toBeNull(); - expect(layBricks(decimalRow, 1, 1, 1)).toBeNull(); - expect(layBricks(1, decimalColumn, 1, 1)).toBeNull(); + expect(layBricks(levelMap, 1, 1)).toBeNull(); }); it('Возвращает массив корректных размеров', () => { - const columnAmount = 10; - const rowAmount = 5; + const levelMap = [ + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + ]; - const bricks = layBricks(columnAmount, rowAmount, 1, 1); - expect(bricks.length).toEqual(rowAmount); - for (const row of bricks) { - expect(row.length).toEqual(columnAmount); - } + const bricks = layBricks(levelMap, 1, 1); + expect(bricks.length).toBe(levelMap.flat().length); + + const emptyLevelMap = [ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + ]; + const emptyBricksArray = layBricks(emptyLevelMap, 1, 1); + expect(emptyBricksArray.length).toBe(0); }); it('Задает корректные координаты кирпичам', () => { - const bricks = layBricks(3, 3, 1, 1); - const columnAmount = 10; - const rowAmount = 5; + const levelMap = [ + [1, 1], + [1, 1], + ]; + const brickWidth = 1; + const brickHeight = 1; - const brickss = layBricks(columnAmount, rowAmount, 1, 1); + const bricks = layBricks(levelMap, brickWidth, brickHeight); + let index = 0; - for (let i = 0; i < rowAmount; i++) { - for (let j = 0; j < columnAmount; j++) { - expect(brickss[i][j].x).toEqual(j); - expect(brickss[i][j]?.y).toEqual(i); + for (let i = 0; i < levelMap.length; i++) { + for (let j = 0; j < levelMap[i].length; j++) { + if (levelMap[i][j] === 0) { + continue; + } + expect(bricks[index].x).toBe(j * brickWidth); + expect(bricks[index].y).toBe(i * brickHeight); + index++; + } + } + }); + + it('Верно записывает тип кирпича', () => { + const levelMap = [ + [2, 1, 3], + [1, 2, 0], + ]; + const brickWidth = 1; + const brickHeight = 1; + + const bricks = layBricks(levelMap, brickWidth, brickHeight); + let index = 0; + + for (let i = 0; i < levelMap.length; i++) { + for (let j = 0; j < levelMap[i].length; j++) { + if (levelMap[i][j] === 0) { + continue; + } + expect(bricks[index].type).toBe(levelMap[i][j]); + index++; } } }); diff --git a/src/entities/perk/perk.js b/src/entities/perk/perk.js new file mode 100644 index 0000000..56c473c --- /dev/null +++ b/src/entities/perk/perk.js @@ -0,0 +1,31 @@ +/** + * Класс сущности "бонус" + * @class + */ +export class Perk { + /** + * @param {number} x координата положения бонуса по оси X + * @param {number} y координата положения бонуса по оси Y + * @param {number} width положительное числовое значение ширины бонуса + * @param {number} height положительное числовое значение высоты бонуса + * @param {string} type тип бонуса + * @param {number} fallSpeed скорость падения бонуса по оси Y + */ + constructor(x, y, width, height, type, fallSpeed) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.type = type; + this.fallSpeed = fallSpeed; + this.alive = true; + } + + /** + * Смещает бонус вниз в зависимости от времени + * @param {number} deltaTime изменение времени из Ticker + */ + moveForward(deltaTime) { + this.y += this.fallSpeed * deltaTime; + } +} diff --git a/src/game.js b/src/game.js index 45a8bae..3f52fc9 100644 --- a/src/game.js +++ b/src/game.js @@ -2,12 +2,15 @@ import { BALL_INITIAL_ANGLE, BALL_RADIUS, BALL_SPEED, + BALL_SPEED_LEVEL_STEP, + BALL_SPEED_TIME_STEP, BRICK_HEIGHT, BRICK_WIDTH, CONTAINER_HEIGHT, CONTAINER_WIDTH, PADDLE_HEIGHT, PADDLE_WIDTH, + PERK_BALL_SPEED_DECREASE, } from './config'; import { Ball } from './entities/ball/ball'; import { layBricks } from './entities/brick/layBricks/layBricks'; @@ -19,17 +22,29 @@ import { tick } from './lib/tick/tick'; */ export class Game { /** - * @param {number} columnAmount количество кирпичей по оси X, неотрицателное, целое число - * @param {number} rowAmount количество кирпичей по оси Y, неотрицателное, целое число + * @param {number[][][]} levels список уровней, каждый элемент которого - карта расположения блоков */ - constructor(columnAmount, rowAmount) { - this.ball = new Ball(100, 100, BALL_RADIUS); - this.ball.horizontalSpeed = BALL_SPEED * Math.cos(BALL_INITIAL_ANGLE); - this.ball.verticalSpeed = -1 * BALL_SPEED * Math.sin(BALL_INITIAL_ANGLE); + constructor(levels) { + this.livesAmount = 3; + this.status = 'in_process'; + this.levels = levels; + this.currentLevel = 0; + this.maxLevel = levels.length - 1; this.paddle = new Paddle(0, CONTAINER_HEIGHT - PADDLE_HEIGHT, PADDLE_WIDTH, PADDLE_HEIGHT); + this.balls = [new Ball(0, 0, BALL_RADIUS, BALL_SPEED, BALL_INITIAL_ANGLE)]; + this.bricks = layBricks(this.levels[this.currentLevel], BRICK_WIDTH, BRICK_HEIGHT); + this.perks = []; - this.bricks = layBricks(columnAmount, rowAmount, BRICK_WIDTH, BRICK_HEIGHT); + this._placeBallOnPaddle(); + } + + /** + * Основной (первый) мяч. Пока играет один мяч - это он. + * @returns {Ball} + */ + get ball() { + return this.balls[0]; } /** @@ -37,6 +52,74 @@ export class Game { * @param {*} deltaTime изменение времени из Ticker */ update(deltaTime) { + if (this.status !== 'in_process') { + return; + } + + // Пока мяч не запущен - держим его на ракетке и не считаем физику + if (this.ball.status === 'idle') { + this._placeBallOnPaddle(); + return; + } + tick(this, CONTAINER_WIDTH, CONTAINER_HEIGHT, deltaTime); + + // Убираем улетевшие мячи. Жизнь теряется только когда не осталось ни одного + const survivingBalls = this.balls.filter((ball) => ball.status !== 'out'); + + if (survivingBalls.length === 0) { + this.livesAmount -= 1; + + if (this.livesAmount === 0) { + this.status = 'over'; + } else { + this._placeBallOnPaddle(); + } + + return; + } + + this.balls = survivingBalls; + + for (const ball of this.balls) { + ball.increaseSpeed(BALL_SPEED_TIME_STEP * deltaTime); + } + + const isLevelComplete = this._checkLevelCompletion(); + + if (isLevelComplete) { + this.currentLevel += 1; + + if (this.currentLevel <= this.maxLevel) { + this._proceedToNextLevel(); + } else { + this.status = 'completed'; + } + } + } + + /** + * Проверяет завершен ли текущий уровень + * @returns {boolean} + */ + _checkLevelCompletion() { + return this.bricks.every((brick) => brick.type === 3 || !brick.alive); + } + + /** + * Запускает переход на новый уровень, возвращает мяч в дефоотное положение и отрисовывает кирпичи по карте уровня. + */ + _proceedToNextLevel() { + this.ball.increaseSpeed(BALL_SPEED_LEVEL_STEP); + this._placeBallOnPaddle(); + this.bricks = layBricks(this.levels[this.currentLevel], BRICK_WIDTH, BRICK_HEIGHT); + } + + /** + * Ставит мяч по центру ракетки, оставляя один мяч в игре + */ + _placeBallOnPaddle() { + this.balls = [this.ball]; + this.ball.reset(this.paddle.x + this.paddle.width / 2, this.paddle.y - this.ball.radius); } } diff --git a/src/lib/calculateCollision/calculateCollision.js b/src/lib/calculateAABBCollision/calculateAABBCollision.js similarity index 97% rename from src/lib/calculateCollision/calculateCollision.js rename to src/lib/calculateAABBCollision/calculateAABBCollision.js index 1ed41b9..c3ea9a4 100644 --- a/src/lib/calculateCollision/calculateCollision.js +++ b/src/lib/calculateAABBCollision/calculateAABBCollision.js @@ -12,7 +12,7 @@ * @param {number} secondObject.bottom - Max Y координата второго объекта * @returns {boolean} */ -export function calculateCollision(firstObject, secondObject) { +export function calculateAABBCollision(firstObject, secondObject) { try { const coordinates = [ firstObject.left, diff --git a/src/lib/calculateCollision/calculateCollision.test.js b/src/lib/calculateAABBCollision/calculateAABBCollision.test.js similarity index 77% rename from src/lib/calculateCollision/calculateCollision.test.js rename to src/lib/calculateAABBCollision/calculateAABBCollision.test.js index c2581cf..efe9920 100644 --- a/src/lib/calculateCollision/calculateCollision.test.js +++ b/src/lib/calculateAABBCollision/calculateAABBCollision.test.js @@ -1,6 +1,6 @@ -import { calculateCollision } from './calculateCollision'; +import { calculateAABBCollision } from './calculateAABBCollision'; -describe('calculateCollision', () => { +describe('calculateAABBCollision', () => { it('Корректно обрабатывает невозможные кейсы (левая координата больше правой)', () => { const firstObject = { left: 0, @@ -16,7 +16,7 @@ describe('calculateCollision', () => { bottom: 10, }; - expect(calculateCollision(firstObject, secondObject)).toBeNull(); + expect(calculateAABBCollision(firstObject, secondObject)).toBeNull(); }); it('Корректно обрабатывает неверный формат данных', () => { @@ -34,7 +34,7 @@ describe('calculateCollision', () => { bottom: 10, }; - expect(calculateCollision(firstObject, secondObject)).toBeNull(); + expect(calculateAABBCollision(firstObject, secondObject)).toBeNull(); }); it('Корректно обрабатывает отсутствие пересечения по X', () => { @@ -52,7 +52,7 @@ describe('calculateCollision', () => { bottom: 10, }; - expect(calculateCollision(firstObject, secondObject)).toBe(false); + expect(calculateAABBCollision(firstObject, secondObject)).toBe(false); }); it('Корректно обрабатывает отсутствие пересечения по Y', () => { @@ -70,7 +70,7 @@ describe('calculateCollision', () => { bottom: 30, }; - expect(calculateCollision(firstObject, secondObject)).toBe(false); + expect(calculateAABBCollision(firstObject, secondObject)).toBe(false); }); it('Корректно обрабатывает отсутствие пересечения по X и Y', () => { @@ -88,7 +88,7 @@ describe('calculateCollision', () => { bottom: 30, }; - expect(calculateCollision(firstObject, secondObject)).toBe(false); + expect(calculateAABBCollision(firstObject, secondObject)).toBe(false); }); it('Корректно обрабатывает пересечение', () => { @@ -106,7 +106,7 @@ describe('calculateCollision', () => { bottom: 30, }; - expect(calculateCollision(firstObject, secondObject)).toBe(true); + expect(calculateAABBCollision(firstObject, secondObject)).toBe(true); }); it('Корректно обрабатывает вхождение', () => { @@ -124,6 +124,6 @@ describe('calculateCollision', () => { bottom: 15, }; - expect(calculateCollision(firstObject, secondObject)).toBe(true); + expect(calculateAABBCollision(firstObject, secondObject)).toBe(true); }); }); diff --git a/src/lib/calculateDirection/calculateDirection.js b/src/lib/calculateDirection/calculateDirection.js index 5ebf113..a0f93bc 100644 --- a/src/lib/calculateDirection/calculateDirection.js +++ b/src/lib/calculateDirection/calculateDirection.js @@ -1,4 +1,4 @@ -import { calculateCollision } from '../calculateCollision/calculateCollision'; +import { calculateAABBCollision } from '../calculateAABBCollision/calculateAABBCollision'; /** * Вычисляет направление наибольшего пересечения по осям и возвращает tuple множителей для изменения координат @@ -17,7 +17,7 @@ import { calculateCollision } from '../calculateCollision/calculateCollision'; export function calculateDirection(firstObject, secondObject) { try { // Запускаем для проверки формата аргументов - const isCollided = calculateCollision(firstObject, secondObject); + const isCollided = calculateAABBCollision(firstObject, secondObject); if (isCollided === null) { return null; diff --git a/src/lib/processBall/processBall.js b/src/lib/processBall/processBall.js new file mode 100644 index 0000000..568a212 --- /dev/null +++ b/src/lib/processBall/processBall.js @@ -0,0 +1,54 @@ +import { Ball } from '../../entities/ball/ball'; + +/** + * Двигает мяч и обрабатывает отскок от стен. Возвращает true, если мяч уже отскочил или вышел за нижнюю границу + * @param {Ball} ball мяч + * @param {number} containerWidth ширина игрового контейнера в пикселях + * @param {number} containerHeight высота игрового контейнера в пикселях + * @returns {boolean} + */ +export function processBall(ball, containerWidth, containerHeight) { + try { + if (!(ball instanceof Ball)) { + throw new Error('Аргумент ball должен быть экземпляром класса мяча'); + } + + if (typeof containerWidth !== 'number' || typeof containerHeight !== 'number') { + throw new Error('Значения размеров игрового контейнера должены являться числами'); + } + + if (containerWidth <= 0 || containerHeight <= 0) { + throw new Error('Размеры игрового контейнера должены быть положительными числами'); + } + + const leftBoundary = ball.radius; + const rightBoundary = containerWidth - ball.radius; + const topBoundary = ball.radius; + const bottomBoundary = containerHeight - ball.radius; + + // Не даем мячу выйти за границы стен слева / справав и меняем направление + if (ball.x <= leftBoundary || ball.x >= rightBoundary) { + ball.x = ball.x <= leftBoundary ? leftBoundary : rightBoundary; + ball.horizontalSpeed *= -1; + return true; + } + + // Не даем мячу выйти за границу стены сверху и меняем направление + if (ball.y <= topBoundary) { + ball.y = topBoundary; + ball.verticalSpeed *= -1; + return true; + } + + // Проверяем выход за границу стены снизу + if (ball.y >= bottomBoundary) { + ball.status = 'out'; + return true; + } + + return false; + } catch (err) { + console.error(err); + return null; + } +} diff --git a/src/lib/processBall/processBall.test.js b/src/lib/processBall/processBall.test.js new file mode 100644 index 0000000..b34fd80 --- /dev/null +++ b/src/lib/processBall/processBall.test.js @@ -0,0 +1,45 @@ +import { Ball } from '../../entities/ball/ball'; +import { processBall } from './processBall'; + +describe('processBall', () => { + it('Возвращает null при неверных типах аргументов', () => { + const ball = new Ball(100, 100, 10, 3); + + expect(processBall(null, 800, 600)).toBeNull(); + expect(processBall(ball, '800', 600)).toBeNull(); + expect(processBall(ball, -1, 600)).toBeNull(); + expect(processBall(ball, 800, -1)).toBeNull(); + }); + + it('Возвращает false, если мяч не касается стен', () => { + const ball = new Ball(400, 300, 10, 3); + + expect(processBall(ball, 800, 600)).toBe(false); + }); + + it('Отражает мяч от боковой стены и возвращает true', () => { + const ball = new Ball(10, 300, 10, 3); + ball.horizontalSpeed = -10; + + expect(processBall(ball, 800, 600)).toBe(true); + expect(ball.x).toBe(ball.radius); + expect(ball.horizontalSpeed).toBe(10); + }); + + it('Отражает мяч от верхней стены и возвращает true', () => { + const ball = new Ball(400, 10, 10, 3); + ball.horizontalSpeed = 0; + ball.verticalSpeed = -10; + + expect(processBall(ball, 800, 600)).toBe(true); + expect(ball.y).toBe(ball.radius); + expect(ball.verticalSpeed).toBe(10); + }); + + it('Помечает мяч вышедшим за нижнюю границу', () => { + const ball = new Ball(400, 595, 10, 3); + + expect(processBall(ball, 800, 600)).toBe(true); + expect(ball.status).toBe('out'); + }); +}); diff --git a/src/lib/processBrickCollision/processBrickCollision.js b/src/lib/processBrickCollision/processBrickCollision.js new file mode 100644 index 0000000..ef2a3cf --- /dev/null +++ b/src/lib/processBrickCollision/processBrickCollision.js @@ -0,0 +1,74 @@ +import { PERK_DROP_CHANCE, PERK_HEIGHT, PERK_WIDTH } from '../../config'; +import { Ball } from '../../entities/ball/ball'; +import { Game } from '../../game'; +import { calculateAABBCollision } from '../calculateAABBCollision/calculateAABBCollision'; +import { calculateDirection } from '../calculateDirection/calculateDirection'; +import { spawnRandomPerk } from '../spawnRandomPerk/spawnRandomPerk'; + +/** + * Базовое взаимодействие мяча и кирпичей: меняет направление, разрушает кирпич и роняет бонус + * @param {Game} game экземпляр класса игры + * @param {Ball} ball мяч + * @returns {boolean | null} true, если произошло столкновение с кирпичом + */ +export function processBrickCollision(game, ball) { + try { + if (!(game instanceof Game)) { + throw new Error('Аргумент game должен быть экземпляром класса игра'); + } + + if (!(ball instanceof Ball)) { + throw new Error('Аргумент ball должен быть экземпляром класса мяч'); + } + + for (const brick of game.bricks) { + if (!brick.alive) { + continue; + } + + const ballObject = { + left: ball.x - ball.radius, + right: ball.x + ball.radius, + top: ball.y - ball.radius, + bottom: ball.y + ball.radius, + }; + const brickObject = { + left: brick.x, + right: brick.x + brick.width, + top: brick.y, + bottom: brick.y + brick.height, + }; + + const isCollided = calculateAABBCollision(ballObject, brickObject); + + if (!isCollided) { + continue; + } + + const directions = calculateDirection(ballObject, brickObject); + + if (directions === null) { + continue; + } + + ball.horizontalSpeed *= directions[0]; + ball.verticalSpeed *= directions[1]; + brick.kill(); + + if (!brick.alive && Math.random() < PERK_DROP_CHANCE) { + const perk = spawnRandomPerk( + brick.x + brick.width / 2 - PERK_WIDTH / 2, + brick.y + brick.height / 2 - PERK_HEIGHT / 2, + ); + game.perks.push(perk); + } + + return true; + } + + return false; + } catch (err) { + console.error(err); + return null; + } +} diff --git a/src/lib/processBrickCollision/processBrickCollision.test.js b/src/lib/processBrickCollision/processBrickCollision.test.js new file mode 100644 index 0000000..e0321fb --- /dev/null +++ b/src/lib/processBrickCollision/processBrickCollision.test.js @@ -0,0 +1,33 @@ +import { Game } from '../../game'; +import { processBrickCollision } from './processBrickCollision'; + +describe('processBrickCollision', () => { + it('Возвращает корректное значение при неверных типах аргументов', () => { + const game = new Game([[[1]]]); + + expect(processBrickCollision(null, game.ball)).toBeNull(); + expect(processBrickCollision(game, null)).toBeNull(); + }); + + it('Возвращает креектное значение если мяч не касается кирпичей', () => { + const game = new Game([[[1]]]); + game.ball.x = 5; + game.ball.y = 500; + + expect(processBrickCollision(game, game.ball)).toBe(false); + }); + + it('При столкновении разрушает кирпич и меняет направление мяча', () => { + const game = new Game([[[1]]]); + const brick = game.bricks[0]; + game.ball.x = brick.x + game.ball.radius + 1; + game.ball.y = brick.y + brick.height + game.ball.radius + 1; + game.ball.horizontalSpeed = 0; + game.ball.verticalSpeed = -10; + game.ball.moveForward(1); + + expect(processBrickCollision(game, game.ball)).toBe(true); + expect(brick.alive).toBe(false); + expect(game.ball.verticalSpeed).toBe(10); + }); +}); diff --git a/src/lib/processReflection/processReflection.js b/src/lib/processReflection/processReflection.js new file mode 100644 index 0000000..6d1223e --- /dev/null +++ b/src/lib/processReflection/processReflection.js @@ -0,0 +1,76 @@ +import { Ball } from '../../entities/ball/ball'; +import { Paddle } from '../../entities/paddle/paddle'; +import { toRadians } from '../toRadians/toRadians'; + +/** + * Проверяет произошло ли столкновение ракетки и мяча + * @param {Paddle} paddle + * @param {Ball} ball + * @returns {boolean} + */ +function didCollide(paddle, ball) { + return ( + ball.verticalSpeed > 0 && + ball.y + ball.radius >= paddle.y && + ball.x >= paddle.x && + ball.x <= paddle.x + paddle.width + ); +} + +/** + * Изменяет направление полета мяча в зависимости от места попадания в ракетку + * @param {Paddle} paddle экземпляр класса ракетка + * @param {Ball} ball экземпляр класса мяч + * @param {number} paddleSectorAmount количество секторов ракетки, положительное целое число + * @param {number} minAngle минимальный угол отскока мяча (от 0 до 180) + * @param {number} maxAngle максимальный угол отскока мяча (от 0 до 180) + * @param {number} ballSpeed константа скорости мяча, положительное число + */ +export function processReflection(paddle, ball, paddleSectorAmount, minAngle, maxAngle, ballSpeed) { + try { + if (!(paddle instanceof Paddle)) { + throw new Error('Аргумерт paddle должен являться экземпляром класса Paddle'); + } + + if (!(ball instanceof Ball)) { + throw new Error('Аргумерт ball должен являться экземпляром класса Ball'); + } + + if (typeof paddleSectorAmount !== 'number' || paddleSectorAmount <= 0 || paddleSectorAmount % 1 !== 0) { + throw new Error('Аргумент paddleSectorAmount должен являться положительным целым числом'); + } + + if (typeof minAngle !== 'number' || minAngle < 0 || minAngle > 180) { + throw new Error('Аргумент minAngle должен являться числом в промежутке от 0 до 180'); + } + + if (typeof maxAngle !== 'number' || maxAngle < 0 || maxAngle > 180) { + throw new Error('Аргумент maxAngle должен являться числом в промежутке от 0 до 180'); + } + + if (minAngle >= maxAngle) { + throw new Error('Значение аргумента maxAngle должено быть строго больше значения аргумента minAngle'); + } + + if (typeof ballSpeed !== 'number' || ballSpeed <= 0) { + throw new Error('Значение аргумента ballSpeed должно быть положительным целым числом'); + } + + if (!didCollide(paddle, ball)) { + return; + } + + const step = paddle.width / paddleSectorAmount; + const sectorIndex = Math.floor((ball.x - paddle.x) / step); + const normalizedSectorIndex = Math.min(Math.max(sectorIndex, 0), paddleSectorAmount - 1); + + const angleStep = (maxAngle - minAngle) / (paddleSectorAmount - 1); + const angleInRadians = toRadians(maxAngle - angleStep * normalizedSectorIndex); + ball.horizontalSpeed = ballSpeed * Math.cos(angleInRadians); + ball.verticalSpeed = -1 * ballSpeed * Math.sin(angleInRadians); + ball.y = paddle.y - ball.radius; + } catch (err) { + console.error(err); + return null; + } +} diff --git a/src/lib/processReflection/processReflection.test.js b/src/lib/processReflection/processReflection.test.js new file mode 100644 index 0000000..e623a9f --- /dev/null +++ b/src/lib/processReflection/processReflection.test.js @@ -0,0 +1,223 @@ +import { Ball } from '../../entities/ball/ball'; +import { Paddle } from '../../entities/paddle/paddle'; +import { processReflection } from './processReflection'; + +describe('processReflection', () => { + it('Корректно обрабатывает неверное значения аргумента paddle', () => { + const ball = new Ball(0, 0, 1, 1, 0); + const sectorAmount = 1; + const minAngle = 1; + const maxAngle = 179; + const speed = 1; + + const ballX = ball.x; + const ballY = ball.y; + const verticalSpeed = ball.verticalSpeed; + const horizontalSpeed = ball.horizontalSpeed; + + const result = processReflection(null, ball, sectorAmount, minAngle, maxAngle, speed); + + expect(result).toBeNull(); + expect(ball.x).toEqual(ballX); + expect(ball.y).toEqual(ballY); + expect(ball.verticalSpeed).toEqual(verticalSpeed); + expect(ball.horizontalSpeed).toEqual(horizontalSpeed); + }); + + it('Корректно обрабатывает неверное значения аргумента ball', () => { + const paddle = new Paddle(0, 0, 2, 1); + const sectorAmount = 1; + const minAngle = 1; + const maxAngle = 179; + const speed = 1; + + const result = processReflection(paddle, null, sectorAmount, minAngle, maxAngle, speed); + + expect(result).toBeNull(); + }); + + it('Корректно обрабатывает неверное значения аргумента paddleSectorAmount', () => { + const paddle = new Paddle(0, 0, 2, 1); + const ball = new Ball(0, 0, 1, 1, 0); + const minAngle = 1; + const maxAngle = 179; + const speed = 1; + + const ballX = ball.x; + const ballY = ball.y; + const verticalSpeed = ball.verticalSpeed; + const horizontalSpeed = ball.horizontalSpeed; + + const result = processReflection(paddle, ball, null, minAngle, maxAngle, speed); + + expect(result).toBeNull(); + expect(ball.x).toEqual(ballX); + expect(ball.y).toEqual(ballY); + expect(ball.verticalSpeed).toEqual(verticalSpeed); + expect(ball.horizontalSpeed).toEqual(horizontalSpeed); + + expect(processReflection(paddle, ball, 0, minAngle, maxAngle, speed)).toBeNull(); + expect(processReflection(paddle, ball, -1, minAngle, maxAngle, speed)).toBeNull(); + expect(processReflection(paddle, ball, 1.5, minAngle, maxAngle, speed)).toBeNull(); + }); + + it('Корректно обрабатывает неверное значения аргумента minAngle', () => { + const paddle = new Paddle(0, 0, 2, 1); + const ball = new Ball(0, 0, 1, 1, 0); + const sectorAmount = 1; + const maxAngle = 178; + const speed = 1; + + const ballX = ball.x; + const ballY = ball.y; + const verticalSpeed = ball.verticalSpeed; + const horizontalSpeed = ball.horizontalSpeed; + + const result = processReflection(paddle, ball, sectorAmount, null, maxAngle, speed); + + expect(result).toBeNull(); + expect(ball.x).toEqual(ballX); + expect(ball.y).toEqual(ballY); + expect(ball.verticalSpeed).toEqual(verticalSpeed); + expect(ball.horizontalSpeed).toEqual(horizontalSpeed); + + expect(processReflection(paddle, ball, sectorAmount, -1, maxAngle, speed)).toBeNull(); + expect(processReflection(paddle, ball, sectorAmount, 360, maxAngle, speed)).toBeNull(); + + const minAngleBiggerThanMaxAngle = 179; + expect(processReflection(paddle, ball, sectorAmount, minAngleBiggerThanMaxAngle, maxAngle, speed)).toBeNull(); + }); + + it('Корректно обрабатывает неверное значения аргумента maxAngle', () => { + const paddle = new Paddle(0, 0, 2, 1); + const ball = new Ball(0, 0, 1, 1, 0); + const sectorAmount = 1; + const minAngle = 1; + const speed = 1; + + const ballX = ball.x; + const ballY = ball.y; + const verticalSpeed = ball.verticalSpeed; + const horizontalSpeed = ball.horizontalSpeed; + + const result = processReflection(paddle, ball, sectorAmount, minAngle, null, speed); + + expect(result).toBeNull(); + expect(ball.x).toEqual(ballX); + expect(ball.y).toEqual(ballY); + expect(ball.verticalSpeed).toEqual(verticalSpeed); + expect(ball.horizontalSpeed).toEqual(horizontalSpeed); + + expect(processReflection(paddle, ball, sectorAmount, minAngle, -1, speed)).toBeNull(); + expect(processReflection(paddle, ball, sectorAmount, minAngle, 360, speed)).toBeNull(); + }); + + it('Корректно обрабатывает неверное значения аргумента speed', () => { + const paddle = new Paddle(0, 0, 2, 1); + const ball = new Ball(0, 0, 1, 1, 0); + const sectorAmount = 1; + const minAngle = 1; + const maxAngle = 179; + + const ballX = ball.x; + const ballY = ball.y; + const verticalSpeed = ball.verticalSpeed; + const horizontalSpeed = ball.horizontalSpeed; + + const result = processReflection(paddle, ball, sectorAmount, minAngle, maxAngle, null); + + expect(result).toBeNull(); + expect(ball.x).toEqual(ballX); + expect(ball.y).toEqual(ballY); + expect(ball.verticalSpeed).toEqual(verticalSpeed); + expect(ball.horizontalSpeed).toEqual(horizontalSpeed); + + expect(processReflection(paddle, ball, sectorAmount, minAngle, maxAngle, -1)).toBeNull(); + }); + + it('Корректно обрабатывает неверное значения аргумента speed', () => { + const paddle = new Paddle(0, 0, 2, 1); + const ball = new Ball(0, 0, 1, 1, 0); + const sectorAmount = 1; + const minAngle = 1; + const maxAngle = 179; + + const ballX = ball.x; + const ballY = ball.y; + const verticalSpeed = ball.verticalSpeed; + const horizontalSpeed = ball.horizontalSpeed; + + const result = processReflection(paddle, ball, sectorAmount, minAngle, maxAngle, null); + + expect(result).toBeNull(); + expect(ball.x).toEqual(ballX); + expect(ball.y).toEqual(ballY); + expect(ball.verticalSpeed).toEqual(verticalSpeed); + expect(ball.horizontalSpeed).toEqual(horizontalSpeed); + + expect(processReflection(paddle, ball, sectorAmount, minAngle, maxAngle, -1)).toBeNull(); + }); + + it('Ничего не изменяет при отсутствии столкновения ракетки и мяча', () => { + const paddle = new Paddle(0, 0, 2, 1); + const ball = new Ball(10, 10, 1, 1, 0); + const sectorAmount = 1; + const minAngle = 1; + const maxAngle = 179; + const speed = 1; + + const ballX = ball.x; + const ballY = ball.y; + const verticalSpeed = ball.verticalSpeed; + const horizontalSpeed = ball.horizontalSpeed; + + const result = processReflection(paddle, ball, sectorAmount, minAngle, maxAngle, speed); + + expect(ball.x).toEqual(ballX); + expect(ball.y).toEqual(ballY); + expect(ball.verticalSpeed).toEqual(verticalSpeed); + expect(ball.horizontalSpeed).toEqual(horizontalSpeed); + }); + + it('Отражает мяч влево при попадании в левый сектор ракетки', () => { + const paddle = new Paddle(0, 100, 90, 10); + const ball = new Ball(5, 99, 1, 5, 0); // левый край, летит вниз + const sectorAmount = 3; + const minAngle = 30; + const maxAngle = 150; + const speed = 10; + + processReflection(paddle, ball, sectorAmount, minAngle, maxAngle, speed); + + expect(ball.horizontalSpeed).toBeLessThan(0); + expect(ball.verticalSpeed).toBeLessThan(0); + }); + + it('Отражает мяч вправо при попадании в правый сектор ракетки', () => { + const paddle = new Paddle(0, 100, 90, 10); + const ball = new Ball(85, 99, 1, 5, 0); + const sectorAmount = 3; + const minAngle = 30; + const maxAngle = 150; + const speed = 10; + + processReflection(paddle, ball, sectorAmount, minAngle, maxAngle, speed); + + expect(ball.horizontalSpeed).toBeGreaterThan(0); + expect(ball.verticalSpeed).toBeLessThan(0); + }); + + it('Отражает мяч вертикально вверх при попадании в центральный сектор', () => { + const paddle = new Paddle(0, 100, 90, 10); + const ball = new Ball(45, 99, 1, 5, 0); + const sectorAmount = 3; + const minAngle = 30; + const maxAngle = 150; + const speed = 10; + + processReflection(paddle, ball, sectorAmount, minAngle, maxAngle, speed); + + expect(ball.horizontalSpeed).toBeCloseTo(0); + expect(ball.verticalSpeed).toBeCloseTo(-speed); + }); +}); diff --git a/src/lib/spawnRandomPerk/spawnRandomPerk.js b/src/lib/spawnRandomPerk/spawnRandomPerk.js new file mode 100644 index 0000000..2aa5fc6 --- /dev/null +++ b/src/lib/spawnRandomPerk/spawnRandomPerk.js @@ -0,0 +1,23 @@ +import { PERK_FALL_SPEED, PERK_HEIGHT, PERK_TYPES, PERK_WIDTH } from '../../config'; +import { Perk } from '../../entities/perk/perk'; + +/** + * Создает случайный бонус в заданной точке + * @param {number} x координата бонуса по оси X + * @param {number} y координата бонуса по оси Y + * @returns {Perk | null} + */ +export function spawnRandomPerk(x, y) { + try { + if (typeof x !== 'number' || typeof y !== 'number') { + throw new Error('Координаты бонуса должны являться числами'); + } + + const type = PERK_TYPES[Math.floor(Math.random() * PERK_TYPES.length)]; + + return new Perk(x, y, PERK_WIDTH, PERK_HEIGHT, type, PERK_FALL_SPEED); + } catch (err) { + console.error(err); + return null; + } +} diff --git a/src/lib/spawnRandomPerk/spawnRandomPerk.test.js b/src/lib/spawnRandomPerk/spawnRandomPerk.test.js new file mode 100644 index 0000000..1e42b82 --- /dev/null +++ b/src/lib/spawnRandomPerk/spawnRandomPerk.test.js @@ -0,0 +1,24 @@ +import { PERK_FALL_SPEED, PERK_HEIGHT, PERK_WIDTH } from '../../config'; +import { Perk } from '../../entities/perk/perk'; +import { spawnRandomPerk } from './spawnRandomPerk'; + +describe('spawnRandomPerk', () => { + it('Возвращает корректное значение при неверных типах аргументов', () => { + expect(spawnRandomPerk(null, 0)).toBeNull(); + expect(spawnRandomPerk(0, null)).toBeNull(); + expect(spawnRandomPerk('10', 0)).toBeNull(); + expect(spawnRandomPerk(0, undefined)).toBeNull(); + }); + + it('Создает бонус в заданной точке с размерами и скоростью из конфига', () => { + const perk = spawnRandomPerk(10, 20); + + expect(perk).toBeInstanceOf(Perk); + expect(perk.x).toBe(10); + expect(perk.y).toBe(20); + expect(perk.width).toBe(PERK_WIDTH); + expect(perk.height).toBe(PERK_HEIGHT); + expect(perk.fallSpeed).toBe(PERK_FALL_SPEED); + expect(perk.alive).toBe(true); + }); +}); diff --git a/src/lib/tick/tick.js b/src/lib/tick/tick.js index 3ca17bc..34545a1 100644 --- a/src/lib/tick/tick.js +++ b/src/lib/tick/tick.js @@ -1,7 +1,9 @@ -import { CONTAINER_HEIGHT, CONTAINER_WIDTH } from '../../config'; +import { MAX_PADDLE_REFLECTION_ANGLE, MIN_PADDLE_REFLECTION_ANGLE, PADDLE_SECTOR_AMOUNT } from '../../config'; import { Game } from '../../game'; -import { calculateCollision } from '../calculateCollision/calculateCollision'; -import { calculateDirection } from '../calculateDirection/calculateDirection'; +import { processBall } from '../processBall/processBall'; +import { processBrickCollision } from '../processBrickCollision/processBrickCollision'; +import { processReflection } from '../processReflection/processReflection'; +import { updatePerks } from '../updatePerks/updatePerks'; /** * Функция для вычисления взаимодействий сущностей игры в зависимости от времени из Ticker @@ -28,70 +30,36 @@ export function tick(game, containerWidth, containerHeight, deltaTime) { throw new Error('Значение изменения времени должно быть положительным числом'); } - const { ball, paddle, bricks } = game; + const { paddle } = game; - ball.moveForward(deltaTime); + // Бонусы падают и ловятся ракеткой каждый кадр + updatePerks(game, containerHeight, deltaTime); - const leftBoundary = ball.radius; - const rightBoundary = containerWidth - ball.radius; - const topBoundary = ball.radius; - const bottomBoundary = containerHeight - ball.radius; + for (const ball of game.balls) { + ball.moveForward(deltaTime); + // Проверка столкновения со стенами и вылета за границу поля снизу + const hitWallOrWentOut = processBall(ball, containerWidth, containerHeight); - // Не даем мячу выйти за границы стен слева / справав и меняем направление - if (ball.x <= leftBoundary || ball.x >= rightBoundary) { - ball.x = ball.x <= leftBoundary ? leftBoundary : rightBoundary; - ball.horizontalSpeed *= -1; - } - - // Не даем мячу выйти за границы стен сверху / снизу и меняем направление - if (ball.y <= topBoundary || ball.y >= bottomBoundary) { - ball.y = ball.y <= topBoundary ? topBoundary : bottomBoundary; - ball.verticalSpeed *= -1; - } - - // Базоввое взаимодействие мяча и кирпича - for (const row of bricks) { - for (const brick of row) { - if (!brick.alive) { - continue; - } - - const ballLeft = ball.x - ball.radius; - const ballRight = ball.x + ball.radius; - const ballTop = ball.y - ball.radius; - const ballBottom = ball.y + ball.radius; - - const brickLeft = brick.x; - const brickRight = brick.x + brick.width; - const brickTop = brick.y; - const brickBottom = brick.y + brick.height; - - const ballObject = { left: ballLeft, right: ballRight, top: ballTop, bottom: ballBottom }; - const brickObject = { left: brickLeft, right: brickRight, top: brickTop, bottom: brickBottom }; - - const isCollided = calculateCollision(ballObject, brickObject); - - if (isCollided) { - const directions = calculateDirection(ballObject, brickObject); - - if (directions !== null) { - ball.horizontalSpeed *= directions[0]; - ball.verticalSpeed *= directions[1]; - brick.kill(); - break; - } - } + if (hitWallOrWentOut) { + continue; } - } - // Базоввое взаимодействие мяча и ракетки - if ( - ball.verticalSpeed > 0 && - ball.y + ball.radius >= paddle.y && - ball.x >= paddle.x && - ball.x <= paddle.x + paddle.width - ) { - ball.verticalSpeed *= -1; + // Проверка столкновения с кирпичами + const hitBrick = processBrickCollision(game, ball); + + if (hitBrick) { + continue; + } + + // Взаимодействие мяча и ракетки - обновление горизонтальной и вертикальной скорости в зависимости от сектора попадания + processReflection( + paddle, + ball, + PADDLE_SECTOR_AMOUNT, + MIN_PADDLE_REFLECTION_ANGLE, + MAX_PADDLE_REFLECTION_ANGLE, + ball.speed, + ); } } catch (err) { console.error(err); diff --git a/src/lib/tick/tick.test.js b/src/lib/tick/tick.test.js index 879c79f..3c3a047 100644 --- a/src/lib/tick/tick.test.js +++ b/src/lib/tick/tick.test.js @@ -11,7 +11,8 @@ describe('tick', () => { }); it('Возвращает корректное значние и не изменяет свойства game при неверых размерах контейнера', () => { - const game = new Game(1, 1); + const levels = [[[1]]]; + const game = new Game(levels); const ballX = game.ball.x; const ballY = game.ball.y; expect(tick(game, -1, -1, 1)).toBeNull(); @@ -20,7 +21,8 @@ describe('tick', () => { }); it('Возвращает корректное значние и не изменяет свойства game при неверном значении времени', () => { - const game = new Game(1, 1); + const levels = [[[1]]]; + const game = new Game(levels); const ballX = game.ball.x; const ballY = game.ball.y; expect(tick(game, 1, 1, -1)).toBeNull(); @@ -29,7 +31,8 @@ describe('tick', () => { }); it('Меняет направление мяча при столкновении со стеной', () => { - const game = new Game(1, 1); + const levels = [[[1]]]; + const game = new Game(levels); game.ball.x = 0; game.ball.horizontalSpeed = -10; tick(game, CONTAINER_WIDTH, CONTAINER_HEIGHT, 1); @@ -38,7 +41,8 @@ describe('tick', () => { }); it('Меняет направление мяча при столкновении с потолком', () => { - const game = new Game(0, 0); + const levels = [[[1]]]; + const game = new Game(1); game.ball.y = 0; game.ball.verticalSpeed = -10; tick(game, CONTAINER_WIDTH, CONTAINER_HEIGHT, 1); @@ -47,10 +51,11 @@ describe('tick', () => { }); it('Убивает кирпич при столкновении и отражает мяч', () => { - const game = new Game(1, 1); - const brick = game.bricks[0][0]; + const levels = [[[1]]]; + const game = new Game(levels); + const brick = game.bricks[0]; - game.ball.x = brick.x + 1; + game.ball.x = brick.x + game.ball.radius + 1; game.ball.y = brick.y + brick.height + game.ball.radius + 1; game.ball.horizontalSpeed = 0; game.ball.verticalSpeed = -10; @@ -62,17 +67,18 @@ describe('tick', () => { }); it('Убивает только один кирпич за тик', () => { - const game = new Game(2, 1); - const [firstBrick, secondBrick] = game.bricks[0]; + const levels = [[[1, 1]]]; + const game = new Game(levels); + const [firstBrick, secondBrick] = game.bricks; - game.ball.x = firstBrick.x + 1; + game.ball.x = firstBrick.x + game.ball.radius + 1; game.ball.y = firstBrick.y + firstBrick.height + game.ball.radius + 1; game.ball.horizontalSpeed = 0; game.ball.verticalSpeed = -10; tick(game, CONTAINER_WIDTH, CONTAINER_HEIGHT, 0.1); - const killedCount = game.bricks[0].filter((b) => !b.alive).length; + const killedCount = game.bricks.filter((b) => !b.alive).length; expect(killedCount).toBe(1); }); }); diff --git a/src/lib/toRadians/toRadians.js b/src/lib/toRadians/toRadians.js new file mode 100644 index 0000000..f8ea6ec --- /dev/null +++ b/src/lib/toRadians/toRadians.js @@ -0,0 +1,8 @@ +/** + * Конвертирует значение угла из градусов в радианы + * @param {number} degrees значение угла в градусах + * @returns {number} + */ +export function toRadians(angleInDegrees) { + return (angleInDegrees * Math.PI) / 180; +} diff --git a/src/lib/updatePerks/updatePerks.js b/src/lib/updatePerks/updatePerks.js new file mode 100644 index 0000000..9740d06 --- /dev/null +++ b/src/lib/updatePerks/updatePerks.js @@ -0,0 +1,97 @@ +import { + BALL_SPLIT_ANGLE, + CONTAINER_WIDTH, + PADDLE_WIDE_WIDTH, + PADDLE_WIDTH, + PERK_BALL_SPEED_DECREASE, + PERK_DURATION_SEC, +} from '../../config'; +import { clone } from '../../entities/ball/clone/clone'; +import { Game } from '../../game'; +import { calculateAABBCollision } from '../calculateAABBCollision/calculateAABBCollision'; + +/** + * Обновляет состояние бонусов, меняет положение, проверяет столкновение с ракеткой и выход за границу поля + * @param {Game} game экземпляр класса игры + * @param {number} containerHeight высота игрового контейнера в пикселях + * @param {number} deltaTime изменение времени из Ticker + */ +export function updatePerks(game, containerHeight, deltaTime) { + try { + if (!(game instanceof Game)) { + throw new Error('Аргумент game должен быть экземпляром класса игры'); + } + + if (typeof containerHeight !== 'number' || containerHeight <= 0) { + throw new Error('Высота игрового контейнера должна быть положительным числом'); + } + + if (typeof deltaTime !== 'number' || deltaTime <= 0) { + throw new Error('Значение изменения времени должно быть положительным числом'); + } + + const { paddle } = game; + + for (const perk of game.perks) { + perk.moveForward(deltaTime); + + const perkObject = { + left: perk.x, + right: perk.x + perk.width, + top: perk.y, + bottom: perk.y + perk.height, + }; + const paddleObject = { + left: paddle.x, + right: paddle.x + paddle.width, + top: paddle.y, + bottom: paddle.y + paddle.height, + }; + + const isCollided = calculateAABBCollision(perkObject, paddleObject); + + if (isCollided) { + switch (perk.type) { + case 'slow': + for (const ball of game.balls) { + if (ball.speed - PERK_BALL_SPEED_DECREASE > 0) { + ball.increaseSpeed(-1 * PERK_BALL_SPEED_DECREASE); + } + } + // ponytail: wall-clock timer, game never pauses so no need to tie to deltaTime + setTimeout(() => { + for (const ball of game.balls) { + ball.increaseSpeed(PERK_BALL_SPEED_DECREASE); + } + }, PERK_DURATION_SEC * 1000); + break; + case 'wide': + paddle.width = PADDLE_WIDE_WIDTH; + paddle.moveTo(paddle.x, 0, CONTAINER_WIDTH); + setTimeout(() => { + paddle.width = PADDLE_WIDTH; + }, PERK_DURATION_SEC * 1000); + break; + case 'life': + game.livesAmount += 1; + break; + case 'clone': + // Основной мяч делится на два клона, разлетающихся под углом + game.balls.push(clone(game.ball, BALL_SPLIT_ANGLE), clone(game.ball, -BALL_SPLIT_ANGLE)); + break; + default: + break; + } + + perk.alive = false; + } else if (perk.y > containerHeight) { + perk.alive = false; + } + } + + game.perks = game.perks.filter((perk) => perk.alive); + } catch (err) { + console.error(err); + return null; + } +} diff --git a/src/lib/updatePerks/updatePerks.test.js b/src/lib/updatePerks/updatePerks.test.js new file mode 100644 index 0000000..8c0b8c0 --- /dev/null +++ b/src/lib/updatePerks/updatePerks.test.js @@ -0,0 +1,70 @@ +import { PADDLE_WIDE_WIDTH } from '../../config'; +import { Perk } from '../../entities/perk/perk'; +import { Game } from '../../game'; +import { updatePerks } from './updatePerks'; + +describe('updatePerks', () => { + it('Возвращает корректное значение при неверных типах аргументов', () => { + const game = new Game([[[1]]]); + + expect(updatePerks(null, 100, 1)).toBeNull(); + expect(updatePerks(game, null, 1)).toBeNull(); + expect(updatePerks(game, -1, 1)).toBeNull(); + expect(updatePerks(game, 100, null)).toBeNull(); + expect(updatePerks(game, 100, -1)).toBeNull(); + }); + + it('Двигает бонус вниз в зависимости от времени', () => { + const game = new Game([[[1]]]); + const perk = new Perk(500, 10, 10, 10, 'slow', 3); + game.perks.push(perk); + + updatePerks(game, 600, 1); + + expect(perk.y).toBe(13); + expect(game.perks).toContain(perk); + }); + + it('Ловит бонус ракеткой, применяет эффект и убирает его', () => { + const game = new Game([[[1]]]); + const speedBefore = game.ball.speed; + const { paddle } = game; + game.perks.push(new Perk(paddle.x, paddle.y - 1, 10, 10, 'slow', 0)); + + updatePerks(game, 600, 1); + + expect(game.perks).toHaveLength(0); + expect(game.ball.speed).toBeLessThan(speedBefore); + }); + + it('Ловит бонус wide, расширяет ракетку до фиксированной ширины', () => { + const game = new Game([[[1]]]); + const { paddle } = game; + game.perks.push(new Perk(paddle.x, paddle.y - 1, 10, 10, 'wide', 0)); + + updatePerks(game, 600, 1); + + expect(game.perks).toHaveLength(0); + expect(paddle.width).toBe(PADDLE_WIDE_WIDTH); + }); + + it('Ловит бонус clone, разделяет мяч на три', () => { + const game = new Game([[[1]]]); + const { paddle } = game; + game.perks.push(new Perk(paddle.x, paddle.y - 1, 10, 10, 'clone', 0)); + + updatePerks(game, 600, 1); + + expect(game.perks).toHaveLength(0); + expect(game.balls).toHaveLength(3); + }); + + it('Убирает бонус, улетевший за нижнюю границу', () => { + const game = new Game([[[1]]]); + game.perks.push(new Perk(0, 601, 10, 10, 'slow', 0)); + + updatePerks(game, 600, 1); + + expect(game.perks).toHaveLength(0); + }); +}); diff --git a/src/main.js b/src/main.js index ed9fa34..21961f7 100644 --- a/src/main.js +++ b/src/main.js @@ -13,10 +13,9 @@ import { PADDLE_HEIGHT, PADDLE_WIDTH, } from './config'; +import { LEVELS } from './const/levels'; import { Game } from './game'; -import { calculateCollision } from './lib/calculateCollision/calculateCollision'; -import { calculateDirection } from './lib/calculateDirection/calculateDirection'; -import { createGameView, syncronizeViewsWithGame } from './view'; +import { createGameView, managePerkViewsLifetime, rebuildBrickViews, syncronizeViewsWithGame } from './view'; (async () => { // Create a new application @@ -39,19 +38,27 @@ import { createGameView, syncronizeViewsWithGame } from './view'; app.stage.addChild(container); - const game = new Game(BRICK_COLUMN_AMOUNT, BRICK_ROW_AMOUNT); + const game = new Game(LEVELS); const views = createGameView(game, container); + let currentLevel = game.currentLevel; container.on('pointermove', (event) => { const localPosition = container.toLocal(event.global); game.paddle.moveTo(localPosition.x, 0, CONTAINER_WIDTH); }); - console.log(game.paddle.x, game.paddle.y); + container.on('pointerdown', () => { + game.ball.launch(); + }); app.ticker.add((time) => { game.update(time.deltaTime); + + if (game.currentLevel !== currentLevel) { + rebuildBrickViews(views, game, container); + currentLevel = game.currentLevel; + } + managePerkViewsLifetime(views, game, container); syncronizeViewsWithGame(views, game); - console.log(game.paddle.x, game.paddle.y); }); })(); diff --git a/src/view.js b/src/view.js index 4aaec1b..e7e2f51 100644 --- a/src/view.js +++ b/src/view.js @@ -2,6 +2,7 @@ import { Container, Graphics } from 'pixi.js'; import { Ball } from './entities/ball/ball'; import { Brick } from './entities/brick/brick'; import { Paddle } from './entities/paddle/paddle'; +import { Perk } from './entities/perk/perk'; import { Game } from './game'; /** @@ -23,36 +24,72 @@ function createPaddleView(paddle) { /** * Создает визуальное отображение кирпича с помощью Pixi.js * @param {Brick} brick экземпляр класса кирпич + * @returns {Graphics} графическое отображение кирпича */ function createBrickView(brick) { - return new Graphics().rect(0, 0, brick.width, brick.height).fill('#000fff'); + const brickView = new Graphics().rect(0, 0, brick.width, brick.height); + + switch (brick.type) { + case 2: + return brickView.fill('#00ff00'); + case 3: + return brickView.fill('#ff00ff'); + case 1: + default: + return brickView.fill('#000fff'); + } +} + +/** + * Создает визуальное отображение бонуса с помощью Pixi.js + * @param {Perk} perk экземпляр класса бонус + * @returns {Graphics} графическое отображение бонуса + */ +function createPerkView(perk) { + const perkView = new Graphics().rect(0, 0, perk.width, perk.height); + + switch (perk.type) { + case 'slow': + return perkView.fill('#00ffff'); + case 'wide': + return perkView.fill('#0f0f0f'); + case 'life': + return perkView.fill('#f0f0f0'); + default: + return perkView.fill('#ffffff'); + } } /** * Создает визуальное отображение всех сущностей в игре с помощью Pixi.js и добавляет в контейнер * @param {Game} game экземпляр класса игра - * @param {Container} container контейнер Pixi.js + * @param {Container} container экземпляр класса контейнер Pixi.js */ export function createGameView(game, container) { try { - const ball = createBallView(game.ball); - container.addChild(ball); + const balls = new Map(); + for (const ball of game.balls) { + const ballView = createBallView(ball); + container.addChild(ballView); + balls.set(ball, ballView); + } const paddle = createPaddleView(game.paddle); container.addChild(paddle); - const bricks = game.bricks.map((row) => - row.map((brick) => { - const brickView = createBrickView(brick); - container.addChild(brickView); - return brickView; - }), - ); + const bricks = game.bricks.map((brick) => { + const brickView = createBrickView(brick); + container.addChild(brickView); + return brickView; + }); + + const perks = new Map(); return { - ball, + balls, paddle, bricks, + perks, }; } catch (err) { console.error(err); @@ -60,6 +97,53 @@ export function createGameView(game, container) { } } +/** + * Создает отображения для новых бонусов и удаляет отображения исчезнувших + * @param {object} views объект с визуальными отображениями сущностей игры + * @param {Game} game экземпляр класса игра + * @param {Container} container экземпляр класса контейнер Pixi.js + */ +export function managePerkViewsLifetime(views, game, container) { + if (views.paddle.width !== game.paddle.width) { + container.removeChild(views.paddle); + views.paddle.destroy(); + views.paddle = createPaddleView(game.paddle); + container.addChild(views.paddle); + } + + for (const [ball, ballView] of views.balls) { + if (!game.balls.includes(ball)) { + container.removeChild(ballView); + ballView.destroy(); + views.balls.delete(ball); + } + } + + for (const ball of game.balls) { + if (!views.balls.has(ball)) { + const ballView = createBallView(ball); + container.addChild(ballView); + views.balls.set(ball, ballView); + } + } + + for (const [perk, perkView] of views.perks) { + if (!game.perks.includes(perk)) { + container.removeChild(perkView); + perkView.destroy(); + views.perks.delete(perk); + } + } + + for (const perk of game.perks) { + if (!views.perks.has(perk)) { + const perkView = createPerkView(perk); + container.addChild(perkView); + views.perks.set(perk, perkView); + } + } +} + /** * Синхронизирует отображение сущностей Pixi.js с логикой игры (координаты и тд.) * @param {object} views объект с визуальными отображениями сущностей игры @@ -71,22 +155,48 @@ export function syncronizeViewsWithGame(views, game) { throw new Error('Аргумент game должен быть экземпляром класса Game'); } - views.ball.x = game.ball.x; - views.ball.y = game.ball.y; + for (const ball of game.balls) { + const ballView = views.balls.get(ball); + ballView.x = ball.x; + ballView.y = ball.y; + } views.paddle.x = game.paddle.x; views.paddle.y = game.paddle.y; for (let i = 0; i < game.bricks.length; i++) { - for (let j = 0; j < game.bricks[i].length; j++) { - const brick = game.bricks[i][j]; - const brickView = views.bricks[i][j]; - brickView.x = brick.x; - brickView.y = brick.y; - brickView.visible = brick.alive; - } + const brick = game.bricks[i]; + const brickView = views.bricks[i]; + brickView.x = brick.x; + brickView.y = brick.y; + brickView.visible = brick.alive; + } + + for (const perk of game.perks) { + const perkView = views.perks.get(perk); + perkView.x = perk.x; + perkView.y = perk.y; } } catch (err) { console.error(err); } } + +/** + * Пересоздает отображения кирпичей под текущий уровень игры. + * @param {object} views объект с визуальными отображениями сущностей игры + * @param {Game} game экземпляр класса игра + * @param {Container} container экземпляр класса контейнер Pixi.js + */ +export function rebuildBrickViews(views, game, container) { + for (const brickView of views.bricks) { + container.removeChild(brickView); + brickView.destroy(); + } + + views.bricks = game.bricks.map((brick) => { + const brickView = createBrickView(brick); + container.addChild(brickView); + return brickView; + }); +}