From b72583290d04ecadf1bbbd33737ef1688db22fb4 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 19 Jul 2026 17:47:01 +0300 Subject: [PATCH] =?UTF-8?q?feat(perk):=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=B1=D0=BE=D0=BD=D1=83=D1=81=20clone=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=B9=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=202=20=D0=B4=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D0=BC=D1=8F=D1=87=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config.js | 3 +- src/game.js | 24 +++++- src/lib/tick/tick.js | 110 +++++------------------- src/lib/updatePerks/updatePerks.js | 13 ++- src/lib/updatePerks/updatePerks.test.js | 11 +++ src/view.js | 33 +++++-- 6 files changed, 94 insertions(+), 100 deletions(-) diff --git a/src/config.js b/src/config.js index 0ec5eb8..7c30ecd 100644 --- a/src/config.js +++ b/src/config.js @@ -13,6 +13,7 @@ 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_SPLIT_ANGLE = 20; export const BRICK_WIDTH = 40; export const BRICK_HEIGHT = 10; @@ -25,4 +26,4 @@ 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']; +export const PERK_TYPES = ['slow', 'wide', 'life', 'clone']; diff --git a/src/game.js b/src/game.js index 67945a7..3f52fc9 100644 --- a/src/game.js +++ b/src/game.js @@ -32,13 +32,21 @@ export class Game { this.maxLevel = levels.length - 1; this.paddle = new Paddle(0, CONTAINER_HEIGHT - PADDLE_HEIGHT, PADDLE_WIDTH, PADDLE_HEIGHT); - this.ball = new Ball(0, 0, BALL_RADIUS, BALL_SPEED, BALL_INITIAL_ANGLE); + 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._placeBallOnPaddle(); } + /** + * Основной (первый) мяч. Пока играет один мяч - это он. + * @returns {Ball} + */ + get ball() { + return this.balls[0]; + } + /** * Изменяет значения сущностей в зависимости от времени * @param {*} deltaTime изменение времени из Ticker @@ -56,7 +64,10 @@ export class Game { tick(this, CONTAINER_WIDTH, CONTAINER_HEIGHT, deltaTime); - if (this.ball.status === 'out') { + // Убираем улетевшие мячи. Жизнь теряется только когда не осталось ни одного + const survivingBalls = this.balls.filter((ball) => ball.status !== 'out'); + + if (survivingBalls.length === 0) { this.livesAmount -= 1; if (this.livesAmount === 0) { @@ -68,7 +79,11 @@ export class Game { return; } - this.ball.increaseSpeed(BALL_SPEED_TIME_STEP * deltaTime); + this.balls = survivingBalls; + + for (const ball of this.balls) { + ball.increaseSpeed(BALL_SPEED_TIME_STEP * deltaTime); + } const isLevelComplete = this._checkLevelCompletion(); @@ -101,9 +116,10 @@ export class Game { } /** - * Ставит мяч по центру ракетки + * Ставит мяч по центру ракетки, оставляя один мяч в игре */ _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/tick/tick.js b/src/lib/tick/tick.js index 49f87fe..34545a1 100644 --- a/src/lib/tick/tick.js +++ b/src/lib/tick/tick.js @@ -1,18 +1,8 @@ -import { - CONTAINER_HEIGHT, - CONTAINER_WIDTH, - MAX_PADDLE_REFLECTION_ANGLE, - MIN_PADDLE_REFLECTION_ANGLE, - PADDLE_SECTOR_AMOUNT, - PERK_DROP_CHANCE, - PERK_HEIGHT, - PERK_WIDTH, -} from '../../config'; +import { MAX_PADDLE_REFLECTION_ANGLE, MIN_PADDLE_REFLECTION_ANGLE, PADDLE_SECTOR_AMOUNT } from '../../config'; import { Game } from '../../game'; -import { calculateAABBCollision } from '../calculateAABBCollision/calculateAABBCollision'; -import { calculateDirection } from '../calculateDirection/calculateDirection'; +import { processBall } from '../processBall/processBall'; +import { processBrickCollision } from '../processBrickCollision/processBrickCollision'; import { processReflection } from '../processReflection/processReflection'; -import { spawnRandomPerk } from '../spawnRandomPerk/spawnRandomPerk'; import { updatePerks } from '../updatePerks/updatePerks'; /** @@ -40,91 +30,37 @@ export function tick(game, containerWidth, containerHeight, deltaTime) { throw new Error('Значение изменения времени должно быть положительным числом'); } - const { ball, paddle, bricks } = game; - - ball.moveForward(deltaTime); + const { paddle } = game; // Бонусы падают и ловятся ракеткой каждый кадр 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; - return; - } - - // Не даем мячу выйти за границу стены сверху и меняем направление - if (ball.y <= topBoundary) { - ball.y = topBoundary; - ball.verticalSpeed *= -1; - return; - } - - // Проверяем выход за границу стены снизу - if (ball.y >= bottomBoundary) { - game.ball.status = 'out'; - return; - } - - // Базоввое взаимодействие мяча и кирпича - for (const brick of bricks) { - if (!brick.alive) { + if (hitWallOrWentOut) { 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 hitBrick = processBrickCollision(game, ball); - 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 = calculateAABBCollision(ballObject, brickObject); - - if (isCollided) { - const directions = calculateDirection(ballObject, brickObject); - - if (directions !== null) { - ball.horizontalSpeed *= directions[0]; - ball.verticalSpeed *= directions[1]; - brick.kill(); - - if (!brick.alive) { - if (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; - } + if (hitBrick) { + continue; } - } - // Взаимодействие мяча и ракетки - обновление горизонтальной и вертикальной скорости в зависимости от сектора попадания - processReflection( - paddle, - ball, - PADDLE_SECTOR_AMOUNT, - 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) { console.error(err); return null; diff --git a/src/lib/updatePerks/updatePerks.js b/src/lib/updatePerks/updatePerks.js index 613e9d6..f18ae79 100644 --- a/src/lib/updatePerks/updatePerks.js +++ b/src/lib/updatePerks/updatePerks.js @@ -1,4 +1,5 @@ -import { CONTAINER_WIDTH, PADDLE_WIDE_WIDTH, PERK_BALL_SPEED_DECREASE } from '../../config'; +import { BALL_SPLIT_ANGLE, CONTAINER_WIDTH, PADDLE_WIDE_WIDTH, PERK_BALL_SPEED_DECREASE } from '../../config'; +import { clone } from '../../entities/ball/clone/clone'; import { Game } from '../../game'; import { calculateAABBCollision } from '../calculateAABBCollision/calculateAABBCollision'; @@ -45,8 +46,10 @@ export function updatePerks(game, containerHeight, deltaTime) { if (isCollided) { switch (perk.type) { case 'slow': - if (game.ball.speed - PERK_BALL_SPEED_DECREASE > 0) { - game.ball.increaseSpeed(-1 * PERK_BALL_SPEED_DECREASE); + for (const ball of game.balls) { + if (ball.speed - PERK_BALL_SPEED_DECREASE > 0) { + ball.increaseSpeed(-1 * PERK_BALL_SPEED_DECREASE); + } } break; case 'wide': @@ -56,6 +59,10 @@ export function updatePerks(game, containerHeight, deltaTime) { 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; } diff --git a/src/lib/updatePerks/updatePerks.test.js b/src/lib/updatePerks/updatePerks.test.js index ef1c87f..8c0b8c0 100644 --- a/src/lib/updatePerks/updatePerks.test.js +++ b/src/lib/updatePerks/updatePerks.test.js @@ -48,6 +48,17 @@ describe('updatePerks', () => { 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)); diff --git a/src/view.js b/src/view.js index 737c0fe..e7e2f51 100644 --- a/src/view.js +++ b/src/view.js @@ -67,8 +67,12 @@ function createPerkView(perk) { */ 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); @@ -82,7 +86,7 @@ export function createGameView(game, container) { const perks = new Map(); return { - ball, + balls, paddle, bricks, perks, @@ -107,6 +111,22 @@ export function managePerkViewsLifetime(views, game, container) { 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); @@ -135,8 +155,11 @@ 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;