Compare commits

...
42 Commits
Author SHA1 Message Date
ilia 494878c14b Merge pull request 'chore: Добавлено описание проекта в README' (#10) from chore/readme into main
Build and push / build (push) Successful in 40s
Reviewed-on: #10
2026-07-21 09:02:28 +00:00
Ilia Mashkov 6302fd2f91 chore: Добавлено описание проекта в README
Build and push / build (push) Skipped
Build and push / build (pull_request) Successful in 40s
2026-07-21 12:00:53 +03:00
ilia 4505a846bb Merge pull request 'Feature/enhanced look' (#9) from feature/enhanced-look into main
Build and push / build (push) Successful in 41s
Reviewed-on: #9
2026-07-21 08:45:13 +00:00
Ilia Mashkov 4d572e8c86 feat: Добавлен счет игры. Очки отличаются для разных типов блоков. Счет в хедере и в окне при завершении игры
Build and push / build (push) Skipped
Build and push / build (pull_request) Successful in 41s
2026-07-21 11:41:15 +03:00
Ilia Mashkov 8cfe62a064 fix: Исправлен эффект увеличения ширины ракетки, работа NineSliceSprite 2026-07-21 11:25:00 +03:00
Ilia Mashkov 3432d02eaf feat: Добавлена смена фона игры при смене уровня 2026-07-21 10:26:48 +03:00
Ilia Mashkov 27f230d8f6 feat: Добавлена анимация для текстуры мяча 2026-07-21 09:36:59 +03:00
ilia 00cb33b9f9 Merge pull request 'chore: Добавлен Dockerfile и Caddyfile' (#8) from chore/docker into main
Build and push / build (push) Successful in 40s
Reviewed-on: #8
2026-07-20 07:57:53 +00:00
Ilia Mashkov 8153233529 chore: Добавлен Dockerfile и Caddyfile
Build and push / build (push) Skipped
Build and push / build (pull_request) Successful in 41s
2026-07-20 10:48:25 +03:00
ilia a16f316b13 Merge pull request 'feat: Добавлено сообщение об окончании игры и кнопка перезагрузки' (#7) from feature/notifications into main
Build and push / build (push) Successful in 39s
Reviewed-on: #7
2026-07-20 07:27:23 +00:00
Ilia Mashkov a11ab8cd5d feat: Добавлено сообщение об окончании игры и кнопка перезагрузки
Build and push / build (push) Skipped
Build and push / build (pull_request) Successful in 41s
2026-07-20 10:22:03 +03:00
ilia 5e9b70e603 Merge pull request 'Feature/appearance' (#6) from feature/appearance into main
Build and push / build (push) Successful in 40s
Reviewed-on: #6
2026-07-20 07:09:10 +00:00
Ilia Mashkov 7a36d23393 refactor: Изменен способ передачи текстур в функции создания спрайтов, исправлен баг с отсутствием аргумента
Build and push / build (push) Skipped
Build and push / build (pull_request) Successful in 43s
2026-07-20 10:01:26 +03:00
Ilia Mashkov 89bf5289b1 fix: Добавлен пропущеный аргумент при создании клонов мяча 2026-07-20 09:55:54 +03:00
Ilia Mashkov 5739575200 feat: Добавлена текстуры бонусов разных типов 2026-07-20 09:52:59 +03:00
Ilia Mashkov 4608e044b0 feat: Добавлена текстуры блоков разных типов 2026-07-20 09:17:23 +03:00
Ilia Mashkov 6430470478 feat: Добавлена текстура ракетки и обновлена логика изменения ширины при подборе бонуса 2026-07-20 09:06:38 +03:00
Ilia Mashkov 2dc8735fe6 feat: Добавлена текстура фонового изображения 2026-07-20 08:43:24 +03:00
Ilia Mashkov 004720acd6 feat: Добавлены файлы текстуры мяча, Graphics заменен на Sprite с текстурой 2026-07-19 21:11:56 +03:00
Ilia Mashkov 2cbff5b924 feat: Добавлен хедер с информацией о текущем уровне и количестве оставшихся жизней 2026-07-19 19:19:24 +03:00
Ilia Mashkov 8f593c6a6f feat: Контейнер с игрой растянут на весь экран 2026-07-19 18:58:25 +03:00
ilia afe028430f Merge pull request 'Feature/advanced game mechanics' (#5) from feature/advanced-game-mechanics into main
Build and push / build (push) Successful in 40s
Reviewed-on: #5
2026-07-19 15:09:20 +00:00
Ilia Mashkov 48fb992d04 feat(perk): Добавлен таймаут для бонуса увеличения размера ракетки и бонуса замедления скорости мяча
Build and push / build (push) Skipped
2026-07-19 18:02:33 +03:00
Ilia Mashkov b72583290d feat(perk): Добавлен бонус clone который добавляет 2 дополнительных мяча 2026-07-19 17:47:01 +03:00
Ilia Mashkov a734600390 refactor: Создана функция processBrickCollision для инкапсуляции логики проверки столкновения мяча с кирпичами 2026-07-19 17:41:51 +03:00
Ilia Mashkov dccdb02f73 refactor: Создана функция processBall для инкапсуляции логики проверки столкновения мяча со стенами 2026-07-19 17:28:56 +03:00
Ilia Mashkov e637d4e62e feat(clone): Добавлена функция для клонирования мяча и изменения горизонтальной и вертикальной скорости согласно указаному углу 2026-07-19 17:11:41 +03:00
Ilia Mashkov d2cfee3ec3 refactor: Переименована функция calculateCollision для лучшей семантики 2026-07-19 17:00:23 +03:00
Ilia Mashkov 8d15f3996f feat(perk): Добавлен бонус дающий жизнь 2026-07-19 15:37:42 +03:00
Ilia Mashkov b7e16f86ea feat(perk): Добавлен бонус увеличивающий размер ракетки 2026-07-19 15:32:09 +03:00
Ilia Mashkov 511b20c939 feat: Добавлена обработка столкновения ракетки и бонуса, логика обработки бонусов подключена в tick 2026-07-19 15:09:56 +03:00
Ilia Mashkov a4c4e2a192 feat(spawnRandomPerk): Добавлена функция создания случайного бонуса по заданым координатам 2026-07-19 15:08:22 +03:00
Ilia Mashkov b2f0810ec2 feat(perk): Добавлена сущность бонуса 2026-07-19 14:13:05 +03:00
Ilia Mashkov b05fb89bc5 feat(ball): Добавлена логика изменения базовой скорости мяча. С течением времени и на каждом следующем уровне 2026-07-19 13:20:10 +03:00
Ilia Mashkov 62abd8b77d refactor(ball): Вычисление ертикальной и гроизонтальной скорости перенесено в класс Ball 2026-07-19 13:15:50 +03:00
Ilia Mashkov 0f057c1add feat(ball): Флаг isOut заменен на поле status, изменено начальное положение мяча при старте игры, добавлена функция запуска мяча по клику 2026-07-19 11:24:37 +03:00
Ilia Mashkov e7b52bdbd9 fix: Добавлено обновление значения текущего уровня в main.js 2026-07-19 11:10:48 +03:00
Ilia Mashkov 500803db84 feat: Добавлена функцияя для пересборки отображений кирпичей и удаления оставшихся отображений кирпичей из контейнера при смене уровня 2026-07-19 10:53:40 +03:00
Ilia Mashkov 5e259f7efc feat(brick): Добавлены типы кирпичей: обычный, с 2 жизнями, неразрушаемый 2026-07-19 10:51:58 +03:00
Ilia Mashkov 111167b600 feat(game): Добавлена логика перехода на новый уровень
Build and push / build (push) Skipped
2026-07-18 18:03:56 +03:00
Ilia Mashkov 4436661547 refactor: Формат хранения кирпичей в классе Game переписан с вложенного массива на обычный массив. Входные данные для значений каждого кирпича берутся из карты уровней 2026-07-18 17:47:33 +03:00
Ilia Mashkov 71583d0fde feat: Добавлен список уровней в формате массива карт уровней. Карта уровня - вложенный массив глубины 2, где числовые значения соответствуют кирпичам разных типов 2026-07-18 17:40:14 +03:00
49 changed files with 1242 additions and 225 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
.yarn/cache
.yarn/unplugged
.yarn/install-state.gz
dist
+22
View File
@@ -0,0 +1,22 @@
:3000 {
root * /usr/share/caddy
encode {
zstd
gzip
match {
header Content-Type text/*
header Content-Type application/javascript*
header Content-Type application/json*
header Content-Type image/svg+xml*
}
}
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
@html path / /index.html
header @html Cache-Control "no-cache"
file_server
}
+22
View File
@@ -0,0 +1,22 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Enable Corepack so we can use Yarn v4 (pinned to match lockfile)
RUN corepack enable && corepack prepare yarn@4.11.0 --activate
# Force Yarn to use node_modules instead of PnP
ENV YARN_NODE_LINKER=node-modules
COPY package.json yarn.lock ./
RUN yarn install --immutable
COPY . .
RUN yarn build && ls -la dist
# Production stage - Caddy
FROM caddy:2-alpine
WORKDIR /usr/share/caddy
# Copy built static files from the builder stage
COPY --from=builder /app/dist .
# Copy our local Caddyfile config
COPY Caddyfile /etc/caddy/Caddyfile
EXPOSE 3000
# Start caddy using the config file
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
+21
View File
@@ -0,0 +1,21 @@
# Arkanoid
Вариация игры арканоид.
- Три уровня нарастающих по сложности (по крайней мере так задумано)
- Ракетка двигается по движению курсора
- Логика отскока мяча от ракетки реализована через разбиение ракетки на сектора и различным углом отскока
- Блоки трех типов: обычный, повышеной прочности и неразбиваемый
- Разные очки за обычный блок и блок повышеной прочности
- Скорость мяча увеличивается скачками от уровня к уровню и постепенно в течение одного уровня
- Из разбитых блоков со случайным шансом выпадают различные типы бонусов: замедление мяча, дополнительная жизнь, увеличение ширины ракетки, добавление двух дополнительных мячей.
- Текстуры сущностей взяты из публично доступных бесплатных источников
## Запуск проекта
Установка зависимостей: `yarn install`
Запуск в dev режиме: `yarn dev`
Сборка: `yarn build`
Запуск тестов: `yarn test`
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

+15
View File
@@ -2,6 +2,7 @@ export const CONTAINER_WIDTH = 800;
export const CONTAINER_HEIGHT = 600; export const CONTAINER_HEIGHT = 600;
export const PADDLE_WIDTH = 50; export const PADDLE_WIDTH = 50;
export const PADDLE_WIDE_WIDTH = 100;
export const PADDLE_HEIGHT = 10; export const PADDLE_HEIGHT = 10;
export const MIN_PADDLE_REFLECTION_ANGLE = 20; export const MIN_PADDLE_REFLECTION_ANGLE = 20;
export const MAX_PADDLE_REFLECTION_ANGLE = 160; export const MAX_PADDLE_REFLECTION_ANGLE = 160;
@@ -9,10 +10,24 @@ export const PADDLE_SECTOR_AMOUNT = 20;
export const BALL_RADIUS = 10; export const BALL_RADIUS = 10;
export const BALL_SPEED = 3; export const BALL_SPEED = 3;
export const BALL_SPEED_LEVEL_STEP = 2;
export const BALL_SPEED_TIME_STEP = 0.002;
export const BALL_INITIAL_ANGLE = 0; export const BALL_INITIAL_ANGLE = 0;
export const BALL_SPLIT_ANGLE = 20;
export const HEADER_HEIGHT = 30;
export const BRICK_WIDTH = 40; export const BRICK_WIDTH = 40;
export const BRICK_HEIGHT = 10; export const BRICK_HEIGHT = 10;
export const BRICK_ROW_AMOUNT = 5; export const BRICK_ROW_AMOUNT = 5;
export const BRICK_COLUMN_AMOUNT = 20; export const BRICK_COLUMN_AMOUNT = 20;
export const BRICK_POINTS_AMOUNT = { 1: 10, 2: 30 };
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;
+33
View File
@@ -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],
],
];
+43 -12
View File
@@ -1,3 +1,5 @@
import { toRadians } from '../../lib/toRadians/toRadians';
/** /**
* Класс сущности "мяч" * Класс сущности "мяч"
* @class * @class
@@ -7,21 +9,23 @@ export class Ball {
* @param {number} x координата положения мяча по оси X * @param {number} x координата положения мяча по оси X
* @param {number} y координата положения мяча по оси Y * @param {number} y координата положения мяча по оси Y
* @param {number} radius положительное числовое значение радиуса мяча * @param {number} radius положительное числовое значение радиуса мяча
* @param {number} verticalSpeed вектор движения мяча по оси Y * @param {number} speed положительное числовое значение скорости мяча
* @param {number} horizontalSpeed вектор движения мяча по оси X * @param {number} angle угол направления движения мяча в градусах
*/ */
constructor(x, y, radius, verticalSpeed = 0, horizontalSpeed = 0) { constructor(x, y, radius, speed = 0, angle = 0) {
this.x = x; this.x = x;
this.y = y; this.y = y;
this.radius = radius; this.radius = radius;
this.verticalSpeed = verticalSpeed; this.speed = speed;
this.horizontalSpeed = horizontalSpeed; this.angle = angle;
this.isOut = false; this.verticalSpeed = speed * Math.cos(toRadians(angle));
this.horizontalSpeed = -1 * speed * Math.sin(toRadians(angle));
this.status = 'idle';
this.defaultX = x; this.defaultX = x;
this.defaultY = y; this.defaultY = y;
this.defaultVerticalSpeed = verticalSpeed; this.defaultVerticalSpeed = this.verticalSpeed;
this.defaultHorizontalSpeed = horizontalSpeed; this.defaultHorizontalSpeed = this.horizontalSpeed;
} }
/** /**
@@ -36,11 +40,38 @@ export class Ball {
/** /**
* Возвращает значения к дефолтным * Возвращает значения к дефолтным
*/ */
reset() { reset(x = this.defaultX, y = this.defaultY) {
this.x = this.defaultX; this.x = x;
this.y = this.defaultY; this.y = y;
this.verticalSpeed = this.defaultVerticalSpeed; this.verticalSpeed = this.defaultVerticalSpeed;
this.horizontalSpeed = this.defaultHorizontalSpeed; this.horizontalSpeed = this.defaultHorizontalSpeed;
this.isOut = false; 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;
} }
} }
+36
View File
@@ -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;
}
}
+26
View File
@@ -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);
});
});
+14 -2
View File
@@ -9,16 +9,28 @@ export class Brick {
* @param {number} y координата положения кирпича по оси Y * @param {number} y координата положения кирпича по оси Y
* @param {number} width положительное числовое значение ширины кирпича * @param {number} width положительное числовое значение ширины кирпича
* @param {number} height положительное числовое значение высоты кирпича * @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.x = x;
this.y = y; this.y = y;
this.width = width; this.width = width;
this.height = height; this.height = height;
this.type = type;
this.alive = true; this.alive = true;
this.livesAmount = this.type;
} }
/**
* В зависимости от типа и оставшихся жизней убивает кирпич
*/
kill() { kill() {
this.alive = false; if (this.type !== 3) {
this.livesAmount -= 1;
if (this.livesAmount === 0) {
this.alive = false;
}
}
} }
} }
+24 -21
View File
@@ -1,37 +1,40 @@
import { Brick } from '../brick'; import { Brick } from '../brick';
/** /**
* Ложит кирпичи по заданым размерам * Создает массив кирпичей проинициализированных значениями в зависимости от расположения на карте уровня
* @param {number} columnAmount количество кирпичей по оси X, неотрицателное, целое число * @param {number[][]} levelMap карта уровня в формате массива
* @param {number} rowAmount количество кирпичей по оси Y, неотрицателное, целое число
* @param {number} brickWidth ширина кирпича, неотрицателное число * @param {number} brickWidth ширина кирпича, неотрицателное число
* @param {number} brickHeight длина кирпича, неотрицателное число * @param {number} brickHeight длина кирпича, неотрицателное число
* @returns {Brick[][]} массив с массивами кирпичей * @param {number} marginTop отступ сверху, неотрицательное число
* @returns {Brick[]} массив кирпичей
*/ */
export function layBricks(columnAmount, rowAmount, brickWidth, brickHeight) { export function layBricks(levelMap, brickWidth, brickHeight, marginTop = 0) {
try { try {
const someArgsArentNumbers = [columnAmount, rowAmount, brickWidth, brickHeight].some( if (!(Array.isArray(levelMap) && levelMap.every(Array.isArray))) {
(arg) => typeof arg !== 'number', throw new Error('Значение карты уровня должно являться вложенным масивом чисел глубины 2');
);
if (someArgsArentNumbers) {
throw new Error('Параметры кладки кирпичей должны являться числами');
} }
const someArgsLessThanZero = [columnAmount, rowAmount, brickWidth, brickHeight].some((arg) => arg < 0); if (!(typeof brickWidth === 'number' && typeof brickHeight === 'number')) {
if (someArgsLessThanZero) { throw new Error('Значения ширины и высоты кирпича должны являться числами');
throw new Error('Параметры кладки кирпичей должны являться положительными целыми числами');
} }
const rowsOrColumnsAreFloat = [columnAmount, rowAmount].some((amount) => amount % 1 !== 0); if (brickWidth < 0 || brickHeight < 0) {
if (rowsOrColumnsAreFloat) { throw new Error('Значения ширины и высоты кирпича должны являться положительными целыми числами');
throw new Error('Размеры рядов и колонок должны являться целыми числами');
} }
const bricks = Array.from({ length: rowAmount }).map((_, rowIndex) => if (typeof marginTop !== 'number' || marginTop < 0) {
Array.from({ length: columnAmount }).map( throw new Error('Значение отступа сверху должно быть неотрицательным числом');
(_, 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, marginTop + i * brickHeight, brickWidth, brickHeight, levelMap[i][j]));
}
}
}
return bricks; return bricks;
} catch (err) { } catch (err) {
+71 -30
View File
@@ -5,54 +5,95 @@ describe('layBricks', () => {
it('Возвращает корректное значение в случае неверного типа параметра', () => { it('Возвращает корректное значение в случае неверного типа параметра', () => {
const wrongType = 'wrong'; const wrongType = 'wrong';
expect(layBricks(wrongType, 1, 1, 1)).toBeNull(); expect(layBricks(wrongType, 1, 1)).toBeNull();
expect(layBricks(1, wrongType, 1, 1)).toBeNull(); expect(layBricks(1, wrongType, 1)).toBeNull();
expect(layBricks(1, 1, wrongType, 1)).toBeNull(); expect(layBricks(1, 1, wrongType)).toBeNull();
expect(layBricks(1, 1, 1, wrongType)).toBeNull();
}); });
it('Возвращает корректное значение в случае неверных размеров кирпича', () => { it('Возвращает корректное значение в случае неверных размеров кирпича', () => {
const wrongWidth = -1; const wrongWidth = -1;
const wrongHeight = -1; const wrongHeight = -1;
expect(layBricks(1, 1, wrongWidth, 1)).toBeNull(); expect(layBricks(1, wrongWidth, 1)).toBeNull();
expect(layBricks(1, 1, 1, wrongHeight)).toBeNull(); expect(layBricks(1, 1, wrongHeight)).toBeNull();
}); });
it('Возвращает корректное значение в случае неверных значений рядов и колонок', () => { it('Возвращает корректное значение в случае некоректной карты уровня', () => {
const negativeRow = -1; const levelMap = ['brick', 'brick'];
const negativeColumn = -1;
const decimalRow = 1.5;
const decimalColumn = 1.5;
expect(layBricks(negativeRow, 1, 1, 1)).toBeNull(); expect(layBricks(levelMap, 1, 1)).toBeNull();
expect(layBricks(1, negativeColumn, 1, 1)).toBeNull(); });
expect(layBricks(decimalRow, 1, 1, 1)).toBeNull();
expect(layBricks(1, decimalColumn, 1, 1)).toBeNull(); it('Возвращает корректное значение в случае некоректного отступа', () => {
const levelMap = [
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
];
expect(layBricks(levelMap, 1, 1, -1)).toBeNull();
}); });
it('Возвращает массив корректных размеров', () => { it('Возвращает массив корректных размеров', () => {
const columnAmount = 10; const levelMap = [
const rowAmount = 5; [1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
];
const bricks = layBricks(columnAmount, rowAmount, 1, 1); const bricks = layBricks(levelMap, 1, 1);
expect(bricks.length).toEqual(rowAmount); expect(bricks.length).toBe(levelMap.flat().length);
for (const row of bricks) {
expect(row.length).toEqual(columnAmount); 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('Задает корректные координаты кирпичам', () => { it('Задает корректные координаты кирпичам', () => {
const bricks = layBricks(3, 3, 1, 1); const levelMap = [
const columnAmount = 10; [1, 1],
const rowAmount = 5; [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 i = 0; i < levelMap.length; i++) {
for (let j = 0; j < columnAmount; j++) { for (let j = 0; j < levelMap[i].length; j++) {
expect(brickss[i][j].x).toEqual(j); if (levelMap[i][j] === 0) {
expect(brickss[i][j]?.y).toEqual(i); 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++;
} }
} }
}); });
+31
View File
@@ -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;
}
}
+77 -18
View File
@@ -2,41 +2,51 @@ import {
BALL_INITIAL_ANGLE, BALL_INITIAL_ANGLE,
BALL_RADIUS, BALL_RADIUS,
BALL_SPEED, BALL_SPEED,
BALL_SPEED_LEVEL_STEP,
BALL_SPEED_TIME_STEP,
BRICK_HEIGHT, BRICK_HEIGHT,
BRICK_WIDTH, BRICK_WIDTH,
CONTAINER_HEIGHT, CONTAINER_HEIGHT,
CONTAINER_WIDTH, CONTAINER_WIDTH,
HEADER_HEIGHT,
PADDLE_HEIGHT, PADDLE_HEIGHT,
PADDLE_WIDTH, PADDLE_WIDTH,
PERK_BALL_SPEED_DECREASE,
} from './config'; } from './config';
import { Ball } from './entities/ball/ball'; import { Ball } from './entities/ball/ball';
import { layBricks } from './entities/brick/layBricks/layBricks'; import { layBricks } from './entities/brick/layBricks/layBricks';
import { Paddle } from './entities/paddle/paddle'; import { Paddle } from './entities/paddle/paddle';
import { tick } from './lib/tick/tick'; import { tick } from './lib/tick/tick';
import { toRadians } from './lib/toRadians/toRadians';
/** /**
* Класс игры c информацией о всех игровых сущностях * Класс игры c информацией о всех игровых сущностях
*/ */
export class Game { export class Game {
/** /**
* @param {number} columnAmount количество кирпичей по оси X, неотрицателное, целое число * @param {number[][][]} levels список уровней, каждый элемент которого - карта расположения блоков
* @param {number} rowAmount количество кирпичей по оси Y, неотрицателное, целое число
*/ */
constructor(columnAmount, rowAmount) { constructor(levels) {
this.livesAmount = 3; this.livesAmount = 3;
this.score = 0;
this.status = 'in_process'; this.status = 'in_process';
this.levels = levels;
this.currentLevel = 0;
this.maxLevel = levels.length - 1;
this.ball = new Ball(
CONTAINER_WIDTH / 2 - BALL_RADIUS / 2,
CONTAINER_HEIGHT / 2 - BALL_RADIUS / 2,
BALL_RADIUS,
BALL_SPEED * Math.cos(toRadians(BALL_INITIAL_ANGLE)),
-1 * BALL_SPEED * Math.sin(toRadians(BALL_INITIAL_ANGLE)),
);
this.paddle = new Paddle(0, CONTAINER_HEIGHT - PADDLE_HEIGHT, PADDLE_WIDTH, PADDLE_HEIGHT); 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, HEADER_HEIGHT);
this.perks = [];
this.bricks = layBricks(columnAmount, rowAmount, BRICK_WIDTH, BRICK_HEIGHT); this._placeBallOnPaddle();
}
/**
* Основной (первый) мяч. Пока играет один мяч - это он.
* @returns {Ball}
*/
get ball() {
return this.balls[0];
} }
/** /**
@@ -47,22 +57,71 @@ export class Game {
if (this.status !== 'in_process') { if (this.status !== 'in_process') {
return; return;
} }
// Пока мяч не запущен - держим его на ракетке и не считаем физику
if (this.ball.status === 'idle') {
this._placeBallOnPaddle();
return;
}
tick(this, CONTAINER_WIDTH, CONTAINER_HEIGHT, deltaTime); tick(this, CONTAINER_WIDTH, CONTAINER_HEIGHT, deltaTime);
if (this.ball.isOut) { // Убираем улетевшие мячи. Жизнь теряется только когда не осталось ни одного
const survivingBalls = this.balls.filter((ball) => ball.status !== 'out');
if (survivingBalls.length === 0) {
this.livesAmount -= 1; this.livesAmount -= 1;
if (this.livesAmount === 0) { if (this.livesAmount === 0) {
this.status = 'over'; this.status = 'over';
} else { } else {
this.ball.reset(); 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';
} }
} }
}
const isAnyBrickAlive = this.bricks.some((row) => row.some((brick) => brick.alive)); /**
* Проверяет завершен ли текущий уровень
* @returns {boolean}
*/
_checkLevelCompletion() {
return this.bricks.every((brick) => brick.type === 3 || !brick.alive);
}
if (!isAnyBrickAlive) { /**
this.status = 'completed'; * Запускает переход на новый уровень, возвращает мяч в дефоотное положение и отрисовывает кирпичи по карте уровня.
} */
_proceedToNextLevel() {
this.ball.increaseSpeed(BALL_SPEED_LEVEL_STEP);
this._placeBallOnPaddle();
this.bricks = layBricks(this.levels[this.currentLevel], BRICK_WIDTH, BRICK_HEIGHT, HEADER_HEIGHT);
}
/**
* Ставит мяч по центру ракетки, оставляя один мяч в игре
*/
_placeBallOnPaddle() {
this.balls = [this.ball];
this.ball.reset(this.paddle.x + this.paddle.width / 2, this.paddle.y - this.ball.radius);
} }
} }
@@ -12,7 +12,7 @@
* @param {number} secondObject.bottom - Max Y координата второго объекта * @param {number} secondObject.bottom - Max Y координата второго объекта
* @returns {boolean} * @returns {boolean}
*/ */
export function calculateCollision(firstObject, secondObject) { export function calculateAABBCollision(firstObject, secondObject) {
try { try {
const coordinates = [ const coordinates = [
firstObject.left, firstObject.left,
@@ -1,6 +1,6 @@
import { calculateCollision } from './calculateCollision'; import { calculateAABBCollision } from './calculateAABBCollision';
describe('calculateCollision', () => { describe('calculateAABBCollision', () => {
it('Корректно обрабатывает невозможные кейсы (левая координата больше правой)', () => { it('Корректно обрабатывает невозможные кейсы (левая координата больше правой)', () => {
const firstObject = { const firstObject = {
left: 0, left: 0,
@@ -16,7 +16,7 @@ describe('calculateCollision', () => {
bottom: 10, bottom: 10,
}; };
expect(calculateCollision(firstObject, secondObject)).toBeNull(); expect(calculateAABBCollision(firstObject, secondObject)).toBeNull();
}); });
it('Корректно обрабатывает неверный формат данных', () => { it('Корректно обрабатывает неверный формат данных', () => {
@@ -34,7 +34,7 @@ describe('calculateCollision', () => {
bottom: 10, bottom: 10,
}; };
expect(calculateCollision(firstObject, secondObject)).toBeNull(); expect(calculateAABBCollision(firstObject, secondObject)).toBeNull();
}); });
it('Корректно обрабатывает отсутствие пересечения по X', () => { it('Корректно обрабатывает отсутствие пересечения по X', () => {
@@ -52,7 +52,7 @@ describe('calculateCollision', () => {
bottom: 10, bottom: 10,
}; };
expect(calculateCollision(firstObject, secondObject)).toBe(false); expect(calculateAABBCollision(firstObject, secondObject)).toBe(false);
}); });
it('Корректно обрабатывает отсутствие пересечения по Y', () => { it('Корректно обрабатывает отсутствие пересечения по Y', () => {
@@ -70,7 +70,7 @@ describe('calculateCollision', () => {
bottom: 30, bottom: 30,
}; };
expect(calculateCollision(firstObject, secondObject)).toBe(false); expect(calculateAABBCollision(firstObject, secondObject)).toBe(false);
}); });
it('Корректно обрабатывает отсутствие пересечения по X и Y', () => { it('Корректно обрабатывает отсутствие пересечения по X и Y', () => {
@@ -88,7 +88,7 @@ describe('calculateCollision', () => {
bottom: 30, bottom: 30,
}; };
expect(calculateCollision(firstObject, secondObject)).toBe(false); expect(calculateAABBCollision(firstObject, secondObject)).toBe(false);
}); });
it('Корректно обрабатывает пересечение', () => { it('Корректно обрабатывает пересечение', () => {
@@ -106,7 +106,7 @@ describe('calculateCollision', () => {
bottom: 30, bottom: 30,
}; };
expect(calculateCollision(firstObject, secondObject)).toBe(true); expect(calculateAABBCollision(firstObject, secondObject)).toBe(true);
}); });
it('Корректно обрабатывает вхождение', () => { it('Корректно обрабатывает вхождение', () => {
@@ -124,6 +124,6 @@ describe('calculateCollision', () => {
bottom: 15, bottom: 15,
}; };
expect(calculateCollision(firstObject, secondObject)).toBe(true); expect(calculateAABBCollision(firstObject, secondObject)).toBe(true);
}); });
}); });
@@ -1,4 +1,4 @@
import { calculateCollision } from '../calculateCollision/calculateCollision'; import { calculateAABBCollision } from '../calculateAABBCollision/calculateAABBCollision';
/** /**
* Вычисляет направление наибольшего пересечения по осям и возвращает tuple множителей для изменения координат * Вычисляет направление наибольшего пересечения по осям и возвращает tuple множителей для изменения координат
@@ -17,7 +17,7 @@ import { calculateCollision } from '../calculateCollision/calculateCollision';
export function calculateDirection(firstObject, secondObject) { export function calculateDirection(firstObject, secondObject) {
try { try {
// Запускаем для проверки формата аргументов // Запускаем для проверки формата аргументов
const isCollided = calculateCollision(firstObject, secondObject); const isCollided = calculateAABBCollision(firstObject, secondObject);
if (isCollided === null) { if (isCollided === null) {
return null; return null;
+54
View File
@@ -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;
}
}
+45
View File
@@ -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');
});
});
@@ -0,0 +1,78 @@
import { BRICK_POINTS_AMOUNT, 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) {
game.score += BRICK_POINTS_AMOUNT[brick.type] ?? 0;
}
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;
}
}
@@ -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);
});
});
@@ -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;
}
}
@@ -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);
});
});
+30 -78
View File
@@ -1,15 +1,9 @@
import { import { MAX_PADDLE_REFLECTION_ANGLE, MIN_PADDLE_REFLECTION_ANGLE, PADDLE_SECTOR_AMOUNT } from '../../config';
BALL_SPEED,
CONTAINER_HEIGHT,
CONTAINER_WIDTH,
MAX_PADDLE_REFLECTION_ANGLE,
MIN_PADDLE_REFLECTION_ANGLE,
PADDLE_SECTOR_AMOUNT,
} from '../../config';
import { Game } from '../../game'; import { Game } from '../../game';
import { calculateCollision } from '../calculateCollision/calculateCollision'; import { processBall } from '../processBall/processBall';
import { calculateDirection } from '../calculateDirection/calculateDirection'; import { processBrickCollision } from '../processBrickCollision/processBrickCollision';
import { processReflection } from '../processReflection/processReflection'; import { processReflection } from '../processReflection/processReflection';
import { updatePerks } from '../updatePerks/updatePerks';
/** /**
* Функция для вычисления взаимодействий сущностей игры в зависимости от времени из Ticker * Функция для вычисления взаимодействий сущностей игры в зависимости от времени из Ticker
@@ -36,79 +30,37 @@ export function tick(game, containerWidth, containerHeight, deltaTime) {
throw new Error('Значение изменения времени должно быть положительным числом'); throw new Error('Значение изменения времени должно быть положительным числом');
} }
const { ball, paddle, bricks } = game; const { paddle } = game;
ball.moveForward(deltaTime); // Бонусы падают и ловятся ракеткой каждый кадр
updatePerks(game, containerHeight, deltaTime);
const leftBoundary = ball.radius; for (const ball of game.balls) {
const rightBoundary = containerWidth - ball.radius; ball.moveForward(deltaTime);
const topBoundary = ball.radius; // Проверка столкновения со стенами и вылета за границу поля снизу
const bottomBoundary = containerHeight - ball.radius; const hitWallOrWentOut = processBall(ball, containerWidth, containerHeight);
// Не даем мячу выйти за границы стен слева / справав и меняем направление if (hitWallOrWentOut) {
if (ball.x <= leftBoundary || ball.x >= rightBoundary) { continue;
ball.x = ball.x <= leftBoundary ? leftBoundary : rightBoundary;
ball.horizontalSpeed *= -1;
return;
}
// Не даем мячу выйти за границу стены сверху и меняем направление
if (ball.y <= topBoundary) {
ball.y = topBoundary;
ball.verticalSpeed *= -1;
return;
}
// Проверяем выход за границу стены снизу
if (ball.y >= bottomBoundary) {
game.ball.isOut = true;
return;
}
// Базоввое взаимодействие мяча и кирпича
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();
return;
}
}
} }
}
// Взаимодействие мяча и ракетки - обновление горизонтальной и вертикальной скорости в зависимости от сектора попадания // Проверка столкновения с кирпичами
processReflection( const hitBrick = processBrickCollision(game, ball);
paddle,
ball, if (hitBrick) {
PADDLE_SECTOR_AMOUNT, continue;
MIN_PADDLE_REFLECTION_ANGLE, }
MAX_PADDLE_REFLECTION_ANGLE,
BALL_SPEED, // Взаимодействие мяча и ракетки - обновление горизонтальной и вертикальной скорости в зависимости от сектора попадания
); processReflection(
paddle,
ball,
PADDLE_SECTOR_AMOUNT,
MIN_PADDLE_REFLECTION_ANGLE,
MAX_PADDLE_REFLECTION_ANGLE,
ball.speed,
);
}
} catch (err) { } catch (err) {
console.error(err); console.error(err);
return null; return null;
+15 -9
View File
@@ -11,7 +11,8 @@ describe('tick', () => {
}); });
it('Возвращает корректное значние и не изменяет свойства game при неверых размерах контейнера', () => { it('Возвращает корректное значние и не изменяет свойства game при неверых размерах контейнера', () => {
const game = new Game(1, 1); const levels = [[[1]]];
const game = new Game(levels);
const ballX = game.ball.x; const ballX = game.ball.x;
const ballY = game.ball.y; const ballY = game.ball.y;
expect(tick(game, -1, -1, 1)).toBeNull(); expect(tick(game, -1, -1, 1)).toBeNull();
@@ -20,7 +21,8 @@ describe('tick', () => {
}); });
it('Возвращает корректное значние и не изменяет свойства game при неверном значении времени', () => { it('Возвращает корректное значние и не изменяет свойства game при неверном значении времени', () => {
const game = new Game(1, 1); const levels = [[[1]]];
const game = new Game(levels);
const ballX = game.ball.x; const ballX = game.ball.x;
const ballY = game.ball.y; const ballY = game.ball.y;
expect(tick(game, 1, 1, -1)).toBeNull(); expect(tick(game, 1, 1, -1)).toBeNull();
@@ -29,7 +31,8 @@ describe('tick', () => {
}); });
it('Меняет направление мяча при столкновении со стеной', () => { it('Меняет направление мяча при столкновении со стеной', () => {
const game = new Game(1, 1); const levels = [[[1]]];
const game = new Game(levels);
game.ball.x = 0; game.ball.x = 0;
game.ball.horizontalSpeed = -10; game.ball.horizontalSpeed = -10;
tick(game, CONTAINER_WIDTH, CONTAINER_HEIGHT, 1); tick(game, CONTAINER_WIDTH, CONTAINER_HEIGHT, 1);
@@ -38,7 +41,8 @@ describe('tick', () => {
}); });
it('Меняет направление мяча при столкновении с потолком', () => { it('Меняет направление мяча при столкновении с потолком', () => {
const game = new Game(0, 0); const levels = [[[1]]];
const game = new Game(1);
game.ball.y = 0; game.ball.y = 0;
game.ball.verticalSpeed = -10; game.ball.verticalSpeed = -10;
tick(game, CONTAINER_WIDTH, CONTAINER_HEIGHT, 1); tick(game, CONTAINER_WIDTH, CONTAINER_HEIGHT, 1);
@@ -47,8 +51,9 @@ describe('tick', () => {
}); });
it('Убивает кирпич при столкновении и отражает мяч', () => { it('Убивает кирпич при столкновении и отражает мяч', () => {
const game = new Game(1, 1); const levels = [[[1]]];
const brick = game.bricks[0][0]; const game = new Game(levels);
const brick = game.bricks[0];
game.ball.x = brick.x + game.ball.radius + 1; game.ball.x = brick.x + game.ball.radius + 1;
game.ball.y = brick.y + brick.height + game.ball.radius + 1; game.ball.y = brick.y + brick.height + game.ball.radius + 1;
@@ -62,8 +67,9 @@ describe('tick', () => {
}); });
it('Убивает только один кирпич за тик', () => { it('Убивает только один кирпич за тик', () => {
const game = new Game(2, 1); const levels = [[[1, 1]]];
const [firstBrick, secondBrick] = game.bricks[0]; const game = new Game(levels);
const [firstBrick, secondBrick] = game.bricks;
game.ball.x = firstBrick.x + game.ball.radius + 1; game.ball.x = firstBrick.x + game.ball.radius + 1;
game.ball.y = firstBrick.y + firstBrick.height + game.ball.radius + 1; game.ball.y = firstBrick.y + firstBrick.height + game.ball.radius + 1;
@@ -72,7 +78,7 @@ describe('tick', () => {
tick(game, CONTAINER_WIDTH, CONTAINER_HEIGHT, 0.1); 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); expect(killedCount).toBe(1);
}); });
}); });
+96
View File
@@ -0,0 +1,96 @@
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);
}
}
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;
}
}
+70
View File
@@ -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);
});
});
+61 -14
View File
@@ -13,45 +13,92 @@ import {
PADDLE_HEIGHT, PADDLE_HEIGHT,
PADDLE_WIDTH, PADDLE_WIDTH,
} from './config'; } from './config';
import { LEVELS } from './const/levels';
import { Game } from './game'; import { Game } from './game';
import { calculateCollision } from './lib/calculateCollision/calculateCollision'; import {
import { calculateDirection } from './lib/calculateDirection/calculateDirection'; createGameView,
import { createGameView, syncronizeViewsWithGame } from './view'; managePerkViewsLifetime,
rebuildBrickViews,
syncronizeViewsWithGame,
updateBackgroundView,
} from './view';
(async () => { (async () => {
// Create a new application
const app = new Application(); const app = new Application();
// Initialize the application
await app.init({ background: '#1099bb', width: CONTAINER_WIDTH, height: CONTAINER_HEIGHT }); await app.init({ background: '#1099bb', width: CONTAINER_WIDTH, height: CONTAINER_HEIGHT });
// Append the application canvas to the document body
document.body.appendChild(app.canvas); document.body.appendChild(app.canvas);
// Create and add a container to the stage
const container = new Container({ const container = new Container({
eventMode: 'static', eventMode: 'static',
hitArea: app.screen, hitArea: app.screen,
}); });
container.x = 0; container.x = 0;
container.y = 0; container.y = 0;
app.stage.addChild(container); app.stage.addChild(container);
const game = new Game(BRICK_COLUMN_AMOUNT, BRICK_ROW_AMOUNT); const textures = await Assets.load([
const views = createGameView(game, container); '/sprites/fire_ball_1.png',
'/sprites/fire_ball_2.png',
'/sprites/fire_ball_3.png',
'/sprites/fire_ball_4.png',
'/sprites/fire_ball_5.png',
'/sprites/fire_ball_6.png',
'/sprites/fire_ball_7.png',
'/sprites/fire_ball_8.png',
'/sprites/background_1.png',
'/sprites/background_2.png',
'/sprites/background_3.png',
'/sprites/paddle.png',
'/sprites/block_1.png',
'/sprites/block_2.png',
'/sprites/block_3.png',
'/sprites/perk_1.png',
'/sprites/perk_2.png',
'/sprites/perk_3.png',
'/sprites/perk_4.png',
]);
const game = new Game(LEVELS);
const views = createGameView(game, container, textures);
let currentLevel = game.currentLevel;
container.on('pointermove', (event) => { container.on('pointermove', (event) => {
const localPosition = container.toLocal(event.global); const localPosition = container.toLocal(event.global);
game.paddle.moveTo(localPosition.x, 0, CONTAINER_WIDTH); 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) => { app.ticker.add((time) => {
game.update(time.deltaTime); game.update(time.deltaTime);
if (game.currentLevel !== currentLevel) {
rebuildBrickViews(views, game, container, textures);
updateBackgroundView(views, game, textures);
currentLevel = game.currentLevel;
}
managePerkViewsLifetime(views, game, container, textures);
syncronizeViewsWithGame(views, game); syncronizeViewsWithGame(views, game);
console.log(game.paddle.x, game.paddle.y);
if (game.status === 'over' || game.status === 'completed') {
showEndScreen(game.status === 'completed' ? 'Победа!' : 'Игра окончена!', game.score);
app.ticker.stop();
}
}); });
})(); })();
/**
* Показывает финальный экран с сообщением и кнопкой рестарта (перезагрузка страницы).
* @param {string} message текст результата игры
* @param {number} score итоговые очки
*/
function showEndScreen(message, score) {
const overlay = document.createElement('div');
overlay.className = 'end-screen';
overlay.innerHTML = `<p>${message}</p><p>Очки: ${score}</p><button class="button" type="button">Начать заново</button>`;
overlay.querySelector('button').addEventListener('click', () => location.reload());
document.body.appendChild(overlay);
}
+40
View File
@@ -1 +1,41 @@
@import "./reset.css"; @import "./reset.css";
body {
min-height: 100vh;
}
canvas {
width: 100vw;
height: 100vh;
display: block;
object-fit: contain;
}
.end-screen {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 3rem;
font-family: sans-serif;
font-weight: 700;
}
.end-screen button {
color: #ffffff;
font-size: 1.5rem;
font-family: sans-serif;
padding: 0.5rem 1.5rem;
cursor: pointer;
transition: all 0.2s ease-out;
}
.end-screen button:hover {
color: #ffffff88;
}
+221 -29
View File
@@ -1,58 +1,165 @@
import { Container, Graphics } from 'pixi.js'; import { AnimatedSprite, Container, Graphics, NineSliceSprite, Sprite, Text } from 'pixi.js';
import { CONTAINER_HEIGHT, CONTAINER_WIDTH } from './config';
import { Ball } from './entities/ball/ball'; import { Ball } from './entities/ball/ball';
import { Brick } from './entities/brick/brick'; import { Brick } from './entities/brick/brick';
import { Paddle } from './entities/paddle/paddle'; import { Paddle } from './entities/paddle/paddle';
import { Perk } from './entities/perk/perk';
import { Game } from './game'; import { Game } from './game';
/** /**
* Создает визуальное отображение мяча с помощью Pixi.js * Создает визуальное отображение мяча с помощью Pixi.js
* @param {Ball} ball экземпляр класса мяч * @param {Ball} ball экземпляр класса мяч
* @param {object} textures объект с текстурами для визуального отображения
*/ */
function createBallView(ball) { function createBallView(ball, textures) {
return new Graphics().circle(0, 0, ball.radius).fill('#ffffff'); const ballFrames = [
textures['/sprites/fire_ball_1.png'],
textures['/sprites/fire_ball_2.png'],
textures['/sprites/fire_ball_3.png'],
textures['/sprites/fire_ball_4.png'],
textures['/sprites/fire_ball_5.png'],
textures['/sprites/fire_ball_6.png'],
textures['/sprites/fire_ball_7.png'],
textures['/sprites/fire_ball_8.png'],
];
const ballSprite = new AnimatedSprite(ballFrames);
ballSprite.animationSpeed = 0.15;
ballSprite.anchor.set(0.5);
ballSprite.width = ball.radius * 2;
ballSprite.height = ball.radius * 2;
ballSprite.play();
return ballSprite;
} }
/** /**
* Создает визуальное отображение ракетки с помощью Pixi.js * Создает визуальное отображение ракетки с помощью Pixi.js
* @param {Paddle} paddle экземпляр класса ракетка * @param {Paddle} paddle экземпляр класса ракетка
* @param {object} textures объект с текстурами для визуального отображения
*/ */
function createPaddleView(paddle) { function createPaddleView(paddle, textures) {
return new Graphics().rect(0, 0, paddle.width, paddle.height).fill('#fff000'); const texture = textures['/sprites/paddle.png'];
const paddleSprite = new NineSliceSprite({ texture, leftWidth: 150, rightWidth: 150, topHeight: 0, bottomHeight: 0 });
const paddleAspectRatio = paddle.height / texture.height;
paddleSprite.scale.set(paddleAspectRatio);
paddleSprite.width = paddle.width / paddleAspectRatio;
return paddleSprite;
} }
/** /**
* Создает визуальное отображение кирпича с помощью Pixi.js * Создает визуальное отображение кирпича с помощью Pixi.js
* @param {Brick} brick экземпляр класса кирпич * @param {Brick} brick экземпляр класса кирпич
* @returns {Graphics} графическое отображение кирпича
* @param {object} textures объект с текстурами для визуального отображения
*/ */
function createBrickView(brick) { function createBrickView(brick, textures) {
return new Graphics().rect(0, 0, brick.width, brick.height).fill('#000fff'); const bricksTextures = [
textures['/sprites/block_1.png'],
textures['/sprites/block_2.png'],
textures['/sprites/block_3.png'],
];
const brickSprite = new Sprite(bricksTextures[brick.type - 1]);
brickSprite.width = brick.width;
brickSprite.height = brick.height;
return brickSprite;
}
/**
* Создает визуальное отображение бонуса с помощью Pixi.js
* @param {Perk} perk экземпляр класса бонус
* @returns {Graphics} графическое отображение бонуса
* @param {object} textures текстуры блоков
*/
function createPerkView(perk, textures) {
let texture;
switch (perk.type) {
case 'slow':
texture = textures['/sprites/perk_1.png'];
break;
case 'wide':
texture = textures['/sprites/perk_2.png'];
break;
case 'life':
texture = textures['/sprites/perk_3.png'];
break;
case 'clone':
texture = textures['/sprites/perk_3.png'];
break;
default:
texture = textures['/sprites/perk_1.png'];
break;
}
const perkSprite = new Sprite(texture);
perkSprite.width = perk.width;
perkSprite.height = perk.height;
return perkSprite;
}
/**
* Возвращает текстуру фона для текущего уровня или дефолтную текстуру фона
* @param {Game} game экземпляр класса игра
* @param {object} textures объект с текстурами для визуального отображения
*/
function backgroundTextureForLevel(game, textures) {
const levelBackground = textures[`/sprites/background_${game.currentLevel + 1}.png`];
if (!levelBackground) {
return textures['/sprites/background_1.png'];
}
return levelBackground;
} }
/** /**
* Создает визуальное отображение всех сущностей в игре с помощью Pixi.js и добавляет в контейнер * Создает визуальное отображение всех сущностей в игре с помощью Pixi.js и добавляет в контейнер
* @param {Game} game экземпляр класса игра * @param {Game} game экземпляр класса игра
* @param {Container} container контейнер Pixi.js * @param {Container} container экземпляр класса контейнер Pixi.js
* @param {object} textures объект с текстурами для визуального отображения
*/ */
export function createGameView(game, container) { export function createGameView(game, container, textures) {
try { try {
const ball = createBallView(game.ball); const background = new Sprite(backgroundTextureForLevel(game, textures));
container.addChild(ball); background.width = CONTAINER_WIDTH;
background.height = CONTAINER_HEIGHT;
container.addChildAt(background, 0);
const paddle = createPaddleView(game.paddle); const header = new Text({ style: { fill: '#ffffff', fontSize: 16 } });
header.x = 8;
header.y = 8;
container.addChild(header);
const balls = new Map();
for (const ball of game.balls) {
const ballView = createBallView(ball, textures);
container.addChild(ballView);
balls.set(ball, ballView);
}
const paddle = createPaddleView(game.paddle, textures);
container.addChild(paddle); container.addChild(paddle);
const bricks = game.bricks.map((row) => const bricks = game.bricks.map((brick) => {
row.map((brick) => { const brickView = createBrickView(brick, textures);
const brickView = createBrickView(brick); container.addChild(brickView);
container.addChild(brickView); return brickView;
return brickView; });
}),
); const perks = new Map();
return { return {
ball, background,
header,
balls,
paddle, paddle,
bricks, bricks,
perks,
}; };
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -60,6 +167,52 @@ export function createGameView(game, container) {
} }
} }
/**
* Создает отображения для новых бонусов и удаляет отображения исчезнувших
* @param {object} views объект с визуальными отображениями сущностей игры
* @param {Game} game экземпляр класса игра
* @param {Container} container экземпляр класса контейнер Pixi.js
* @param {object} textures объект с текстурами для визуального отображения
*/
export function managePerkViewsLifetime(views, game, container, textures) {
// Изменяем ширину отображения ракетки основываясь на aspect ratio по оси X и ширине ракетки
if (views.paddle.width * views.paddle.scale.x !== game.paddle.width) {
views.paddle.width = game.paddle.width / views.paddle.scale.x;
}
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, textures);
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, textures);
container.addChild(perkView);
views.perks.set(perk, perkView);
}
}
}
/** /**
* Синхронизирует отображение сущностей Pixi.js с логикой игры (координаты и тд.) * Синхронизирует отображение сущностей Pixi.js с логикой игры (координаты и тд.)
* @param {object} views объект с визуальными отображениями сущностей игры * @param {object} views объект с визуальными отображениями сущностей игры
@@ -71,22 +224,61 @@ export function syncronizeViewsWithGame(views, game) {
throw new Error('Аргумент game должен быть экземпляром класса Game'); throw new Error('Аргумент game должен быть экземпляром класса Game');
} }
views.ball.x = game.ball.x; views.header.text = `Уровень: ${game.currentLevel + 1} Количество жизней: ${game.livesAmount} Очки: ${game.score}`;
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.x = game.paddle.x;
views.paddle.y = game.paddle.y; views.paddle.y = game.paddle.y;
for (let i = 0; i < game.bricks.length; i++) { for (let i = 0; i < game.bricks.length; i++) {
for (let j = 0; j < game.bricks[i].length; j++) { const brick = game.bricks[i];
const brick = game.bricks[i][j]; const brickView = views.bricks[i];
const brickView = views.bricks[i][j]; brickView.x = brick.x;
brickView.x = brick.x; brickView.y = brick.y;
brickView.y = brick.y; brickView.visible = brick.alive;
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) { } catch (err) {
console.error(err); console.error(err);
} }
} }
/**
* Меняет текстуру фона под текущий уровень игры.
* @param {object} views объект с визуальными отображениями сущностей игры
* @param {Game} game экземпляр класса игра
* @param {object} textures объект с текстурами для визуального отображения
*/
export function updateBackgroundView(views, game, textures) {
views.background.texture = backgroundTextureForLevel(game, textures);
}
/**
* Пересоздает отображения кирпичей под текущий уровень игры.
* @param {object} views объект с визуальными отображениями сущностей игры
* @param {Game} game экземпляр класса игра
* @param {Container} container экземпляр класса контейнер Pixi.js
* @param {object} textures объект с текстурами для визуального отображения
*/
export function rebuildBrickViews(views, game, container, textures) {
for (const brickView of views.bricks) {
container.removeChild(brickView);
brickView.destroy();
}
views.bricks = game.bricks.map((brick) => {
const brickView = createBrickView(brick, textures);
container.addChild(brickView);
return brickView;
});
}