javascript

Почему свой игровой движок — это проще, чем кажется

  • вторник, 20 января 2026 г. в 00:00:03
https://habr.com/ru/articles/986110/

Команда JavaScript for Devs подготовила перевод большой практической статьи о создании игрового движка с нуля — без шейдеров, GPU-магии и «взрослых» фреймворков. Автор шаг за шагом показывает, как из простых веб-примитивов вырастает полноценная игра, а затем — универсальный движок, и почему такой подход даёт больше свободы и выразительности, чем готовые решения.


Мы собираемся написать игровой движок на JavaScript — без AI, и под AI я имею в виду LLM. Да, ребята, это будет олдскул. Если, конечно, под олдскулом понимать «делать всё так, как это делали в давние-давние времена», в допотопную эпоху пятилетней давности. Время, когда мир был чище. Время, когда слякоть была на свободном выпасе и исключительно рукотворной.

Если вы ищете быстрый перекус для фарма ауры под тиктоки — что-нибудь вроде «как заставить AI выплюнуть игру про змейку или симуляцию жидкости», — у меня для вас только одно сообщение.

Во-первых, что такое игровой движок? Если спросить кого-нибудь вживую, вам, скорее всего, ответят пустым взглядом, а затем сообщат: «Сэр, это Wendy’s».

Если спросить кого-нибудь в интернете, вам укажут на Unity или Unreal 5 и скажут: «Вот это!». Если интернет-собеседник носит федору, он упомянет GameMaker, Godot или Love2D. И он будет одновременно прав и неправ — в таком себе шрёдингеровском смысле.

Чтобы сделать игровой движок, для начала нужно понять, что такое игровой движок. А чтобы по-настоящему это понять, вы должны уже один построить. К счастью для вас, я покажу один простой приём, который разрушит эту суперпозицию.

Игры — это подмножество игровых движков. Иначе говоря, в каждой игре по определению существует движок, который её приводит в действие.

Всё, что нужно для понимания и создания игрового движка, — это сделать игру. Конкретно мы будем делать игру на JavaScript, потому что это просто и переносимо. И как раз подходящая игра у меня есть.

Вы можете сразу перейти к финальной версии игры здесь, но я всё же рекомендую хотя бы бегло просмотреть этот туториал, чтобы увидеть шаги, которые к ней приводят.

Breakout

«Breakout? Что это вообще такое? Разве AI не может сделать это в один заход?» — спрашиваешь ты.

Что ж, дорогой читатель, тебя ждёт приятный сюрприз.

Breakout — это отличная игра, похожая на Pong. Отбиваешь мяч и уничтожаешь блоки. Проще некуда и идеальный вариант для старта. Вот увидишь — будет весело.

Её главная прелесть в том, что основа предельно проста для понимания, но при этом не настолько ограничивающая, чтобы мы не могли немного поразвлечься. Кроме того, даже хорошо, что AI может сделать её «в один шот», потому что, когда мы закончим, мы сможем с чистой совестью порадоваться тому, что проделали нечто совершенно бессмысленное. В конце концов, зачем мне писать «Мону Лизу», если я могу просто распечатать фотографию этой красотки?

Начнём. Чтобы создать игру, сначала нужно создать вселенную.

Вселенная

Для начала нам нужна вселенная. Я назову свою вселенную «Game».

const Game = {};

Она прекрасна. А теперь — да будет пространство.

const Game = {
  width: 100,
  height: 100,
};

Но что за пространство, если в нём ничего не существует?

const Game = {
  width: 100,
  height: 100,
  ball: {
    x: 50,
    y: 50,
    size: 20,
  },
};

Общий принцип понятен. Не усложняй. Всё отлично.

Какая ещё производительность? Тсс. Мы только начали готовить.

Но вселенная пока что слишком статична.

const Game = {
  width: 100,
  height: 100,
  ball: {
    x: 50,
    y: 50,
    dx: 1,
    dy: 1,
    size: 20,
  },
  tick() {
    this.ball.x += this.ball.dx;
    this.ball.y += this.ball.dy;
  },
};

Теперь нужно хорошенько облучить наши глазные яблоки синим светом, чтобы превратить абстрактное во что-то более осязаемое. Я как раз знаю, что для этого подойдёт.

const container = document.getElementById(containerId);
const screenWidth = container.clientWidth;
const screenHeight = container.clientHeight;
const ballDiv = document.createElement("div");
Object.assign(ballDiv.style, {
  position: "absolute",
  left: "0",
  top: "0",
});
container.appendChild(ballDiv);

const Game = {
  width: screenWidth,
  height: screenHeight,
  // the rest
  draw() {
    const b = this.ball;
    ballDiv.style.boxShadow = `${b.x}px ${b.y}px 0 ${
      b.size / 2
    }px #fff`;
  },
};

function universe() {
  Game.tick();
  Game.draw();
  requestAnimationFrame(universe);
}

universe();

Можешь запустить это ниже и посмотреть, как оно работает.

Если хотите поиграть самостоятельно, то welcome в источник :)

Исходный код
const container = document.getElementById(containerId);
const screenWidth = container.clientWidth;
const screenHeight = container.clientHeight;
const ballDiv = document.createElement('div');
Object.assign(ballDiv.style, {
  position: 'absolute',
  left: '0',
  top: '0',
});
container.appendChild(ballDiv);

const Game = {
  width: screenWidth,
  height: screenHeight,
  ball: {
      x: 50,
      y: 50,
      dx: 1,
      dy: 1,
      size: 20,
  },
  tick() {
      this.ball.x += this.ball.dx;
      this.ball.y += this.ball.dy;
  },
  draw() {
      const b = this.ball;
      ballDiv.style.boxShadow = `${b.x}px ${b.y}px 0 ${b.size/2}px #fff`;
  }
};

function universe() {
  Game.tick();
  Game.draw();
  frameId = requestAnimationFrame(universe)
}

universe();
container.addEventListener('unload', () => cancelAnimationFrame(frameId))

«Теперь я стал Богом, творцом миров»

Мы рендерим так: размещаем div с position: absolute в левом верхнем углу экрана. Затем рисуем шар с помощью box-shadow, который будет соответствовать нулевому размеру этого div. Четвёртый параметр box-shadow — это значение spread, и когда у div нет ширины и высоты, spread в 1px даст видимый «пятнышко». spread в 20px — это, по сути, квадрат шириной/высотой 20px, центрированный относительно заданного смещения x/y у тени.

Что? Это плохой способ рисовать шар? Не переживай, друг. Смотри сюда: ты видел, как он движется?

Ты мог и не заметить, но этот маленький белый шарик действительно сдвинулся! Потрясающе. Однако, по идее, в Breakout шар должен отскакивать от краёв.

Пора надеть шапочку для размышлений.

Шапочка для размышлений

У нас есть вселенная. Теперь нужно реализовать остальной Breakout. Полезно сначала выписать требования.

  • мячи отскакивают

  • существуют кирпичи

  • мячи отскакивают от кирпичей

  • мячи уничтожают кирпичи

  • существует ракетка

  • мячи отскакивают от ракетки

Есть ещё несколько правил про проигрыш, но, думаю, это и есть базовая механика.

Для начала, в мире не должно быть всего одного мяча — их должно быть много. Это простое изменение: используем массив мячей. Заодно пригодится функция во вселенной, которая будет создавать мяч.

balls: [],
addBall(x = this.width / 2, y = this.height / 2, speed = 8) {
  const angle = Math.random()*2*Math.PI;
  this.balls.push({
    x,
    y,
    dx: Math.cos(angle)*speed,
    dy: Math.sin(angle)*speed,
    size: 20,
  });
}

Теперь мы задаём мячу случайное направление. Есть формула, позволяющая найти точку на единичной окружности по углу. Мы берём случайный угол и по формуле получаем точку, учитывая, что математические функции ожидают радианы. После этого можно задать скорость, просто масштабировав направление на нужный коэффициент. Это работает, потому что единичная окружность всегда нормализована: её радиус равен 1.

Рисовать несколько мячей элементарно: просто рисуем больше теней.

draw() {
  ballDiv.style.boxShadow = this.balls.map(b => `${b.x}px ${b.y}px 0 ${b.size/2}px #fff`).join();
}

С отскоками ещё проще. Нужно проверить, вышел ли мяч за границы. Если вышел — корректируем позицию и разворачиваем скорость по нужной оси: в зависимости от того, ударился он о левую/правую стенку или о верх/низ.

Никаких навороченных алгоритмов. Если x мяча плюс половина размера больше ширины — мы откатываем dx и инвертируем его. То же самое для оси y.

tick() {
  const {width: w, height: h} = this;
  this.balls.forEach((b) => {
    b.x += b.dx;
    b.y += b.dy;
    if (b.x + b.size/2 > w || b.x < b.size/2) {
      b.x -= b.dx;
      b.dx *= -1;
    }
    if (b.y + b.size/2 > h || b.y < b.size/2) {
      b.y -= b.dy;
      b.dy *= -1;
    }
  });
},

Один — число одиночества, но это необязательно, если выдать ему компанию в лице 2 и 3.

Исходный код
const container = document.getElementById(containerId);
const screenWidth = container.clientWidth;
const screenHeight = container.clientHeight;
const ballDiv = document.createElement("div");
container.appendChild(ballDiv);
Object.assign(ballDiv.style, {
  position: "absolute",
  left: "0",
  top: "0",
});

const Game = {
  width: screenWidth,
  height: screenHeight,
  balls: [],
  addBall(x = this.width / 2, y = this.height / 2, speed = 8) {
    const angle = Math.random() * 2 * Math.PI;
    this.balls.push({
      x,
      y,
      dx: Math.cos(angle) * speed,
      dy: Math.sin(angle) * speed,
      size: 20,
    });
  },
  tick() {
    const { width: w, height: h } = this;
    this.balls.forEach((b) => {
      b.x += b.dx;
      b.y += b.dy;
      if (b.x + b.size / 2 > w || b.x < b.size / 2) {
        b.x -= b.dx;
        b.dx *= -1;
      }
      if (b.y + b.size / 2 > h || b.y < b.size / 2) {
        b.y -= b.dy;
        b.dy *= -1;
      }
    });
  },
  draw() {
    ballDiv.style.boxShadow = this.balls
      .map((b) => `${b.x}px ${b.y}px 0 ${b.size / 2}px #fff`)
      .join();
  },
};

Game.addBall();
Game.addBall();
Game.addBall();

function universe() {
  Game.tick();
  Game.draw();
  frameId = requestAnimationFrame(universe);
}

universe();
container.addEventListener('unload', () => cancelAnimationFrame(frameId))

Смотри, как носятся. Красота. Давай сделаем кирпичи.

Знаешь, кирпичи в каком-то смысле похожи на мячи. Добавим кирпич по центру и оставим мячик летать вокруг.

const brickDiv = document.createElement('div');
container.appendChild(brickDiv);
const BRICK_SIZE = [60, 15];
Object.assign(brickDiv.style, {
  position: 'absolute',
  borderRadius: '2px',
  left: `-${BRICK_SIZE[0]}px`,
  top: `-${BRICK_SIZE[1]}px`,
  width: `${BRICK_SIZE[0]}px`,
  height: `${BRICK_SIZE[1]}px`,
});

const Game = {
  // ...
  bricks: [],
  addBrick(x = this.width / 2, y = this.height / 2) {
    this.bricks.push({
      x: x - BRICK_SIZE[0] / 2,
      y: y - BRICK_SIZE[1] / 2,
    });
  },
  // ...
  draw() {
    // ...

    const [bW, bH] = BRICK_SIZE;
    brickDiv.style.boxShadow = this.bricks.map(b => `${b.x + bW}px ${b.y +bH}px 0 0 #fff`).join();
  }
};

Game.addBall(60, Game.height - 60);
Game.addBrick();

Кирпичи немного отличаются. У spread в box-shadow расширение одинаковое во все стороны, то есть нельзя задать разные ширину и высоту. Ничего страшного. Если вынести div за пределы экрана и задать ему размеры кирпича, мы всё равно можем рисовать через box-shadow. Просто при отрисовке нужно корректировать координаты с учётом того, что div находится «в минусе», чтобы (0,0) соответствовало левому верхнему углу экрана.

И это прекрасно, потому что сейчас в игре всего два div на все мячи и все кирпичи. Вот это я понимаю — производительность!

Исходный код
const container = document.getElementById(containerId);
const screenWidth = container.clientWidth;
const screenHeight = container.clientHeight;
const ballDiv = document.createElement('div');
container.appendChild(ballDiv);
Object.assign(ballDiv.style, {
  position: 'absolute',
  left: '0',
  top: '0',
});
const brickDiv = document.createElement('div');
container.appendChild(brickDiv);
const BRICK_SIZE = [60, 15];
Object.assign(brickDiv.style, {
  position: 'absolute',
  borderRadius: '2px',
  left: `-${BRICK_SIZE[0]}px`,
  top: `-${BRICK_SIZE[1]}px`,
  width: `${BRICK_SIZE[0]}px`,
  height: `${BRICK_SIZE[1]}px`,
});

const Game = {
  width: screenWidth,
  height: screenHeight,
  balls: [],
  bricks: [],
  addBall(x = this.width / 2, y = this.height / 2, speed = 8) {
    const angle = 75 *(Math.PI / 180);
    this.balls.push({
      x,
      y,
      dx: Math.cos(angle)*speed,
      dy: Math.sin(angle)*speed,
      size: 10,
    });
  },
  addBrick(x = this.width / 2, y = this.height / 2) {
    this.bricks.push({
      x,
      y,
    });
  },
  tick() {
    const {width: w, height: h} = this;
    this.balls.forEach((b) => {
      b.x += b.dx;
      b.y += b.dy;
      if (b.x + b.size/2 > w || b.x < b.size/2) {
          b.x -= b.dx;
          b.dx *= -1;
      }
      if (b.y + b.size/2 > h || b.y < b.size/2) {
          b.y -= b.dy;
          b.dy *= -1;
      }
    });
  },
  draw() {
    ballDiv.style.boxShadow = this.balls.map(b => `${b.x}px ${b.y}px 0 ${b.size/2}px #fff`).join();

    const [bW, bH] = BRICK_SIZE;
    brickDiv.style.boxShadow = this.bricks.map(b => `${b.x + bW/2}px ${b.y +bH/2}px 0 0 #fff`).join();
  }
};

Game.addBall(60, Game.height - 60);
Game.addBrick();

function universe() {
  Game.tick();
  Game.draw();
  frameId = requestAnimationFrame(universe);
}

universe();
container.addEventListener('unload', () => cancelAnimationFrame(frameId));

Но выглядит неправильно, и ты наверняка знаешь почему. Нет отскоков!

Так как же отскакивать? Если подумать — нужно всего лишь проверить, пересекается ли мяч с кирпичом хотя бы частично.

Можно считать, что x и y — это центр мяча и центр кирпича. Тогда, если правая граница мяча больше левой границы кирпича и левая граница мяча меньше правой границы кирпича — по оси x есть пересечение.

То же самое делаем для оси y: проверяем, что верх и низ мяча пересекаются с верхом и низом кирпича — тогда есть пересечение и по y.

Если проделать это для левой/правой/верхней/нижней границы мяча и сравнить с кирпичом — мы поймём, что произошло столкновение.

Дальше можно сделать, как раньше: откатить изменение dx/dy и инвертировать ту ось, по которой перекрытие больше.

Запутался? Возьми телефон и найди вокруг что-нибудь более-менее квадратное. Наложи угол телефона на угол «квадратной штуки». Пересечение этих двух «квадратных штук» — это прямоугольник. Вот он нам и нужен.

Если вдруг вокруг вообще нет ничего квадратного, вот картинка, которая поможет.

Можно добавить это в tick после проверки границ экрана.

const [brickWidth, brickHeight] = BRICK_SIZE;
const ballL = b.x - b.size/2;
const ballR = b.x + b.size/2;
const ballT = b.y - b.size/2;
const ballB = b.y + b.size/2;
for (const brick of this.bricks) {
  const brickL = brick.x - brickWidth/2;
  const brickR = brick.x + brickWidth/2;
  const brickT = brick.y - brickHeight/2;
  const brickB = brick.y + brickHeight/2;
  if ((ballL > brickR || ballR < brickL)) {
    continue;
  }
  if ((ballT > brickB || ballB < brickT)) {
    continue;
  }
  const xDif = Math.min(ballR - brickL, brickR - ballL);
  const yDif = Math.min(ballB - brickT, brickB - ballT);
  if (xDif < yDif ) {
    b.x -= b.dx;
    b.dx *= -1;
  } else if (xDif > yDif) {
    b.y -= b.dy;
    b.dy *= -1;
  } else {
    b.x -= b.dx;
    b.dx *= -1;
    b.y -= b.dy;
    b.dy *= -1;
  }
  break;
};

Зная величину перекрытия, мы можем определить ось: по какой перекрытие меньше, по той и был удар. Стоит отметить, что это не идеальное решение.

Что если мяч летит со скоростью света? Он может полностью «перепрыгнуть» кирпич! Есть способы закрыть этот крайний случай.

Можно решить это особым указом: ни один мяч не должен летать со скоростью света. Всё, проблема решена без единой строки кода.

Вообще, есть и другая проблема. Иногда при очень «тонких» касаниях отката по dx/dy недостаточно, чтобы на следующем кадре не было повторного столкновения — и тогда мяч может залипнуть в блоке. Простой способ — откатывать мяч чуть дальше, чем один шаг.

b.x -= b.dx*1.02;
// ... 
b.y -= b.dy*1.02;

Посмотрим, как это выглядит теперь.

Исходный код
const container = document.getElementById(containerId);
const screenWidth = container.clientWidth;
const screenHeight = container.clientHeight;
const ballDiv = document.createElement('div');
container.appendChild(ballDiv);
Object.assign(ballDiv.style, {
  position: 'absolute',
  left: '0',
  top: '0',
});
const brickDiv = document.createElement('div');
container.appendChild(brickDiv);
const BRICK_SIZE = [60, 15];
Object.assign(brickDiv.style, {
  position: 'absolute',
  borderRadius: '2px',
  left: `-${BRICK_SIZE[0]}px`,
  top: `-${BRICK_SIZE[1]}px`,
  width: `${BRICK_SIZE[0]}px`,
  height: `${BRICK_SIZE[1]}px`,
});

const Game = {
  width: screenWidth,
  height: screenHeight,
  balls: [],
  bricks: [],
  addBall(x = this.width / 2, y = this.height / 2, speed = 6) {
    const angle = -78 * (Math.PI / 180);
    this.balls.push({
      x,
      y,
      dx: Math.cos(angle) * speed,
      dy: Math.sin(angle) * speed,
      size: 10,
    });
  },
  addBrick(x = this.width / 2, y = this.height / 2) {
    this.bricks.push({
      x,
      y,
    });
  },
  tick() {
    const { width: w, height: h } = this;
    this.balls.forEach((b) => {
      b.x += b.dx;
      b.y += b.dy;
      if (b.x + b.size / 2 > w || b.x < b.size / 2) {
        b.x -= b.dx;
        b.dx *= -1;
      }
      if (b.y + b.size / 2 > h || b.y < b.size / 2) {
        b.y -= b.dy;
        b.dy *= -1;
      }

      const [brickWidth, brickHeight] = BRICK_SIZE;
      const ballL = b.x - b.size / 2;
      const ballR = b.x + b.size / 2;
      const ballT = b.y - b.size / 2;
      const ballB = b.y + b.size / 2;
      for (const brick of this.bricks) {
        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if ((ballL > brickR || ballR < brickL)) {
          continue;
        }
        if ((ballT > brickB || ballB < brickT)) {
          continue;
        }
        const xDif = Math.min(ballR - brickL, brickR - ballL);
        const yDif = Math.min(ballB - brickT, brickB - ballT);
        if (xDif < yDif) {
          b.x -= b.dx;
          b.dx *= -1;
        } else if (xDif > yDif) {
          b.y -= b.dy;
          b.dy *= -1;
        } else {
          b.x -= b.dx;
          b.dx *= -1;
          b.y -= b.dy;
          b.dy *= -1;
        }
        // we are done.
        break;
      };
    });
  },
  draw() {
    ballDiv.style.boxShadow = this.balls.map(b => `${b.x}px ${b.y}px 0 ${b.size / 2}px #fff`).join();

    const [bW, bH] = BRICK_SIZE;
    brickDiv.style.boxShadow = this.bricks.map(b => `${b.x + bW / 2}px ${b.y + bH / 2}px 0 0 #fff`).join();
  }
};

Game.addBall(60, Game.height - 60);
Game.addBrick();

function universe() {
  Game.tick();
  Game.draw();
  frameId = requestAnimationFrame(universe);
}

universe();
container.addEventListener('unload', () => cancelAnimationFrame(frameId));

Отлично. Что такое? Ты хотел физический движок? Так вот же он. Точный ли? Не особо, но насколько вообще можно быть точным с числами с плавающей точкой? Расслабься, не переживай. Мы тут делаем игровой движок, а не физический, не будь смешным.

Наша «физика» почти готова. Будет совсем несложно удалять блок при попадании и обновлять счётчик очков или что-нибудь в этом роде.

addBrick(x = this.width / 2, y = this.height / 2) {
  this.bricks.push({
    x,
    y,
    isAlive: true,
  });
},

this.balls.forEach((b) => {
  // ...
  for (const brick of this.bricks) {
    if (!brick.isAlive) {
       continue;
    }
    // ... 
    brick.isAlive = false;
    // maybe update score or something
    break;
  };
});

this.bricks = this.bricks.filter(b => b.isAlive);

Можно чистить и прямо в середине цикла. Неважно — пока не станет важно, а сейчас не стало.

Пора добавить в мир ракетку. Ракетка — как кирпич, только она не умирает, и ею можно управлять.

Кстати, про управление. Как будем делать? Стрелки? WASD? мышь? тач? Ого!

Выберем ввод через тач/мышь. Да, поддержка клавиатуры и геймпада была бы неплохим расширением для нашего «движка ввода», но этого вполне достаточно. Покроем и десктоп, и мобилки. Да что там — даже смарт-ТВ должны потянуть.

Для начала ракетка должна существовать.

Узри: гребущая ракетка, полученная путём «впиливания» кирпича в ракетку.

const paddleDiv = document.createElement('div');
container.appendChild(paddleDiv);
const PADDLE_SIZE = [100, 10];
Object.assign(paddleDiv.style, {
  position: 'absolute',
  left: `-${PADDLE_SIZE[0]}px`,
  top: `-${PADDLE_SIZE[1]}px`,
  width: `${PADDLE_SIZE[0]}px`,
  height: `${PADDLE_SIZE[1]}px`,
});

const Game = {
  // ...
  paddles: [],

  addPaddle(x = this.width /2, y = this.height - PADDLE_SIZE[1]*2) {
    this.paddles.push({
      x,
      y,
      width: PADDLE_SIZE[0],
      height: PADDLE_SIZE[1],
    });   
  }

  draw() {
    // ...

    const [pW, pH] = PADDLE_SIZE;
    paddleDiv.style.boxShadow = this.paddles.map(p => `${p.x + pW/2}px ${p.y + pH/2}px 0 0 #fff`).join();
  }
}

Хороший вопрос: а правда ли нам нужно больше одной ракетки? Не знаю, но это побочный эффект копипасты кода кирпичей.

Можно подумать, что код столкновений тоже можно просто скопировать, да? И ты был бы прав. Но так мы нарушим правила.

Видишь ли, я открою тебе маленький секрет про Breakout, который большинство туториалов в интернете вообще не упоминает.

То, что делает Breakout не умопомрачительно скучным уже через 3,50 секунды, — это возможность целиться мячом ракеткой. И тут не нужно никакого сложного «углового момента» или чего-то такого. Всё проще.

Идея в том, что направление мяча зависит от того, в какую точку по оси x он попадает по ракетке. Если он ударяет точно по центру, мяч улетает вверх под идеальные 90 градусов. Чем ближе удар к левому краю, тем ближе направление к 180 градусам. Чем ближе к правому — тем ближе к 0 градусам.

А редкие попадания по самому краю мы обрабатываем так: инвертируем и x, и y. Это почти гарантирует, что мяч «спасён». Почему? Потому что мы хотим, чтобы игрок чувствовал себя молодцом, когда вытаскивает такой «идеальный» сейв.

Эти правила делают игру более активной: ты стараешься идеально выставлять угол, чтобы доставать эти противные кирпичи в углах.

Чтобы помочь, я набросал небольшой рисунок.

Если присмотреться, угол между центром ракетки и мячом — это почти ровно то направление, в котором мы хотим отправить мяч. Если попадание по центру, получается 90 градусов вверх. Чем ближе мяч к левому или правому краю ракетки, тем агрессивнее отклоняется угол полёта.

Это не идеально, но, по-моему, работает. Логика простая: находим угол между центром мяча и центром ракетки и задаём мячу движение в этом направлении.

Какие ещё векторы? Я не уверен, что это такое. Но я знаю: если взять арктангенс разницы между двумя точками, мы получим угол в радианах. Дальше можно использовать тот же код, что и для случайной точки на единичной окружности, чтобы получить значения dx/dy для этого угла.

Выглядит это так.

const angle = Math.atan2(b.y - p.y, b.x - p.x);
// get the speed by finding the length of direction by treating it as a triangle
const speed = Math.sqrt(b.dx*b.dx + b.dy*b.dy);
// get the point on unit circle of angle and then scale by speed
b.dx = Math.cos(angle)*speed;
b.dy = Math.sin(angle)*speed;

Соберём это вместе внутри цикла по мячам.

// check paddles
for (const p of this.paddles) {
  // just like bricks
  const pL = p.x - p.width/2;
  const pR = p.x + p.width/2;
  const pT = p.y - p.height/2;
  const pB = p.y + p.height/2;
  if ((ballL > pR || ballR < pL)) {
    continue;
  }
  if ((ballT > pB || ballB < pT)) {
    continue;
  }
  // but no need for overlap
  b.x -= b.dx;
  b.y -= b.dy;
  const angle = Math.atan2(b.y - p.y, b.x - p.x);
  const speed = Math.sqrt(b.dx*b.dx + b.dy*b.dy);
  b.dx = Math.cos(angle)*speed;
  b.dy = Math.sin(angle)*speed;
  break;
};

Для ввода мы берём x позицию мыши/тача и выставляем всем ракеткам одинаковое значение x. Не нужно переусложнять систему ввода в нашем движке.

const Game = {
  handleMouseOrTouchInput(event) {
    event.preventDefault();
    const x = (event?.touches?.[0]?.clientX) ?? event.offsetX;
    const y = (event?.touches?.[0]?.clientY) ?? event.offsetY;
    this.paddles.forEach(p => p.x = x);
  },
}

// ... 

container.addEventListener("pointermove", (e) => Game.handleMouseOrTouchInput(e));
container.addEventListener("touchmove", (e) => Game.handleMouseOrTouchInput(e));

И вот у нас почти вся механика готова.

Исходный код
const container = document.getElementById(containerId);
const screenWidth = container.clientWidth;
const screenHeight = container.clientHeight;
const ballDiv = document.createElement("div");
container.appendChild(ballDiv);
Object.assign(ballDiv.style, {
  position: "absolute",
  left: "0",
  top: "0",
});
const brickDiv = document.createElement("div");
container.appendChild(brickDiv);
const BRICK_SIZE = [60, 15];
Object.assign(brickDiv.style, {
  position: "absolute",
  borderRadius: "2px",
  left: `-${BRICK_SIZE[0]}px`,
  top: `-${BRICK_SIZE[1]}px`,
  width: `${BRICK_SIZE[0]}px`,
  height: `${BRICK_SIZE[1]}px`,
});
const paddleDiv = document.createElement("div");
container.appendChild(paddleDiv);
const PADDLE_SIZE = [100, 10];
Object.assign(paddleDiv.style, {
  position: "absolute",
  left: `-${PADDLE_SIZE[0]}px`,
  top: `-${PADDLE_SIZE[1]}px`,
  width: `${PADDLE_SIZE[0]}px`,
  height: `${PADDLE_SIZE[1]}px`,
});

const Game = {
  width: screenWidth,
  height: screenHeight,
  balls: [],
  bricks: [],
  paddles: [],

  handleMouseOrTouchInput(event) {
    event.preventDefault();
    event.stopPropagation();
    const x = event?.touches?.[0]?.clientX ?? event.offsetX;
    const y = event?.touches?.[0]?.clientY ?? event.offsetY;
    this.paddles.forEach((p) => (p.x = x));
  },

  addPaddle(x = this.width / 2, y = this.height - PADDLE_SIZE[1] * 2) {
    this.paddles.push({
      x,
      y,
      width: PADDLE_SIZE[0],
      height: PADDLE_SIZE[1],
    });
  },
  addBall(x = this.width / 2, y = this.height / 2, speed = 6) {
    const thresholdDegrees = 20;
    const arc = thresholdDegrees * (Math.PI / 180);
    let angle = arc + Math.random() * (Math.PI - 2 * arc);
    if (Math.random() > 0.5) {
      angle += Math.PI;
    }
    this.balls.push({
      x,
      y,
      dx: Math.cos(angle) * speed,
      dy: Math.sin(angle) * speed,
      size: 10,
    });
  },
  addBrick(x = this.width / 2, y = this.height / 2) {
    this.bricks.push({
      x,
      y,
      isAlive: true,
    });
  },
  tick() {
    const { width: w, height: h } = this;
    this.balls.forEach((b) => {
      b.x += b.dx;
      b.y += b.dy;
      if (b.x + b.size / 2 > w || b.x < b.size / 2) {
        b.x -= b.dx;
        b.dx *= -1;
      }
      if (b.y + b.size / 2 > h || b.y < b.size / 2) {
        b.y -= b.dy;
        b.dy *= -1;
      }

      const ballL = b.x - b.size / 2;
      const ballR = b.x + b.size / 2;
      const ballT = b.y - b.size / 2;
      const ballB = b.y + b.size / 2;

      // check bricks.
      const [brickWidth, brickHeight] = BRICK_SIZE;
      for (const brick of this.bricks) {
        if (!brick.isAlive) {
          continue;
        }

        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if (ballL > brickR || ballR < brickL) {
          continue;
        }
        if (ballT > brickB || ballB < brickT) {
          continue;
        }
        const xDif = Math.min(ballR - brickL, brickR - ballL);
        const yDif = Math.min(ballB - brickT, brickB - ballT);
        if (xDif < yDif) {
          b.x -= b.dx;
          b.dx *= -1;
        } else if (xDif > yDif) {
          b.y -= b.dy;
          b.dy *= -1;
        } else {
          b.x -= b.dx;
          b.dx *= -1;
          b.y -= b.dy;
          b.dy *= -1;
        }

        brick.isAlive = false;
        break;
      }

      // check paddles
      for (const p of this.paddles) {
        const pL = p.x - p.width / 2;
        const pR = p.x + p.width / 2;
        const pT = p.y - p.height / 2;
        const pB = p.y + p.height / 2;
        if (ballL > pR || ballR < pL) {
          continue;
        }
        if (ballT > pB || ballB < pT) {
          continue;
        }
        b.x -= b.dx;
        b.y -= b.dy;
        const angle = Math.atan2(b.y - p.y, b.x - p.x);
        const speed = Math.sqrt(b.dx * b.dx + b.dy * b.dy);
        b.dx = Math.cos(angle) * speed;
        b.dy = Math.sin(angle) * speed;
        // yup vectors work too ;)
        {
          /* let dirX = (b.x - p.x);
                let dirY = (b.y - p.y);
                const mag = Math.sqrt(dirX*dirX + dirY * dirY);
                dirX /= mag;
                dirY /= mag;
                b.dx = speed * dirX;
                b.dy = speed * dirY; */
        }
        break;
      }
    });

    this.bricks = this.bricks.filter((b) => b.isAlive);
  },
  draw() {
    ballDiv.style.boxShadow = this.balls
      .map((b) => `${b.x}px ${b.y}px 0 ${b.size / 2}px #fff`)
      .join();

    const [bW, bH] = BRICK_SIZE;
    brickDiv.style.boxShadow = this.bricks
      .map((b) => `${b.x + bW / 2}px ${b.y + bH / 2}px 0 0 #fff`)
      .join();

    const [pW, pH] = PADDLE_SIZE;
    paddleDiv.style.boxShadow = this.paddles
      .map((p) => `${p.x + pW / 2}px ${p.y + pH / 2}px 0 0 #fff`)
      .join();
  },
};

Game.addBall(60, Game.height - 60);
Game.addBrick();
Game.addPaddle();

container.addEventListener("pointermove", (e) =>
  Game.handleMouseOrTouchInput(e)
);
container.addEventListener("touchmove", (e) => Game.handleMouseOrTouchInput(e));

function universe() {
  Game.tick();
  Game.draw();
  frameId = requestAnimationFrame(universe);
}

universe();
container.addEventListener('unload', () => cancelAnimationFrame(frameId));

Потрясающе! Добавь больше кирпичей, счёт — и это уже игра.

Но придержи коней, ковбой, мы ещё не закончили.

Тут есть серьёзные проблемы: немного дёргано, а игра и выглядит, и ощущается довольно пресно. Если честно, она слегка… так себе.

Но ничего страшного: если хорошенько отполировать кусок… ну, в общем, он заблестит как бриллиант — по крайней мере, так мне говорила мама.

Полировка говнокода

Окей, первым делом стоит слегка «отполировать» код: почистить числа, подтянуть углы, закрыть крайние случаи и так далее. Вся полировка здесь — это в основном добавление нескольких дополнительных условий, чтобы обработать редкие, но неприятные ситуации.

Например, ракеткой довольно легко выбить мяч за пределы экрана. Ещё он может залипнуть в ракетке.

Другая проблема в том, что сейчас игра будет работать ровно с той скоростью, с какой крутится requestAnimationFrame. Это значит: если устройство рисует 120 или 144 кадра в секунду, игра будет идти в два раза быстрее, чем должна.

Исправить это несложно, потому что requestAnimationFrame передаёт нам, сколько времени прошло с прошлого вызова. Это можно использовать, чтобы нормализовать скорость игры.

Если прошлое обновление заняло 1 мс — мы масштабируем всё на крошечную долю, а если заняло 500 мс — масштабируем в 500 раз относительно 1 мс. Так мы фактически отвязываем логику от частоты кадров.

Ничего сложного — всего строк пять.

const Game = {
  tick(dt) {
    // scale the updates by time
    b.x += b.dx * dt;
    b.y += b.dy * dt;
    // etc
  }
}

let lastTime = 0;
function universe(delta) {
  if (lastTime === 0) {
    lastTime = delta;
  }

  // normalize from ms to seconds.
  const dt = (timestamp - lastTime) / 1000;
  Game.tick(dt);
  Game.draw();

  lastTime = delta;
  requestAnimationFrame(universe);
}

universe(lastTime);

Раньше скорость мяча была 6 — то есть 6 пикселей на каждый requestAnimationFrame, или 660 пикселей в секунду. Проблема в том, что если устройство обновляет экран 120 раз в секунду, получается 6120 пикселей/с. Ничего себе, вот это скорость.

С нормализацией по delta time получаем 6 пикселей в секунду. Чтобы вернуть прежнюю скорость, можно поставить speed равным 360. Другие движки могут использовать единицы вроде метров. Мне нравятся пиксели, так что остаёмся на них.

Дальше: мяч может залипнуть в ракетке или кирпиче и вылететь за пределы. Обе проблемы из-за того, что мы не делаем «идеальную» коррекцию при столкновении. Мы просто откатываем последний шаг и надеемся, что мяч при этом не «откатится» прямо в другое столкновение.

Для ракетки и границ это несложно исправить: можно откатывать мяч на величину перекрытия. Но с кирпичами при экстремальных касаниях угла простой фикс по перекрытию ломается. Потому что на некоторых шагах времени перекрытие выглядит как удар по оси y, хотя на самом деле это удар по оси x.

Случается не всегда, но когда случается — в раю наступает грустный день, и всё ощущается ужасно неправильно.

Один из способов исправить — решать столкновения по осям последовательно, следя за тем, чтобы позиция мяча обновлялась только по одной оси за раз.

const bHalfSize = b.size/2;
const [brickWidth, brickHeight] = BRICK_SIZE;
// x axis
b.x += ballDx;
// recalc ball edges
for (const brick of this.bricks) {
  if (!brick.isAlive || brick.health <= 0) {
    continue;
  }
  // recalc brick edges
  if ((ballT > brickB || ballB < brickT) || (ballL > brickR || ballR < brickL)) {
    continue;
  }

  b.x = ballDx > 0 ? brickL - bHalfSize - 1 : brickR + bHalfSize + 1;
  b.dx *= -1;
  didBounce = true;
  break;
};

// y axis
b.y += ballDy;
// recalc ball edges
for (const brick of this.bricks) {
  if (!brick.isAlive || brick.health <= 0) {
    continue;
  }

  // recalc brick edges
  if ((ballT > brickB || ballB < brickT) || (ballL > brickR || ballR < brickL)) {
    continue;
  }

  b.y = ballDy > 0 ? b.y = brickT - bHalfSize - 1 : brickB + bHalfSize + 1;
  b.dy *= -1;
  didBounce = true;
  break;
};

Отлично, но есть ещё один момент.

Я также буду притворяться, что центральная точка ракетки находится чуть ниже по оси y. Зачем? Чтобы угол отражения мяча от ракетки получался более плавным. Сейчас слишком легко заставить мяч резко уходить по оси x. А резкое движение по x означает очень медленное продвижение по y, из-за чего вся игра «ощущается» медленнее. Да, это может помочь загонять мяч в узкие углы, но не должно быть так, что дефолтный геймплей — смотреть, как мяч 100 раз бьётся о боковые стенки, еле-еле поднимаясь вверх, прежде чем снова спуститься вниз.

Более естественно это будет ощущаться, если считать ракетку коробкой с равными сторонами. Тогда диапазон возможных углов сокращается примерно со 120 градусов до идеальных 90. Это отлично, потому что удары по краям всё ещё дают агрессивное движение из стороны в сторону, что иногда полезно при прицеливании. При желании это легко подкрутить, сместив центральную точку, скажем, на 50 % длины, если хочется более широкого диапазона углов. Мы любим, когда есть варианты.

Что? Это не настоящая физика? Послушай, будь другом. Мы сейчас говорим про ощущение от игры. Здесь важна не «реальность», а то, как это чувствуется.

const xDif = Math.min(ballR - pL, pR - ballL);
const yDif = Math.min(ballB - pT, pB - ballT);
if (xDif < yDif) {
  b.x += b.x > p.x ? xDif : -xDif;
} else {
  b.y += b.y > p.y ? yDif : -yDif;
}
// find the top of the paddle then move down the width
const angle = Math.atan2(b.y - (p.y - p.height/2 + p.width/2) , b.x - p.x);
const speed = Math.sqrt(b.dx*b.dx + b.dy*b.dy);
b.dx = Math.cos(angle)*speed;
b.dy = Math.sin(angle)*speed;

И вот, пожалуйста — небольшое изменение, а опыт уже заметно лучше.

Ну как, чувствуется?

Исходный код
const container = document.getElementById(containerId);
const screenWidth = container.clientWidth;
const screenHeight = container.clientHeight;
const ballDiv = document.createElement("div");
container.appendChild(ballDiv);
Object.assign(ballDiv.style, {
  position: "absolute",
  left: "0",
  top: "0",
});
const brickDiv = document.createElement("div");
container.appendChild(brickDiv);
const BRICK_SIZE = [60, 15];
Object.assign(brickDiv.style, {
  position: "absolute",
  borderRadius: "2px",
  left: `-${BRICK_SIZE[0]}px`,
  top: `-${BRICK_SIZE[1]}px`,
  width: `${BRICK_SIZE[0]}px`,
  height: `${BRICK_SIZE[1]}px`,
});
const paddleDiv = document.createElement("div");
container.appendChild(paddleDiv);
const PADDLE_SIZE = [100, 10];
Object.assign(paddleDiv.style, {
  position: "absolute",
  left: `-${PADDLE_SIZE[0]}px`,
  top: `-${PADDLE_SIZE[1]}px`,
  width: `${PADDLE_SIZE[0]}px`,
  height: `${PADDLE_SIZE[1]}px`,
});

const Game = {
  camera: {
    scale: 1,
    offsetX: 0,
    offsetY: 0,
  },
  width: screenWidth,
  height: screenHeight,
  balls: [],
  bricks: [],
  paddles: [],
  handleMouseOrTouchInput(event) {
    event.preventDefault();
    event.stopPropagation();
    const x = event?.touches?.[0]?.clientX ?? event.offsetX;
    const y = event?.touches?.[0]?.clientY ?? event.offsetY;
    this.paddles.forEach(
      (p) =>
        (p.x = Math.min(this.width - p.width / 2, Math.max(p.width / 2, x)))
    );
  },

  addPaddle(x = this.width / 2, y = this.height - PADDLE_SIZE[1] * 2) {
    this.paddles.push({
      x,
      y,
      width: PADDLE_SIZE[0],
      height: PADDLE_SIZE[1],
    });
  },
  addBall(x = this.width / 2, y = this.height / 2, speed = 360) {
    const thresholdDegrees = 20;
    const arc = thresholdDegrees * (Math.PI / 180);
    let angle = arc + Math.random() * (Math.PI - 2 * arc);
    if (Math.random() > 0.5) {
      angle += Math.PI;
    }
    this.balls.push({
      x,
      y,
      dx: Math.cos(angle) * speed,
      dy: Math.sin(angle) * speed,
      size: 10,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
    });
  },
  addBrick(x = this.width / 2, y = this.height / 2) {
    this.bricks.push({
      x,
      y,
      isAlive: true,
    });
  },
  tick(dt) {
    const { width: w, height: h } = this;
    this.balls.forEach((b) => {
      let didBounce = false;
      let ballDx = b.dx * dt;
      let ballDy = b.dy * dt;
      const bHalfSize = b.size / 2;

      // check bricks.
      const [brickWidth, brickHeight] = BRICK_SIZE;
      // x axis
      b.x += ballDx;
      let ballL = b.x - bHalfSize;
      let ballR = b.x + bHalfSize;
      let ballT = b.y - bHalfSize;
      let ballB = b.y + bHalfSize;
      for (const brick of this.bricks) {
        if (!brick.isAlive || brick.health <= 0) {
          continue;
        }

        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if (
          ballT > brickB ||
          ballB < brickT ||
          ballL > brickR ||
          ballR < brickL
        ) {
          continue;
        }

        b.x = ballDx > 0 ? brickL - bHalfSize - 1 : brickR + bHalfSize + 1;
        b.dx *= -1;
        didBounce = true;
        break;
      }

      // y axis
      b.y += ballDy;
      ballL = b.x - bHalfSize;
      ballR = b.x + bHalfSize;
      ballT = b.y - bHalfSize;
      ballB = b.y + bHalfSize;
      for (const brick of this.bricks) {
        if (!brick.isAlive || brick.health <= 0) {
          continue;
        }

        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if (
          ballT > brickB ||
          ballB < brickT ||
          ballL > brickR ||
          ballR < brickL
        ) {
          continue;
        }

        b.y =
          ballDy > 0 ? (b.y = brickT - bHalfSize - 1) : brickB + bHalfSize + 1;
        b.dy *= -1;
        didBounce = true;
        break;
      }

      // check bounds
      if (b.x + b.size / 2 > w || b.x < b.size / 2) {
        b.x = ballDx > 0 ? w - b.size / 2 : b.size / 2;
        b.dx *= -1;
        didBounce = true;
      }
      if (b.y + b.size / 2 > h || b.y < b.size / 2) {
        b.y = ballDy > 0 ? h - b.size / 2 : b.size / 2;
        b.dy *= -1;
        didBounce = true;
      }

      // check paddles
      for (const p of this.paddles) {
        const pL = p.x - p.width / 2;
        const pR = p.x + p.width / 2;
        const pT = p.y - p.height / 2;
        const pB = p.y + p.height / 2;
        if (ballL > pR || ballR < pL) {
          continue;
        }
        if (ballT > pB || ballB < pT) {
          continue;
        }
        const xDif = Math.min(ballR - pL, pR - ballL);
        const yDif = Math.min(ballB - pT, pB - ballT);
        if (xDif < yDif) {
          b.x += b.x > p.x ? xDif : -xDif;
        } else {
          b.y += b.y > p.y ? yDif : -yDif;
        }

        const angle = Math.atan2(
          b.y - (p.y - p.height / 2 + p.width / 2),
          b.x - p.x
        );
        const speed = Math.sqrt(b.dx * b.dx + b.dy * b.dy);
        b.dx = Math.cos(angle) * speed;
        b.dy = Math.sin(angle) * speed;
        break;
      }
    });

    this.bricks = this.bricks.filter((b) => b.isAlive);
  },
  draw() {
    ballDiv.style.boxShadow = this.balls
      .map(
        (b) =>
          `${b.x + b.offsetX}px ${b.y + b.offsetY}px 0 ${
            (b.size / 2) * b.scale
          }px #fff`
      )
      .join();

    const [bW, bH] = BRICK_SIZE;
    brickDiv.style.boxShadow = this.bricks
      .map((b) => `${b.x + bW / 2}px ${b.y + bH / 2}px 0 0 #fff`)
      .join();

    const [pW, pH] = PADDLE_SIZE;
    paddleDiv.style.boxShadow = this.paddles
      .map((p) => `${p.x + pW / 2}px ${p.y + pH / 2}px 0 0 #fff`)
      .join();
  },
};

Game.addBall(60, Game.height - 60);
Game.addBrick();
Game.addPaddle();

container.addEventListener("pointermove", (e) =>
  Game.handleMouseOrTouchInput(e)
);
container.addEventListener("touchmove", (e) => Game.handleMouseOrTouchInput(e));

let lastTime = 0;
function universe(delta) {
  if (lastTime === 0) {
    lastTime = delta;
  }

  const dt = (delta - lastTime) / 1000;
  Game.tick(dt);
  Game.draw();

  lastTime = delta;
  frameId = requestAnimationFrame(universe);
}

universe(lastTime);
container.addEventListener('unload', () => cancelAnimationFrame(frameId));

К слову об ощущениях от игры. Часть «полировки какашки» — сделать её сочной. Давайте слегка увлажним всё это утренней росой.

Делаем сочнее

«Сочность» — это про то, чтобы подкармливать того липкого маленького гоблина в голове, который обожает дофамин. Я мог бы долго подбирать слова, объясняя, что такое «сочность», но проще просто покрутить пару игровых автоматов или сыграть в Balatro. ВОТ это и есть сочность.

Нам нужно, чтобы каждое событие ощущалось приятно. Вернёмся к мячу и кирпичу. Когда мяч от чего-то отскакивает, можно добавить эффект масштаба и вспышку цвета.

Идея в том, чтобы дать награду за ожидание события, которого этот слизистый гоблин так жаждет. Ты видишь, как мяч летит к кирпичу, ждёшь-ждёшь, почти… УДАР!

И этот УДАР должен ощущаться как удар.

Добавить это довольно просто с помощью легендарной техники, передаваемой от поколения к поколению среди геймдевов, — «tweening». Самый простой tween — это линейная интерполяция значений во времени. Не переживай: мы уже делали почти то же самое, используя delta time выше.

Хорошо бы иметь сладенький API, чтобы добавлять твины было удобно. Пусть мяч при попадании «подпрыгивает» размером.

tween(b, 'size', b.size, b.size*.8, 0.1);
// delay the second tween by the duration of the first
tween(b, 'size', b.size*.8, b.size, 0.1, 0.1);

Реализация может выглядеть так.

// linear interpolation
const lerp = (from, to, time) => from + (to-from) * time;

const Game = {
  tweens: new Set(),

  tween(target, prop, from, to, duration, delay = 0) {
    this.tweens.add({target, prop, from, to, duration, delay, t: 0});
  }

  tick(dt) {
    for(const tween of this.tweens) {
      const {target, prop, from, to, duration } = tween;
      tween.delay -= dt;
      if (tween.delay > 0) continue;
      target[prop] = lerp(from, to, Math.min(1, tween.t/duration));
      if(tween.t > duration) this.tweens.delete(tween);
      tween.t += dt;
    }
  }
}

Работать-то будет, но при наложении твинов важно помнить про ситуацию, когда несколько твинов одновременно крутят одно и то же значение. Допустим, твин A уже наполовину сделан: размер сейчас 15 и идёт к 20. И тут стартует другой твин по логике выше: он возьмёт текущий размер 15, уменьшит его на 20 % и потом будет твинить обратно к 15. Это неверно, потому что исходный размер вообще-то 20!

И что же делать? Ещё кода? Уж точно нет.

Трюк простой: анимировать другое значение, которое влияет только на то, как всё выглядит. Это ещё и уберёт любые «физические» проблемы, которые появляются, когда ты твинешь значения, участвующие в симуляции.

Мы добавим объектам scale, смещение и, возможно, цвет — для всего, что мы хотим твинить. Обычно анимируют свойства вроде размера/позиции/поворота/скоса/цвета. Визуальные штуки, а не логические параметры вроде скорости или здоровья.

Ещё одна полезная мысль — про «стэкинг» эффектов. Да, можно пользоваться задержкой, но что если хочется настроить, скажем, 5 твинов подряд, как для тряски экрана?

С задержками это можно сделать, если аккуратно отслеживать все нужные тайминги, но, возможно, есть API поприятнее.

// tween b.scale from 1->.8->1 over .2s with .1s delay
tween(b, 'scale', [1, 0.8, 1], 0.2, .1);

Отлично: передаём массив «ключевых кадров» в tween. Он анимирует значения между ними по общей длительности. Задержка тоже остаётся.

// linear interpolation
const lerp = (from, to, time) => from + (to-from) * time;

const Game = {
  tweens: new Set(),

  tween(target, prop, keyframes, dMs, delayMs = 0) {
    this.tweens.add({target, prop, keyframes, duration: dMs / 1000, delay: delayMs / 1000, t: 0});
  }

  tick(dt) {
    // other code

    for (const tween of this.tweens) {
      const { target, prop, keyframes, duration } = tween;

      tween.delay -= dt;
      if (tween.delay > 0) continue;
      tween.t += dt;
      const frames = keyframes.length - 1;
      const progress = Math.min(1, tween.t / duration);
      const kIdx = Math.min(frames - 1, Math.floor(frames * progress));
      const localProgress = (progress - kIdx / frames) / (1 / frames);
      target[prop] = lerp(keyframes[kIdx], keyframes[kIdx + 1], localProgress);

      if (tween.t < duration) {
          continue;
      }

      target[prop] = keyframes[keyframes.length - 1];
      this.tweens.delete(tween);
    }
  },
}

Смотри-ка: примерно 15 строк кода — и у нас уже есть слегка кривоватые ключевые анимации. Суть в том, что мы находим локальный участок между двумя ключевыми кадрами и переводим общий прогресс твина в локальный прогресс между этими кадрами. Мы всегда предполагаем, что кадров минимум два — то есть есть хотя бы пара from/to. Массив из трёх значений даёт два участка: [1,0,1] — от 1 к 0, потом от 0 к 1.

Ещё момент: пусть tween принимает длительность и задержку в миллисекундах, а не в секундах. Можно и секунды, но, по-моему, миллисекунды проще подбирать.

Целые числа радуют мой мозг.

Окей, теперь можно добавить «сочности». Сделаем тряску при попадании и вспышку цвета. Для тряски экрана нужна «камера», которую мы будем слегка дёргать. А всем объектам, которые могут твиниться, дадим одинаковые поля. Пока это offsetX, offsetY, color и scale.

const Game = {
  camera: {
    scale: 1,
    offsetX: 0,
    offsetY: 0,
  },

  addBall(x = this.width / 2, y = this.height / 2, speed = 360) {
    this.balls.push({
      //...
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      color: [255,255,255],
    });
  },
  // other objects too.

  draw() {
    ballDiv.style.boxShadow = this.balls.map(b => `${b.x + b.offsetX}px ${b.y + b.offsetY}px 0 ${(b.size/2)*b.scale}px rgb(${b.color.join()})`).join(); 

    // other objects

    container.style.transform = `translate(${this.camera.offsetX}px,${this.camera.offsetY}px)`;
  },
}

Дальше — вспомогательные твины.

shakeIt(obj, dist = 4, dir = undefined) {
  let ox = -dist/2 + Math.random()*dist;
  let oy = -dist/2 + Math.random()*dist;
  if (dir) {
    ox = dir[0] * dist;
    oy = dir[1] * dist;
  }
  this.tween(obj, 'offsetX', [0, ox, -ox, ox/2, 0], 260);
  this.tween(obj, 'offsetY', [0, oy, -oy, oy/2, 0], 260);
},
flashIt(obj) {
  this.tween(obj.color, '0', [100, 100+Math.random()*155, 255], 180);
  this.tween(obj.color, '1', [100, 100+Math.random()*155, 255], 180);
  this.tween(obj.color, '2', [100, 100+Math.random()*155, 255], 180);
}

Теперь, когда мяч бьётся о край экрана, можно делать так:

this.shakeIt(this.camera, 3);

А когда он попадает по ракетке — трясём её вниз, потому что это ожидаемое направление удара.

this.shakeIt(p, 3, [0,1]);
this.flashIt(p);

Кирпич пока не будет умирать — дадим ему тоже потрястись и помигать.

Исходный код
const container = document.getElementById(containerId);
const screenWidth = container.clientWidth;
const screenHeight = container.clientHeight;
const ballDiv = document.createElement('div');
container.appendChild(ballDiv);
Object.assign(ballDiv.style, {
  position: 'absolute',
  left: '0',
  top: '0',
});
const brickDiv = document.createElement('div');
container.appendChild(brickDiv);
const BRICK_SIZE = [60, 15];
Object.assign(brickDiv.style, {
  position: 'absolute',
  borderRadius: '2px',
  left: `-${BRICK_SIZE[0]}px`,
  top: `-${BRICK_SIZE[1]}px`,
  width: `${BRICK_SIZE[0]}px`,
  height: `${BRICK_SIZE[1]}px`,
});
const paddleDiv = document.createElement('div');
container.appendChild(paddleDiv);
const PADDLE_SIZE = [100, 10];
Object.assign(paddleDiv.style, {
  position: 'absolute',
  left: `-${PADDLE_SIZE[0]}px`,
  top: `-${PADDLE_SIZE[1]}px`,
  width: `${PADDLE_SIZE[0]}px`,
  height: `${PADDLE_SIZE[1]}px`,
});

const lerp = (from, to, time) => from + (to-from) * time;

const Game = {
    camera: {
      scale: 1,
      offsetX: 0,
      offsetY: 0,
    },
    width: screenWidth,
    height: screenHeight,
    balls: [],
    bricks: [],
    paddles: [],
    tweens: new Set(),

    tween(target, prop, keyframes, duration, delay = 0) {
      this.tweens.add({target, prop, keyframes, duration: duration/1000, delay: delay/1000, t: 0});
    },

    handleMouseOrTouchInput(event) {
      event.preventDefault();
      event.stopPropagation();
      const x = (event?.touches?.[0]?.clientX) ?? event.offsetX;
      const y = (event?.touches?.[0]?.clientY) ?? event.offsetY;
      this.paddles.forEach(p => p.x = Math.min(this.width-p.width/2, Math.max(p.width/2, x)));
    },

    addPaddle(x = this.width /2, y = this.height - PADDLE_SIZE[1]*2) {
      this.paddles.push({
        x,
        y,
        width: PADDLE_SIZE[0],
        height: PADDLE_SIZE[1],
        scale: 1,
        offsetX: 0,
        offsetY: 0,
        color: [255,255,255],
      });
    },
    addBall(x = this.width / 2, y = this.height / 2, speed = 360) {
      const thresholdDegrees = 20;
      const arc = thresholdDegrees * (Math.PI / 180);
      let angle = arc + Math.random() * (Math.PI - 2 * arc);
      if (Math.random() > 0.5) {
        angle += Math.PI;
      }

      this.balls.push({
        x,
        y,
        dx: Math.cos(angle)*speed,
        dy: Math.sin(angle)*speed,
        size: 10,
        scale: 1,
        offsetX: 0,
        offsetY: 0,
        color: [255,255,255],
      });
    },
    addBrick(x = this.width / 2, y = this.height / 2) {
      this.bricks.push({
        x,
        y,
        isAlive: true,
        scale: 1,
        offsetX: 0,
        offsetY: 0,
        color: [255,255,255],
      });
    },

    tick(dt) {
        const {width: w, height: h} = this;
        this.balls.forEach((b) => {
            let didBounce = false;
            const ballDx = b.dx * dt;
            const ballDy = b.dy * dt;
            b.x += ballDx;
            b.y += ballDy;
            if (b.x + b.size/2 > w || b.x < b.size/2) {
                b.x = ballDx > 0 ? w - b.size / 2 : b.size / 2;
                b.dx *= -1;
                this.shakeIt(this.camera, 3);
                didBounce = true;
            }
            if (b.y + b.size/2 > h || b.y < b.size/2) {
                b.y = ballDy > 0 ? h - b.size / 2: b.size / 2;
                b.dy *= -1;
                this.shakeIt(this.camera, 3);
                didBounce = true;
            }

            const ballL = b.x - b.size/2;
            const ballR = b.x + b.size/2;
            const ballT = b.y - b.size/2;
            const ballB = b.y + b.size/2;

            // check bricks.
            const [brickWidth, brickHeight] = BRICK_SIZE;
            for (const brick of this.bricks) {
                if (!brick.isAlive) {
                    continue;
                }

                const brickL = brick.x - brickWidth/2;
                const brickR = brick.x + brickWidth/2;
                const brickT = brick.y - brickHeight/2;
                const brickB = brick.y + brickHeight/2;
                if ((ballL > brickR || ballR < brickL)) {
                    continue;
                }
                if ((ballT > brickB || ballB < brickT)) {
                    continue;
                }
                const xDif = Math.min(ballR - brickL, brickR - ballL);
                const yDif = Math.min(ballB - brickT, brickB - ballT);
                if (xDif < yDif ) {
                    b.x -= ballDx;
                    b.dx *= -1;
                } else if (xDif > yDif) {
                    b.y -= ballDy;
                    b.dy *= -1;
                } else {
                    b.x -= ballDx;
                    b.dx *= -1;
                    b.y -= ballDy;
                    b.dy *= -1;
                }
                didBounce = true;
                this.flashIt(brick);
                this.shakeIt(brick, 5)
                {/* brick.isAlive = false; */}
                break;
            };

            // check paddles
            for (const p of this.paddles) {
                const pL = p.x - p.width/2;
                const pR = p.x + p.width/2;
                const pT = p.y - p.height/2;
                const pB = p.y + p.height/2;
                if ((ballL > pR || ballR < pL)) {
                    continue;
                }
                if ((ballT > pB || ballB < pT)) {
                    continue;
                }
                didBounce = true;
                const xDif = Math.min(ballR - pL, pR - ballL);
                const yDif = Math.min(ballB - pT, pB - ballT);
                if (xDif < yDif) {
                    b.x += b.x > p.x ? xDif : -xDif;
                } else {
                    b.y += b.y > p.y ? yDif : -yDif;
                }

                const angle = Math.atan2(b.y - (p.y - p.height/2 + p.width/2) , b.x - p.x);
                const speed = Math.sqrt(b.dx*b.dx + b.dy*b.dy);
                b.dx = Math.cos(angle)*speed;
                b.dy = Math.sin(angle)*speed;

                this.shakeIt(p, 3, [0,1]);
                this.flashIt(p);
                break;
            };

            if (didBounce) {
                this.tween(b, 'scale', [0.8, 1.2, 1], 120);
                this.flashIt(b);
            }
        });

        this.bricks = this.bricks.filter(b => b.isAlive);

        // do the tween
        for (const tween of this.tweens) {
            const { target, prop, keyframes, duration } = tween;
            tween.delay -= dt;
            if (tween.delay > 0) continue;
            tween.t += dt;
            const frames = keyframes.length - 1;
            const progress = Math.min(1, tween.t / duration);
            const kIdx = Math.min(frames - 1, Math.floor(frames * progress));
            const localProgress = (progress - kIdx / frames) / (1 / frames);
            target[prop] = lerp(keyframes[kIdx], keyframes[kIdx + 1], localProgress);
            if (tween.t < duration) {
                continue;
            }

            target[prop] = keyframes[keyframes.length - 1];
            this.tweens.delete(tween);
        }
    },

    draw() {
        ballDiv.style.boxShadow = this.balls.map(b => `${b.x + b.offsetX}px ${b.y + b.offsetY}px 0 ${(b.size/2)*b.scale}px rgb(${b.color.join()})`).join();

        const [bW, bH] = BRICK_SIZE;
        brickDiv.style.boxShadow = this.bricks.map(b => `${b.x + (bW)/2 + b.offsetX}px ${b.y + (bH)/2 + b.offsetY}px 0 0 rgb(${b.color.join()})`).join();
        
        const [pW, pH] = PADDLE_SIZE;
        paddleDiv.style.boxShadow = this.paddles.map(p => `${p.x + pW/2 + p.offsetX}px ${p.y + pH/2 + p.offsetY}px 0 0 rgb(${p.color.join()})`).join();

        container.style.transform = `translate(${this.camera.offsetX}px,${this.camera.offsetY}px)`;
    },

    shakeIt(obj, dist = 4, dir = undefined) {
        let ox = -dist/2 + Math.random()*dist;
        let oy = -dist/2 + Math.random()*dist;
        if (dir) {
            ox = dir[0] * dist;
            oy = dir[1] * dist;
        }
        this.tween(obj, 'offsetX', [0, ox, -ox, ox/2, 0], 260);
        this.tween(obj, 'offsetY', [0, oy, -oy, oy/2, 0], 260);
    },
    flashIt(obj) {
        this.tween(obj.color, '0', [100, 100+Math.random()*155, 255], 180);
        this.tween(obj.color, '1', [100, 100+Math.random()*155, 255], 180);
        this.tween(obj.color, '2', [100, 100+Math.random()*155, 255], 180);
    }
};

Game.addBall(60, Game.height - 60);
Game.addBrick();
Game.addPaddle();

container.addEventListener("pointermove", e => Game.handleMouseOrTouchInput(e));
container.addEventListener("touchmove", e => Game.handleMouseOrTouchInput(e));

let lastTime = 0;
function universe(delta) {
    if (lastTime === 0) {
        lastTime = delta;
    }

    const dt = (delta - lastTime) / 1000;
    Game.tick(dt);
    Game.draw();

    lastTime = delta;
    frameId = requestAnimationFrame(universe);
}

universe(lastTime);
container.addEventListener('unload', () => cancelAnimationFrame(frameId));

Весело, да? Тряска экрана, конечно, немного перебор для такого маленького игрового пространства, но уже заметно, что игра ощущается лучше. Но, по-моему, всё ещё недостаточно «мокро».

Отлично, последнее, что можно сюда прикрутить, — это возможность вызывать событие (или что-то похожее) по завершении твина. Это удобно, например, чтобы проиграть анимацию перед тем, как убрать кирпич.

tween(target, prop, keyframes, duration, delay = 0, onComplete = undefined) {
  this.tweens.add({
    target, prop, keyframes, t: 0,
    duration: duration/1000,
    delay: delay/1000,
    onComplete
  });
},
// later
for (const tween of this.tweens) {
    // other code

  target[prop] = keyframes[keyframes.length - 1];
  this.tweens.delete(tween);
  if (tween.onComplete) {
    tween.onComplete();
  }
}

// in the bricks
this.flashIt(brick);
this.shakeIt(brick, 5)
this.tween(brick, 'scale', [1, 0], 300, 0, () => brick.isAlive = false);

Игра стала сочной, но всё равно немного… так себе. Блеска всё ещё не хватает.

Ты думал, я сказал «хватит полировать»?

Окей, наша игра прошла большой путь, и пора добавить контента. Это же дико весело.

  • Счёт

  • Уровни

  • Power Ups

  • Жизни

  • Смерть и победа

Счёт

Счёт — это просто. Рисуем текст в верхней части игры. Каждый раз, когда ломаем кирпич, получаем 100 очков. Почему 100, а не 1? Потому что людям нравятся большие числа.

const scoreContainer = document.createElement('div');
container.appendChild(scoreContainer);
// set styles

const Game = {
  score: 0,
  lives: 3,
  level: 1,
}

draw() {
  // other draw
  scoreContainer.innerText = `${this.score.toLocaleString()}`;
}

Отлично. Можно сделать сочнее, добавив твин при обновлении счёта.

Уровни

Для уровней можно было бы хранить конфиги в файле, но пока хватит пары небольших скриптов, которые расставляют кирпичи, исходя из размера экрана.

spawnLevel_1() {
  let [w, h] = BRICK_SIZE;
  w += 1; // little padding around bricks
  h += 1;
  const countW = Math.floor(this.width / w);
  const countH = Math.floor(this.height / h);
  const rW = this.width % w;
  const rH = this.height % h;
  const sx = rW /2 + w / 2;
  const sy = rH /2 + h / 2;
  const xPad = 1; // brick count padding.
  const yPad = 4; // only does the starting
  for (let i = xPad; i < countW - xPad; i++) {
    const x = sx + i*w;
    for (let j = yPad; j < countH; j++) {
      const y = sy + j*h;
      if (y < 40) continue;
      if (y > 180) break; // space for play area.
      this.addBrick(x, y);
    }
  }
}

Отлично. Это заполняет экран кирпичами с разумными промежутками. Работает, но можно сделать лучше, если мы хотим много уровней.

Было бы очень удобно иметь spawnLevel(config), который принимает необязательную конфигурацию. Давай сделаем!

И ещё — полезно иметь необязательный predicate для особой логики размещения кирпичей.

spawnLevel(config) {
  const {
    blockWidth, blockHeight, screenWidth, screenHeight,
    brickGutterPx, xBrickPad, yBrickPad,
    playAreaPxBot, playAreaPxTop,
    playAreaPxLeft, playAreaPxRight,
    predicate,
  } = {...defaults, ...config};
  const brickAreaW = screenWidth - playAreaPxRight - playAreaPxLeft;
  const brickAreaH = screenHeight - playAreaPxBot - playAreaPxTop;
  const bW = blockWidth + brickGutterPx;
  const bH = blockHeight + brickGutterPx;
  const xBrickCount = Math.floor(brickAreaW / bW);
  const yBrickCount = Math.floor(brickAreaH / bH);
  const rW = brickAreaW % bW;
  const rH = brickAreaH % bH;
  const sx = playAreaPxLeft + rW / 2 + bW / 2;
  const sy = playAreaPxTop + rH / 2 + bH / 2;
  for (let i = xBrickPad; i < xBrickCount - xBrickPad; i++) {
    const x = sx + i * bW;
    for (let j = yBrickPad; j < yBrickCount; j++) {
      const y = sy + j * bH;
      predicate({ x, y, i, j, xBrickCount, yBrickCount });
    }
  }
}

А дальше добавляем уровень примерно так.

goToNextLevel() {
  this.level++;
  if (this.level === 1) {
    this.spawnLevel({
      predicate: ({x, y, i, j, countW}) => {
        return j % 3 === 0 ? undefined : this.addBrick(x, y);
      }
    });
  }
  // etc.
}

Отлично. Подмешай немного небезопасной загрузки внешнего JavaScript — и у нас будут моды уровней! А что насчёт Power Ups?

У меня есть сила!

Power Ups будут с некоторым шансом падать, когда кирпич дохнет. Будут и «особые» кирпичи, которые всегда роняют конкретные Power Ups, или даже могут по-хитрому взрывать соседние кирпичи. Весь мир у наших ног.

Какие Power Ups сделаем?

  • Multi-ball — делит все мячи на два

  • Big ball — создаёт здоровенный мяч

  • Power Ball — создаёт power ball, который прошивает кирпичи насквозь

  • Paddle Size — увеличивает ракетку, складывается

  • Gutter Guard — стенка, которая один раз спасает от падения, складывается

  • Laser Paddle — стреляет лазерами, когда по ней ударяет мяч

  • Extra Life — даёт дополнительную жизнь

  • Bonus Points — даёт бонусные очки

  • Power Up Omega Device — усиливает омега-устройство

  • Speed up — ускоряет мячи

  • Slow Down — замедляет мячи

Отлично. Power Up — это простой цветной кружок, который падает вниз. Когда он касается ракетки (проверкой на пересечение), мы его убиваем и выдаём игроку силу. Если улетает за пределы игрового поля — удаляем, считается, что игрок промахнулся.

Ты уже понял схему, да? Копируем div для мячей, называем его powerUpDiv, добавляем в draw, делаем addPowerUp с type, копируем код мячей, выкидываем всё, кроме столкновения с ракеткой, и добавляем пачку if для разных типов Power Ups.

Вкусно. И да — не забудь удалить Power Up после касания ракетки.

const powerUpDiv = document.createElement("div");
container.appendChild(powerUpDiv);
const POWER_UP_SIZE = 16;
Object.assign(powerUpDiv.style, {
  position: "absolute",
  left: `-${POWER_UP_SIZE}px`,
  top: `-${POWER_UP_SIZE}px`,
  width: `${POWER_UP_SIZE}px`,
  height: `${POWER_UP_SIZE}px`,
  borderRadius: '9000px',
});

const Game = {
  //...
  powerUps: [],
  addPowerUp(x = this.width / 2, y = this.height / 2, type = 0, speed = 160) {
    this.powerUps.push({
      x,
      y,
      dx: 0,
      dy: speed,
      type,
      isAlive: true,
      size: 16,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      // different power colors
      color: [...this.powerColors[type]],
    });
  },
  //...

  tick() {
    // power ups
    for (const power of this.powerUps) {
      // update and check collision

      if (power.y > this.height) {
        power.isAlive = false;
        // whoop you missed it
        continue;
      }

      if (power.isAlive) {
        continue;
      }

      if (power.type === 0) {
        // THE POWER!
      }
    }
    
    this.powerUps = this.powerUps.filter((pow) => pow.isAlive);
  }
}

Добавить Power Up, когда блок откидывается, несложно. Давай будем щедрыми и дадим шанс 10 %.

damageBrick(brick) {
  // check if we are dead
  // if so, check for power up
  if (Math.random() > .9) {
    this.addPowerUp(brick.x, brick.y);
  }
},

Вызываем это, когда кирпич получает удар — и Power Ups у нас уже появляются. Но пока они почти ничего не делают. Исправим.

Сначала — разделение мячей.

const PowerUpTypes = {
  MultiBall: 0,
  BigBall: 1,
  PowerBall: 2,
  // etc
}

// in power up loop
if (power.type === PowerUpTypes.MultiBall) {
  this.balls.map(b => [b.x, b.y]).forEach(([x,y]) => this.addBall(x,y));
}

Работает, но такой лес из if выглядит немного стрёмно. Было бы круто, если бы это сводилось к одной строке.

const BallTypes = {
  Normal: 0,
  PowerBall: 1,
}

const PowerUpEffects = {
  [PowerUpTypes.MultiBall]: (game, powerUp) => {
    game.balls.map(b => [b.x, b.y]).forEach(([x,y]) => game.addBall(x,y));
  },
  [PowerUpTypes.BigBall]: (game, powerUp) => {
    game.balls.forEach(b => b.size *= 2);
  },
  [PowerUpTypes.PowerBall]: (game, powerUp) => {
    game.addBall(powerUp.x, powerUp.y, BallTypes.PowerBall);
    game.balls[game.balls.length-1].dy = -Math.abs(game.balls[game.balls.length-1].dy);
  },
  [PowerUpTypes.ExtraLife]: (game, powerUp) => {
    game.lives += 1;
  },
  [PowerUpTypes.SpeedUp]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.dx *= 1.25;
      b.dy *= 1.25;
    });
  },
  [PowerUpTypes.SpeedDown]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.dx *= .9;
      b.dy *= .9;
    });
  },
};

// in loop it is one line.
PowerUpEffects[power.type]?.(game, power);

// spawn by picking a random power up.
if (Math.random() > 0.9) {
  const types = Object.values(PowerUpTypes);
  this.addPowerUp(brick.x, brick.y, types[Math.floor(Math.random()*types.length)]);
}

Самые простые мы уже выбили!

Что? Это лучший способ делать Power Ups? Лучшие практики?

Не думай об этом. Смотри туда, на игру. Видишь, насколько она стала веселее?

Исходный код
const container = document.getElementById(containerId);
const screenWidth = container.clientWidth;
const screenHeight = container.clientHeight;
const ballDiv = document.createElement("div");
container.appendChild(ballDiv);
Object.assign(ballDiv.style, {
  position: "absolute",
  left: "0",
  top: "0",
});
const BallTypes = {
  Normal: 0,
  PowerBall: 1,
};

const powerUpDiv = document.createElement("div");
container.appendChild(powerUpDiv);
const POWER_UP_SIZE = 16;
Object.assign(powerUpDiv.style, {
  position: "absolute",
  left: `-${POWER_UP_SIZE}px`,
  top: `-${POWER_UP_SIZE}px`,
  width: `${POWER_UP_SIZE}px`,
  height: `${POWER_UP_SIZE}px`,
  borderRadius: '9000px',
});
const PowerUpTypes = {
  MultiBall: 0,
  BigBall: 1,
  PowerBall: 2,
  // PaddleSize: 3,
  // GutterGuard: 4,
  // LaserPaddle: 5,
  ExtraLife: 6,
  // BonusPoints: 7,
  // OmegaDevice: 8,
  SpeedUp: 9,
  SpeedDown: 10,
};
const PowerUpColors = {
  [PowerUpTypes.MultiBall]: [255, 200, 255],
  [PowerUpTypes.BigBall]: [200, 200, 255],
  [PowerUpTypes.PowerBall]: [255, 50, 50],
  [PowerUpTypes.ExtraLife]: [100, 255, 100],
  [PowerUpTypes.SpeedUp]: [100, 255, 255],
  [PowerUpTypes.SpeedDown]: [255, 100, 255],
};
const PowerUpEffects = {
  [PowerUpTypes.MultiBall]: (game, powerUp) => {
    game.balls.map(b => [b.x, b.y]).forEach(([x,y]) => game.addBall(x,y));
  },
  [PowerUpTypes.BigBall]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.size *= 2;
  });
  },
  [PowerUpTypes.PowerBall]: (game, powerUp) => {
    game.addBall(powerUp.x, powerUp.y, BallTypes.PowerBall);
    game.balls[game.balls.length-1].dy = -Math.abs(game.balls[game.balls.length-1].dy);
    
  },
  [PowerUpTypes.ExtraLife]: (game, powerUp) => {
    game.lives += 1;
  },
  [PowerUpTypes.SpeedUp]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.dx *= 1.25;
      b.dy *= 1.25;
    });
  },
  [PowerUpTypes.SpeedDown]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.dx *= .9;
      b.dy *= .9;
    });
  },
};

const brickDiv = document.createElement("div");
container.appendChild(brickDiv);
const BRICK_SIZE = [36, 12];
Object.assign(brickDiv.style, {
  position: "absolute",
  borderRadius: "2px",
  left: `-${BRICK_SIZE[0]}px`,
  top: `-${BRICK_SIZE[1]}px`,
  width: `${BRICK_SIZE[0]}px`,
  height: `${BRICK_SIZE[1]}px`,
});

const paddleDiv = document.createElement("div");
container.appendChild(paddleDiv);
const PADDLE_SIZE = [70, 10];
Object.assign(paddleDiv.style, {
  position: "absolute",
  left: `-${PADDLE_SIZE[0]}px`,
  top: `-${PADDLE_SIZE[1]}px`,
  width: `${PADDLE_SIZE[0]}px`,
  height: `${PADDLE_SIZE[1]}px`,
});

const scoreContainer = document.createElement("div");
container.appendChild(scoreContainer);
Object.assign(scoreContainer.style, {
  position: "absolute",
  top: 0,
  right: 0,
  fontSize: "16px",
  width: "100%",
  marginTop: "min(5%, 1.5rem)",
  textAlign: "center",
  textShadow: '1px 4px 0 #000a'
});

const lerp = (from, to, time) => from + (to - from) * time;

const Game = {
  score: 0,
  lives: 3,
  level: 1,
  frame: 0,
  camera: {
    scale: 1,
    offsetX: 0,
    offsetY: 0,
  },
  width: screenWidth,
  height: screenHeight,
  balls: [],
  bricks: [],
  paddles: [],
  powerUps: [],
  tweens: new Set(),

  tween(target, prop, keyframes, duration, delay = 0, onComplete = undefined) {
    this.tweens.add({
      target,
      prop,
      keyframes,
      t: 0,
      duration: duration / 1000,
      delay: delay / 1000,
      onComplete,
    });
  },

  handleMouseOrTouchInput(event) {
    event.preventDefault();
    event.stopPropagation();
    const x = event?.touches?.[0]?.clientX ?? event.offsetX;
    const y = event?.touches?.[0]?.clientY ?? event.offsetY;
    this.paddles.forEach(
      (p) =>
        (p.x = Math.min(this.width - p.width / 2, Math.max(p.width / 2, x)))
    );
  },

  addPaddle(x = this.width / 2, y = this.height - PADDLE_SIZE[1] * 2) {
    this.paddles.push({
      x,
      y,
      width: PADDLE_SIZE[0],
      height: PADDLE_SIZE[1],
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      color: [255, 255, 255],
    });
  },
  addBall(x = this.width / 2, y = this.height / 2, type = BallTypes.Normal, speed = 240) {
    const thresholdDegrees = 20;
    const arc = thresholdDegrees * (Math.PI / 180);
    let angle = arc + Math.random() * (Math.PI - 2 * arc);
    this.balls.push({
      x,
      y,
      dx: Math.cos(angle) * speed,
      dy: Math.sin(angle) * speed,
      size: 10,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      baseColor: type === BallTypes.Normal ? [255, 255, 255] : [255, 55, 55],
      color: type === BallTypes.Normal ? [255, 255, 255] : [255, 55, 55],
      type,
    });
  },
  addBrick(x = this.width / 2, y = this.height / 2, type = 0) {
    this.bricks.push({
      x,
      y,
      isAlive: true,
      type,
      health: 1,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      color: [255, 255, 255],
    });
  },

  addPowerUp(x = this.width / 2, y = this.height / 2, type = PowerUpTypes.MultiBall, speed = 160) {
    this.powerUps.push({
      x,
      y,
      dx: 0,
      dy: speed,
      type,
      isAlive: true,
      size: 16,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      color: [...PowerUpColors[type]],
    });
  },

  tick(dt) {
    const { width: w, height: h } = this;
    this.frame++;

    this.balls.forEach((b) => {
      let didBounce = false;
      let ballDx = b.dx * dt;
      let ballDy = b.dy * dt;
      const bHalfSize = b.size / 2;

      // check bricks.
      const [brickWidth, brickHeight] = BRICK_SIZE;
      // x axis
      b.x += ballDx;
      let ballL = b.x - bHalfSize;
      let ballR = b.x + bHalfSize;
      let ballT = b.y - bHalfSize;
      let ballB = b.y + bHalfSize;
      for (const brick of this.bricks) {
        if (!brick.isAlive || brick.health <= 0) {
          continue;
        }

        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if (
          ballT > brickB ||
          ballB < brickT ||
          ballL > brickR ||
          ballR < brickL
        ) {
          continue;
        }

        if (b.type !== BallTypes.PowerBall) {
          b.x = ballDx > 0 ? brickL - bHalfSize - 1 : brickR + bHalfSize + 1;
          b.dx *= -1;
        }
        didBounce = true;
        this.damageBrick(brick);
        break;
      }

      // y axis
      b.y += ballDy;
      ballL = b.x - bHalfSize;
      ballR = b.x + bHalfSize;
      ballT = b.y - bHalfSize;
      ballB = b.y + bHalfSize;
      for (const brick of this.bricks) {
        if (!brick.isAlive || brick.health <= 0) {
          continue;
        }

        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if (
          ballT > brickB ||
          ballB < brickT ||
          ballL > brickR ||
          ballR < brickL
        ) {
          continue;
        }

        if (b.type !== BallTypes.PowerBall) {
          b.y = ballDy > 0 ? (b.y = brickT - bHalfSize - 1) : brickB + bHalfSize + 1;
          b.dy *= -1;
        }
        didBounce = true;
        this.damageBrick(brick);
        break;
      }

      // check bounds
      if (b.x + b.size / 2 > w || b.x < b.size / 2) {
        b.x = ballDx > 0 ? w - b.size / 2 : b.size / 2;
        b.dx *= -1;
        this.shakeIt(this.camera, 3);
        didBounce = true;
      }
      if (b.y + b.size / 2 > h || b.y < b.size / 2) {
        b.y = ballDy > 0 ? h - b.size / 2 : b.size / 2;
        b.dy *= -1;
        this.shakeIt(this.camera, 3);
        didBounce = true;
      }

      // check paddles
      for (const p of this.paddles) {
        const pL = p.x - p.width / 2;
        const pR = p.x + p.width / 2;
        const pT = p.y - p.height / 2;
        const pB = p.y + p.height / 2;
        if (ballL > pR || ballR < pL) {
          continue;
        }
        if (ballT > pB || ballB < pT) {
          continue;
        }
        didBounce = true;
        const xDif = Math.min(ballR - pL, pR - ballL);
        const yDif = Math.min(ballB - pT, pB - ballT);
        if (xDif < yDif) {
          b.x += b.x > p.x ? xDif : -xDif;
        } else {
          b.y += b.y > p.y ? yDif : -yDif;
        }

        const angle = Math.atan2(
          b.y - (p.y - p.height / 2 + p.width / 2),
          b.x - p.x
        );
        const speed = Math.sqrt(b.dx * b.dx + b.dy * b.dy);
        b.dx = Math.cos(angle) * speed;
        b.dy = Math.sin(angle) * speed;

        this.shakeIt(p, 3, [0, 1]);
        this.flashIt(p);
        break;
      }

      if (didBounce) {
        this.tween(b, "scale", [0.8, 1.2, 1], 120);
        this.flashIt(b);
      }
    });

    this.bricks = this.bricks.filter((b) => b.isAlive);

    // power ups
    for (const power of this.powerUps) {
      power.x += power.dx * dt;
      power.y += power.dy * dt;
      const halfSize = power.size/2;
      let left = power.x - halfSize;
      let right = power.x + halfSize;
      let top = power.y - halfSize;
      let bot = power.y + halfSize;

      power.scale = 1 + 0.1*Math.sin(this.frame*.2);

      // check paddles
      for (const p of this.paddles) {
        const pL = p.x - p.width / 2;
        const pR = p.x + p.width / 2;
        const pT = p.y - p.height / 2;
        const pB = p.y + p.height / 2;
        if (left > pR || right < pL) {
          continue;
        }
        if (top > pB || bot < pT) {
          continue;
        }
        power.isAlive = false;

        this.shakeIt(p, 3, [0, 1]);
        this.flashIt(p);
        break;
      }

      if (power.y > this.height) {
        power.isAlive = false;
        continue;
      }

      if (power.isAlive) {
        continue;
      }

      // fight, fight, fight dah power!
      PowerUpEffects[power.type]?.(this, power);
    }
    
    this.powerUps = this.powerUps.filter((pow) => pow.isAlive);

    // do the tween
    for (const tween of this.tweens) {
      const { target, prop, keyframes, duration } = tween;
      tween.delay -= dt;
      if (tween.delay > 0) continue;
      tween.t += dt;
      const frames = keyframes.length - 1;
      const progress = Math.min(1, tween.t / duration);
      const kIdx = Math.min(frames - 1, Math.floor(frames * progress));
      const localProgress = (progress - kIdx / frames) / (1 / frames);
      target[prop] = lerp(keyframes[kIdx], keyframes[kIdx + 1], localProgress);
      if (tween.t < duration) {
        continue;
      }

      target[prop] = keyframes[keyframes.length - 1];
      if (tween.onComplete) {
        tween.onComplete();
      }
      this.tweens.delete(tween);
    }
  },

  damageBrick(brick) {
    this.flashIt(brick);
    this.shakeIt(brick, 5);
    brick.health -= 1;
    if (brick.health > 0) {
      return;
    }

    this.updateScore(100);
    this.tween(
      brick,
      "scale",
      [1, 1],
      120,
      160,
      () => (brick.isAlive = false)
    );

    if (Math.random() > 0.9) {
      const types = Object.values(PowerUpTypes);
      this.addPowerUp(brick.x, brick.y, types[Math.floor(Math.random()*types.length)]);
    }
  },

  draw() {
    ballDiv.style.boxShadow = this.balls
      .map(
        (b) =>
          `${b.x + b.offsetX}px ${b.y + b.offsetY}px 0 ${
            (b.size / 2) * b.scale
          }px rgb(${b.color.join()})`
      )
      .join();

    powerUpDiv.style.boxShadow = this.powerUps
      .map(
        (power) =>
          `${power.x + power.size/2 + power.offsetX}px ${power.y + power.size/2 + power.offsetY}px 0 ${
            (power.size / 2) * (power.scale - 1)
          }px rgb(${power.color.join()})`
      )
      .join();

    const [bW, bH] = BRICK_SIZE;
    brickDiv.style.boxShadow = this.bricks
      .map(
        (b) =>
          `${b.x + bW / 2 + b.offsetX}px ${b.y + bH / 2 + b.offsetY}px 0 ${
            (bH / 2) * (b.scale - 1)
          }px rgb(${b.color.join()})`
      )
      .join();

    const [pW, pH] = PADDLE_SIZE;
    paddleDiv.style.boxShadow = this.paddles
      .map(
        (p) =>
          `${p.x + pW / 2 + p.offsetX}px ${
            p.y + pH / 2 + p.offsetY
          }px 0 0 rgb(${p.color.join()})`
      )
      .join();

    container.style.transform = `translate(${this.camera.offsetX}px,${this.camera.offsetY}px)`;
    scoreContainer.innerText = `${this.score.toLocaleString()}`;
  },

  shakeIt(obj, dist = 4, dir = undefined) {
    let ox = -dist/2 + Math.random() * dist;
    let oy = -dist/2 + Math.random() * dist;
    if (dir) {
      ox = dir[0] * dist;
      oy = dir[1] * dist;
    }
    this.tween(obj, "offsetX", [0, ox, -ox, ox / 2, 0], 260);
    this.tween(obj, "offsetY", [0, oy, -oy, oy / 2, 0], 260);
  },
  flashIt(obj) {
    this.tween(obj.color, "0", [obj.color[0], 100 + Math.random() * 155, obj.baseColor?.[0] || 255], 180);
    this.tween(obj.color, "1", [obj.color[1], 100 + Math.random() * 155, obj.baseColor?.[1] || 255], 180);
    this.tween(obj.color, "2", [obj.color[2], 100 + Math.random() * 155, obj.baseColor?.[2] || 255], 180);
  },
  updateScore(val) {
    this.score += val;
    this.tween(scoreContainer.style, 'scale', [.85, 1.5, 1], 300);
  },

  // levels
  spawnNextLevel() {
    const levels = {
      1: () => this.spawnLevel(),
    };
    levels?.[this.level++]?.();
  },
  spawnLevel(config) {
    const defaults = {
      blockWidth: BRICK_SIZE[0],
      blockHeight: BRICK_SIZE[1],
      screenWidth: this.width,
      screenHeight: this.height,
      brickGutterPx: 1.5,
      xBrickPad: 1,
      yBrickPad: 0,
      playAreaPxBot: 180,
      playAreaPxTop: 60,
      playAreaPxLeft: 0,
      playAreaPxRight: 0,
      predicate: ({ x, y }) => this.addBrick(x, y),
    };
    const {
      blockWidth,
      blockHeight,
      screenWidth,
      screenHeight,
      brickGutterPx,
      xBrickPad,
      yBrickPad,
      playAreaPxBot,
      playAreaPxTop,
      playAreaPxLeft,
      playAreaPxRight,
      predicate,
    } = { ...defaults, ...config };
    const bW = blockWidth + brickGutterPx;
    const bH = blockHeight + brickGutterPx;
    const countW = Math.floor(screenWidth / bW);
    const countH = Math.floor(screenHeight / bH);
    const rW = screenWidth % bW;
    const rH = screenHeight % bH;
    const sx = rW / 2 + bW / 2;
    const sy = rH / 2 + bH / 2;
    for (let i = xBrickPad; i < countW - xBrickPad; i++) {
      const x = sx + i * bW;
      if (x < playAreaPxLeft || x > screenWidth - playAreaPxRight) continue;
      for (let j = yBrickPad; j < countH; j++) {
        const y = sy + j * bH;
        if (y < playAreaPxTop || y > screenHeight - playAreaPxBot) continue;
        predicate({ x, y, i, j, countW, countH });
      }
    }
  },
};

Game.addBall(60, Game.height - 60);
Game.addPaddle();
Game.spawnNextLevel();

container.addEventListener("pointermove", (e) =>
  Game.handleMouseOrTouchInput(e)
);
container.addEventListener("touchmove", (e) => Game.handleMouseOrTouchInput(e));

let lastTime = 0;
function universe(delta) {
  if (lastTime === 0) {
    lastTime = delta;
  }

  const dt = (delta - lastTime) / 1000;
  Game.tick(dt);
  Game.draw();

  lastTime = delta;
  frameId = requestAnimationFrame(universe);
}

universe(lastTime);
container.addEventListener('unload', () => cancelAnimationFrame(frameId));

Мы ещё немного поработали за кадром: сделали power ball красным, сделали Power Ups «виляющими», добавили больше типов — ничего серьёзного.

Новый код становится довольно однообразным, так что последние Power Ups мы просто пройдём очередью. О, ты хотел увидеть код? Во всех наших превью есть маленькая кнопка «code», на неё можно нажать. Не переживай — остальные Power Ups довольно неинтересные.

  • Paddle Size — отрефакторить ракетки так, чтобы у них был переменный размер, и увеличивать ширину всех ракеток так же, как Power Up на размер мяча.

  • Gutter Guard — добавить кирпич с типом "GutterMaster" внизу или около того. Увеличивать ему health на 3. Делать это стакающимся.

  • Laser Paddle — перекрасить ракетку в бирюзовый, и каждый раз, когда мяч по ней попадает, стрелять лазерами вверх из центра ракетки. Лазеры — это мячи с типом "laser", которые умирают при попадании в кирпич или при вылете за границы.

  • Bonus Points — легко: делать Power Up тем больше, чем больше очков он даёт, по формуле вроде points = power up size * 10. Ещё — мигать ракеткой жёлтым.

  • Omega Device — омега-устройство — это особая power для Штейна, которую можно заряжать. Когда она активируется, она наносит урон всем кирпичам! Мега-тряска экрана.

Пу-пу-пу-пу-пауэр!

Исходный код
const container = document.getElementById(containerId);
const screenWidth = container.clientWidth;
const screenHeight = container.clientHeight;
const ballDiv = document.createElement("div");
container.appendChild(ballDiv);
Object.assign(ballDiv.style, {
  position: "absolute",
  left: "0",
  top: "0",
});
const BallTypes = {
  Normal: 0,
  PowerBall: 1,
  Laser: 2,
  PowerLaser: 3,
};
const BallColors = {
  [BallTypes.Normal]: [255,255,255],
  [BallTypes.PowerBall]: [255,55,55],
  [BallTypes.Laser]: [55,255,255],
  [BallTypes.PowerLaser]: [255,70,70],
};

const paddleDiv = document.createElement("div");
container.appendChild(paddleDiv);
const PADDLE_SIZE = [70, 10];
const PaddleTypes = {
  Normal: 0,
  Laser: 1,
};
const PaddleColors = {
  [PaddleTypes.Normal]: [255, 255, 255],
  [PaddleTypes.Laser]: [55, 255, 255],
};
Object.assign(paddleDiv.style, {
  position: "absolute",
  left: `-${PADDLE_SIZE[0]}px`,
  top: `-${PADDLE_SIZE[1]}px`,
  width: `${PADDLE_SIZE[0]}px`,
  height: `${PADDLE_SIZE[1]}px`,
});

const powerUpDiv = document.createElement("div");
container.appendChild(powerUpDiv);
const POWER_UP_SIZE = 16;
Object.assign(powerUpDiv.style, {
  position: "absolute",
  left: `-${POWER_UP_SIZE}px`,
  top: `-${POWER_UP_SIZE}px`,
  width: `${POWER_UP_SIZE}px`,
  height: `${POWER_UP_SIZE}px`,
  borderRadius: '9000px',
});
const PowerUpTypes = {
  MultiBall: 0,
  BigBall: 1,
  PowerBall: 2,
  PaddleSize: 3,
  GutterGuard: 4,
  LaserPaddle: 5,
  ExtraLife: 6,
  BonusPoints: 7,
  OmegaDevice: 8,
  SpeedUp: 9,
  SpeedDown: 10,
};
const PowerUpColors = {
  [PowerUpTypes.MultiBall]: [255, 200, 255],
  [PowerUpTypes.BigBall]: [200, 200, 255],
  [PowerUpTypes.PowerBall]: [255, 50, 50],
  [PowerUpTypes.ExtraLife]: [100, 255, 100],
  [PowerUpTypes.SpeedUp]: [100, 255, 255],
  [PowerUpTypes.SpeedDown]: [255, 100, 255],

  [PowerUpTypes.LaserPaddle]: [200, 255, 255],
  [PowerUpTypes.OmegaDevice]: [255, 0, 255],
  [PowerUpTypes.BonusPoints]: [255, 255, 0],
  [PowerUpTypes.PaddleSize]: [255, 255, 255],
  [PowerUpTypes.GutterGuard]: [255, 255, 255],
};
const PowerUpEffects = {
  [PowerUpTypes.MultiBall]: (game, powerUp) => {
    game.balls.map(b => [b.x, b.y]).forEach(([x,y]) => game.addBall(x,y));
  },
  [PowerUpTypes.BigBall]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.size *= 2;
    });
  },
  [PowerUpTypes.PaddleSize]: (game, powerUp) => {
    game.paddles.forEach(paddle => {
      paddle.width += PADDLE_SIZE[0] * 0.2;
    });
  },
  [PowerUpTypes.GutterGuard]: (game, powerUp) => {
    game.gutterGuardHealth += 3;
    game.tween(gutterDiv.style, 'scale', this.gutterGuardHealth > 0 ? [1, 1] : [0, 1.1, 1], 280);
  },
  [PowerUpTypes.BonusPoints]: (game, powerUp) => {
    game.updateScore(Math.floor(powerUp.scale * 1000));
  },
  [PowerUpTypes.OmegaDevice]: (game, powerUp) => {
    game.omegaDevicePower += 1;
    if (game.omegaDevicePower >= 5) {
      game.omegaDevicePower = 0;
      for (const brick of game.bricks) {
        game.damageBrick(brick);
      }
      game.megaShake(game.camera, 30);
    }
  },
  [PowerUpTypes.LaserPaddle]: (game, powerUp) => {
    game.paddles.forEach(paddle => {
      paddle.type = PaddleTypes.Laser;
      paddle.baseColor = PaddleColors[PaddleTypes.Laser];
      game.shakeIt(paddle, 6, [0, 1]);
      game.flashIt(paddle);
      game.tween(paddle, 'scale', [1, 0.8, 1.2, 1], 280);
    });
  },
  [PowerUpTypes.PowerBall]: (game, powerUp) => {
    game.addBall(powerUp.x, powerUp.y, BallTypes.PowerBall);
    game.balls[game.balls.length-1].dy = -Math.abs(game.balls[game.balls.length-1].dy);
  },
  [PowerUpTypes.ExtraLife]: (game, powerUp) => {
    game.lives += 1;
  },
  [PowerUpTypes.SpeedUp]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.dx *= 1.25;
      b.dy *= 1.25;
    });
  },
  [PowerUpTypes.SpeedDown]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.dx *= .9;
      b.dy *= .9;
    });
  },
};

const brickDiv = document.createElement("div");
container.appendChild(brickDiv);
const BRICK_SIZE = [36, 12];
const BrickTypes = {
  Normal: 0,
  Unbreakable: 1,
  MultiBall: 2,
  PowerBall: 3,
  Explode: 4,
}
Object.assign(brickDiv.style, {
  position: "absolute",
  borderRadius: "2px",
  left: `-${BRICK_SIZE[0]}px`,
  top: `-${BRICK_SIZE[1]}px`,
  width: `${BRICK_SIZE[0]}px`,
  height: `${BRICK_SIZE[1]}px`,
});

const gutterDiv = document.createElement("div");
const GUTTER_GUARD_SIZE = [screenWidth, 8];
container.appendChild(gutterDiv);

const scoreContainer = document.createElement("div");
container.appendChild(scoreContainer);
Object.assign(scoreContainer.style, {
  position: "absolute",
  top: 0,
  right: 0,
  fontSize: "16px",
  width: "100%",
  marginTop: "min(5%, 1.5rem)",
  textAlign: "center",
  textShadow: '1px 4px 0 #000a'
});

const lerp = (from, to, time) => from + (to - from) * time;

const Game = {
  score: 0,
  lives: 3,
  level: 1,
  frame: 0,
  gutterGuardHealth: 5,
  omegaDevicePower: 0,
  camera: {
    scale: 1,
    offsetX: 0,
    offsetY: 0,
  },
  width: screenWidth,
  height: screenHeight,
  balls: [],
  bricks: [],
  paddles: [],
  powerUps: [],
  tweens: new Set(),

  tween(target, prop, keyframes, duration, delay = 0, onComplete = undefined, iterations = 1) {
    this.tweens.add({
      target,
      prop,
      keyframes,
      t: 0,
      duration: duration / 1000,
      iterations,
      delay: delay / 1000,
      onComplete,
    });
  },

  handleMouseOrTouchInput(event) {
    event.preventDefault();
    event.stopPropagation();
    const x = event?.touches?.[0]?.clientX ?? event.offsetX;
    const y = event?.touches?.[0]?.clientY ?? event.offsetY;
    this.paddles.forEach(
      (p) =>
        (p.x = Math.min(this.width - p.width / 2, Math.max(p.width / 2, x)))
    );
  },

  addPaddle(x = this.width / 2, y = this.height - PADDLE_SIZE[1] * 2, type = PaddleTypes.Normal) {
    this.paddles.push({
      x,
      y,
      width: PADDLE_SIZE[0],
      height: PADDLE_SIZE[1],
      type,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      baseColor: [...PaddleColors[type]],
      color: [...PaddleColors[type]],
    });
  },
  addBall(x = this.width / 2, y = this.height / 2, type = BallTypes.Normal, speed = 240) {
    const thresholdDegrees = 20;
    const arc = thresholdDegrees * (Math.PI / 180);
    let angle = arc + Math.random() * (Math.PI - 2 * arc);
    this.balls.push({
      x,
      y,
      dx: Math.cos(angle) * speed,
      dy: Math.sin(angle) * speed,
      size: 10,
      isAlive: true,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      baseColor: [...BallColors[type]],
      color: [...BallColors[type]],
      type,
    });

    if (BallTypes.Laser === type || type === BallTypes.PowerLaser) {
      const ball = this.balls[this.balls.length-1];
      ball.dy = -speed - Math.random()*100;
      ball.dx *= 0.5;
      this.tween(ball, 'size', [12,18,6,16,6], 320, 0, undefined, Infinity);
    }
  },
  addBrick(x = this.width / 2, y = this.height / 2, type = 0, color = [255,255,255]) {
    this.bricks.push({
      x,
      y,
      type,
      health: 1,
      isAlive: true,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      baseColor: color,
      color
    });
  },
  addPowerUp(x = this.width / 2, y = this.height / 2, type = PowerUpTypes.MultiBall, speed = 160) {
    this.powerUps.push({
      x,
      y,
      dx: 0,
      dy: speed,
      type,
      isAlive: true,
      size: 16,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      color: [...PowerUpColors[type]],
    });
  },

  tick(dt) {
    this.frame++;

    this.tickBalls(dt);
    this.balls = this.balls.filter((ball) => ball.isAlive);
    this.bricks = this.bricks.filter((brick) => brick.isAlive);
    this.tickPowerUps(dt);
    this.powerUps = this.powerUps.filter((pow) => pow.isAlive);
    this.tickTweens(dt);
  },

  tickPowerUps(dt) {
    for (const power of this.powerUps) {
      power.x += power.dx * dt;
      power.y += power.dy * dt;
      const halfSize = power.size/2;
      let left = power.x - halfSize;
      let right = power.x + halfSize;
      let top = power.y - halfSize;
      let bot = power.y + halfSize;

      power.scale = 1 + 0.1*Math.sin(this.frame*.2);

      // check paddles
      for (const p of this.paddles) {
        const pL = p.x - p.width / 2;
        const pR = p.x + p.width / 2;
        const pT = p.y - p.height / 2;
        const pB = p.y + p.height / 2;
        if (left > pR || right < pL) {
          continue;
        }
        if (top > pB || bot < pT) {
          continue;
        }
        power.isAlive = false;

        this.shakeIt(p, 3, [0, 1]);
        this.flashIt(p, power.color);
        break;
      }

      if (power.y > this.height) {
        power.isAlive = false;
        continue;
      }

      if (power.isAlive) {
        continue;
      }
      PowerUpEffects[power.type]?.(this, power);
    }
  },

  tickBalls(dt) {
    const { width: w, height: h } = this;

    this.balls.forEach((b) => {
      if (!b.isAlive) {
        return;
      }

      let didBounce = false;
      let edgeBounce = false;
      let brickBounce = false;
      let ballDx = b.dx * dt;
      let ballDy = b.dy * dt;
      const bHalfSize = b.size / 2;

      // check bricks.
      const [brickWidth, brickHeight] = BRICK_SIZE;
      // x axis
      b.x += ballDx;
      let ballL = b.x - bHalfSize;
      let ballR = b.x + bHalfSize;
      let ballT = b.y - bHalfSize;
      let ballB = b.y + bHalfSize;
      for (const brick of this.bricks) {
        if (!brick.isAlive || brick.health <= 0) {
          continue;
        }

        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if (
          ballT > brickB ||
          ballB < brickT ||
          ballL > brickR ||
          ballR < brickL
        ) {
          continue;
        }

        if (b.type !== BallTypes.PowerBall && b.type !== BallTypes.PowerLaser) {
          b.x = ballDx > 0 ? brickL - bHalfSize - 1 : brickR + bHalfSize + 1;
          b.dx *= -1;
        }
        brickBounce = true;
        b.isAlive = BallTypes.Laser !== b.type;
        this.damageBrick(brick);
        break;
      }

      // y axis
      b.y += ballDy;
      ballL = b.x - bHalfSize;
      ballR = b.x + bHalfSize;
      ballT = b.y - bHalfSize;
      ballB = b.y + bHalfSize;
      for (const brick of this.bricks) {
        if (!brick.isAlive || brick.health <= 0) {
          continue;
        }

        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if (
          ballT > brickB ||
          ballB < brickT ||
          ballL > brickR ||
          ballR < brickL
        ) {
          continue;
        }

        if (b.type !== BallTypes.PowerBall && b.type !== BallTypes.PowerLaser) {
          b.y = ballDy > 0 ? (b.y = brickT - bHalfSize - 1) : brickB + bHalfSize + 1;
          b.dy *= -1;
        }
        brickBounce = true;
        b.isAlive = BallTypes.Laser !== b.type;
        this.damageBrick(brick);
        break;
      }

      // check bounds x
      if (b.x + b.size / 2 > w || b.x < b.size / 2) {
        b.x = ballDx > 0 ? w - b.size / 2 : b.size / 2;
        b.dx *= -1;
        this.shakeIt(this.camera, 3);
        edgeBounce = true;
      }
      // bounds y
      if (b.y < b.size / 2) {
        b.y = ballDy > 0 ? h - b.size / 2 : b.size / 2;
        b.dy *= -1;
        this.shakeIt(this.camera, 3);
        edgeBounce = true;
      }
      // y bottom
      const ggh = GUTTER_GUARD_SIZE[1];
      if (this.gutterGuardHealth > 0 && b.y + b.size/2 >= h - ggh) {
        b.y = this.height - ggh - b.size/2 - 1;
        b.dy *= -1;
        this.gutterGuardHealth--;
        this.tween(gutterDiv.style, 'opacity', [1, 0.5, 1], 280);
        this.tween(gutterDiv.style, 'marginTop', this.gutterGuardHealth > 1 ? [1, 1] : [1, 1.1, 0], 280);
        edgeBounce = true;
      } else if (b.y + b.size / 2 > h) {
        b.isAlive = false;
      }

      // check paddles
      for (const p of this.paddles) {
        if (b.type === BallTypes.Laser) continue;

        const pL = p.x - p.width / 2;
        const pR = p.x + p.width / 2;
        const pT = p.y - p.height / 2;
        const pB = p.y + p.height / 2;
        if (ballL > pR || ballR < pL) {
          continue;
        }
        if (ballT > pB || ballB < pT) {
          continue;
        }
        edgeBounce = true;
        const xDif = Math.min(ballR - pL, pR - ballL);
        const yDif = Math.min(ballB - pT, pB - ballT);
        if (xDif < yDif) {
          b.x += b.x > p.x ? xDif : -xDif;
        } else {
          b.y = p.y - p.height / 2 - b.size/2 - 1;
        }

        const angle = Math.atan2(
          b.y - (p.y - p.height / 2 + p.width / 2),
          b.x - p.x
        );
        const speed = Math.sqrt(b.dx * b.dx + b.dy * b.dy);
        b.dx = Math.cos(angle) * speed;
        b.dy = Math.sin(angle) * speed;

        this.shakeIt(p, 3, [0, 1]);
        this.flashIt(p);

        if (p.type === PaddleTypes.Laser) {
          const type = b.type === BallTypes.PowerBall ? BallTypes.PowerLaser : BallTypes.Laser;
          this.addBall(b.x, b.y+b.size/2, type, 480);
          this.addBall(b.x, b.y+b.size/2, type, 480);
          this.addBall(b.x, b.y+b.size/2, type, 480);
          this.addBall(b.x, b.y+b.size/2, type, 480);
        }
        break;
      }

      didBounce = edgeBounce || brickBounce;
      if (!didBounce) {
        return;
      }
      this.tween(b, "scale", [0.8, 1.2, 1], 60, 0, () => {
        if (b.type === BallTypes.Laser) {
          b.isAlive = false;
        }
        if (b.type === BallTypes.PowerLaser && edgeBounce) {
          b.isAlive = false;
        }
      });
      this.flashIt(b);
    });
  },

  tickTweens(dt) {
    // do the tween
    for (const tween of this.tweens) {
      const { target, prop, keyframes, duration } = tween;
      tween.delay -= dt;
      if (tween.delay > 0) continue;
      tween.t += dt;
      const frames = keyframes.length - 1;
      const progress = Math.min(1, tween.t / duration);
      const kIdx = Math.min(frames - 1, Math.floor(frames * progress));
      const localProgress = (progress - kIdx / frames) / (1 / frames);
      target[prop] = lerp(keyframes[kIdx], keyframes[kIdx + 1], localProgress);
      if (tween.t < duration) {
        continue;
      }

      target[prop] = keyframes[keyframes.length - 1];
      if (tween.onComplete) {
        tween.onComplete();
      }
      tween.iterations -= 1;
      if (tween.iterations <= 0) {
        this.tweens.delete(tween);
      } else {
        tween.t = 0;
      }
    }
  },

  damageBrick(brick) {
    this.flashIt(brick);
    this.shakeIt(brick, 6);
    brick.health -= 1;
    if (brick.health > 0) {
      return;
    }

    this.updateScore(100);
    this.tween(
      brick,
      "scale",
      [1, 1],
      120,
      160,
      () => (brick.isAlive = false)
    );

    if (Math.random() > 0.7) {
      const types = Object.values(PowerUpTypes);
      this.addPowerUp(brick.x, brick.y, types[Math.floor(Math.random()*types.length)]);
    }
  },

  draw() {
    const [w, h] = GUTTER_GUARD_SIZE;
    Object.assign(gutterDiv.style, {
      position: "absolute",
      left: `-${w}`,
      top: `${this.height - h - 1 }px`,
      width: `${w}px`,
      height: `${h}px`,
      display: this.gutterGuardHealth > 0 ? 'initial' : 'none',
      boxShadow: `inset 0 0 0 1px #fff, inset 0 0 0 2px #000, inset 0 0 0 3px #fff`,
    });

    const [bW, bH] = BRICK_SIZE;
    brickDiv.style.boxShadow = this.bricks
      .map(
        (b) =>
          `${b.x + bW / 2 + b.offsetX}px ${b.y + bH / 2 + b.offsetY}px 0 ${
            (bH / 2) * (b.scale - 1)
          }px rgb(${b.color.join()})`
      )
      .join();

    ballDiv.style.boxShadow = this.balls
      .map(
        (b) =>
          `${b.x + b.offsetX}px ${b.y + b.offsetY}px 0 ${
            (b.size / 2) * b.scale
          }px rgb(${b.color.join()})`
      )
      .join();

    powerUpDiv.style.boxShadow = this.powerUps
      .map(
        (power) =>
          `${power.x + power.size/2 + power.offsetX}px ${power.y + power.size/2 + power.offsetY}px 0 ${
            (power.size / 2) * (power.scale - 1)
          }px rgb(${power.color.join()})`
      )
      .join();

    if (this.paddles.length > 0) {
      const paddle = this.paddles[0];
      Object.assign(paddleDiv.style, {
        left: `-${paddle.width}px`,
        top: `-${paddle.height}px`,
        width: `${paddle.width}px`,
        height: `${paddle.height}px`,
      });
      paddleDiv.style.boxShadow = this.paddles
        .map(
          (p) =>
            `${p.x + p.width / 2 + p.offsetX}px ${
              p.y + p.height / 2 + p.offsetY
            }px 0 0 rgb(${p.color.join()})`
        )
        .join();
    }

    container.style.transform = `translate(${this.camera.offsetX}px,${this.camera.offsetY}px)`;
    scoreContainer.innerText = `${this.score.toLocaleString()}`;
  },

  shakeIt(obj, dist = 4, dir = undefined) {
    let ox = -dist/2 + Math.random() * dist;
    let oy = -dist/2 + Math.random() * dist;
    if (dir) {
      ox = dir[0] * dist;
      oy = dir[1] * dist;
    }
    this.tween(obj, "offsetX", [0, ox, -ox, ox / 2, 0], 260);
    this.tween(obj, "offsetY", [0, oy, -oy, oy / 2, 0], 260);
  },
  megaShake(obj, dist = 8) {
    let offSets = new Array(10).fill(0).map(() => -dist/2 + Math.random() * dist);
    this.tween(obj, "offsetX", [0, ...offSets, 0], 260);
    this.tween(obj, "offsetY", [0, ...offSets.reverse(), 0], 620);
  },
  flashIt(obj, color,) {
    this.tween(obj.color, "0", [obj.color[0], color?.[0] ?? 100 + Math.random() * 155, obj.baseColor?.[0] || 255], 180);
    this.tween(obj.color, "1", [obj.color[1], color?.[1] ?? 100 + Math.random() * 155, obj.baseColor?.[1] || 255], 180);
    this.tween(obj.color, "2", [obj.color[2], color?.[2] ?? 100 + Math.random() * 155, obj.baseColor?.[2] || 255], 180);
  },
  updateScore(val) {
    this.score += val;
    this.tween(scoreContainer.style, 'scale', [.85, 1.5, 1], 300);
  },

  // levels
  spawnNextLevel() {
    const levels = {
      1: () => this.spawnLevel(),
    };
    levels?.[this.level++]?.();
  },
  spawnLevel(config) {
    const defaults = {
      blockWidth: BRICK_SIZE[0],
      blockHeight: BRICK_SIZE[1],
      screenWidth: this.width,
      screenHeight: this.height,
      brickGutterPx: 1.5,
      xBrickPad: 1,
      yBrickPad: 0,
      playAreaPxBot: 180,
      playAreaPxTop: 60,
      playAreaPxLeft: 0,
      playAreaPxRight: 0,
      predicate: ({ x, y }) => this.addBrick(x, y),
    };
    const {
      blockWidth,
      blockHeight,
      screenWidth,
      screenHeight,
      brickGutterPx,
      xBrickPad,
      yBrickPad,
      playAreaPxBot,
      playAreaPxTop,
      playAreaPxLeft,
      playAreaPxRight,
      predicate,
    } = { ...defaults, ...config };
    const bW = blockWidth + brickGutterPx;
    const bH = blockHeight + brickGutterPx;
    const countW = Math.floor(screenWidth / bW);
    const countH = Math.floor(screenHeight / bH);
    const rW = screenWidth % bW;
    const rH = screenHeight % bH;
    const sx = rW / 2 + bW / 2;
    const sy = rH / 2 + bH / 2;
    for (let i = xBrickPad; i < countW - xBrickPad; i++) {
      const x = sx + i * bW;
      if (x < playAreaPxLeft || x > screenWidth - playAreaPxRight) continue;
      for (let j = yBrickPad; j < countH; j++) {
        const y = sy + j * bH;
        if (y < playAreaPxTop || y > screenHeight - playAreaPxBot) continue;
        predicate({ x, y, i, j, countW, countH });
      }
    }
  },
};

Game.addBall(60, Game.height - 60);
Game.addPaddle();
Game.paddles[Game.paddles.length-1].type = PaddleTypes.Laser;
Game.spawnNextLevel();

container.addEventListener("pointermove", (e) =>
  Game.handleMouseOrTouchInput(e)
);
container.addEventListener("touchmove", (e) => Game.handleMouseOrTouchInput(e));

let lastTime = 0;
function universe(delta) {
  if (lastTime === 0) {
    lastTime = delta;
  }

  const dt = (delta - lastTime) / 1000;
  Game.tick(dt);
  Game.draw();

  lastTime = delta;
  frameId = requestAnimationFrame(universe);
}

universe(lastTime);
container.addEventListener('unload', () => cancelAnimationFrame(frameId));

Жизнь, смерть и ещё уровни

Что такое победа без поражения?

Чтобы завершить финальную «полировку», округлим всё до целостной игры. Игра должна стартовать на паузе, чтобы дать игроку время подготовиться. Аналогично, когда все мячи умерли, игра должна поставить паузу перед тем, как потратить одну из «жизней» игрока, то есть запасных мячей. Нам нужна логика, которая уничтожает мяч, если он вылетает за границы, но только для нижней части экрана.

Большую часть этого реализовать довольно просто. Для начала нашей вселенной нужна кнопка выключения — ну или хотя бы пауза. Она не «полностью» замораживает симуляцию, но почти.

const Game = {
  isPaused: true,


  tick(dt) {
    if (isPaused) {
      return;
    }
  }
}

Так не пойдёт. Мы всё равно хотим, чтобы твины продолжали работать — меню же не должно просто «впрыгивать» в существование без анимации.

 tick(dt) {
    if (!isPaused) {
      // update game
    }

    updateTween(dt);
  }

Теперь можно добавить в игру ещё несколько div под UI. Один для заголовка паузы, один для жизней, один для кнопки «play», и ещё один — просто на всякий случай.

const menuPaused = document.createElement('div');
const titleDiv = document.createElement('div');
menuPaused.appendChild(titleDiv);
// etc
const msgDiv = document.createElement('div');
const startButton = document.createElement('button');
const scoreContainer = document.createElement('div');

У нас есть родитель для меню, поэтому можно добавить полупрозрачный фон и blur, чтобы появилась «пауза-атмосфера». Со стилями лучше не перебарщивать: чем проще, тем лучше, так что заголовок и кнопка остаются максимально лаконичными.

В заголовке показываем текущий уровень, а кнопка говорит «play» или что-то в этом духе. Жизни — это число с небольшим зелёным оформлением, чтобы было понятно: это твоя «прочность».

Лично я не люблю текст в играх, если от него можно уйти. Чем больше текста, тем более культурно-локальной становится игра. Лучшие игры — универсальны. Иконки помогают избегать текста, но иногда без него никак. Тут можно придерживаться принципа «меньше — лучше».

Можно было бы расширить систему ввода: слушать тапы и проверять, попали ли они в границы списка кнопок. Но проще просто кинуть div и навесить на него пару обработчиков. Поможет, если мы добавим в Game несколько вспомогательных функций, чтобы управлять и состоянием игры, и UI вместе с ним.

Вот идея.

if (b.y > this.height) {
  // we are outside the screen 
  b.isAlive = false;
}

// after updating balls
this.balls = this.balls.filter(b => b.isAlive);

if (this.balls.length === 0) {
  this.doTheDeathThing();
}
if (this.blocks.length === 0) {
  this.doTheWinThing();
}

Когда ты умираешь: мега-тряска экрана, «буф» анимация индикатора жизней, сброс ракетки, сброс мяча и пауза. Вычищать весь стейт — муторно, но вполне реально.

onLifeLost() {
  this.showMenu();
  this.onResetLevel();
  this.megaShake(this.camera, 30);
  // delayed boof
  this.tween(livesContainer.style, 'scale',
    [1, 0.7, 2, .8, 1.5, .9, 1.2, .95, 1], 680, 540,
    // late update as we want it obvs a live was lost
    () => livesContainer.innerText = `${this.lives}`
  );
},
onDeath() {},
onWinLevel() {
  this.onResetLevel();
  this.spawnNextLevel();
  this.showMenu();
},
onResetLevel() {
  // clean up.
  this.powerUps = [];
  this.paddles = [];
  this.addBall(this.width/2, Game.height - 60);
  this.addPaddle();
},
onStartGame() {
  // start
},
onResetGame() {
  // reset all props
},

Префикс on тут — маленькая подсказка, что всё это происходит как реакция на событие. Что? Event bus? Подписки? Слишком сложно. Забей. Давай лучше анимируем меню.

Анимации кажутся сложными, но на деле нет — мы можем переиспользовать твины. Сделаем show/hide для меню.

hideMenu() {
  this.tween(menuPaused.style, 'scale', [1,1.1,0.5], 380);
  this.tween(menuPaused.style, 'opacity', [1,0], 300, 80, () => {
    menuPaused.style.opacity = 1;
    menuPaused.style.scale = 1;
    menuPaused.style.display = 'none';
    this.isPaused = false;
  });
},
showMenu() {
  this.isPaused = true;
  menuPaused.style.display = 'flex';
  titleDiv.innerText = `Level ${this.level}`;
  this.tween(menuPaused.style, 'scale', [0.5, 0.4, 1.1, 1], 380);
  this.tween(menuPaused.style, 'opacity', [0,1], 300);
}

flex/none — это значения CSS. flex — просто раскладка в строку/колонку. Контейнер меню у нас — flex-колонка. При показе мы сначала ставим паузу, а при скрытии снимаем паузу только после того, как анимация закончится.

Просто, но работает.

Теперь мы можем выигрывать и проигрывать. Проигрыш сбрасывает только счёт и уровень обратно в 0. Вместо «последнего уровня» игра будет начинаться заново с первого, но стартовая скорость мяча будет расти, а кирпичи станут крепче. Может, Power Ups начнут падать чаще.

Но чтобы это работало, нужно обновить goToNextLevel.

spawnNextLevel() {
  this.level++;
  const levels = {
    0: () => this.spawnLevel(),
    1: () => this.spawnLevel({
      predicate: ({x, y, i, j, xBrickCount}) => {
        if ((i % (xBrickCount >> 1)) === 0) {
          return;
        }
        let color = [255, 255, 255];
        if (j % 5 === 0) color = [255,200,200];
        if (j % 3 === 0) color = [200,255,200];
        this.addBrick(x, y, 0, color);
      }
    }),
  };
  const nLevels = Object.keys(levels).length;
  levels?.[((this.level - 1) % nLevels)]?.();
},

Если уровней меньше двух, циклический переход сломается — побочный эффект взятия по модулю 1. Ещё мы обновили общее количество кирпичей по осям x и y, доступное в predicate.

На этом этапе можно добавить больше уровней с разными цветами кирпичей, более прочными кирпичами и так далее. Думаю, 10 уровней — хорошее число.

СТОП. Я знаю, о чём ты думаешь. Разве AI не может просто «намагичить» нам пару крутых раскладок?

Ну давай, не будь таким. Расслабься. Будь креативным. Думай об этом как о тренировке для собеседований по «leet» коду. Нам нужны несколько небольших алгоритмов, которые раскладывают кирпичи интересными способами.

Давай используем этот большой красивый…

Помни: главное — чтобы игроку было весело. Мы дозируем дофамин для маленького габыча в голове. Ожидание, разгон, разгон, разрядка и сброс. Уровни должны поддерживать этот цикл, но при этом оставаться интересными по мере усложнения.

  1. Простой вариант: весь экран в белых кирпичах.

  2. Экран полный кирпичей. Две строки отсутствуют и два столбца отсутствуют. Кирпичи в верхней и нижней строке требуют два удара. Каждый третий кирпич — синий, каждый четвёртый — красный.

  3. Два центральных столбца отсутствуют, а крайние столбцы по границе экрана — фиолетовые кирпичи, которым нужно 5 ударов, чтобы лопнуть. Все остальные кирпичи — синие.

  4. Чередование: две полные строки, одна пустая. В нижней из двух полных строк кирпичи получают 5 ударов, в верхней — 2. По строкам чередуются оттенки серого.

  5. Назад к базе: полный экран, радужные строки, у кирпичей случайная прочность 1–3 удара.

  6. Колонны: чередуем полный и пустой столбец. В каждой второй строке кирпичи получают 3 удара.

  7. Triple trouble. В середине вырезаем по 2 строки/столбца, получаем 4 «квадранта» блоков. Затем в каждую позицию добавляем по 2 кирпича. Первый — белый, второй — синий, третий — красный. (Если ты не американец — используй флаг своей страны.)

  8. Жёсткое дно. Нижний кирпич имеет бесконечное здоровье с типом unbreakable. Возможно, придётся добавить кода.

    Ого, это так весело. Сразу хочется добавить ещё кирпичей, да? Типа бомб-кирпичей. Или «респаун»-кирпичей, которые будут возрождать кирпичи вокруг себя. А как насчёт warp-кирпичей, которые после удара телепортируются в другое место?

    Потом-потом, сначала добьём первые десять.

  9. Почти пусто. Есть только край — прочные кирпичи, но в центре остаётся небольшой «островок» из нескольких слоёв кирпичей. Обод — в два слоя: синий и бирюзовый, а центр — оранжевый.

  10. Босс-уровень. Полный экран, у всех кирпичей здоровье 5.

Босс-уровень скучный. Вернёмся к нему позже, когда добавим босса. О, босс будет особенно острым.

Уровни получаются длинными, так что детали можно опустить. Но вот несколько подсказок.

({x, y, i, j, yBrickCount, xBrickCount}) => {
  // store the mid points
  const midX = Math.floor(xBrickCount / 2);
  const midY = Math.floor(yBrickCount / 2);
  if (j === yBrickCount-1 || j === 0 || i === 0 || i === xBrickCount -1) {
    // edges
  }
  if (j === yBrickCount-2 || j === 1 || i === 1 || i === xBrickCount -2) {
    // second layer edge
  }
  if (i >= midX - 1 && i <= midX+1 && j >= midY - 1 && j <= midY+1) {
    // mid point expanding out
  }
  if (i === 0) // left
  if (i === xBrickCount-1) // right
  if (i % 3 === 0) // column skip
  if (j % 3 === 0) // row skip
}

С таким шаблоном несложно быстро набросать и потом подкрутить уровни в будущем.

Раз уж мы здесь, добавим игре ещё немного блеска.

  • Когда Power Up касается ракетки, мигать ракеткой тем же цветом, что и Power Up.

  • Сделать анимацию меню более «богатой».

  • Сделать power ball менее имбовым: пусть он отскакивает от unbreakable кирпичей и будет «менее мощным».

  • Отслеживать, какие кирпичи мяч уже задел с момента последнего «не-кирпичного» удара, и пропускать кирпичи, которые мы уже задели. Тогда power ball не будет сносить всё подчистую — мы оставим пространство для power creep.

Ну что, чувствуешь теперь, мистер Крабс?!?!?

Исходный код
const container = document.getElementById(containerId);
const screenWidth = container.clientWidth;
const screenHeight = container.clientHeight;

// create menu ui
const menuPaused = document.createElement('div');
container.appendChild(menuPaused);
Object.assign(menuPaused.style, {
  position: 'absolute',
  top: 0,
  left: 0,
  width: '100%',
  height: '100%',
  backdropFilter: 'blur(2px)',
  backgroundColor: '#0008',
  display: 'flex',
  flexDirection: 'column',
  justifyContent: 'center',
  letterSpacing: '2px',
  alignItems: 'center',
  gap: '32px',
  zIndex: 1,
});
const titleDiv = document.createElement('div');
titleDiv.innerText = 'LOADING';
Object.assign(titleDiv.style, {
  fontSize: '32px',
  fontWeight: 'bold',
  textAlign: 'center',
  textTransform: 'uppercase',
});
const msgDiv = document.createElement('div');
const startButton = document.createElement('button');
startButton.innerText = 'START';
Object.assign(startButton.style, {
  padding: '8px 16px',
  minHeight: '32px',
  minWidth: '120px',
  fontSize: '16px',
  letterSpacing: '4px',
  border: 'none',
  boxShadow: '0 0 0 1px #fffa, 0 0 0 2px #000, 0 0 0 3px #fffa',
  textShadow: '1px 2px #fff2',
  color: '#fffb',
  background: 'transparent',
  outline: 'none',
})
menuPaused.appendChild(titleDiv);
menuPaused.appendChild(msgDiv);
menuPaused.appendChild(startButton);

const ballDiv = document.createElement("div");
container.appendChild(ballDiv);
Object.assign(ballDiv.style, {
  position: "absolute",
  left: "0",
  top: "0",
});
const BallTypes = {
  Normal: 0,
  PowerBall: 1,
  Laser: 2,
  PowerLaser: 3,
};
const BallColors = {
  [BallTypes.Normal]: [255,255,255],
  [BallTypes.PowerBall]: [255,55,55],
  [BallTypes.Laser]: [55,255,255],
  [BallTypes.PowerLaser]: [255,70,70],
};

const brickDiv = document.createElement("div");
container.appendChild(brickDiv);
const BRICK_SIZE = [36, 12];
const BrickTypes = {
  Normal: 0,
  Unbreakable: 1,
  MultiBall: 2,
  PowerBall: 3,
  Explode: 4,
}
Object.assign(brickDiv.style, {
  position: "absolute",
  borderRadius: "2px",
  left: `-${BRICK_SIZE[0]}px`,
  top: `-${BRICK_SIZE[1]}px`,
  width: `${BRICK_SIZE[0]}px`,
  height: `${BRICK_SIZE[1]}px`,
});

const paddleDiv = document.createElement("div");
container.appendChild(paddleDiv);
const PADDLE_SIZE = [70, 10];
const PaddleTypes = {
  Normal: 0,
  Laser: 1,
};
const PaddleColors = {
  [PaddleTypes.Normal]: [255, 255, 255],
  [PaddleTypes.Laser]: [55, 255, 255],
};
Object.assign(paddleDiv.style, {
  position: "absolute",
  left: `-${PADDLE_SIZE[0]}px`,
  top: `-${PADDLE_SIZE[1]}px`,
  width: `${PADDLE_SIZE[0]}px`,
  height: `${PADDLE_SIZE[1]}px`,
});

const powerUpDiv = document.createElement("div");
container.appendChild(powerUpDiv);
const POWER_UP_SIZE = 16;
Object.assign(powerUpDiv.style, {
  position: "absolute",
  left: `-${POWER_UP_SIZE}px`,
  top: `-${POWER_UP_SIZE}px`,
  width: `${POWER_UP_SIZE}px`,
  height: `${POWER_UP_SIZE}px`,
  borderRadius: '9000px',
});
const PowerUpTypes = {
  MultiBall: 0,
  BigBall: 1,
  PowerBall: 2,
  PaddleSize: 3,
  GutterGuard: 4,
  LaserPaddle: 5,
  ExtraLife: 6,
  BonusPoints: 7,
  OmegaDevice: 8,
  SpeedUp: 9,
  SpeedDown: 10,
};
const PowerUpColors = {
  [PowerUpTypes.MultiBall]: [255, 200, 255],
  [PowerUpTypes.BigBall]: [200, 200, 255],
  [PowerUpTypes.PowerBall]: [255, 50, 50],
  [PowerUpTypes.ExtraLife]: [100, 255, 100],
  [PowerUpTypes.SpeedUp]: [100, 255, 255],
  [PowerUpTypes.SpeedDown]: [255, 100, 255],

  [PowerUpTypes.LaserPaddle]: [200, 255, 255],
  [PowerUpTypes.OmegaDevice]: [255, 0, 255],
  [PowerUpTypes.BonusPoints]: [255, 255, 0],
  [PowerUpTypes.PaddleSize]: [255, 255, 255],
  [PowerUpTypes.GutterGuard]: [255, 255, 255],
};
const PowerUpEffects = {
  [PowerUpTypes.MultiBall]: (game, powerUp) => {
    game.balls.map(b => [b.x, b.y]).forEach(([x,y]) => game.addBall(x,y));
  },
  [PowerUpTypes.BigBall]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.size *= 2;
    });
  },
  [PowerUpTypes.PaddleSize]: (game, powerUp) => {
    game.paddles.forEach(paddle => {
      paddle.width += PADDLE_SIZE[0] * 0.2;
    });
  },
  [PowerUpTypes.GutterGuard]: (game, powerUp) => {
    game.gutterGuardHealth += 3;
    game.tween(gutterDiv.style, 'scale', this.gutterGuardHealth > 0 ? [1, 1] : [0, 1.1, 1], 280);
  },
  [PowerUpTypes.BonusPoints]: (game, powerUp) => {
    game.updateScore(Math.floor(powerUp.scale * 1000));
  },
  [PowerUpTypes.OmegaDevice]: (game, powerUp) => {
    game.omegaDevicePower += 1;
    if (game.omegaDevicePower >= 5) {
      game.omegaDevicePower = 0;
      for (const brick of game.bricks) {
        game.damageBrick(brick);
      }
      game.megaShake(game.camera, 30);
    }
  },
  [PowerUpTypes.LaserPaddle]: (game, powerUp) => {
    game.paddles.forEach(paddle => {
      paddle.type = PaddleTypes.Laser;
      paddle.baseColor = PaddleColors[PaddleTypes.Laser];
      game.shakeIt(paddle, 6, [0, 1]);
      game.flashIt(paddle);
      game.tween(paddle, 'scale', [1, 0.8, 1.2, 1], 280);
    });
  },
  [PowerUpTypes.PowerBall]: (game, powerUp) => {
    game.addBall(powerUp.x, powerUp.y, BallTypes.PowerBall);
    game.balls[game.balls.length-1].dy = -Math.abs(game.balls[game.balls.length-1].dy);
  },
  [PowerUpTypes.ExtraLife]: (game, powerUp) => {
    game.updateLives(1);
  },
  [PowerUpTypes.SpeedUp]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.dx *= 1.25;
      b.dy *= 1.25;
    });
  },
  [PowerUpTypes.SpeedDown]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.dx *= .9;
      b.dy *= .9;
    });
  },
};

const gutterDiv = document.createElement("div");
const GUTTER_GUARD_SIZE = [screenWidth, 8];
container.appendChild(gutterDiv);

const scoreContainer = document.createElement("div");
container.appendChild(scoreContainer);
Object.assign(scoreContainer.style, {
  position: "absolute",
  top: 0,
  right: 0,
  fontSize: "16px",
  width: "100%",
  fontWeight: 'bold',
  letterSpacing: '2px',
  marginTop: "min(5%, 1.5rem)",
  textAlign: "center",
  textShadow: '1px 4px 0 #000a',
  zIndex: 2,
});

const livesContainer = document.createElement("div");
container.appendChild(livesContainer);
Object.assign(livesContainer.style, {
  position: "absolute",
  top: '16px',
  left: '16px',
  fontSize: "22px",
  fontWeight: 'bold',
  width: "100%",
  textAlign: "left",
  textShadow: '#0008 1px 1px 0px, #44ff44 3px 1px 0px, #44ff44 1px 2px 0px',
  zIndex: 2,
  width: 'fit-content',
});

const lerp = (from, to, time) => from + (to - from) * time;

const Game = {
  isPaused: false,
  score: 0,
  lives: 3,
  level: 1,
  frame: 0,
  gutterGuardHealth: 5,
  omegaDevicePower: 0,
  camera: {
    scale: 1,
    offsetX: 0,
    offsetY: 0,
  },
  width: screenWidth,
  height: screenHeight,
  balls: [],
  bricks: [],
  paddles: [],
  powerUps: [],
  tweens: new Set(),

  tween(target, prop, keyframes, duration, delay = 0, onComplete = undefined, iterations = 1) {
    this.tweens.add({
      target,
      prop,
      keyframes,
      t: 0,
      duration: duration / 1000,
      iterations,
      delay: delay / 1000,
      onComplete,
    });
  },

  handleMouseOrTouchInput(event) {
    if (event.target !== container) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();
    const x = event?.touches?.[0]?.clientX ?? event.offsetX;
    const y = event?.touches?.[0]?.clientY ?? event.offsetY;
    this.paddles.forEach(
      (p) =>
        (p.x = Math.min(this.width - p.width / 2, Math.max(p.width / 2, x)))
    );
  },

  addPaddle(x = this.width / 2, y = this.height - PADDLE_SIZE[1] * 2, type = PaddleTypes.Normal) {
    this.paddles.push({
      x,
      y,
      width: PADDLE_SIZE[0],
      height: PADDLE_SIZE[1],
      type,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      baseColor: [...PaddleColors[type]],
      color: [...PaddleColors[type]],
    });
  },
  addBall(x = this.width / 2, y = this.height / 2, type = BallTypes.Normal, speed = 240) {
    const thresholdDegrees = 20;
    const arc = thresholdDegrees * (Math.PI / 180);
    let angle = arc + Math.random() * (Math.PI - 2 * arc);
    this.balls.push({
      x,
      y,
      dx: Math.cos(angle) * speed,
      dy: Math.sin(angle) * speed,
      size: 10,
      isAlive: true,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      baseColor: [...BallColors[type]],
      color: [...BallColors[type]],
      type,
      lastHits: new Set(),
    });

    if (BallTypes.Laser === type || type === BallTypes.PowerLaser) {
      const ball = this.balls[this.balls.length-1];
      ball.dy = -speed - Math.random()*100;
      ball.dx *= 0.5;
      this.tween(ball, 'size', [12,18,6,16,6], 320, 0, undefined, Infinity);
    }
  },
  addBrick(x = this.width / 2, y = this.height / 2, type = 0, color = [255,255,255]) {
    this.bricks.push({
      x,
      y,
      type,
      health: type === BrickTypes.Unbreakable ? Infinity : 1,
      isAlive: true,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      baseColor: color,
      color
    });

    return this.bricks[this.bricks.length -1];
  },
  addPowerUp(x = this.width / 2, y = this.height / 2, type = PowerUpTypes.MultiBall, speed = 160) {
    this.powerUps.push({
      x,
      y,
      dx: 0,
      dy: speed,
      type,
      isAlive: true,
      size: 16,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      color: [...PowerUpColors[type]],
    });
  },

  tick(dt) {
    this.frame++;

    if (!this.isPaused) {
      this.tickBalls(dt);
      this.balls = this.balls.filter((ball) => ball.isAlive);
      this.bricks = this.bricks.filter((brick) => brick.isAlive);
      this.tickPowerUps(dt);
      this.powerUps = this.powerUps.filter((pow) => pow.isAlive);

      if (this.balls.length === 0) {
        if (this.lives > 0) {
          this.lives--;
          this.onLifeLost();
        } else {
          this.onResetGame();
        }
      }
      if (this.bricks.filter(b => b.type !== BrickTypes.Unbreakable).length === 0) {
        this.onWinLevel();
      }
    }

    this.tickTweens(dt);
  },

  tickPowerUps(dt) {
    for (const power of this.powerUps) {
      power.x += power.dx * dt;
      power.y += power.dy * dt;
      const halfSize = power.size/2;
      let left = power.x - halfSize;
      let right = power.x + halfSize;
      let top = power.y - halfSize;
      let bot = power.y + halfSize;

      power.scale = 1 + 0.1*Math.sin(this.frame*.2);

      // check paddles
      for (const p of this.paddles) {
        const pL = p.x - p.width / 2;
        const pR = p.x + p.width / 2;
        const pT = p.y - p.height / 2;
        const pB = p.y + p.height / 2;
        if (left > pR || right < pL) {
          continue;
        }
        if (top > pB || bot < pT) {
          continue;
        }
        power.isAlive = false;

        this.shakeIt(p, 3, [0, 1]);
        this.flashIt(p, power.color);
        break;
      }

      if (power.y > this.height) {
        power.isAlive = false;
        continue;
      }

      if (power.isAlive) {
        continue;
      }
      PowerUpEffects[power.type]?.(this, power);
    }
  },

  tickBalls(dt) {
    const { width: w, height: h } = this;

    this.balls.forEach((b) => {
      if (!b.isAlive) {
        return;
      }

      let didBounce = false;
      let edgeBounce = false;
      let brickBounce = false;
      let ballDx = b.dx * dt;
      let ballDy = b.dy * dt;
      const bHalfSize = b.size / 2;

      // check bricks.
      const [brickWidth, brickHeight] = BRICK_SIZE;
      // x axis
      b.x += ballDx;
      let ballL = b.x - bHalfSize;
      let ballR = b.x + bHalfSize;
      let ballT = b.y - bHalfSize;
      let ballB = b.y + bHalfSize;
      for (const brick of this.bricks) {
        if (!brick.isAlive || brick.health <= 0 || b.lastHits.has(brick)) {
          continue;
        }

        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if (
          ballT > brickB ||
          ballB < brickT ||
          ballL > brickR ||
          ballR < brickL
        ) {
          continue;
        }

        if ((b.type !== BallTypes.PowerBall && b.type !== BallTypes.PowerLaser) || brick.type === BrickTypes.Unbreakable) {
          b.x = ballDx > 0 ? brickL - bHalfSize - 1 : brickR + bHalfSize + 1;
          b.dx *= -1;
        }
        brickBounce = true;
        b.isAlive = BallTypes.Laser !== b.type;
        this.damageBrick(brick);
        b.lastHits.add(brick);
        break;
      }

      // y axis
      b.y += ballDy;
      ballL = b.x - bHalfSize;
      ballR = b.x + bHalfSize;
      ballT = b.y - bHalfSize;
      ballB = b.y + bHalfSize;
      for (const brick of this.bricks) {
        if (!brick.isAlive || brick.health <= 0 || b.lastHits.has(brick)) {
          continue;
        }

        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if (
          ballT > brickB ||
          ballB < brickT ||
          ballL > brickR ||
          ballR < brickL
        ) {
          continue;
        }

        if ((b.type !== BallTypes.PowerBall && b.type !== BallTypes.PowerLaser) || brick.type === BrickTypes.Unbreakable) {
          b.y = ballDy > 0 ? (b.y = brickT - bHalfSize - 1) : brickB + bHalfSize + 1;
          b.dy *= -1;
        }
        brickBounce = true;
        b.isAlive = BallTypes.Laser !== b.type;
        this.damageBrick(brick);
        b.lastHits.add(brick);
        break;
      }

      // check bounds x
      if (b.x + b.size / 2 > w || b.x < b.size / 2) {
        b.x = ballDx > 0 ? w - b.size / 2 : b.size / 2;
        b.dx *= -1;
        this.shakeIt(this.camera, 3);
        edgeBounce = true;
      }
      // bounds y
      if (b.y < b.size / 2) {
        b.y = ballDy > 0 ? h - b.size / 2 : b.size / 2;
        b.dy *= -1;
        this.shakeIt(this.camera, 3);
        edgeBounce = true;
      }
      // y bottom
      const ggh = GUTTER_GUARD_SIZE[1];
      if (this.gutterGuardHealth > 0 && b.y + b.size/2 >= h - ggh) {
        b.y = this.height - ggh - b.size/2 - 1;
        b.dy *= -1;
        this.gutterGuardHealth--;
        this.tween(gutterDiv.style, 'opacity', [1, 0.5, 1], 280);
        this.tween(gutterDiv.style, 'marginTop', this.gutterGuardHealth > 1 ? [1, 1] : [1, 1.1, 0], 280);
        edgeBounce = true;
      } else if (b.y + b.size / 2 > h) {
        b.isAlive = false;
      }

      // check paddles
      for (const p of this.paddles) {
        if (b.type === BallTypes.Laser) continue;

        const pL = p.x - p.width / 2;
        const pR = p.x + p.width / 2;
        const pT = p.y - p.height / 2;
        const pB = p.y + p.height / 2;
        if (ballL > pR || ballR < pL) {
          continue;
        }
        if (ballT > pB || ballB < pT) {
          continue;
        }
        edgeBounce = true;
        const xDif = Math.min(ballR - pL, pR - ballL);
        const yDif = Math.min(ballB - pT, pB - ballT);
        if (xDif < yDif) {
          b.x += b.x > p.x ? xDif : -xDif;
        } else {
          b.y = p.y - p.height / 2 - b.size/2 - 1;
        }

        const angle = Math.atan2(
          b.y - (p.y - p.height / 2 + p.width / 2),
          b.x - p.x
        );
        const speed = Math.sqrt(b.dx * b.dx + b.dy * b.dy);
        b.dx = Math.cos(angle) * speed;
        b.dy = Math.sin(angle) * speed;

        this.shakeIt(p, 3, [0, 1]);
        this.flashIt(p);

        if (p.type === PaddleTypes.Laser) {
          const type = b.type === BallTypes.PowerBall ? BallTypes.PowerLaser : BallTypes.Laser;
          this.addBall(b.x, b.y+b.size/2, type, 480);
          this.addBall(b.x, b.y+b.size/2, type, 480);
          this.addBall(b.x, b.y+b.size/2, type, 480);
          this.addBall(b.x, b.y+b.size/2, type, 480);
        }
        break;
      }

      didBounce = edgeBounce || brickBounce;
      if (!didBounce) {
        return;
      }
      if (!brickBounce) {
        b.lastHits.clear();
      }

      this.tween(b, "scale", [0.8, 1.2, 1], 60, 0, () => {
        if (b.type === BallTypes.Laser) {
          b.isAlive = false;
        }
        if (b.type === BallTypes.PowerLaser && edgeBounce) {
          b.isAlive = false;
        }
      });
      this.flashIt(b);
    });
  },

  tickTweens(dt) {
    // do the tween
    for (const tween of this.tweens) {
      const { target, prop, keyframes, duration } = tween;
      tween.delay -= dt;
      if (tween.delay > 0) continue;
      tween.t += dt;
      const frames = keyframes.length - 1;
      const progress = Math.min(1, tween.t / duration);
      const kIdx = Math.min(frames - 1, Math.floor(frames * progress));
      const localProgress = (progress - kIdx / frames) / (1 / frames);
      target[prop] = lerp(keyframes[kIdx], keyframes[kIdx + 1], localProgress);
      if (tween.t < duration) {
        continue;
      }

      target[prop] = keyframes[keyframes.length - 1];
      if (tween.onComplete) {
        tween.onComplete();
      }
      tween.iterations -= 1;
      if (tween.iterations <= 0) {
        this.tweens.delete(tween);
      } else {
        tween.t = 0;
      }
    }
  },

  damageBrick(brick) {
    this.flashIt(brick);
    this.shakeIt(brick, 6);
    brick.health -= 1;
    if (brick.health > 0) {
      return;
    }

    this.updateScore(100);
    this.tween(
      brick,
      "scale",
      [1, 1],
      120,
      160,
      () => (brick.isAlive = false)
    );

    if (Math.random() > 0.7) {
      const types = Object.values(PowerUpTypes);
      this.addPowerUp(brick.x, brick.y, types[Math.floor(Math.random()*types.length)]);
    }
  },

  draw() {
    const [w, h] = GUTTER_GUARD_SIZE;
    Object.assign(gutterDiv.style, {
      position: "absolute",
      left: `-${w}`,
      top: `${this.height - h - 1 }px`,
      width: `${w}px`,
      height: `${h}px`,
      display: this.gutterGuardHealth > 0 ? 'initial' : 'none',
      boxShadow: `inset 0 0 0 1px #fff, inset 0 0 0 2px #000, inset 0 0 0 3px #fff`,
    });

    const [bW, bH] = BRICK_SIZE;
    brickDiv.style.boxShadow = this.bricks
      .map(
        (b) =>
          `${b.x + bW / 2 + b.offsetX}px ${b.y + bH / 2 + b.offsetY}px 0 ${
            (bH / 2) * (b.scale - 1)
          }px rgb(${b.color.join()})`
      )
      .join();

    ballDiv.style.boxShadow = this.balls
      .map(
        (b) =>
          `${b.x + b.offsetX}px ${b.y + b.offsetY}px 0 ${
            (b.size / 2) * b.scale
          }px rgb(${b.color.join()})`
      )
      .join();

    powerUpDiv.style.boxShadow = this.powerUps
      .map(
        (power) =>
          `${power.x + power.size/2 + power.offsetX}px ${power.y + power.size/2 + power.offsetY}px 0 ${
            (power.size / 2) * (power.scale - 1)
          }px rgb(${power.color.join()})`
      )
      .join();

    if (this.paddles.length > 0) {
      const paddle = this.paddles[0];
      Object.assign(paddleDiv.style, {
        left: `-${paddle.width}px`,
        top: `-${paddle.height}px`,
        width: `${paddle.width}px`,
        height: `${paddle.height}px`,
      });
      paddleDiv.style.boxShadow = this.paddles
        .map(
          (p) =>
            `${p.x + p.width / 2 + p.offsetX}px ${
              p.y + p.height / 2 + p.offsetY
            }px 0 0 rgb(${p.color.join()})`
        )
        .join();
    }

    container.style.transform = `translate(${this.camera.offsetX}px,${this.camera.offsetY}px)`;
    scoreContainer.innerText = `${this.score.toLocaleString()}`;
  },

  shakeIt(obj, dist = 4, dir = undefined) {
    let ox = -dist/2 + Math.random() * dist;
    let oy = -dist/2 + Math.random() * dist;
    if (dir) {
      ox = dir[0] * dist;
      oy = dir[1] * dist;
    }
    this.tween(obj, "offsetX", [0, ox, -ox, ox / 2, 0], 260);
    this.tween(obj, "offsetY", [0, oy, -oy, oy / 2, 0], 260);
  },
  megaShake(obj, dist = 8) {
    let offSets = new Array(10).fill(0).map(() => -dist/2 + Math.random() * dist);
    this.tween(obj, "offsetX", [0, ...offSets, 0], 260);
    this.tween(obj, "offsetY", [0, ...offSets.reverse(), 0], 620);
  },
  flashIt(obj, color,) {
    this.tween(obj.color, "0", [obj.color[0], color?.[0] ?? 100 + Math.random() * 155, obj.baseColor?.[0] ?? 255], 180);
    this.tween(obj.color, "1", [obj.color[1], color?.[1] ?? 100 + Math.random() * 155, obj.baseColor?.[1] ?? 255], 180);
    this.tween(obj.color, "2", [obj.color[2], color?.[2] ?? 100 + Math.random() * 155, obj.baseColor?.[2] ?? 255], 180);
  },
  updateScore(val) {
    this.score += val;
    this.tween(scoreContainer.style, 'scale', [.85, 1.5, 1], 300);
  },
  updateLives(val = 1) {
    this.lives += val;
    livesContainer.innerText = `${this.lives}`;
    this.tween(livesContainer.style, 'scale', [1, 0.7, 2, .8, 1.5, .9, 1.2, .95, 1], 680);
  },
  spawnNextLevel() {
    this.level++;
    const levels = {
      0: () => this.spawnLevel(),
      1: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          if ((i === midX - 1 || i === midX) || (j === midY - 1 || j === midY)) {
            return;
          }

          let color = (i+j+1) % 3 !== 0 ? [255,255,255] : [100,255,255];
          if ((i+j+1) % 4 === 0) {
            color = [255,200,200];
          }

          const brick = this.addBrick(x, y, 0, color);
          if (j === 0 || j === yBrickCount-1) {
            brick.health = 2;
          }
        }
      }),
      2: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          if (i === midX - 1 || i === midX) {
            return;
          }

          if (i === 0 || j === 0 || i === xBrickCount-1 || j === yBrickCount-1) {
            const brick = this.addBrick(x, y, 0, [75, 0, 130]);
            brick.health = 2;
          } else {
            this.addBrick(x, y, 0, [100, 100, 255]);
          }
        }
      }),
      3: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          if ((j+1) % 3 === 0) {
            return;
          }

          const brick = this.addBrick(x, y, 0, j%2 === 0 ? [200,200,200] : [100,100,100]);
          if (j === 0) {
            brick.health = 2;
          } else if (j === yBrickCount-1) {
            brick.health = 5;
          }
        }
      }),
      4: () => this.spawnLevel({
        predicate: ({x, y, i, j}) => {
          const colors = [
            [255, 0, 0],
            [255, 165, 0],
            [255, 255, 0],
            [0, 128, 0],
            [0, 0, 255],
            [75, 0, 130],
            [148, 0, 211] 
          ];

          const brick = this.addBrick(x, y, 0, [...colors[j % (colors.length-1)]]);
          brick.health += Math.floor(Math.random()*3);
        }
      }),
      5: () => this.spawnLevel({
        predicate: ({x, y, i, j}) => {
          if (i % 2 === 0) return;
          const colors = [
            [100, 200, 100],
            [100, 220, 100],
            [100, 255, 100],
            [150, 255, 150],
          ];
          const brick = this.addBrick(x, y, 0, [...colors[j % (colors.length-1)]]);
          if (j % 2 === 0) brick.health =3;
        }
      }),
      6: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          if (i === midX || j === midY) {
            return;
          }

          this.addBrick(x, y, 0, [255,255,255]);
          this.addBrick(x, y, 0, [55,55,255]);
          this.addBrick(x, y, 0, [255,55,55]);
        }
      }),
      7: () => this.spawnLevel({
        predicate: ({x, y, i, j, yBrickCount, xBrickCount}) => {
          if (j === yBrickCount-1) {
            this.addBrick(x, y, BrickTypes.Unbreakable, [80,80,80]);
            return;
          }
          if (i === 0 || i === xBrickCount) {
            this.addBrick(x, y, 0, [255,j%2 === 0 ? 55:100, 255]);
            return;
          }
          this.addBrick(x, y, 0, [255,55,55]);
        }
      }),
      8: () => this.spawnLevel({
        predicate: ({x, y, i, j, yBrickCount, xBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          if (j === yBrickCount-1 || j === 0 || i === 0 || i === xBrickCount -1) {
            const brick = this.addBrick(x, y, 0, [40,40,200]);
            brick.health = 5;
            return;
          }
          if (j === yBrickCount-2 || j === 1 || i === 1 || i === xBrickCount -2) {
            const brick = this.addBrick(x, y, 0, [20,20,180]);
            brick.health = 3;
            return;
          }
          if (i >= midX - 1 && i <= midX+1 && j >= midY - 1 && j <= midY+1) {
            const brick = this.addBrick(x, y, 0, [255, 127, 40]);
            brick.health = 2;
            return;
          }
        }
      }),
      9: () => this.spawnLevel({
        predicate: ({x, y, i, j, yBrickCount, xBrickCount}) => {
          const brick = this.addBrick(x,y,0, [20+5*i, 20+4*(yBrickCount-y), 20+10*(xBrickCount-i)]);
          brick.health = 5;
        }
      }),
    };
    const nLevels = Object.keys(levels).length;
    levels?.[((this.level - 1) % nLevels)]?.();
  },
  spawnLevel(config) {
    const defaults = {
      blockWidth: BRICK_SIZE[0],
      blockHeight: BRICK_SIZE[1],
      screenWidth: this.width,
      screenHeight: this.height,
      brickGutterPx: 1.5,
      xBrickPad: 1,
      yBrickPad: 0,
      playAreaPxBot: 180,
      playAreaPxTop: 60,
      playAreaPxLeft: 0,
      playAreaPxRight: 0,
      predicate: ({ x, y }) => this.addBrick(x, y),
    };
    const {
      blockWidth,
      blockHeight,
      screenWidth,
      screenHeight,
      brickGutterPx,
      xBrickPad,
      yBrickPad,
      playAreaPxBot,
      playAreaPxTop,
      playAreaPxLeft,
      playAreaPxRight,
      predicate,
    } = { ...defaults, ...config };
    const brickAreaW = screenWidth - playAreaPxRight - playAreaPxLeft;
    const brickAreaH = screenHeight - playAreaPxBot - playAreaPxTop;
    const bW = blockWidth + brickGutterPx;
    const bH = blockHeight + brickGutterPx;
    const xBrickCount = Math.floor(brickAreaW / bW);
    const yBrickCount = Math.floor(brickAreaH / bH);
    const rW = brickAreaW % bW;
    const rH = brickAreaH % bH;
    const sx = playAreaPxLeft + rW / 2 + bW / 2;
    const sy = playAreaPxTop + rH / 2 + bH / 2;
    for (let i = xBrickPad; i < xBrickCount - xBrickPad; i++) {
      const x = sx + i * bW;
      for (let j = yBrickPad; j < yBrickCount; j++) {
        const y = sy + j * bH;
        predicate({ x, y, i, j, xBrickCount, yBrickCount });
      }
    }
  },

  // events
  onLifeLost() {
    this.showMenu();
    this.megaShake(this.camera, 30);
    this.tween(livesContainer.style, 'scale',
      [1, 0.7, 2, .8, 1.5, .9, 1.2, .95, 1], 680, 540,
      () => livesContainer.innerText = `${this.lives}`
    );
    this.onResetLevel();
  },
  onDeath() {
    this.showMenu();
  },
  onWinLevel() {
    this.onResetLevel();
    this.bricks = [];
    this.spawnNextLevel();
    this.showMenu();
  },
  onResetLevel() {
    this.balls = [];
    this.powerUps = [];
    this.paddles = [];
    this.addBall(this.width/2, Game.height - 60);
    const b = this.balls[this.balls.length - 1];
    b.dy = -Math.abs(b.dy);
    this.addPaddle(this.width / 2);
  },
  onStartGame() {
    this.paddles.forEach(p => {
      p.x = this.width/2;
    });
    this.hideMenu();
  },
  onResetGame() {
    this.showMenu();
    this.onResetLevel();
    this.bricks = [];
    this.gutterGuardHealth = 0;
    this.score = 0;
    this.lives = 3;
    this.level = 0;
    this.spawnNextLevel();
    livesContainer.innerText = `${this.lives}`;
  },
  hideMenu() {
    this.tween(menuPaused.style, 'opacity', [1,0], 300, 80, () => {
      menuPaused.style.opacity = 1;
      menuPaused.style.scale = 1;
      menuPaused.style.display = 'none';
      this.isPaused = false;
    });
    this.tween(titleDiv.style, 'scale', [1,1.1,0.5], 380);
    this.tween(titleDiv.style, 'opacity', [0,1], 300);
    this.tween(startButton.style, 'scale', [1,1.1,0.5], 380);
    this.tween(startButton.style, 'opacity', [0,1], 300);
  },
  showMenu() {
    this.isPaused = true;
    menuPaused.style.display = 'flex';
    titleDiv.innerText = `Level ${this.level}`;
    this.tween(titleDiv.style, 'scale', [0.5, 0.4, 1.1, 1], 380);
    this.tween(titleDiv.style, 'opacity', [0,1], 300);
    this.tween(startButton.style, 'scale', [0.5, 0.4, 1.1, 1], 380);
    this.tween(startButton.style, 'opacity', [0,1], 300);
    this.tween(menuPaused.style, 'opacity', [0,1], 300);
  }
};

container.addEventListener("pointermove", (e) =>
  Game.handleMouseOrTouchInput(e)
);
container.addEventListener("touchmove", (e) => Game.handleMouseOrTouchInput(e));
startButton.addEventListener("click", (e) => {
  Game.hideMenu();
});

let lastTime = 0;
function universe(delta) {
  if (lastTime === 0) {
    lastTime = delta;
  }

  const dt = (delta - lastTime) / 1000;
  Game.tick(dt);
  Game.draw();

  lastTime = delta;
  frameId = requestAnimationFrame(universe);
}

// document.addEventListener('keydown', e => {
//   Game.bricks = [];
// });

// start it all
Game.onResetGame();
universe(lastTime);
container.addEventListener('unload', () => cancelAnimationFrame(frameId));

Нет, так не пойдёт. Что-то здесь всё ещё не так. Просто ощущается неправильно.

Что? Ты что-то сказал? Знаешь, чего не хватает? Ну так скажи уже.

Я тебя не слышу!

Если игра издаёт звук в лесу и рядом никого нет, чтобы его услышать…

Можно ли считать её игрой без звука? Я так не думаю.

Именно так: если ты играешь с выключенным звуком — ты неправ. Чёрт возьми, будь у нас такой же простой способ стимулировать обоняние, как слух, можешь не сомневаться — мы бы заставили игроков нюхать самые разные отвратительные фантастические штуки. Энергетический шар пах бы дымом, а лазеры — выжженным озоном.

Но пока — только звук. Звуковая среда игры стягивает атмосферу воедино. Лучше хоть что-то, чем ничего, но здесь на сцену выходит ещё и фактор сочности.

Например, когда мяч ударяется о кирпич — должен ли проигрываться звук blishf? А что, если он попадает в край? А если он подряд попал в три кирпича, потом в край, а потом снова в кирпич? Может, blishf должен повышаться на ноту — превращаться в blaushf — и так далее для каждого последовательного кирпича, с сбросом при ударе о край.

Будет ли одна короткая мелодия на повторе? Или у каждого уровня может быть своя тема? А как насчёт музыки для боссов?

Тут не о чем долго думать. Наш API звукового движка предельно простой.

this.playSound(name, pitch, volume, loop);
this.stopSound(name);
this.muteAudio(true|false);

Нам не нужны ни панорамирование, ни доплер, ни реверберации на основе свёртки, чтобы получить приятное для ушей звучание. Возможность отключить звук должна быть всегда — по какой-нибудь причине обязательно найдутся сумасшедшие игроки, которым не нравится конкретная песня или звуковой эффект, и они захотят его выключить. Отполированный опыт всегда даёт такую возможность, и пусть наша игра — кусок дерьма, но блестящий.

Не усложняй. Используй любые встроенные возможности работы со звуком. С JavaScript мы в надёжных руках.

Аудиофайлы — это первые игровые «ассеты», о которых нужно задуматься. Ассеты — это ресурсы, которые нужно загрузить. Мы можем дождаться загрузки всех звуков перед стартом игры — это отличная идея. Немного подождал и можно играть, никаких экранов загрузки.

Что такое? Ограничения по памяти? RAM? Расслабься, друг. Не переживай. Мы ещё даже ни одного звука не загрузили. Это проблема для нас завтрашних.

Пора запускать музыку.

Мы будем использовать стандартный звуковой API JavaScript. На первый взгляд он может показаться сложным, но для нас всё будет предельно просто. Мы создаём AudioContext и подключаем к нему узел Gain. Этот корневой узел позволит глобально отключать весь звук. Затем мы будем отслеживать загруженные аудиофайлы и источники, которые в данный момент проигрываются для каждого файла.

Аудиофайл — это декодированные звуковые данные, а источник — это то, что читает эти данные и воспроизводит звук. У аудиоисточников есть выход, к которому мы можем подключиться, например к нашему Gain-узлу.

Если мы хотим управлять громкостью отдельного источника, ему понадобится собственный Gain-узел.
Источник → локальный Gain → мастер-Gain → AudioContext.

Есть и другие узлы, которые предоставляет JavaScript, но нам больше ничего не нужно. Код получается очень простой.

Сначала предзагрузка.

audioCtx: new AudioContext(),
masterGainNode: null,
audioFiles: new Map(),
audioSources: new Map(),
isAudioMuted: false,
async preload() {
  const sounds = [
    // sound files
  ];
  this.masterGainNode = this.audioCtx.createGain();
  this.masterGainNode.connect(this.audioCtx.destination);

  return Promise.all(sounds.map(sound =>
    fetch(`/sounds/bronc/${sound}.ogg`)
      .then(res => res.arrayBuffer())
      .then(ab => this.audioCtx.decodeAudioData(ab))
      .then(buff => {
        this.audioSources.set(sound, new Set());
        this.audioFiles.set(sound, buff);
      });
  ));
},

После того как мы получили данные звука, мы декодируем их и добавляем в список загруженных. Хорошей практикой будет не запускать игру, пока звук не загрузился. Мы будем использовать формат .ogg — он без потерь, но при этом достаточно хорошо сжат. Можешь использовать mp3 или что угодно ещё — как тебе нравится. Мы всегда сможем вернуться к этому и дополнительно сжать файлы, если они раздуются.

// maybe toss a loading screen in
Game.preload().then(() => {
  Game.onResetGame();
  universe(lastTime);
});

Нехорошо, когда сайт начинает проигрывать звук без предупреждения, поэтому звук по умолчанию отключён. Чтобы что-то услышать, пользователь должен взаимодействовать со страницей. his.audioCtx.state === 'suspended'Один из способов проверить состояние, если ты не уверен, или если у тебя есть большая кнопка «Start», которую нажимают первой. Кроме того, если игрок на мобильном устройстве, ему придётся отключить беззвучный режим уведомлений. Ещё одна особенность Web Audio API.

Проигрывать звук тоже довольно просто. Мы берём буфер по имени, создаём источник, подключаем его — и запускаем!

playSound(name, pitch = 1, volume = 1, loop = false, onComplete = undefined) {
  if (!this.audioFiles.has(name)) {
    console.warn('woops no file', name);
    return;
  }

  const buff = this.audioFiles.get(name);
  const source = this.audioCtx.createBufferSource();
  source.buffer = buff;
  source.playbackRate.value = pitch;
  source.loop = loop;

  const gainNode = this.audioCtx.createGain(); 
  gainNode.gain.value = volume;
  
  source.connect(gainNode);
  gainNode.connect(this.masterGainNode);

  this.audioSources.get(name).add(source);
  source.onended = () => {
    this.audioSources.get(name).delete(source);
    source.disconnect(gainNode);
    gainNode.disconnect(this.masterGainNode);

    if (onComplete) {
      onComplete();
    }
  };
  source.start(0);
}

Хорошей идеей будет всё аккуратно чистить, когда мы закончили. Pitch — это просто увеличение или уменьшение скорости воспроизведения звука.

Если бы нам не нужно было останавливать звуки, мы могли бы не отслеживать активные источники, но раз мы это делаем, у нас появляется такая возможность.

stopSound(name) {
  if (!this.audioFiles.has(name)) {
    console.warn('woops no file', name);
    return;
  }

  const sources = this.audioSources.get(name);
  sources.forEach(s => {
    s.stop(0);
  });
},

И, конечно же, замечательная возможность полностью заглушить звук.

muteAudio(isMuted = false) {
  this.masterGainNode.gain.setTargetAtTime(
    isMuted ? 0 : 1,
    this.audioCtx.currentTime,
    0.1
  );
}

Это выключает всё. Target time плавно переводит значение — в нашем случае gain, то есть громкость — к нужному уровню за заданное время, 0.1 секунды. Это как плавная анимация от включено к выключено.

Бум! Наш звуковой движок готов. Думал, будет страшнее, да? А теперь начинается по-настоящему страшная часть — саунд-дизайн.

Здесь нужен вкус и немного экспериментов. Подбор или создание звуковых ассетов требует времени. Не переживай, если сначала всё звучит ужасно. Можно взять бесплатные ассеты из public domain и всё равно добиться хорошего результата, если выбирать с умом.

Opengameart.org — старый, но любимый ресурс. Там есть лицензии, но я всегда использую public domain. Ты также можешь делать звуки сам. Это не так сложно: что-нибудь запиши и попробуй. Не стесняйся немного обработать файлы вручную.

Мы можем менять pitch и громкость прямо в движке, а значит, даже подправлять звук уже в игре.

Например, отскок от края оказался слишком высоким по тону — простое решение просто занизить его.

if (edgeBounce) this.playSound('edge_hit.ogg', 0.5 + Math.random()*0.2);
if (paddleBounce) this.playSound('paddle_hit.ogg', 1 + Math.random()*0.2);

Небольшая случайность в pitch помогает разбавить повторяемость, не добавляя новые звуковые файлы.

Сделать всё по-настоящему сочным — значит снова работать с ожиданием и наградой. Для ударов по кирпичам мячи будут отслеживать, сколько кирпичей они задели с момента последнего отскока от ракетки. При каждом последовательном попадании будет проигрываться всё более высокая нота, в итоге доходя до нескольких аккордов, а затем происходить сброс с общим повышением pitch.

const Game = {
  hitSounds: [
    'sound_1',
    //etc
  ],
};

// later on
if (brickBounce) {
  const cycle = Math.floor(b.comboCount / this.hitSounds.length);
  const boost = 0.20;
  const rndPitch = Math.random()*.1;
  this.playSound(this.hitSounds[b.comboCount % this.hitSounds.length], 1 + boost*cycle + rndPitch, 2.2);
  b.comboCount++;
}

Аудиофайлы оказались недостаточно громкими, поэтому мы их немного усиливаем. Имей в виду: громкость у нас в децибелах, а это не линейная шкала. Усиление на 2.2 — это «в 2.2 раза» именно в децибелах, но это не превращается в «в 2.2 раза громче» в том, как это воспринимает человек.

Power ups тоже должны ощущаться мощно. Создать уникальный звук, который отражает, что делает конкретный power up, — часть того самого «ощущения», которого мы добиваемся. Не обязательно попадать в идеал, но это помогает игроку заранее понять, чего ждать. Например, power up на замедление или ускорение — это что-то вроде «дун-дун-дууун-дунннн», уходящее вниз по тону, или «дох-дох-дооох-дихх», уходящее вверх — в зависимости от того, замедление это или ускорение.

Получение лазерной биты звучит как включение лазерной пушки — с басом и нарастающим синтезатором. Увеличение ракетки — это тянущийся «растягивающий» звук. Разделение мячей — хрустящий щелчок ножниц. Так делаем для максимального числа звуков. И обязательно почти везде добавляй лёгкое изменение pitch — это действительно помогает разбить повторяемость.

Омега-устройство будет отсчётом до большого бабаха.

[PowerUpTypes.OmegaDevice]: (game) => {
  game.omegaDevicePower += 1;
  game.playSound('power_omega', 1 - (0.1*game.omegaDevicePower) - Math.random()*.05);
  if (game.omegaDevicePower > 5) {
    game.omegaDevicePower = 0;
    for (const brick of game.bricks) {
      game.damageBrick(brick);
    }
    game.megaShake(game.camera, 30);
  }
  game.playSound('gameover', 0.8 +  Math.random()*.2);
},

Отличная работа. Осталась только музыка.

spawnNextLevel() {
  const levels = {
    // level spawn code
  };

  const nLevels = Object.keys(levels).length;
  levels?.[((this.level - 1) % nLevels)]?.();

  this.stopSound(this.bkgSong);
  this.bkgSong = this.bkgSongs[(this.level - 1) % this.bkgSongs.length] ?? 'song_1';
  this.playSound(this.bkgSong, 1, 0.28, true);
}

Для такой игры музыка должна скорее оставаться на фоне, а не быть, так сказать, главным героем. В некоторых играх, наоборот, хочется вывести музыку на передний план. У каждого уровня — своя композиция.

Мы отслеживаем, какая тема сейчас играет, и останавливаем её перед тем, как включить следующую. Мы, конечно, «могли бы» при переключении глушить вообще все звуки, но это ощущается слишком резко.

Последний штрих, чтобы довести звук до блеска, — дать игрокам возможность отключить всё полностью. Я знаю! Кто вообще на свете захочет так сделать?!?

Добавь кнопку в правый нижний угол и сделай её сочной.

Отлично. Единственное, что, возможно, придётся подправить, — это босс. Кстати, о главном злодее.

Исходный код
const container = document.getElementById(containerId);
const screenWidth = container.clientWidth;
const screenHeight = container.clientHeight;

// create menu ui
const menuPaused = document.createElement('div');
container.appendChild(menuPaused);
Object.assign(menuPaused.style, {
  position: 'absolute',
  top: 0,
  left: 0,
  width: '100%',
  height: '100%',
  backdropFilter: 'blur(2px)',
  backgroundColor: '#0008',
  display: 'flex',
  flexDirection: 'column',
  justifyContent: 'center',
  letterSpacing: '2px',
  alignItems: 'center',
  gap: '32px',
  zIndex: 1,
});
const titleDiv = document.createElement('div');
titleDiv.innerText = 'LOADING';
Object.assign(titleDiv.style, {
  fontSize: '32px',
  fontWeight: 'bold',
  textAlign: 'center',
  textTransform: 'uppercase',
  textShadow: '#0008 1px 1px 0px, red 3px 1px 0px, red 1px 2px 0px',
});

const msgDiv = document.createElement('div');
const soundToggle = document.createElement('button');
soundToggle.innerText = 'Audio On';
Object.assign(soundToggle.style, {
  position: 'absolute',
  bottom: '8px',
  right: '8px',
  padding: '6px 12px',
  fontSize: '12px',
  border: 'none',
  textShadow: '1px 2px #fff2',
  color: '#fffb',
  background: 'transparent',
  outline: 'none',
  borderRadius: '32px',
  boxShadow: '0px -1px 0 0px #fff8, 0px 2px 0 #fff',
});

const startButton = document.createElement('button');
startButton.innerText = 'START';
Object.assign(startButton.style, {
  padding: '8px 16px',
  minHeight: '32px',
  minWidth: '120px',
  fontSize: '16px',
  letterSpacing: '4px',
  border: 'none',
  boxShadow: '0 0 0 1px #fffa, 0 0 0 2px #000, 0 0 0 3px #fffa',
  textShadow: '1px 2px #fff2',
  color: '#fffb',
  background: 'transparent',
  outline: 'none',
})
menuPaused.appendChild(titleDiv);
menuPaused.appendChild(soundToggle);
menuPaused.appendChild(startButton);

const ballDiv = document.createElement("div");
container.appendChild(ballDiv);
Object.assign(ballDiv.style, {
  position: "absolute",
  left: "0",
  top: "0",
});
const BallTypes = {
  Normal: 0,
  PowerBall: 1,
  Laser: 2,
  PowerLaser: 3,
};
const BallColors = {
  [BallTypes.Normal]: [255,255,255],
  [BallTypes.PowerBall]: [255,55,55],
  [BallTypes.Laser]: [55,255,255],
  [BallTypes.PowerLaser]: [255,70,70],
};

const brickDiv = document.createElement("div");
container.appendChild(brickDiv);
const BRICK_SIZE = [36, 12];
const BrickTypes = {
  Normal: 0,
  Unbreakable: 1,
  MultiBall: 2,
  PowerBall: 3,
  Explode: 4,
}
Object.assign(brickDiv.style, {
  position: "absolute",
  borderRadius: "2px",
  left: `-${BRICK_SIZE[0]}px`,
  top: `-${BRICK_SIZE[1]}px`,
  width: `${BRICK_SIZE[0]}px`,
  height: `${BRICK_SIZE[1]}px`,
});

const paddleDiv = document.createElement("div");
container.appendChild(paddleDiv);
const PADDLE_SIZE = [70, 10];
const PaddleTypes = {
  Normal: 0,
  Laser: 1,
};
const PaddleColors = {
  [PaddleTypes.Normal]: [255, 255, 255],
  [PaddleTypes.Laser]: [55, 255, 255],
};
Object.assign(paddleDiv.style, {
  position: "absolute",
  left: `-${PADDLE_SIZE[0]}px`,
  top: `-${PADDLE_SIZE[1]}px`,
  width: `${PADDLE_SIZE[0]}px`,
  height: `${PADDLE_SIZE[1]}px`,
});

const powerUpDiv = document.createElement("div");
container.appendChild(powerUpDiv);
const POWER_UP_SIZE = 16;
Object.assign(powerUpDiv.style, {
  position: "absolute",
  left: `-${POWER_UP_SIZE}px`,
  top: `-${POWER_UP_SIZE}px`,
  width: `${POWER_UP_SIZE}px`,
  height: `${POWER_UP_SIZE}px`,
  borderRadius: '9000px',
});
const PowerUpTypes = {
  MultiBall: 0,
  BigBall: 1,
  PowerBall: 2,
  PaddleSize: 3,
  GutterGuard: 4,
  LaserPaddle: 5,
  ExtraLife: 6,
  BonusPoints: 7,
  OmegaDevice: 8,
  SpeedUp: 9,
  SpeedDown: 10,
};
const PowerUpColors = {
  [PowerUpTypes.MultiBall]: [255, 200, 255],
  [PowerUpTypes.BigBall]: [200, 200, 255],
  [PowerUpTypes.PowerBall]: [255, 50, 50],
  [PowerUpTypes.ExtraLife]: [100, 255, 100],
  [PowerUpTypes.SpeedUp]: [100, 255, 255],
  [PowerUpTypes.SpeedDown]: [255, 100, 255],

  [PowerUpTypes.LaserPaddle]: [200, 255, 255],
  [PowerUpTypes.OmegaDevice]: [255, 0, 255],
  [PowerUpTypes.BonusPoints]: [255, 255, 0],
  [PowerUpTypes.PaddleSize]: [255, 255, 255],
  [PowerUpTypes.GutterGuard]: [255, 255, 255],
};
const PowerUpEffects = {
  [PowerUpTypes.MultiBall]: (game, powerUp) => {
    game.balls.map(b => [b.x, b.y]).forEach(([x,y]) => game.addBall(x,y));
    game.playSound('power_split', 1+Math.random()*.2, 1.2);
  },
  [PowerUpTypes.BigBall]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.size *= 2;
    });
    game.playSound('power_big_ball', 1+Math.random()*.2, 1);
  },
  [PowerUpTypes.PaddleSize]: (game, powerUp) => {
    game.paddles.forEach(paddle => {
      paddle.width += PADDLE_SIZE[0] * 0.3;
    });
    game.playSound('power_big_paddle', 1+Math.random()*.2, 1.4);
  },
  [PowerUpTypes.GutterGuard]: (game, powerUp) => {
    game.gutterGuardHealth += 3;
    game.tween(gutterDiv.style, 'scale', this.gutterGuardHealth > 0 ? [1, 1] : [0, 1.1, 1], 280);
    game.playSound('power_gg', 1+Math.random()*.2);
  },
  [PowerUpTypes.BonusPoints]: (game, powerUp) => {
    game.updateScore(Math.floor(powerUp.scale * 1000));
    game.playSound('hit_c5_chord', 1+Math.random()*.2);
  },
  [PowerUpTypes.OmegaDevice]: (game, powerUp) => {
    game.omegaDevicePower += 1;
    game.playSound('power_omega', 1 + (0.5*game.omegaDevicePower) + Math.random()*.2);
    if (game.omegaDevicePower > 5) {
      game.omegaDevicePower = 0;
      for (const brick of game.bricks) {
        game.damageBrick(brick);
      }
      game.megaShake(game.camera, 30);
      game.playSound('gameover', 1 + (0.5*game.omegaDevicePower) + Math.random()*.2);
    }
  },
  [PowerUpTypes.LaserPaddle]: (game, powerUp) => {
    game.paddles.forEach(paddle => {
      paddle.type = PaddleTypes.Laser;
      paddle.baseColor = PaddleColors[PaddleTypes.Laser];
      game.shakeIt(paddle, 6, [0, 1]);
      game.flashIt(paddle);
      game.tween(paddle, 'scale', [1, 0.8, 1.2, 1], 280);
    });
    game.playSound('power_laser', 1 + Math.random()*.2);
  },
  [PowerUpTypes.PowerBall]: (game, powerUp) => {
    game.addBall(powerUp.x, powerUp.y, BallTypes.PowerBall);
    game.balls[game.balls.length-1].dy = -Math.abs(game.balls[game.balls.length-1].dy);
    game.playSound('power_ball', 1+Math.random()*.2);
  },
  [PowerUpTypes.ExtraLife]: (game, powerUp) => {
    game.updateLives(1);
    game.playSound('power_life', 1+Math.random()*.2);
  },
  [PowerUpTypes.SpeedUp]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.dx *= 1.25;
      b.dy *= 1.25;
    });
    game.playSound('power_speed_up', 1+Math.random()*.2);
  },
  [PowerUpTypes.SpeedDown]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.dx *= .9;
      b.dy *= .9;
    });
    game.playSound('power_speed_down', 1+Math.random()*.2);
  },
};

const gutterDiv = document.createElement("div");
const GUTTER_GUARD_SIZE = [screenWidth, 8];
container.appendChild(gutterDiv);

const scoreContainer = document.createElement("div");
container.appendChild(scoreContainer);
Object.assign(scoreContainer.style, {
  position: "absolute",
  top: 0,
  right: 0,
  fontSize: "16px",
  width: "100%",
  fontWeight: 'bold',
  letterSpacing: '2px',
  marginTop: "min(5%, 1.5rem)",
  textAlign: "center",
  textShadow: '1px 4px 0 #000a',
  zIndex: 2,
});

const livesContainer = document.createElement("div");
container.appendChild(livesContainer);
Object.assign(livesContainer.style, {
  position: "absolute",
  top: '16px',
  left: '16px',
  fontSize: "22px",
  fontWeight: 'bold',
  width: "100%",
  textAlign: "left",
  textShadow: '#0008 1px 1px 0px, #44ff44 3px 1px 0px, #44ff44 1px 2px 0px',
  zIndex: 2,
  width: 'fit-content',
});

const lerp = (from, to, time) => from + (to - from) * time;

const Game = {
  isPaused: false,
  isMuted: false,
  score: 0,
  lives: 3,
  level: 1,
  frame: 0,
  gutterGuardHealth: 5,
  omegaDevicePower: 0,
  camera: {
    scale: 1,
    offsetX: 0,
    offsetY: 0,
  },
  width: screenWidth,
  height: screenHeight,
  balls: [],
  bricks: [],
  paddles: [],
  powerUps: [],
  worms: [],
  tweens: new Set(),
  bkgSong: '',

  tween(target, prop, keyframes, duration, delay = 0, onComplete = undefined, iterations = 1) {
    this.tweens.add({
      target,
      prop,
      keyframes,
      t: 0,
      duration: duration / 1000,
      iterations,
      delay: delay / 1000,
      onComplete,
    });
  },

  handleMouseOrTouchInput(event) {
    if (event.target !== container) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();
    const x = event?.touches?.[0]?.clientX ?? event.offsetX;
    const y = event?.touches?.[0]?.clientY ?? event.offsetY;
    this.paddles.forEach(
      (p) =>
        (p.x = Math.min(this.width - p.width / 2, Math.max(p.width / 2, x)))
    );
  },

  addPaddle(x = this.width / 2, y = this.height - PADDLE_SIZE[1] * 2, type = PaddleTypes.Normal) {
    this.paddles.push({
      x,
      y,
      width: PADDLE_SIZE[0],
      height: PADDLE_SIZE[1],
      type,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      baseColor: [...PaddleColors[type]],
      color: [...PaddleColors[type]],
    });
  },
  addBall(x = this.width / 2, y = this.height / 2, type = BallTypes.Normal, speed = 240) {
    const thresholdDegrees = 20;
    const arc = thresholdDegrees * (Math.PI / 180);
    let angle = arc + Math.random() * (Math.PI - 2 * arc);
    this.balls.push({
      x,
      y,
      dx: Math.cos(angle) * speed,
      dy: Math.sin(angle) * speed,
      size: 10,
      isAlive: true,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      comboCount: 0,
      baseColor: [...BallColors[type]],
      color: [...BallColors[type]],
      type,
      lastHits: new Set(),
    });

    if (BallTypes.Laser === type || type === BallTypes.PowerLaser) {
      const ball = this.balls[this.balls.length-1];
      ball.dy = -speed - Math.random()*100;
      ball.dx *= 0.5;
      this.tween(ball, 'size', [12,18,6,16,6], 320, 0, undefined, Infinity);
    }
  },
  addBrick(x = this.width / 2, y = this.height / 2, type = 0, color = [255,255,255]) {
    this.bricks.push({
      x,
      y,
      type,
      health: type === BrickTypes.Unbreakable ? Infinity : 1,
      isAlive: true,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      baseColor: color,
      color
    });

    return this.bricks[this.bricks.length -1];
  },
  addPowerUp(x = this.width / 2, y = this.height / 2, type = PowerUpTypes.MultiBall, speed = 160) {
    this.powerUps.push({
      x,
      y,
      dx: 0,
      dy: speed,
      type,
      isAlive: true,
      size: 16,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      color: [...PowerUpColors[type]],
    });
  },
  addWorm(x = this.width/2, y = this.height/2, bricks = [], size = 30, speed = 300) {
    this.worms.push({
      x,
      y,
      dx: 0,
      dy: 0,
      size,
      tail: bricks,
      targetX: x,
      targetY: y,
      speed
    });
  },

  tick(dt) {
    this.frame++;

    if (!this.isPaused) {
      this.tickBalls(dt);
      this.balls = this.balls.filter((ball) => ball.isAlive);
      this.bricks = this.bricks.filter((brick) => brick.isAlive);
      this.tickPowerUps(dt);
      this.powerUps = this.powerUps.filter((pow) => pow.isAlive);
      this.tickWorms(dt);

      if (this.balls.length === 0) {
        if (this.lives > 0) {
          this.lives--;
          this.onLifeLost();
        } else {
          this.onResetGame();
        }
      }
      if (this.bricks.filter(b => b.type !== BrickTypes.Unbreakable).length === 0) {
        this.onWinLevel();
      }
    }

    this.tickTweens(dt);
  },

  tickPowerUps(dt) {
    for (const power of this.powerUps) {
      power.x += power.dx * dt;
      power.y += power.dy * dt;
      const halfSize = power.size/2;
      let left = power.x - halfSize;
      let right = power.x + halfSize;
      let top = power.y - halfSize;
      let bot = power.y + halfSize;

      power.scale = 1 + 0.1*Math.sin(this.frame*.2);

      // check paddles
      for (const p of this.paddles) {
        const pL = p.x - p.width / 2;
        const pR = p.x + p.width / 2;
        const pT = p.y - p.height / 2;
        const pB = p.y + p.height / 2;
        if (left > pR || right < pL) {
          continue;
        }
        if (top > pB || bot < pT) {
          continue;
        }
        power.isAlive = false;

        this.shakeIt(p, 3, [0, 1]);
        this.flashIt(p, power.color);
        break;
      }

      if (power.y > this.height) {
        power.isAlive = false;
        continue;
      }

      if (power.isAlive) {
        continue;
      }

      PowerUpEffects[power.type]?.(this, power);
    }
  },
  tickBalls(dt) {
    const { width: w, height: h } = this;

    this.balls.forEach((b) => {
      if (!b.isAlive) {
        return;
      }

      let didBounce = false;
      let edgeBounce = false;
      let paddleBounce = false;
      let brickBounce = false;
      let ballDx = b.dx * dt;
      let ballDy = b.dy * dt;
      const bHalfSize = b.size / 2;

      // check bricks.
      const [brickWidth, brickHeight] = BRICK_SIZE;
      // x axis
      b.x += ballDx;
      let ballL = b.x - bHalfSize;
      let ballR = b.x + bHalfSize;
      let ballT = b.y - bHalfSize;
      let ballB = b.y + bHalfSize;
      for (const brick of this.bricks) {
        if (!brick.isAlive || brick.health <= 0 || b.lastHits.has(brick)) {
          continue;
        }

        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if (
          ballT > brickB ||
          ballB < brickT ||
          ballL > brickR ||
          ballR < brickL
        ) {
          continue;
        }

        if ((b.type !== BallTypes.PowerBall && b.type !== BallTypes.PowerLaser) || brick.type === BrickTypes.Unbreakable) {
          b.x = ballDx > 0 ? brickL - bHalfSize - 1 : brickR + bHalfSize + 1;
          b.dx *= -1;
        }
        brickBounce = true;
        b.isAlive = BallTypes.Laser !== b.type;
        this.damageBrick(brick);
        if (BallTypes.Normal !== b.type) {
          b.lastHits.add(brick);
        }
        break;
      }

      // y axis
      b.y += ballDy;
      ballL = b.x - bHalfSize;
      ballR = b.x + bHalfSize;
      ballT = b.y - bHalfSize;
      ballB = b.y + bHalfSize;
      for (const brick of this.bricks) {
        if (!brick.isAlive || brick.health <= 0 || b.lastHits.has(brick)) {
          continue;
        }

        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if (
          ballT > brickB ||
          ballB < brickT ||
          ballL > brickR ||
          ballR < brickL
        ) {
          continue;
        }

        if ((b.type !== BallTypes.PowerBall && b.type !== BallTypes.PowerLaser) || brick.type === BrickTypes.Unbreakable) {
          b.y = ballDy > 0 ? (b.y = brickT - bHalfSize - 1) : brickB + bHalfSize + 1;
          b.dy *= -1;
        }
        brickBounce = true;
        b.isAlive = BallTypes.Laser !== b.type;
        this.damageBrick(brick);
        if (BallTypes.Normal !== b.type) {
          b.lastHits.add(brick);
        }
        break;
      }

      // check bounds x
      if (b.x + b.size / 2 > w || b.x < b.size / 2) {
        b.x = ballDx > 0 ? w - b.size / 2 : b.size / 2;
        b.dx *= -1;
        this.shakeIt(this.camera, 3);
        edgeBounce = true;
      }
      // bounds y
      if (b.y < b.size / 2) {
        b.y = ballDy > 0 ? h - b.size / 2 : b.size / 2;
        b.dy *= -1;
        this.shakeIt(this.camera, 3);
        edgeBounce = true;
      }
      // y bottom
      const ggh = GUTTER_GUARD_SIZE[1];
      if (this.gutterGuardHealth > 0 && b.y + b.size/2 >= h - ggh) {
        b.y = this.height - ggh - b.size/2 - 1;
        b.dy *= -1;
        this.gutterGuardHealth--;
        this.tween(gutterDiv.style, 'opacity', [1, 0.5, 1], 280);
        this.tween(gutterDiv.style, 'marginTop', this.gutterGuardHealth > 1 ? [1, 1] : [1, 1.1, 0], 280);
        edgeBounce = true;
      } else if (b.y + b.size / 2 > h) {
        b.isAlive = false;
      }

      // check paddles
      for (const p of this.paddles) {
        if (b.type === BallTypes.Laser) continue;

        const pL = p.x - p.width / 2;
        const pR = p.x + p.width / 2;
        const pT = p.y - p.height / 2;
        const pB = p.y + p.height / 2;
        if (ballL > pR || ballR < pL) {
          continue;
        }
        if (ballT > pB || ballB < pT) {
          continue;
        }
        paddleBounce = true;
        const xDif = Math.min(ballR - pL, pR - ballL);
        const yDif = Math.min(ballB - pT, pB - ballT);
        if (xDif < yDif) {
          b.x += b.x > p.x ? xDif : -xDif;
        }
        b.y = p.y - p.height / 2 - b.size/2 - 1;

        const angle = Math.atan2(
          b.y - (p.y - p.height / 2 + p.width / 2),
          b.x - p.x
        );
        const speed = Math.sqrt(b.dx * b.dx + b.dy * b.dy);
        b.dx = Math.cos(angle) * speed;
        b.dy = Math.sin(angle) * speed;

        this.shakeIt(p, 3, [0, 1]);
        this.flashIt(p);

        if (p.type === PaddleTypes.Laser) {
          const type = b.type === BallTypes.PowerBall ? BallTypes.PowerLaser : BallTypes.Laser;
          this.addBall(b.x, b.y+b.size/2, type, 480);
          this.addBall(b.x, b.y+b.size/2, type, 480);
          this.addBall(b.x, b.y+b.size/2, type, 480);
          this.addBall(b.x, b.y+b.size/2, type, 480);
        }
        break;
      }

      didBounce = edgeBounce || brickBounce || paddleBounce;
      if (!didBounce) {
        return;
      }
      if (!brickBounce) {
        b.lastHits.clear();
      }
      if (edgeBounce) this.playSound('edge_hit', 0.5 + Math.random()*0.2, 1);
      if (paddleBounce) {
        this.playSound('paddle_hit', 1 + Math.random()*0.1, .4);
        b.comboCount = 0;
      }
      if (brickBounce) {
        const cycle = Math.floor(b.comboCount / this.hitSounds.length);
        const boost = 0.20;
        const rndPitch = Math.random()*.1;
        this.playSound(this.hitSounds[b.comboCount % this.hitSounds.length], 1 + boost*cycle + rndPitch, 2.2);
        b.comboCount++;
      }

      this.tween(b, "scale", [0.8, 1.2, 1], 60, 0, () => {
        if (b.type === BallTypes.Laser) {
          b.isAlive = false;
        }
        if (b.type === BallTypes.PowerLaser && edgeBounce) {
          b.isAlive = false;
        }
      });
      this.flashIt(b);
    });
  },
  tickWorms(dt) {
    const playAreaHeight = this.height - 100; 
    const bW = BRICK_SIZE[0]+1;
    const bH = BRICK_SIZE[1]+1;
        
    for (const worm of this.worms) {
      let { targetX, targetY, speed } = worm;
      worm.tail = worm.tail.filter(b => b.isAlive);

      const dirX = targetX - worm.x;
      const dirY = targetY - worm.y;
      const dist = Math.sqrt(dirX * dirX + dirY * dirY) || 1;
      worm.dx += dirX / dist * speed * dt;
      worm.dy += dirY / dist * speed * dt;
      worm.dx *= 0.99;
      worm.dy *= 0.98;
      worm.x += worm.dx*dt;
      worm.y += worm.dy*dt;

      if (dist < worm.size / 2) {
        worm.targetX = Math.random()*this.width;
        worm.targetY = Math.random()*playAreaHeight;
      }


      const occupiedGrid = new Map();
      for (const brick of worm.tail) {
        const gx = Math.floor(brick.x / bW);
        const gy = Math.floor(brick.y / bH);
        occupiedGrid.set(`${gx},${gy}`, true); 
      }

      const headRadius = worm.size / 2;
      const minGridX = Math.floor((worm.x - headRadius) / bW);
      const maxGridX = Math.floor((worm.x + headRadius) / bW);
      const minGridY = Math.floor((worm.y - headRadius) / bH);
      const maxGridY = Math.floor((worm.y + headRadius) / bH);
      let targetGridCells = new Map();
      
      for (let gx = minGridX; gx <= maxGridX; gx++) {
        for (let gy = minGridY; gy <= maxGridY; gy++) {
          const key = `${gx},${gy}`;
          if (!occupiedGrid.has(key)) {
            targetGridCells.set(key, {gx, gy})
          }
        }
      }
        
      if (targetGridCells.size > 0) {
        const randomIndex = Math.floor(Math.random() * targetGridCells.size);
        const { gx, gy } = [...targetGridCells.values()][randomIndex];
        const oldestBrick = worm.tail.pop(); 
        const brickKey = `${oldestBrick?.x},${oldestBrick?.y}`;
        if (oldestBrick && !targetGridCells.has(brickKey)) {
          oldestBrick.x = gx * bW + bW / 2;
          oldestBrick.y = gy * bH + bH / 2;
          worm.tail.unshift(oldestBrick);
        }
      }
    }
  },
  tickTweens(dt) {
    // do the tween
    for (const tween of this.tweens) {
      const { target, prop, keyframes, duration } = tween;
      tween.delay -= dt;
      if (tween.delay > 0) continue;
      tween.t += dt;
      const frames = keyframes.length - 1;
      const progress = Math.min(1, tween.t / duration);
      const kIdx = Math.min(frames - 1, Math.floor(frames * progress));
      const localProgress = (progress - kIdx / frames) / (1 / frames);
      target[prop] = lerp(keyframes[kIdx], keyframes[kIdx + 1], localProgress);
      if (tween.t < duration) {
        continue;
      }

      target[prop] = keyframes[keyframes.length - 1];
      if (tween.onComplete) {
        tween.onComplete();
      }
      tween.iterations -= 1;
      if (tween.iterations <= 0) {
        this.tweens.delete(tween);
      } else {
        tween.t = 0;
      }
    }
  },

  damageBrick(brick) {
    this.flashIt(brick);
    this.shakeIt(brick, 6);
    brick.health -= 1;
    if (brick.health > 0) {
      return;
    }

    this.updateScore(100);
    this.tween(
      brick,
      "scale",
      [1, 1],
      120,
      160,
      () => (brick.isAlive = false)
    );

    if (Math.random() > 0.85) {
      const types = Object.values(PowerUpTypes);
      this.addPowerUp(brick.x, brick.y, types[Math.floor(Math.random()*types.length)]);
    }
  },

  draw() {
    const [w, h] = GUTTER_GUARD_SIZE;
    Object.assign(gutterDiv.style, {
      position: "absolute",
      left: `-${w}`,
      top: `${this.height - h - 1 }px`,
      width: `${w}px`,
      height: `${h}px`,
      display: this.gutterGuardHealth > 0 ? 'initial' : 'none',
      boxShadow: `inset 0 0 0 1px #fff, inset 0 0 0 2px #000, inset 0 0 0 3px #fff`,
    });

    const [bW, bH] = BRICK_SIZE;
    brickDiv.style.boxShadow = this.bricks
      .map(
        (b) =>
          `${b.x + bW / 2 + b.offsetX}px ${b.y + bH / 2 + b.offsetY}px 0 ${
            (bH / 2) * (b.scale - 1)
          }px rgb(${b.color.join()})`
      )
      .join();

    ballDiv.style.boxShadow = this.balls
      .map(
        (b) =>
          `${b.x + b.offsetX}px ${b.y + b.offsetY}px 0 ${
            (b.size / 2) * b.scale
          }px rgb(${b.color.join()})`
      )
      .join();

    powerUpDiv.style.boxShadow = this.powerUps
      .map(
        (power) =>
          `${power.x + power.size/2 + power.offsetX}px ${power.y + power.size/2 + power.offsetY}px 0 ${
            (power.size / 2) * (power.scale - 1)
          }px rgb(${power.color.join()})`
      )
      .join();

    if (this.paddles.length > 0) {
      const paddle = this.paddles[0];
      Object.assign(paddleDiv.style, {
        left: `-${paddle.width}px`,
        top: `-${paddle.height}px`,
        width: `${paddle.width}px`,
        height: `${paddle.height}px`,
      });
      paddleDiv.style.boxShadow = this.paddles
        .map(
          (p) =>
            `${p.x + p.width / 2 + p.offsetX}px ${
              p.y + p.height / 2 + p.offsetY
            }px 0 0 rgb(${p.color.join()})`
        )
        .join();
    }

    container.style.transform = `translate(${this.camera.offsetX}px,${this.camera.offsetY}px)`;
    scoreContainer.innerText = `${this.score.toLocaleString()}`;
  },

  shakeIt(obj, dist = 4, dir = undefined) {
    let ox = -dist/2 + Math.random() * dist;
    let oy = -dist/2 + Math.random() * dist;
    if (dir) {
      ox = dir[0] * dist;
      oy = dir[1] * dist;
    }
    this.tween(obj, "offsetX", [0, ox, -ox, ox / 2, 0], 260);
    this.tween(obj, "offsetY", [0, oy, -oy, oy / 2, 0], 260);
  },
  megaShake(obj, dist = 8) {
    let offSets = new Array(10).fill(0).map(() => -dist/2 + Math.random() * dist);
    this.tween(obj, "offsetX", [0, ...offSets, 0], 260);
    this.tween(obj, "offsetY", [0, ...offSets.reverse(), 0], 620);
  },
  flashIt(obj, color,) {
    this.tween(obj.color, "0", [obj.color[0], color?.[0] ?? 100 + Math.random() * 155, obj.baseColor?.[0] ?? 255], 180);
    this.tween(obj.color, "1", [obj.color[1], color?.[1] ?? 100 + Math.random() * 155, obj.baseColor?.[1] ?? 255], 180);
    this.tween(obj.color, "2", [obj.color[2], color?.[2] ?? 100 + Math.random() * 155, obj.baseColor?.[2] ?? 255], 180);
  },
  updateScore(val) {
    this.score += val;
    this.tween(scoreContainer.style, 'scale', [.85, 1.5, 1], 300);
  },
  updateLives(val = 1) {
    this.lives += val;
    livesContainer.innerText = `${this.lives}`;
    this.tween(livesContainer.style, 'scale', [1, 0.7, 2, .8, 1.5, .9, 1.2, .95, 1], 680);
  },
  spawnNextLevel() {
    this.level++;
    const levels = {
      0: () => this.spawnLevel({
        predicate: ({x, y, i, j}) => {
          const brick = this.addBrick(x, y);
          // const delay = (i+j) * 30;
          // brick.scale = 0;
          // Game.tween(brick, 'scale', [0, 1.2, 1], 480, delay);
        }
      }),
      1: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          if ((i === midX - 1 || i === midX) || (j === midY - 1 || j === midY)) {
            return;
          }

          let color = (i+j+1) % 3 !== 0 ? [255,255,255] : [100,255,255];
          if ((i+j+1) % 4 === 0) {
            color = [255,200,200];
          }

          const brick = this.addBrick(x, y, 0, color);
          if (j === 0 || j === yBrickCount-1) {
            brick.health = 2;
          }
          // brick.scale = 0;
          // Game.tween(brick, 'scale', [0, 1], 480, distance(i, j, midX, midY) * 60);
        }
      }),
      2: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          if (i === midX - 1 || i === midX) {
            return;
          }

          let brick;
          if (i === 0 || j === 0 || i === xBrickCount-1 || j === yBrickCount-1) {
            brick = this.addBrick(x, y, 0, [75, 0, 130]);
            brick.health = 2;
          } else {
            brick = this.addBrick(x, y, 0, [100, 100, 255]);
          }
          // const delay = (xBrickCount+yBrickCount - i - j) * 30;
          // brick.scale = 0;
          // Game.tween(brick, 'scale', [0, 1.2, 1], 480, delay);
        }
      }),
      3: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          if ((j+1) % 3 === 0) {
            return;
          }

          const brick = this.addBrick(x, y, 0, j%2 === 0 ? [200,200,200] : [100,100,100]);
          if (j === 0) {
            brick.health = 2;
          } else if (j === yBrickCount-1) {
            brick.health = 5;
          }

          // const delay = (i + (yBrickCount - j)) * 30;
          // brick.scale = 0;
          // Game.tween(brick, 'scale', [0, 1.2, 1], 480, delay);
        }
      }),
      4: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          const colors = [
            [255, 0, 0],
            [255, 165, 0],
            [255, 255, 0],
            [0, 128, 0],
            [0, 0, 255],
            [75, 0, 130],
            [148, 0, 211] 
          ];

          const brick = this.addBrick(x, y, 0, [...colors[j % (colors.length-1)]]);
          // brick.health += Math.floor(Math.random()*3);
          // Game.tween(brick, 'scale', [0, 1], 680, distance(i, j, 0, midY) * 60);
        }
      }),
      5: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          if (i % 2 === 0) return;
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          const colors = [
            [100, 200, 100],
            [100, 220, 100],
            [100, 255, 100],
            [150, 255, 150],
          ];
          const brick = this.addBrick(x, y, 0, [...colors[j % (colors.length-1)]]);
          if (j % 2 === 0) brick.health = 3;
          
          // brick.scale = 0;
          // Game.tween(brick, 'scale', [0, 1], 480, distance(i, j, midX, 0) * 60);
        }
      }),
      6: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          if (i === midX || j === midY) {
            return;
          }

          const b1 = this.addBrick(x, y, 0, [255,255,255]);
          const b2 = this.addBrick(x, y, 0, [55,55,255]);
          const b3 = this.addBrick(x, y, 0, [255,55,55]);
          // b1.scale = 0;
          // b2.scale = 0;
          // b3.scale = 0;
          // Game.tween(b1, 'scale', [0, 1], 480, distance(i, j, midX, yBrickCount) * 60);
          // Game.tween(b2, 'scale', [0, 1], 480, distance(i, j, xBrickCount, midY) * 90);
          // Game.tween(b3, 'scale', [0, 1], 480, distance(i, j, xBrickCount, yBrickCount) * 120);
        }
      }),
      7: () => this.spawnLevel({
        predicate: ({x, y, i, j, yBrickCount, xBrickCount}) => {
          if (j === yBrickCount-1) {
            const b1 = this.addBrick(x, y, BrickTypes.Unbreakable, [80,80,80]);
            // b1.scale = 0;
            // Game.tween(b1, 'scale', [0, 1], 480, distance(i, j, midX, yBrickCount) * 60);
            return;
          }
          if (i === 0 || i === xBrickCount) {
            const b2 = this.addBrick(x, y, 0, [255,j%2 === 0 ? 55:100, 255]);
            // b2.scale = 0;
            // Game.tween(b2, 'scale', [0, 1], 480, distance(i, j, xBrickCount, midY) * 90);
            return;
          }
          const b3 = this.addBrick(x, y, 0, [255,55,55]);
          // b3.scale = 0;
          // Game.tween(b3, 'scale', [0, 1], 480, distance(i, j, xBrickCount, yBrickCount) * 120);
        }
      }),
      8: () => this.spawnLevel({
        predicate: ({x, y, i, j, yBrickCount, xBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          if (j === yBrickCount-1 || j === 0 || i === 0 || i === xBrickCount -1) {
            const brick = this.addBrick(x, y, 0, [40,40,200]);
            brick.health = 5;
            return;
          }
          if (j === yBrickCount-2 || j === 1 || i === 1 || i === xBrickCount -2) {
            const brick = this.addBrick(x, y, 0, [20,20,180]);
            brick.health = 3;
            return;
          }
          if (i >= midX - 1 && i <= midX+1 && j >= midY - 1 && j <= midY+1) {
            const brick = this.addBrick(x, y, 0, [255, 127, 40]);
            brick.health = 2;
            return;
          }
        }
      }),
      9: () => {
        this.spawnLevel({
          predicate: ({x, y, i, j, yBrickCount, xBrickCount}) => {
            const brick = this.addBrick(-x,-y,0, [20+5*i, 20+4*(yBrickCount-y), 20+10*(xBrickCount-i)]);
            brick.health = 5;
          }
        });

        // this.addWorm(undefined, undefined, this.bricks, 50);
      },
    };
    const nLevels = Object.keys(levels).length;
    levels?.[((this.level - 1) % nLevels)]?.();

    this.stopSound(this.bkgSong);

    this.bkgSong = this.bkgSongs[(this.level - 1) % this.bkgSongs.length];
    this.playSound(this.bkgSong, 1, 0.28, true);
  },
  spawnLevel(config) {
    const defaults = {
      blockWidth: BRICK_SIZE[0],
      blockHeight: BRICK_SIZE[1],
      screenWidth: this.width,
      screenHeight: this.height,
      brickGutterPx: 1.5,
      xBrickPad: 1,
      yBrickPad: 0,
      playAreaPxBot: 180,
      playAreaPxTop: 60,
      playAreaPxLeft: 0,
      playAreaPxRight: 0,
      predicate: ({ x, y }) => this.addBrick(x, y),
    };
    const {
      blockWidth,
      blockHeight,
      screenWidth,
      screenHeight,
      brickGutterPx,
      xBrickPad,
      yBrickPad,
      playAreaPxBot,
      playAreaPxTop,
      playAreaPxLeft,
      playAreaPxRight,
      predicate,
    } = { ...defaults, ...config };
    const brickAreaW = screenWidth - playAreaPxRight - playAreaPxLeft;
    const brickAreaH = screenHeight - playAreaPxBot - playAreaPxTop;
    const bW = blockWidth + brickGutterPx;
    const bH = blockHeight + brickGutterPx;
    const xBrickCount = Math.floor(brickAreaW / bW);
    const yBrickCount = Math.floor(brickAreaH / bH);
    const rW = brickAreaW % bW;
    const rH = brickAreaH % bH;
    const sx = playAreaPxLeft + rW / 2 + bW / 2;
    const sy = playAreaPxTop + rH / 2 + bH / 2;
    for (let i = xBrickPad; i < xBrickCount - xBrickPad; i++) {
      const x = sx + i * bW;
      for (let j = yBrickPad; j < yBrickCount; j++) {
        const y = sy + j * bH;
        predicate({ x, y, i, j, xBrickCount, yBrickCount });
      }
    }
  },

  // events
  onLifeLost() {
    this.playSound('gameover', 1 + Math.random()*.2);
    this.showMenu();
    this.megaShake(this.camera, 30);
    this.tween(livesContainer.style, 'scale',
      [1, 0.7, 2, .8, 1.5, .9, 1.2, .95, 1], 680, 540,
      () => livesContainer.innerText = `${this.lives}`
    );
    this.onResetLevel();
  },
  onDeath() {
    this.showMenu();

  },
  onWinLevel() {
    this.onResetLevel();
    this.bricks = [];
    this.spawnNextLevel();
    this.showMenu();
  },
  onResetLevel() {
    this.balls = [];
    this.powerUps = [];
    this.paddles = [];
    this.addBall(this.width/2, Game.height - 60);
    const b = this.balls[this.balls.length -1];
    b.dy = -Math.abs(b.dy);
    this.addPaddle(this.width / 2);
  },
  onStartGame() {
    this.paddles.forEach(p => {
      p.x = this.width/2;
    });
    this.hideMenu();
  },
  onResetGame() {
    this.showMenu();
    this.onResetLevel();
    this.bricks = [];
    this.gutterGuardHealth = 0;
    this.score = 0;
    this.lives = 3;
    this.level = 0;
    this.spawnNextLevel();
    livesContainer.innerText = `${this.lives}`;
  },
  hideMenu() {
    this.playSound('menu_open', 1 + Math.random()*2);
    this.tween(menuPaused.style, 'opacity', [1,0], 300, 80, () => {
      menuPaused.style.opacity = 1;
      menuPaused.style.scale = 1;
      menuPaused.style.display = 'none';
      this.isPaused = false;
    });
    this.tween(titleDiv.style, 'scale', [1,1.1,0.5], 380);
    this.tween(titleDiv.style, 'opacity', [0,1], 300);
    this.tween(startButton.style, 'scale', [1,1.1,0.5], 380);
    this.tween(startButton.style, 'opacity', [0,1], 300);
  },
  showMenu() {
    this.playSound('menu_open', 1 + Math.random()*2);
    this.isPaused = true;
    menuPaused.style.display = 'flex';
    titleDiv.innerText = `Level ${this.level}`;
    this.tween(titleDiv.style, 'scale', [0.5, 0.4, 1.1, 1], 380);
    this.tween(titleDiv.style, 'opacity', [0,1], 300);
    this.tween(startButton.style, 'scale', [0.5, 0.4, 1.1, 1], 380);
    this.tween(startButton.style, 'opacity', [0,1], 300);
    this.tween(menuPaused.style, 'opacity', [0,1], 300);
  },

  audioCtx: new AudioContext(),
  masterGainNode: null,
  audioFiles: new Map(),
  audioSources: new Map(),
  isAudioMuted: false,
  hitSounds: [
    'hit_c5',
    'hit_d5',
    'hit_e5',
    'hit_f5',
    'hit_g5',
    'hit_a5',
    'hit_b5',
    'hit_c6',
    'hit_c5_chord',
    'hit_d5_chord',
    'hit_e5_chord',
    'hit_f5_chord',
  ],
  bkgSongs: [
    'song_1',
    'song_2',
    'song_3',
    'song_4',
    'song_5',
    'song_6',
    'song_7',
    'song_8',
    'song_9',
    'song_10',
  ],
  async preload() {
    const sounds = [
      'paddle_hit',
      'edge_hit',
      'menu_open',
      'power_ball',
      'power_speed_up',
      'power_speed_down',
      'power_split',
      'power_omega',
      'power_laser',
      'power_life',
      'power_gg',
      'power_big_ball',
      'power_big_paddle',
      'turn_on',
      'turn_off',
      'gameover',
      ...this.hitSounds,
      ...this.bkgSongs
    ];
    this.masterGainNode = this.audioCtx.createGain();
    this.masterGainNode.connect(this.audioCtx.destination);

    return Promise.all(sounds.map(sound => 
      fetch(`/sounds/bronc/${sound}.ogg`)
        .then(res => {
          if (!res.ok) throw new Error(`failed to load ${sound}`);
          return res.arrayBuffer();
        })
        .then(ab => this.audioCtx.decodeAudioData(ab))
        .then(buff => {
          this.audioSources.set(sound, new Set());
          this.audioFiles.set(sound, buff);
        }).catch((e) => {
          console.error(e);
        })
    ));
  },
  playSound(name, pitch = 1, volume = 1, loop = false, onComplete = undefined) {
    if (!this.audioFiles.has(name)) {
      console.warn('woops no file to play', name);
      return;
    }

    const sources = this.audioSources.get(name);
    const buff = this.audioFiles.get(name);
    const source = this.audioCtx.createBufferSource();
    source.buffer = buff;
    source.playbackRate.value = pitch;
    source.loop = loop;

    const gainNode = this.audioCtx.createGain(); 
    gainNode.gain.value = volume;
    
    source.connect(gainNode);
    gainNode.connect(this.masterGainNode);

    sources.add(source);
    source.onended = () => {
      sources.delete(source);
      source.disconnect(gainNode);
      gainNode.disconnect(this.masterGainNode);

      if (onComplete) {
        onComplete();
      }
    };
    source.start(0);
  },
  stopSound(name) {
    if (!this.audioFiles.has(name)) {
      console.warn('woops cannot stop missing file', name);
      return;
    }

    const sources = this.audioSources.get(name);
    sources.forEach(s => {
      s.stop(0);
    });
  },
  stopAllSounds() {
    this.audioFiles.forEach((_, name) => {
      this.stopSound(name);
    });
  },
  muteAudio(isMuted = false) {
    this.isMuted = isMuted;
    this.masterGainNode.gain.setTargetAtTime(
      isMuted ? 0 : 1,
      this.audioCtx.currentTime,
      0.1
    );
    soundToggle.innerText = `Sound ${isMuted ? 'Off' : 'On'}`;
    soundToggle.style.boxShadow = isMuted ? '0px -1px 0 0px #fff6, 0px 1px 0 #fff' : '0px 1px 0 1px #fff4, 0px -1px 0 #fff';
    Game.tween(soundToggle.style, 'opacity', isMuted ? [1, 0.5] : [0.5, 1], 300);
  }
};

container.addEventListener("pointermove", (e) =>
  Game.handleMouseOrTouchInput(e)
);
container.addEventListener("touchmove", (e) => Game.handleMouseOrTouchInput(e));
startButton.addEventListener("click", (e) => {
  Game.onStartGame();
});
soundToggle.addEventListener('click', () => {
  Game.muteAudio(!Game.isMuted);
  Game.playSound(Game.isMuted ? 'turn_off' : 'turn_on', 1 + Math.random()*0.2, 2);
});
soundToggle.addEventListener('pointerdown', () => {
  Game.tween(soundToggle.style, 'scale', [1,1.1,0.8], 200);
});
soundToggle.addEventListener('pointerup', () => {
  Game.tween(soundToggle.style, 'scale', [0.8,0.75,1.1,1], 200);
})

let lastTime = 0;
function universe(delta) {
  if (lastTime === 0) {
    lastTime = delta;
  }

  const dt = (delta - lastTime) / 1000;
  Game.tick(dt);
  Game.draw();

  lastTime = delta;
  frameId = requestAnimationFrame(universe);
}

// document.addEventListener('keydown', e => {
//   Game.bricks = [];
// });

// load and start it all
Game.preload().then(() => {
  Game.onResetGame();
  Game.muteAudio(false);
  universe(lastTime);
});

container.addEventListener('unload', () => {
  cancelAnimationFrame(frameId);
  Game.stopAllSounds();
});

function distance(p1x, p1y, p2x, p2y) {
  return Math.sqrt((p1x - p2x)*(p1x - p2x) + (p1y - p2y)*(p1y - p2y));

Создаём главного злодея

Время босса. Наш финальный уровень вроде выглядит неплохо, но он немного скучный. Давай заменим его на крутую битву с боссом.

Битва с боссом? В breakout-игре? Как это вообще будет работать?

Идея в том, чтобы червь извивался, двигаясь туда-сюда. Его тело будет собрано из прочных кирпичей. Это можно сделать, двигая кирпичи так, будто они — хвост червя. Пока червь перемещается, мы определяем, какие позиции нужно заполнить у головы, и телепортируем самые старые кирпичи в эти места — как в классической игре про snek.

Это ещё и устроит хаос в физике, потому что кирпичи могут «варпнуться» прямо поверх мяча. Всё пойдёт вразнос. Будет мощно. Но на реализацию придётся потратить усилия.

Босс будет ещё одним типом объекта во вселенной. В данном случае назовём их worms, потому что это будут извивающиеся маленькие твари. Им нужны позиция, скорость, размер и точка назначения. Ещё им нужно знать, какие кирпичи составляют их тело. Возможно, одновременно будет несколько worms — кто знает!

Обрабатывать массив «боссов»-worms удобно ещё и потому, что это соответствует принятой в нашем игровом движке схеме: массивы по типам объектов.

const Game = {
  worms: [],

  addWorm(x = this.width/2, y = this.height/2, bricks = [], size = 30, speed = 300) {
    this.worms.push({
      x,
      y,
      dx: 0,
      dy: 0,
      size,
      tail: bricks,
      targetX: x,
      targetY: y,
      speed
    });
  },

  tickWorms() {
    // move the wormy boys!
  }
}

Простейшая часть движения — прикладывать «силу» к dx и dy червя в направлении цели, к которой мы хотим, чтобы он двигался. Когда он достаточно близко к целевой точке, выбираем новую случайную цель и повторяем. Так получается эффект «перелёта» и скольжения мимо точки, из-за чего движение выглядит более живым и органичным.

tickWorms(dt) {      
  for (const worm of this.worms) {
    let { targetX, targetY, speed } = worm;

    const dirX = targetX - worm.x;
    const dirY = targetY - worm.y;
    const dist = Math.sqrt(dirX * dirX + dirY * dirY) || 1;
    worm.dx += dirX / dist * speed * dt;
    worm.dy += dirY / dist * speed * dt;
    worm.dx *= 0.99;
    worm.dy *= 0.98;
    worm.x += worm.dx*dt;
    worm.y += worm.dy*dt;

    if (dist < worm.size / 2) {
      worm.targetX = Math.random()*this.width;
      worm.targetY = Math.random()*playAreaHeight;
    }

    // update worm body
  }
}

Немного «трения» тут помогает приглушить движение. || нужен, чтобы не делить на ноль. Можешь защититься от этого и другим способом, если тебе удобнее.

После перемещения пора обновлять тело. Тут несколько шагов — начинаем с вычисления позиций хвоста червя на сетке.

tickWorms(dt) {
  const playAreaHeight = this.height - 100; 
  const bW = BRICK_SIZE[0]+1;
  const bH = BRICK_SIZE[1]+1;
  for (const worm of this.worms) {
    // update worm movement

    worm.tail = worm.tail.filter(b => b.isAlive);
    const occupiedGrid = new Map();
    for (const brick of worm.tail) {
      const gx = Math.floor(brick.x / bW);
      const gy = Math.floor(brick.y / bH);
      occupiedGrid.set(`${gx},${gy}`, true); 
    }


  }
}

Сначала обязательно отфильтруй умершие кирпичи, потому что каждый worm отслеживает лишь подмножество кирпичей из вселенной.

Дальше собираем сетку пустых мест вокруг головы, исходя из размера и позиции червя.

const headRadius = worm.size / 2;
const minGridX = Math.floor((worm.x - headRadius) / bW);
const maxGridX = Math.floor((worm.x + headRadius) / bW);
const minGridY = Math.floor((worm.y - headRadius) / bH);
const maxGridY = Math.floor((worm.y + headRadius) / bH);
let targetGridCells = new Map();

for (let gx = minGridX; gx <= maxGridX; gx++) {
  for (let gy = minGridY; gy <= maxGridY; gy++) {
    const key = `${gx},${gy}`;
    if (!occupiedGrid.has(key)) {
      targetGridCells.set(key, {gx, gy})
    }
  }
}

Вместо того чтобы пытаться заполнить все эти места, мы будем перемещать всего один кирпич в одну пустую клетку.

if (targetGridCells.size > 0) {
  const randomIndex = Math.floor(Math.random() * targetGridCells.size);
  const { gx, gy } = [...targetGridCells.values()][randomIndex];
  const oldestBrick = worm.tail.pop(); 
  const brickKey = `${oldestBrick?.x},${oldestBrick?.y}`;
  if (oldestBrick && !targetGridCells.has(brickKey)) {
    oldestBrick.x = gx * bW + bW / 2;
    oldestBrick.y = gy * bH + bH / 2;
    worm.tail.unshift(oldestBrick);
  }
}

Может случиться кадр, когда у worm вообще нет хвоста до срабатывания условия победы, так что не забудь защититься от этого. Ещё визуально выглядит чуть лучше, если кирпичи двигаются только тогда, когда они вне диапазона размера worm.

И наконец — добавляем всё это в уровень с боссом.

0: () => {
  this.spawnLevel({
    predicate: ({x, y, i, j, yBrickCount, xBrickCount}) => {
      // negative so bricks start off screen until moved
      const brick = this.addBrick(-x,-y,0, [20+5*i, 20+4*(yBrickCount-y), 20+10*(xBrickCount-i)]);
      brick.health = 5;
    }
  });

  this.addWorm(undefined, undefined, this.bricks, 50);
},

Время прогнать плейтест босса.

Исходный код
const container = document.getElementById(containerId);
const screenWidth = container.clientWidth;
const screenHeight = container.clientHeight;

// create menu ui
const menuPaused = document.createElement('div');
container.appendChild(menuPaused);
Object.assign(menuPaused.style, {
  position: 'absolute',
  top: 0,
  left: 0,
  width: '100%',
  height: '100%',
  backdropFilter: 'blur(2px)',
  backgroundColor: '#0008',
  display: 'flex',
  flexDirection: 'column',
  justifyContent: 'center',
  letterSpacing: '2px',
  alignItems: 'center',
  gap: '32px',
  zIndex: 1,
});
const titleDiv = document.createElement('div');
titleDiv.innerText = 'LOADING';
Object.assign(titleDiv.style, {
  fontSize: '32px',
  fontWeight: 'bold',
  textAlign: 'center',
  textTransform: 'uppercase',
  textShadow: '#0008 1px 1px 0px, red 3px 1px 0px, red 1px 2px 0px',
});

const msgDiv = document.createElement('div');
const soundToggle = document.createElement('button');
soundToggle.innerText = 'Audio On';
Object.assign(soundToggle.style, {
  position: 'absolute',
  bottom: '8px',
  right: '8px',
  padding: '6px 12px',
  fontSize: '12px',
  border: 'none',
  textShadow: '1px 2px #fff2',
  color: '#fffb',
  background: 'transparent',
  outline: 'none',
  borderRadius: '32px',
  boxShadow: '0px -1px 0 0px #fff8, 0px 2px 0 #fff',
});

const startButton = document.createElement('button');
startButton.innerText = 'START';
Object.assign(startButton.style, {
  padding: '8px 16px',
  minHeight: '32px',
  minWidth: '120px',
  fontSize: '16px',
  letterSpacing: '4px',
  border: 'none',
  boxShadow: '0 0 0 1px #fffa, 0 0 0 2px #000, 0 0 0 3px #fffa',
  textShadow: '1px 2px #fff2',
  color: '#fffb',
  background: 'transparent',
  outline: 'none',
})
menuPaused.appendChild(titleDiv);
menuPaused.appendChild(soundToggle);
menuPaused.appendChild(startButton);

const ballDiv = document.createElement("div");
container.appendChild(ballDiv);
Object.assign(ballDiv.style, {
  position: "absolute",
  left: "0",
  top: "0",
});
const BallTypes = {
  Normal: 0,
  PowerBall: 1,
  Laser: 2,
  PowerLaser: 3,
};
const BallColors = {
  [BallTypes.Normal]: [255,255,255],
  [BallTypes.PowerBall]: [255,55,55],
  [BallTypes.Laser]: [55,255,255],
  [BallTypes.PowerLaser]: [255,70,70],
};

const brickDiv = document.createElement("div");
container.appendChild(brickDiv);
const BRICK_SIZE = [36, 12];
const BrickTypes = {
  Normal: 0,
  Unbreakable: 1,
  MultiBall: 2,
  PowerBall: 3,
  Explode: 4,
}
Object.assign(brickDiv.style, {
  position: "absolute",
  borderRadius: "2px",
  left: `-${BRICK_SIZE[0]}px`,
  top: `-${BRICK_SIZE[1]}px`,
  width: `${BRICK_SIZE[0]}px`,
  height: `${BRICK_SIZE[1]}px`,
});

const paddleDiv = document.createElement("div");
container.appendChild(paddleDiv);
const PADDLE_SIZE = [70, 10];
const PaddleTypes = {
  Normal: 0,
  Laser: 1,
};
const PaddleColors = {
  [PaddleTypes.Normal]: [255, 255, 255],
  [PaddleTypes.Laser]: [55, 255, 255],
};
Object.assign(paddleDiv.style, {
  position: "absolute",
  left: `-${PADDLE_SIZE[0]}px`,
  top: `-${PADDLE_SIZE[1]}px`,
  width: `${PADDLE_SIZE[0]}px`,
  height: `${PADDLE_SIZE[1]}px`,
});

const powerUpDiv = document.createElement("div");
container.appendChild(powerUpDiv);
const POWER_UP_SIZE = 16;
Object.assign(powerUpDiv.style, {
  position: "absolute",
  left: `-${POWER_UP_SIZE}px`,
  top: `-${POWER_UP_SIZE}px`,
  width: `${POWER_UP_SIZE}px`,
  height: `${POWER_UP_SIZE}px`,
  borderRadius: '9000px',
});
const PowerUpTypes = {
  MultiBall: 0,
  BigBall: 1,
  PowerBall: 2,
  PaddleSize: 3,
  GutterGuard: 4,
  LaserPaddle: 5,
  ExtraLife: 6,
  BonusPoints: 7,
  OmegaDevice: 8,
  SpeedUp: 9,
  SpeedDown: 10,
};
const PowerUpColors = {
  [PowerUpTypes.MultiBall]: [255, 200, 255],
  [PowerUpTypes.BigBall]: [200, 200, 255],
  [PowerUpTypes.PowerBall]: [255, 50, 50],
  [PowerUpTypes.ExtraLife]: [100, 255, 100],
  [PowerUpTypes.SpeedUp]: [100, 255, 255],
  [PowerUpTypes.SpeedDown]: [255, 100, 255],

  [PowerUpTypes.LaserPaddle]: [200, 255, 255],
  [PowerUpTypes.OmegaDevice]: [255, 0, 255],
  [PowerUpTypes.BonusPoints]: [255, 255, 0],
  [PowerUpTypes.PaddleSize]: [255, 255, 255],
  [PowerUpTypes.GutterGuard]: [255, 255, 255],
};
const PowerUpEffects = {
  [PowerUpTypes.MultiBall]: (game, powerUp) => {
    game.balls.map(b => [b.x, b.y]).forEach(([x,y]) => game.addBall(x,y));
    game.playSound('power_split', 1+Math.random()*.2, 1.2);
  },
  [PowerUpTypes.BigBall]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.size *= 2;
    });
    game.playSound('power_big_ball', 1+Math.random()*.2, 1);
  },
  [PowerUpTypes.PaddleSize]: (game, powerUp) => {
    game.paddles.forEach(paddle => {
      paddle.width += PADDLE_SIZE[0] * 0.3;
    });
    game.playSound('power_big_paddle', 1+Math.random()*.2, 1.4);
  },
  [PowerUpTypes.GutterGuard]: (game, powerUp) => {
    game.gutterGuardHealth += 3;
    game.tween(gutterDiv.style, 'scale', this.gutterGuardHealth > 0 ? [1, 1] : [0, 1.1, 1], 280);
    game.playSound('power_gg', 1+Math.random()*.2);
  },
  [PowerUpTypes.BonusPoints]: (game, powerUp) => {
    game.updateScore(Math.floor(powerUp.scale * 1000));
    game.playSound('hit_c5_chord', 1+Math.random()*.2);
  },
  [PowerUpTypes.OmegaDevice]: (game, powerUp) => {
    game.omegaDevicePower += 1;
    game.playSound('power_omega', 1 + (0.5*game.omegaDevicePower) + Math.random()*.2);
    if (game.omegaDevicePower > 5) {
      game.omegaDevicePower = 0;
      for (const brick of game.bricks) {
        game.damageBrick(brick);
      }
      game.megaShake(game.camera, 30);
      game.playSound('gameover', 1 + (0.5*game.omegaDevicePower) + Math.random()*.2);
    }
  },
  [PowerUpTypes.LaserPaddle]: (game, powerUp) => {
    game.paddles.forEach(paddle => {
      paddle.type = PaddleTypes.Laser;
      paddle.baseColor = PaddleColors[PaddleTypes.Laser];
      game.shakeIt(paddle, 6, [0, 1]);
      game.flashIt(paddle);
      game.tween(paddle, 'scale', [1, 0.8, 1.2, 1], 280);
    });
    game.playSound('power_laser', 1 + Math.random()*.2);
  },
  [PowerUpTypes.PowerBall]: (game, powerUp) => {
    game.addBall(powerUp.x, powerUp.y, BallTypes.PowerBall);
    game.balls[game.balls.length-1].dy = -Math.abs(game.balls[game.balls.length-1].dy);
    game.playSound('power_ball', 1+Math.random()*.2);
  },
  [PowerUpTypes.ExtraLife]: (game, powerUp) => {
    game.updateLives(1);
    game.playSound('power_life', 1+Math.random()*.2);
  },
  [PowerUpTypes.SpeedUp]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.dx *= 1.25;
      b.dy *= 1.25;
    });
    game.playSound('power_speed_up', 1+Math.random()*.2);
  },
  [PowerUpTypes.SpeedDown]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.dx *= .9;
      b.dy *= .9;
    });
    game.playSound('power_speed_down', 1+Math.random()*.2);
  },
};

const gutterDiv = document.createElement("div");
const GUTTER_GUARD_SIZE = [screenWidth, 8];
container.appendChild(gutterDiv);

const scoreContainer = document.createElement("div");
container.appendChild(scoreContainer);
Object.assign(scoreContainer.style, {
  position: "absolute",
  top: 0,
  right: 0,
  fontSize: "16px",
  width: "100%",
  fontWeight: 'bold',
  letterSpacing: '2px',
  marginTop: "min(5%, 1.5rem)",
  textAlign: "center",
  textShadow: '1px 4px 0 #000a',
  zIndex: 2,
});

const livesContainer = document.createElement("div");
container.appendChild(livesContainer);
Object.assign(livesContainer.style, {
  position: "absolute",
  top: '16px',
  left: '16px',
  fontSize: "22px",
  fontWeight: 'bold',
  width: "100%",
  textAlign: "left",
  textShadow: '#0008 1px 1px 0px, #44ff44 3px 1px 0px, #44ff44 1px 2px 0px',
  zIndex: 2,
  width: 'fit-content',
});

const lerp = (from, to, time) => from + (to - from) * time;

const Game = {
  isPaused: false,
  isMuted: false,
  score: 0,
  lives: 3,
  level: 1,
  frame: 0,
  gutterGuardHealth: 5,
  omegaDevicePower: 0,
  camera: {
    scale: 1,
    offsetX: 0,
    offsetY: 0,
  },
  width: screenWidth,
  height: screenHeight,
  balls: [],
  bricks: [],
  paddles: [],
  powerUps: [],
  worms: [],
  tweens: new Set(),
  bkgSong: '',

  tween(target, prop, keyframes, duration, delay = 0, onComplete = undefined, iterations = 1) {
    this.tweens.add({
      target,
      prop,
      keyframes,
      t: 0,
      duration: duration / 1000,
      iterations,
      delay: delay / 1000,
      onComplete,
    });
  },

  handleMouseOrTouchInput(event) {
    if (event.target !== container) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();
    const x = event?.touches?.[0]?.clientX ?? event.offsetX;
    const y = event?.touches?.[0]?.clientY ?? event.offsetY;
    this.paddles.forEach(
      (p) =>
        (p.x = Math.min(this.width - p.width / 2, Math.max(p.width / 2, x)))
    );
  },

  addPaddle(x = this.width / 2, y = this.height - PADDLE_SIZE[1] * 2, type = PaddleTypes.Normal) {
    this.paddles.push({
      x,
      y,
      width: PADDLE_SIZE[0],
      height: PADDLE_SIZE[1],
      type,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      baseColor: [...PaddleColors[type]],
      color: [...PaddleColors[type]],
    });
  },
  addBall(x = this.width / 2, y = this.height / 2, type = BallTypes.Normal, speed = 240) {
    const thresholdDegrees = 20;
    const arc = thresholdDegrees * (Math.PI / 180);
    let angle = arc + Math.random() * (Math.PI - 2 * arc);
    this.balls.push({
      x,
      y,
      dx: Math.cos(angle) * speed,
      dy: Math.sin(angle) * speed,
      size: 10,
      isAlive: true,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      comboCount: 0,
      baseColor: [...BallColors[type]],
      color: [...BallColors[type]],
      type,
      lastHits: new Set(),
    });

    if (BallTypes.Laser === type || type === BallTypes.PowerLaser) {
      const ball = this.balls[this.balls.length-1];
      ball.dy = -speed - Math.random()*100;
      ball.dx *= 0.5;
      this.tween(ball, 'size', [12,18,6,16,6], 320, 0, undefined, Infinity);
    }
  },
  addBrick(x = this.width / 2, y = this.height / 2, type = 0, color = [255,255,255]) {
    this.bricks.push({
      x,
      y,
      type,
      health: type === BrickTypes.Unbreakable ? Infinity : 1,
      isAlive: true,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      baseColor: color,
      color
    });

    return this.bricks[this.bricks.length -1];
  },
  addPowerUp(x = this.width / 2, y = this.height / 2, type = PowerUpTypes.MultiBall, speed = 160) {
    this.powerUps.push({
      x,
      y,
      dx: 0,
      dy: speed,
      type,
      isAlive: true,
      size: 16,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      color: [...PowerUpColors[type]],
    });
  },
  addWorm(x = this.width/2, y = this.height/2, bricks = [], size = 30, speed = 300) {
    this.worms.push({
      x,
      y,
      dx: 0,
      dy: 0,
      size,
      tail: bricks,
      targetX: x,
      targetY: y,
      speed
    });
  },

  tick(dt) {
    this.frame++;

    if (!this.isPaused) {
      this.tickBalls(dt);
      this.balls = this.balls.filter((ball) => ball.isAlive);
      this.bricks = this.bricks.filter((brick) => brick.isAlive);
      this.tickPowerUps(dt);
      this.powerUps = this.powerUps.filter((pow) => pow.isAlive);
      this.tickWorms(dt);

      if (this.balls.length === 0) {
        if (this.lives > 0) {
          this.lives--;
          this.onLifeLost();
        } else {
          this.onResetGame();
        }
      }
      if (this.bricks.filter(b => b.type !== BrickTypes.Unbreakable).length === 0) {
        this.onWinLevel();
      }
    }

    this.tickTweens(dt);
  },

  tickPowerUps(dt) {
    for (const power of this.powerUps) {
      power.x += power.dx * dt;
      power.y += power.dy * dt;
      const halfSize = power.size/2;
      let left = power.x - halfSize;
      let right = power.x + halfSize;
      let top = power.y - halfSize;
      let bot = power.y + halfSize;

      power.scale = 1 + 0.1*Math.sin(this.frame*.2);

      // check paddles
      for (const p of this.paddles) {
        const pL = p.x - p.width / 2;
        const pR = p.x + p.width / 2;
        const pT = p.y - p.height / 2;
        const pB = p.y + p.height / 2;
        if (left > pR || right < pL) {
          continue;
        }
        if (top > pB || bot < pT) {
          continue;
        }
        power.isAlive = false;

        this.shakeIt(p, 3, [0, 1]);
        this.flashIt(p, power.color);
        break;
      }

      if (power.y > this.height) {
        power.isAlive = false;
        continue;
      }

      if (power.isAlive) {
        continue;
      }

      PowerUpEffects[power.type]?.(this, power);
    }
  },
  tickBalls(dt) {
    const { width: w, height: h } = this;

    this.balls.forEach((b) => {
      if (!b.isAlive) {
        return;
      }

      let didBounce = false;
      let edgeBounce = false;
      let paddleBounce = false;
      let brickBounce = false;
      let ballDx = b.dx * dt;
      let ballDy = b.dy * dt;
      const bHalfSize = b.size / 2;

      // check bricks.
      const [brickWidth, brickHeight] = BRICK_SIZE;
      // x axis
      b.x += ballDx;
      let ballL = b.x - bHalfSize;
      let ballR = b.x + bHalfSize;
      let ballT = b.y - bHalfSize;
      let ballB = b.y + bHalfSize;
      for (const brick of this.bricks) {
        if (!brick.isAlive || brick.health <= 0 || b.lastHits.has(brick)) {
          continue;
        }

        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if (
          ballT > brickB ||
          ballB < brickT ||
          ballL > brickR ||
          ballR < brickL
        ) {
          continue;
        }

        if ((b.type !== BallTypes.PowerBall && b.type !== BallTypes.PowerLaser) || brick.type === BrickTypes.Unbreakable) {
          b.x = ballDx > 0 ? brickL - bHalfSize - 1 : brickR + bHalfSize + 1;
          b.dx *= -1;
        }
        brickBounce = true;
        b.isAlive = BallTypes.Laser !== b.type;
        this.damageBrick(brick);
        if (BallTypes.Normal !== b.type) {
          b.lastHits.add(brick);
        }
        break;
      }

      // y axis
      b.y += ballDy;
      ballL = b.x - bHalfSize;
      ballR = b.x + bHalfSize;
      ballT = b.y - bHalfSize;
      ballB = b.y + bHalfSize;
      for (const brick of this.bricks) {
        if (!brick.isAlive || brick.health <= 0 || b.lastHits.has(brick)) {
          continue;
        }

        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if (
          ballT > brickB ||
          ballB < brickT ||
          ballL > brickR ||
          ballR < brickL
        ) {
          continue;
        }

        if ((b.type !== BallTypes.PowerBall && b.type !== BallTypes.PowerLaser) || brick.type === BrickTypes.Unbreakable) {
          b.y = ballDy > 0 ? (b.y = brickT - bHalfSize - 1) : brickB + bHalfSize + 1;
          b.dy *= -1;
        }
        brickBounce = true;
        b.isAlive = BallTypes.Laser !== b.type;
        this.damageBrick(brick);
        if (BallTypes.Normal !== b.type) {
          b.lastHits.add(brick);
        }
        break;
      }

      // check bounds x
      if (b.x + b.size / 2 > w || b.x < b.size / 2) {
        b.x = ballDx > 0 ? w - b.size / 2 : b.size / 2;
        b.dx *= -1;
        this.shakeIt(this.camera, 3);
        edgeBounce = true;
      }
      // bounds y
      if (b.y < b.size / 2) {
        b.y = ballDy > 0 ? h - b.size / 2 : b.size / 2;
        b.dy *= -1;
        this.shakeIt(this.camera, 3);
        edgeBounce = true;
      }
      // y bottom
      const ggh = GUTTER_GUARD_SIZE[1];
      if (this.gutterGuardHealth > 0 && b.y + b.size/2 >= h - ggh) {
        b.y = this.height - ggh - b.size/2 - 1;
        b.dy *= -1;
        this.gutterGuardHealth--;
        this.tween(gutterDiv.style, 'opacity', [1, 0.5, 1], 280);
        this.tween(gutterDiv.style, 'marginTop', this.gutterGuardHealth > 1 ? [1, 1] : [1, 1.1, 0], 280);
        edgeBounce = true;
      } else if (b.y + b.size / 2 > h) {
        b.isAlive = false;
      }

      // check paddles
      for (const p of this.paddles) {
        if (b.type === BallTypes.Laser) continue;

        const pL = p.x - p.width / 2;
        const pR = p.x + p.width / 2;
        const pT = p.y - p.height / 2;
        const pB = p.y + p.height / 2;
        if (ballL > pR || ballR < pL) {
          continue;
        }
        if (ballT > pB || ballB < pT) {
          continue;
        }
        paddleBounce = true;
        const xDif = Math.min(ballR - pL, pR - ballL);
        const yDif = Math.min(ballB - pT, pB - ballT);
        if (xDif < yDif) {
          b.x += b.x > p.x ? xDif : -xDif;
        }
        b.y = p.y - p.height / 2 - b.size/2 - 1;

        const angle = Math.atan2(
          b.y - (p.y - p.height / 2 + p.width / 2),
          b.x - p.x
        );
        const speed = Math.sqrt(b.dx * b.dx + b.dy * b.dy);
        b.dx = Math.cos(angle) * speed;
        b.dy = Math.sin(angle) * speed;

        this.shakeIt(p, 3, [0, 1]);
        this.flashIt(p);

        if (p.type === PaddleTypes.Laser) {
          const type = b.type === BallTypes.PowerBall ? BallTypes.PowerLaser : BallTypes.Laser;
          this.addBall(b.x, b.y+b.size/2, type, 480);
          this.addBall(b.x, b.y+b.size/2, type, 480);
          this.addBall(b.x, b.y+b.size/2, type, 480);
          this.addBall(b.x, b.y+b.size/2, type, 480);
        }
        break;
      }

      didBounce = edgeBounce || brickBounce || paddleBounce;
      if (!didBounce) {
        return;
      }
      if (!brickBounce) {
        b.lastHits.clear();
      }
      if (edgeBounce) this.playSound('edge_hit', 0.5 + Math.random()*0.2, 1);
      if (paddleBounce) {
        this.playSound('paddle_hit', 1 + Math.random()*0.1, .4);
        b.comboCount = 0;
      }
      if (brickBounce) {
        const cycle = Math.floor(b.comboCount / this.hitSounds.length);
        const boost = 0.20;
        const rndPitch = Math.random()*.1;
        this.playSound(this.hitSounds[b.comboCount % this.hitSounds.length], 1 + boost*cycle + rndPitch, 2.2);
        b.comboCount++;
      }

      this.tween(b, "scale", [0.8, 1.2, 1], 60, 0, () => {
        if (b.type === BallTypes.Laser) {
          b.isAlive = false;
        }
        if (b.type === BallTypes.PowerLaser && edgeBounce) {
          b.isAlive = false;
        }
      });
      this.flashIt(b);
    });
  },
  tickWorms(dt) {
    const playAreaHeight = this.height - 100; 
    const bW = BRICK_SIZE[0]+1;
    const bH = BRICK_SIZE[1]+1;
        
    for (const worm of this.worms) {
      let { targetX, targetY, speed } = worm;
      worm.tail = worm.tail.filter(b => b.isAlive);

      const dirX = targetX - worm.x;
      const dirY = targetY - worm.y;
      const dist = Math.sqrt(dirX * dirX + dirY * dirY) || 1;
      worm.dx += dirX / dist * speed * dt;
      worm.dy += dirY / dist * speed * dt;
      worm.dx *= 0.99;
      worm.dy *= 0.98;
      worm.x += worm.dx*dt;
      worm.y += worm.dy*dt;

      if (dist < worm.size / 2) {
        worm.targetX = Math.random()*this.width;
        worm.targetY = Math.random()*playAreaHeight;
      }


      const occupiedGrid = new Map();
      for (const brick of worm.tail) {
        const gx = Math.floor(brick.x / bW);
        const gy = Math.floor(brick.y / bH);
        occupiedGrid.set(`${gx},${gy}`, true); 
      }

      const headRadius = worm.size / 2;
      const minGridX = Math.floor((worm.x - headRadius) / bW);
      const maxGridX = Math.floor((worm.x + headRadius) / bW);
      const minGridY = Math.floor((worm.y - headRadius) / bH);
      const maxGridY = Math.floor((worm.y + headRadius) / bH);
      let targetGridCells = new Map();
      
      for (let gx = minGridX; gx <= maxGridX; gx++) {
        for (let gy = minGridY; gy <= maxGridY; gy++) {
          const key = `${gx},${gy}`;
          if (!occupiedGrid.has(key)) {
            targetGridCells.set(key, {gx, gy})
          }
        }
      }
        
      if (targetGridCells.size > 0) {
        const randomIndex = Math.floor(Math.random() * targetGridCells.size);
        const { gx, gy } = [...targetGridCells.values()][randomIndex];
        const oldestBrick = worm.tail.pop(); 
        const brickKey = `${oldestBrick?.x},${oldestBrick?.y}`;
        if (oldestBrick && !targetGridCells.has(brickKey)) {
          oldestBrick.x = gx * bW + bW / 2;
          oldestBrick.y = gy * bH + bH / 2;
          if (worm.tail.length > 8) {
            this.tween(oldestBrick, 'scale', [0, 1.5, 0.8, 1.1, 1], 480);
          }
          worm.tail.unshift(oldestBrick);
        }
      }
    }
  },
  tickTweens(dt) {
    // do the tween
    for (const tween of this.tweens) {
      const { target, prop, keyframes, duration } = tween;
      tween.delay -= dt;
      if (tween.delay > 0) continue;
      tween.t += dt;
      const frames = keyframes.length - 1;
      const progress = Math.min(1, tween.t / duration);
      const kIdx = Math.min(frames - 1, Math.floor(frames * progress));
      const localProgress = (progress - kIdx / frames) / (1 / frames);
      target[prop] = lerp(keyframes[kIdx], keyframes[kIdx + 1], localProgress);
      if (tween.t < duration) {
        continue;
      }

      target[prop] = keyframes[keyframes.length - 1];
      if (tween.onComplete) {
        tween.onComplete();
      }
      tween.iterations -= 1;
      if (tween.iterations <= 0) {
        this.tweens.delete(tween);
      } else {
        tween.t = 0;
      }
    }
  },

  damageBrick(brick) {
    this.flashIt(brick);
    this.shakeIt(brick, 6);
    brick.health -= 1;
    if (brick.health > 0) {
      return;
    }

    this.updateScore(100);
    this.tween(
      brick,
      "scale",
      [1, 1],
      120,
      160,
      () => (brick.isAlive = false)
    );

    if (Math.random() > 0.85) {
      const types = Object.values(PowerUpTypes);
      this.addPowerUp(brick.x, brick.y, types[Math.floor(Math.random()*types.length)]);
    }
  },

  draw() {
    const [w, h] = GUTTER_GUARD_SIZE;
    Object.assign(gutterDiv.style, {
      position: "absolute",
      left: `-${w}`,
      top: `${this.height - h - 1 }px`,
      width: `${w}px`,
      height: `${h}px`,
      display: this.gutterGuardHealth > 0 ? 'initial' : 'none',
      boxShadow: `inset 0 0 0 1px #fff, inset 0 0 0 2px #000, inset 0 0 0 3px #fff`,
    });

    const [bW, bH] = BRICK_SIZE;
    brickDiv.style.boxShadow = this.bricks
      .map(
        (b) =>
          `${b.x + bW / 2 + b.offsetX}px ${b.y + bH / 2 + b.offsetY}px 0 ${
            (bH / 2) * (b.scale - 1)
          }px rgb(${b.color.join()})`
      )
      .join();

    ballDiv.style.boxShadow = this.balls
      .map(
        (b) =>
          `${b.x + b.offsetX}px ${b.y + b.offsetY}px 0 ${
            (b.size / 2) * b.scale
          }px rgb(${b.color.join()})`
      )
      .join();

    powerUpDiv.style.boxShadow = this.powerUps
      .map(
        (power) =>
          `${power.x + power.size/2 + power.offsetX}px ${power.y + power.size/2 + power.offsetY}px 0 ${
            (power.size / 2) * (power.scale - 1)
          }px rgb(${power.color.join()})`
      )
      .join();

    if (this.paddles.length > 0) {
      const paddle = this.paddles[0];
      Object.assign(paddleDiv.style, {
        left: `-${paddle.width}px`,
        top: `-${paddle.height}px`,
        width: `${paddle.width}px`,
        height: `${paddle.height}px`,
      });
      paddleDiv.style.boxShadow = this.paddles
        .map(
          (p) =>
            `${p.x + p.width / 2 + p.offsetX}px ${
              p.y + p.height / 2 + p.offsetY
            }px 0 0 rgb(${p.color.join()})`
        )
        .join();
    }

    container.style.transform = `translate(${this.camera.offsetX}px,${this.camera.offsetY}px)`;
    scoreContainer.innerText = `${this.score.toLocaleString()}`;
  },

  shakeIt(obj, dist = 4, dir = undefined) {
    let ox = -dist/2 + Math.random() * dist;
    let oy = -dist/2 + Math.random() * dist;
    if (dir) {
      ox = dir[0] * dist;
      oy = dir[1] * dist;
    }
    this.tween(obj, "offsetX", [0, ox, -ox, ox / 2, 0], 260);
    this.tween(obj, "offsetY", [0, oy, -oy, oy / 2, 0], 260);
  },
  megaShake(obj, dist = 8) {
    let offSets = new Array(10).fill(0).map(() => -dist/2 + Math.random() * dist);
    this.tween(obj, "offsetX", [0, ...offSets, 0], 260);
    this.tween(obj, "offsetY", [0, ...offSets.reverse(), 0], 620);
  },
  flashIt(obj, color,) {
    this.tween(obj.color, "0", [obj.color[0], color?.[0] ?? 100 + Math.random() * 155, obj.baseColor?.[0] ?? 255], 180);
    this.tween(obj.color, "1", [obj.color[1], color?.[1] ?? 100 + Math.random() * 155, obj.baseColor?.[1] ?? 255], 180);
    this.tween(obj.color, "2", [obj.color[2], color?.[2] ?? 100 + Math.random() * 155, obj.baseColor?.[2] ?? 255], 180);
  },
  updateScore(val) {
    this.score += val;
    this.tween(scoreContainer.style, 'scale', [.85, 1.5, 1], 300);
  },
  updateLives(val = 1) {
    this.lives += val;
    livesContainer.innerText = `${this.lives}`;
    this.tween(livesContainer.style, 'scale', [1, 0.7, 2, .8, 1.5, .9, 1.2, .95, 1], 680);
  },
  spawnNextLevel() {
    this.level++;
    const levels = {
      9: () => this.spawnLevel({
        predicate: ({x, y, i, j}) => {
          const brick = this.addBrick(x, y);
          // const delay = (i+j) * 30;
          // brick.scale = 0;
          // Game.tween(brick, 'scale', [0, 1.2, 1], 480, delay);
        }
      }),
      1: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          if ((i === midX - 1 || i === midX) || (j === midY - 1 || j === midY)) {
            return;
          }

          let color = (i+j+1) % 3 !== 0 ? [255,255,255] : [100,255,255];
          if ((i+j+1) % 4 === 0) {
            color = [255,200,200];
          }

          const brick = this.addBrick(x, y, 0, color);
          if (j === 0 || j === yBrickCount-1) {
            brick.health = 2;
          }
          // brick.scale = 0;
          // Game.tween(brick, 'scale', [0, 1], 480, distance(i, j, midX, midY) * 60);
        }
      }),
      2: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          if (i === midX - 1 || i === midX) {
            return;
          }

          let brick;
          if (i === 0 || j === 0 || i === xBrickCount-1 || j === yBrickCount-1) {
            brick = this.addBrick(x, y, 0, [75, 0, 130]);
            brick.health = 2;
          } else {
            brick = this.addBrick(x, y, 0, [100, 100, 255]);
          }
          // const delay = (xBrickCount+yBrickCount - i - j) * 30;
          // brick.scale = 0;
          // Game.tween(brick, 'scale', [0, 1.2, 1], 480, delay);
        }
      }),
      3: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          if ((j+1) % 3 === 0) {
            return;
          }

          const brick = this.addBrick(x, y, 0, j%2 === 0 ? [200,200,200] : [100,100,100]);
          if (j === 0) {
            brick.health = 2;
          } else if (j === yBrickCount-1) {
            brick.health = 5;
          }

          // const delay = (i + (yBrickCount - j)) * 30;
          // brick.scale = 0;
          // Game.tween(brick, 'scale', [0, 1.2, 1], 480, delay);
        }
      }),
      4: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          const colors = [
            [255, 0, 0],
            [255, 165, 0],
            [255, 255, 0],
            [0, 128, 0],
            [0, 0, 255],
            [75, 0, 130],
            [148, 0, 211] 
          ];

          const brick = this.addBrick(x, y, 0, [...colors[j % (colors.length-1)]]);
          // brick.health += Math.floor(Math.random()*3);
          // Game.tween(brick, 'scale', [0, 1], 680, distance(i, j, 0, midY) * 60);
        }
      }),
      5: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          if (i % 2 === 0) return;
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          const colors = [
            [100, 200, 100],
            [100, 220, 100],
            [100, 255, 100],
            [150, 255, 150],
          ];
          const brick = this.addBrick(x, y, 0, [...colors[j % (colors.length-1)]]);
          if (j % 2 === 0) brick.health = 3;
          
          // brick.scale = 0;
          // Game.tween(brick, 'scale', [0, 1], 480, distance(i, j, midX, 0) * 60);
        }
      }),
      6: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          if (i === midX || j === midY) {
            return;
          }

          const b1 = this.addBrick(x, y, 0, [255,255,255]);
          const b2 = this.addBrick(x, y, 0, [55,55,255]);
          const b3 = this.addBrick(x, y, 0, [255,55,55]);
          // b1.scale = 0;
          // b2.scale = 0;
          // b3.scale = 0;
          // Game.tween(b1, 'scale', [0, 1], 480, distance(i, j, midX, yBrickCount) * 60);
          // Game.tween(b2, 'scale', [0, 1], 480, distance(i, j, xBrickCount, midY) * 90);
          // Game.tween(b3, 'scale', [0, 1], 480, distance(i, j, xBrickCount, yBrickCount) * 120);
        }
      }),
      7: () => this.spawnLevel({
        predicate: ({x, y, i, j, yBrickCount, xBrickCount}) => {
          if (j === yBrickCount-1) {
            const b1 = this.addBrick(x, y, BrickTypes.Unbreakable, [80,80,80]);
            // b1.scale = 0;
            // Game.tween(b1, 'scale', [0, 1], 480, distance(i, j, midX, yBrickCount) * 60);
            return;
          }
          if (i === 0 || i === xBrickCount) {
            const b2 = this.addBrick(x, y, 0, [255,j%2 === 0 ? 55:100, 255]);
            // b2.scale = 0;
            // Game.tween(b2, 'scale', [0, 1], 480, distance(i, j, xBrickCount, midY) * 90);
            return;
          }
          const b3 = this.addBrick(x, y, 0, [255,55,55]);
          // b3.scale = 0;
          // Game.tween(b3, 'scale', [0, 1], 480, distance(i, j, xBrickCount, yBrickCount) * 120);
        }
      }),
      8: () => this.spawnLevel({
        predicate: ({x, y, i, j, yBrickCount, xBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          if (j === yBrickCount-1 || j === 0 || i === 0 || i === xBrickCount -1) {
            const brick = this.addBrick(x, y, 0, [40,40,200]);
            brick.health = 5;
            return;
          }
          if (j === yBrickCount-2 || j === 1 || i === 1 || i === xBrickCount -2) {
            const brick = this.addBrick(x, y, 0, [20,20,180]);
            brick.health = 3;
            return;
          }
          if (i >= midX - 1 && i <= midX+1 && j >= midY - 1 && j <= midY+1) {
            const brick = this.addBrick(x, y, 0, [255, 127, 40]);
            brick.health = 2;
            return;
          }
        }
      }),
      0: () => {
        this.spawnLevel({
          predicate: ({x, y, i, j, yBrickCount, xBrickCount}) => {
            const brick = this.addBrick(-x,-y,0, [20+5*i, 20+4*(yBrickCount-y), 20+10*(xBrickCount-i)]);
            brick.health = 5;
          }
        });

        this.addWorm(undefined, undefined, this.bricks, 50);
      },
    };
    const nLevels = Object.keys(levels).length;
    levels?.[((this.level - 1) % nLevels)]?.();

    this.stopSound(this.bkgSong);

    this.bkgSong = this.bkgSongs[(this.level - 1) % this.bkgSongs.length];
    this.playSound(this.bkgSong, 1, 0.28, true);
  },
  spawnLevel(config) {
    const defaults = {
      blockWidth: BRICK_SIZE[0],
      blockHeight: BRICK_SIZE[1],
      screenWidth: this.width,
      screenHeight: this.height,
      brickGutterPx: 1.5,
      xBrickPad: 1,
      yBrickPad: 0,
      playAreaPxBot: 180,
      playAreaPxTop: 60,
      playAreaPxLeft: 0,
      playAreaPxRight: 0,
      predicate: ({ x, y }) => this.addBrick(x, y),
    };
    const {
      blockWidth,
      blockHeight,
      screenWidth,
      screenHeight,
      brickGutterPx,
      xBrickPad,
      yBrickPad,
      playAreaPxBot,
      playAreaPxTop,
      playAreaPxLeft,
      playAreaPxRight,
      predicate,
    } = { ...defaults, ...config };
    const brickAreaW = screenWidth - playAreaPxRight - playAreaPxLeft;
    const brickAreaH = screenHeight - playAreaPxBot - playAreaPxTop;
    const bW = blockWidth + brickGutterPx;
    const bH = blockHeight + brickGutterPx;
    const xBrickCount = Math.floor(brickAreaW / bW);
    const yBrickCount = Math.floor(brickAreaH / bH);
    const rW = brickAreaW % bW;
    const rH = brickAreaH % bH;
    const sx = playAreaPxLeft + rW / 2 + bW / 2;
    const sy = playAreaPxTop + rH / 2 + bH / 2;
    for (let i = xBrickPad; i < xBrickCount - xBrickPad; i++) {
      const x = sx + i * bW;
      for (let j = yBrickPad; j < yBrickCount; j++) {
        const y = sy + j * bH;
        predicate({ x, y, i, j, xBrickCount, yBrickCount });
      }
    }
  },

  // events
  onLifeLost() {
    this.playSound('gameover', 1 + Math.random()*.2);
    this.showMenu();
    this.megaShake(this.camera, 30);
    this.tween(livesContainer.style, 'scale',
      [1, 0.7, 2, .8, 1.5, .9, 1.2, .95, 1], 680, 540,
      () => livesContainer.innerText = `${this.lives}`
    );
    this.onResetLevel();
  },
  onDeath() {
    this.showMenu();

  },
  onWinLevel() {
    this.onResetLevel();
    this.bricks = [];
    this.spawnNextLevel();
    this.showMenu();
  },
  onResetLevel() {
    this.balls = [];
    this.powerUps = [];
    this.paddles = [];
    this.addBall(this.width/2, Game.height - 60);
    const b = this.balls[this.balls.length - 1];
    b.dy = -Math.abs(b.dy);
    this.addPaddle(this.width / 2);
  },
  onStartGame() {
    this.paddles.forEach(p => {
      p.x = this.width/2;
    });
    this.hideMenu();
  },
  onResetGame() {
    this.showMenu();
    this.onResetLevel();
    this.bricks = [];
    this.gutterGuardHealth = 0;
    this.score = 0;
    this.lives = 3;
    this.level = 0;
    this.spawnNextLevel();
    livesContainer.innerText = `${this.lives}`;
  },
  hideMenu() {
    this.playSound('menu_open', 1 + Math.random()*2);
    this.tween(menuPaused.style, 'opacity', [1,0], 300, 80, () => {
      menuPaused.style.opacity = 1;
      menuPaused.style.scale = 1;
      menuPaused.style.display = 'none';
      this.isPaused = false;
    });
    this.tween(titleDiv.style, 'scale', [1,1.1,0.5], 380);
    this.tween(titleDiv.style, 'opacity', [0,1], 300);
    this.tween(startButton.style, 'scale', [1,1.1,0.5], 380);
    this.tween(startButton.style, 'opacity', [0,1], 300);
  },
  showMenu() {
    this.playSound('menu_open', 1 + Math.random()*2);
    this.isPaused = true;
    menuPaused.style.display = 'flex';
    titleDiv.innerText = `Level ${this.level}`;
    this.tween(titleDiv.style, 'scale', [0.5, 0.4, 1.1, 1], 380);
    this.tween(titleDiv.style, 'opacity', [0,1], 300);
    this.tween(startButton.style, 'scale', [0.5, 0.4, 1.1, 1], 380);
    this.tween(startButton.style, 'opacity', [0,1], 300);
    this.tween(menuPaused.style, 'opacity', [0,1], 300);
  },

  audioCtx: new AudioContext(),
  masterGainNode: null,
  audioFiles: new Map(),
  audioSources: new Map(),
  isAudioMuted: false,
  hitSounds: [
    'hit_c5',
    'hit_d5',
    'hit_e5',
    'hit_f5',
    'hit_g5',
    'hit_a5',
    'hit_b5',
    'hit_c6',
    'hit_c5_chord',
    'hit_d5_chord',
    'hit_e5_chord',
    'hit_f5_chord',
  ],
  bkgSongs: [
    'song_1',
    'song_2',
    'song_3',
    'song_4',
    'song_5',
    'song_6',
    'song_7',
    'song_8',
    'song_9',
    'song_10',
  ],
  async preload() {
    const sounds = [
      'paddle_hit',
      'edge_hit',
      'menu_open',
      'power_ball',
      'power_speed_up',
      'power_speed_down',
      'power_split',
      'power_omega',
      'power_omega_boom',
      'power_laser',
      'power_life',
      'power_gg',
      'power_big_ball',
      'power_big_paddle',
      'turn_on',
      'turn_off',
      'gameover',
      ...this.hitSounds,
      ...this.bkgSongs
    ];
    this.masterGainNode = this.audioCtx.createGain();
    this.masterGainNode.connect(this.audioCtx.destination);

    return Promise.all(sounds.map(sound => 
      fetch(`/sounds/bronc/${sound}.ogg`)
        .then(res => {
          if (!res.ok) throw new Error(`failed to load ${sound}`);
          return res.arrayBuffer();
        })
        .then(ab => this.audioCtx.decodeAudioData(ab))
        .then(buff => {
          this.audioSources.set(sound, new Set());
          this.audioFiles.set(sound, buff);
        }).catch((e) => {
          console.error(e);
        })
    ));
  },
  playSound(name, pitch = 1, volume = 1, loop = false, onComplete = undefined) {
    if (!this.audioFiles.has(name)) {
      console.warn('woops no file to play', name);
      return;
    }

    const sources = this.audioSources.get(name);
    const buff = this.audioFiles.get(name);
    const source = this.audioCtx.createBufferSource();
    source.buffer = buff;
    source.playbackRate.value = pitch;
    source.loop = loop;

    const gainNode = this.audioCtx.createGain(); 
    gainNode.gain.value = volume;
    
    source.connect(gainNode);
    gainNode.connect(this.masterGainNode);

    sources.add(source);
    source.onended = () => {
      sources.delete(source);
      source.disconnect(gainNode);
      gainNode.disconnect(this.masterGainNode);

      if (onComplete) {
        onComplete();
      }
    };
    source.start(0);
  },
  stopSound(name) {
    if (!this.audioFiles.has(name)) {
      console.warn('woops cannot stop missing file', name);
      return;
    }

    const sources = this.audioSources.get(name);
    sources.forEach(s => {
      s.stop(0);
    });
  },
  stopAllSounds() {
    this.audioFiles.forEach((_, name) => {
      this.stopSound(name);
    });
  },
  muteAudio(isMuted = false) {
    this.isMuted = isMuted;
    this.masterGainNode.gain.setTargetAtTime(
      isMuted ? 0 : 1,
      this.audioCtx.currentTime,
      0.1
    );
    soundToggle.innerText = `Sound ${isMuted ? 'Off' : 'On'}`;
    soundToggle.style.boxShadow = isMuted ? '0px -1px 0 0px #fff6, 0px 1px 0 #fff' : '0px 1px 0 1px #fff4, 0px -1px 0 #fff';
    Game.tween(soundToggle.style, 'opacity', isMuted ? [1, 0.5] : [0.5, 1], 300);
  }
};

container.addEventListener("pointermove", (e) =>
  Game.handleMouseOrTouchInput(e)
);
container.addEventListener("touchmove", (e) => Game.handleMouseOrTouchInput(e));
startButton.addEventListener("click", (e) => {
  Game.onStartGame();
});
soundToggle.addEventListener('click', () => {
  Game.muteAudio(!Game.isMuted);
  Game.playSound(Game.isMuted ? 'turn_off' : 'turn_on', 1 + Math.random()*0.2, 2);
});
soundToggle.addEventListener('pointerdown', () => {
  Game.tween(soundToggle.style, 'scale', [1,1.1,0.8], 200);
});
soundToggle.addEventListener('pointerup', () => {
  Game.tween(soundToggle.style, 'scale', [0.8,0.75,1.1,1], 200);
})

let lastTime = 0;
function universe(delta) {
  if (lastTime === 0) {
    lastTime = delta;
  }

  const dt = (delta - lastTime) / 1000;
  Game.tick(dt);
  Game.draw();

  lastTime = delta;
  frameId = requestAnimationFrame(universe);
}

// document.addEventListener('keydown', e => {
//   Game.bricks = [];
// });

// load and start it all
Game.preload().then(() => {
  Game.onResetGame();
  Game.muteAudio(false);
  universe(lastTime);
});

container.addEventListener('unload', () => {
  cancelAnimationFrame(frameId);
  Game.stopAllSounds();
});

function distance(p1x, p1y, p2x, p2y) {
  return Math.sqrt((p1x - p2x)*(p1x - p2x) + (p1y - p2y)*(p1y - p2y));
}

Очень даже неплохо. 10 очков Слизерину!

Теперь можно сделать так, чтобы после каждого убийства босса все кирпичи становились прочнее, а игра — быстрее. Можно даже спавнить по два босса за один цикл «Game».

Кажется, мы уже понимаем, куда всё это движется.

Числа растут

Если уж быть занудой, игра — не игра, если это не roguelike или roguelite. Не я придумал правила: это игроки, когда перестали покупать игры и начали играть в азартные игры. Мы знаем, как это делается. После каждого раунда показывай три карты. Каждая карта — это постоянный power up для всех следующих уровней.

Похлопай себя по плечу: всё это время все power ups и так были довольно мультипликативными.

  • +1 к урону мяча

  • Больше ракетка

  • Быстрее заряд Омега-устройства

  • Большие мячи (мячи стартуют больше)

  • +1 защитник жёлоба

  • Splitter (мячи разделяются при ударе о ракетку)

  • Wingman (маленькие авто-отслеживающие ракетки)

В какой-то момент игрок всё равно должен проиграть. Тогда можно дать ему потратить накопленный счёт на перманентные улучшения. Больше стартовых жизней, больше ракетка, заменить фирменную силу Омега-устройства на Chthonic Cannon.

Важно ещё и собирать статистику. Например, сколько раз мяч ударялся о край, сколько было power ups, максимальный размер мяча, сколько мячей потеряно и так далее. Можно добавить достижения: набрал определённые показатели — открыл больше карт. Да чего уж там, можно добавить чисто косметические скины, вроде разных эффектов удара мяча.

Пока отложим эти возможности и добавим последний слой полировки, чтобы стянуть текущий опыт в более плотный вид.

Анимировать появление кирпичей

brick.scale = 0;
// location based
Game.tween(brick, 'scale', [0, 1], 480, distance(i, j, midX, midY) * 60);
// directional
Game.tween(brick, 'scale', [0, 1], 480, (i+j) * 60);

Сделать так, чтобы кирпичи «пульсировали» на основе анимации входа

Мы могли бы так сделать, но это ощущалось как слишком много движения. Хотя это несложно: добавь бесконечный tween после завершения входной анимации.

brick.scale = 0;
// location based
Game.tween(brick, 'scale', [0, 1], 480, distance(i, j, midX, midY) * 60);
// directional
Game.tween(brick, 'scale', [0, 1], 480, (i+j) * 60, () => {
  Game.tween(brick, 'scale', [1,.9,1], 480 (i+j)*60, undefined, Infinity);
});

Подкрутить анимацию текста

Текст немного пресный — это можно исправить нашим tween-движком.

makeFancyText(element, text, height = 3, durationMs = 2000, staggerMs = 120) {
  element.innerHTML = ''; 
  text.split("").forEach((char, index) => {
    const span = document.createElement('span');
    span.textContent = char;
    span.style.display = "inline-block";
    span.style.whiteSpace = "pre";
    span.style.translate = "0 calc(var(--y, 0) * 1px)";
    element.appendChild(span);

    const proxy = {
      y: 0,
      set y(val) {
        span.style.setProperty('--y', `${val}`);
      }
    }
    this.tween(proxy, 'y', [0, -height, 0, height, 0], durationMs, staggerMs * index, undefined, Infinity);
  });
}

В CSS для некоторых свойств нужно указывать единицы измерения. Наш tween-движок твинает только числа — и это отлично, поэтому маленький обходной путь здесь такой: использовать proxy, чтобы перехватывать обновления поля.

Power Ups

Power ups выглядят немного уныло. Добавив tween с ещё одним слоем, можно сделать им «затменный» вид, который мне кажется приятным.

Фейковый CRT-эффект

Было бы интересно добавить фейковый CRT-эффект. Достаточно неплохо работает SVG-фильтр, который делает так называемую хроматическую аберрацию, а сверху можно накинуть повторяющийся градиентный паттерн для «сканлайнов». Это не даёт той самой пиксельной кривизны экрана, которую ты мог бы ожидать, но выглядит вполне симпатично.

Естественно, это можно отключать. Игра — не игра, если пользователи не могут настроить её под себя.

<svg width="0" height="0" style="position:absolute;">
  <defs>
    <filter id="full-crt">
      <feColorMatrix in="SourceGraphic" type="matrix" 
        values="1 0 0 0 0  
                0 0 0 0 0  
                0 0 0 0 0  
                0 0 0 1 0" result="redChannel" />
      <feColorMatrix in="SourceGraphic" type="matrix" 
        values="0 0 0 0 0  
                0 1 0 0 0  
                0 0 1 0 0  
                0 0 0 1 0" result="greenBlueChannel" />
      <feOffset in="redChannel" dx="-1" dy="0" result="redShifted" />
      <feOffset in="greenBlueChannel" dx="1" dy="0" result="greenBlueShifted" />
      <feBlend in="redShifted" in2="greenBlueShifted" mode="screen" />
    </filter>
  </defs>
</svg>

По сути, это сдвигает цветовые каналы влево и вправо, давая по всей игре 1-пиксельное красно-циановое «смешение». Назовём это шейдером для бедных.

Индикатор Омега-устройства

Игрок не понимает, насколько заряжено Омега-устройство. Скопируй счётчик жизней, перенеси его вправо, добавь фиолетовую тень — и вопрос закрыт.

Отлично. Теперь у нас есть игра.

Исходный код
const container = document.getElementById(containerId);
const screenWidth = container.clientWidth;
const screenHeight = container.clientHeight;

// create menu ui
const menuPaused = document.createElement('div');
container.appendChild(menuPaused);
Object.assign(menuPaused.style, {
  position: 'absolute',
  top: 0,
  left: 0,
  width: '100%',
  height: '100%',
  backdropFilter: 'blur(2px)',
  backgroundColor: '#0008',
  display: 'flex',
  flexDirection: 'column',
  justifyContent: 'center',
  letterSpacing: '2px',
  alignItems: 'center',
  gap: '32px',
  zIndex: 1,
});
const titleDiv = document.createElement('div');
titleDiv.innerText = 'LOADING';
Object.assign(titleDiv.style, {
  fontSize: '32px',
  fontWeight: 'bold',
  textAlign: 'center',
  textTransform: 'uppercase',
  textShadow: '#0008 1px 1px 0px, red 3px 1px 0px, red 1px 2px 0px',
});

const msgDiv = document.createElement('div');
const soundToggle = document.createElement('button');
soundToggle.innerText = 'Audio On';
Object.assign(soundToggle.style, {
  position: 'absolute',
  bottom: '8px',
  right: '8px',
  padding: '6px 12px',
  fontSize: '12px',
  border: 'none',
  textShadow: '1px 2px #fff2',
  color: '#fffb',
  background: 'transparent',
  outline: 'none',
  borderRadius: '32px',
  boxShadow: '0px -1px 0 0px #fff8, 0px 2px 0 #fff',
});

const crtToggle = document.createElement('button');
crtToggle.innerText = 'Crt On';
Object.assign(crtToggle.style, {
  position: 'absolute',
  bottom: 'calc(8px + 26px + 8px)',
  right: '8px',
  padding: '6px 12px',
  fontSize: '12px',
  border: 'none',
  textShadow: '1px 2px #fff2',
  color: '#fffb',
  background: 'transparent',
  outline: 'none',
  borderRadius: '32px',
  boxShadow: '0px -1px 0 0px #fff8, 0px 2px 0 #fff',
});

const startButton = document.createElement('button');
startButton.innerText = 'START';
Object.assign(startButton.style, {
  padding: '8px 16px',
  minHeight: '32px',
  minWidth: '120px',
  fontSize: '16px',
  letterSpacing: '4px',
  border: 'none',
  boxShadow: '0 0 0 1px #fffa, 0 0 0 2px #000, 0 0 0 3px #fffa',
  textShadow: '1px 2px #fff2',
  color: '#fffb',
  background: 'transparent',
  outline: 'none',
})
menuPaused.appendChild(titleDiv);
menuPaused.appendChild(soundToggle);
menuPaused.appendChild(crtToggle);
menuPaused.appendChild(startButton);

const ballDiv = document.createElement("div");
container.appendChild(ballDiv);
Object.assign(ballDiv.style, {
  position: "absolute",
  left: "0",
  top: "0",
});
const BallTypes = {
  Normal: 0,
  PowerBall: 1,
  Laser: 2,
  PowerLaser: 3,
};
const BallColors = {
  [BallTypes.Normal]: [255,255,255],
  [BallTypes.PowerBall]: [255,55,55],
  [BallTypes.Laser]: [55,255,255],
  [BallTypes.PowerLaser]: [255,70,70],
};

const brickDiv = document.createElement("div");
container.appendChild(brickDiv);
const BRICK_SIZE = [36, 12];
const BrickTypes = {
  Normal: 0,
  Unbreakable: 1,
  MultiBall: 2,
  PowerBall: 3,
  Explode: 4,
}
Object.assign(brickDiv.style, {
  position: "absolute",
  borderRadius: "2px",
  left: `-${BRICK_SIZE[0]}px`,
  top: `-${BRICK_SIZE[1]}px`,
  width: `${BRICK_SIZE[0]}px`,
  height: `${BRICK_SIZE[1]}px`,
});

const paddleDiv = document.createElement("div");
container.appendChild(paddleDiv);
const PADDLE_SIZE = [70, 10];
const PaddleTypes = {
  Normal: 0,
  Laser: 1,
};
const PaddleColors = {
  [PaddleTypes.Normal]: [255, 255, 255],
  [PaddleTypes.Laser]: [55, 255, 255],
};
Object.assign(paddleDiv.style, {
  position: "absolute",
  left: `-${PADDLE_SIZE[0]}px`,
  top: `-${PADDLE_SIZE[1]}px`,
  width: `${PADDLE_SIZE[0]}px`,
  height: `${PADDLE_SIZE[1]}px`,
});

const powerUpDiv = document.createElement("div");
container.appendChild(powerUpDiv);
const POWER_UP_SIZE = 16;
Object.assign(powerUpDiv.style, {
  position: "absolute",
  left: `-${POWER_UP_SIZE}px`,
  top: `-${POWER_UP_SIZE}px`,
  width: `${POWER_UP_SIZE}px`,
  height: `${POWER_UP_SIZE}px`,
  borderRadius: '9000px',
});
const PowerUpTypes = {
  MultiBall: 0,
  BigBall: 1,
  PowerBall: 2,
  PaddleSize: 3,
  GutterGuard: 4,
  LaserPaddle: 5,
  ExtraLife: 6,
  BonusPoints: 7,
  OmegaDevice: 8,
  SpeedUp: 9,
  SpeedDown: 10,
};
const PowerUpColors = {
  [PowerUpTypes.MultiBall]: [255, 200, 255],
  [PowerUpTypes.BigBall]: [200, 200, 255],
  [PowerUpTypes.PowerBall]: [255, 50, 50],
  [PowerUpTypes.ExtraLife]: [100, 255, 100],
  [PowerUpTypes.SpeedUp]: [100, 255, 255],
  [PowerUpTypes.SpeedDown]: [255, 100, 255],

  [PowerUpTypes.LaserPaddle]: [200, 255, 255],
  [PowerUpTypes.OmegaDevice]: [255, 0, 255],
  [PowerUpTypes.BonusPoints]: [255, 255, 0],
  [PowerUpTypes.PaddleSize]: [255, 255, 255],
  [PowerUpTypes.GutterGuard]: [255, 255, 255],
};
const PowerUpEffects = {
  [PowerUpTypes.MultiBall]: (game, powerUp) => {
    game.balls.map(b => [b.x, b.y]).forEach(([x,y]) => game.addBall(x,y));
    game.playSound('power_split', 1+Math.random()*.2, 1.2);
  },
  [PowerUpTypes.BigBall]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.size *= 2;
    });
    game.playSound('power_big_ball', 1+Math.random()*.2, 1);
  },
  [PowerUpTypes.PaddleSize]: (game, powerUp) => {
    game.paddles.forEach(paddle => {
      paddle.width += PADDLE_SIZE[0] * 0.3;
    });
    game.playSound('power_big_paddle', 1+Math.random()*.2, 1.4);
  },
  [PowerUpTypes.GutterGuard]: (game, powerUp) => {
    game.gutterGuardHealth += 3;
    game.tween(gutterDiv.style, 'scale', this.gutterGuardHealth > 0 ? [1, 1] : [0, 1.1, 1], 280);
    game.playSound('power_gg', 1+Math.random()*.2);
  },
  [PowerUpTypes.BonusPoints]: (game, powerUp) => {
    game.updateScore(Math.floor(powerUp.scale * 1000));
    game.playSound('hit_c5_chord', 1+Math.random()*.2);
  },
  [PowerUpTypes.OmegaDevice]: (game, powerUp) => {
    game.updateSteinPower(1);
    game.playSound('power_omega', 1 + (0.5*game.omegaDevicePower) + Math.random()*.2);
    if (game.omegaDevicePower >= 6) {
      game.updateSteinPower(-6);
      for (const brick of game.bricks) {
        game.damageBrick(brick);
      }
      game.megaShake(game.camera, 30);
      game.playSound('gameover', 1 + (0.5*game.omegaDevicePower) + Math.random()*.2);
    }
  },
  [PowerUpTypes.LaserPaddle]: (game, powerUp) => {
    game.paddles.forEach(paddle => {
      paddle.type = PaddleTypes.Laser;
      paddle.baseColor = PaddleColors[PaddleTypes.Laser];
      game.shakeIt(paddle, 6, [0, 1]);
      game.flashIt(paddle);
      game.tween(paddle, 'scale', [1, 0.8, 1.2, 1], 280);
    });
    game.playSound('power_laser', 1 + Math.random()*.2);
  },
  [PowerUpTypes.PowerBall]: (game, powerUp) => {
    game.addBall(powerUp.x, powerUp.y, BallTypes.PowerBall);
    game.balls[game.balls.length-1].dy = -Math.abs(game.balls[game.balls.length-1].dy);
    game.playSound('power_ball', 1+Math.random()*.2);
  },
  [PowerUpTypes.ExtraLife]: (game, powerUp) => {
    game.updateLives(1);
    game.playSound('power_life', 1+Math.random()*.2);
  },
  [PowerUpTypes.SpeedUp]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.dx *= 1.25;
      b.dy *= 1.25;
    });
    game.playSound('power_speed_up', 1+Math.random()*.2);
  },
  [PowerUpTypes.SpeedDown]: (game, powerUp) => {
    game.balls.forEach(b => {
      b.dx *= .9;
      b.dy *= .9;
    });
    game.playSound('power_speed_down', 1+Math.random()*.2);
  },
};

const gutterDiv = document.createElement("div");
const GUTTER_GUARD_SIZE = [screenWidth, 8];
container.appendChild(gutterDiv);

const scoreContainer = document.createElement("div");
container.appendChild(scoreContainer);
Object.assign(scoreContainer.style, {
  position: "absolute",
  top: 0,
  right: 0,
  fontSize: "16px",
  width: "100%",
  fontWeight: 'bold',
  letterSpacing: '2px',
  marginTop: "min(5%, 1.5rem)",
  textAlign: "center",
  textShadow: '1px 4px 0 #000a',
  zIndex: 2,
});

const livesContainer = document.createElement("div");
container.appendChild(livesContainer);
Object.assign(livesContainer.style, {
  position: "absolute",
  top: '16px',
  left: '16px',
  fontSize: "22px",
  fontWeight: 'bold',
  width: "100%",
  textAlign: "left",
  textShadow: '#0008 1px 1px 0px, #44ff44 3px 1px 0px, #44ff44 1px 2px 0px',
  zIndex: 2,
  width: 'fit-content',
});

const steinPowerMeterContainer = document.createElement("div");
container.appendChild(steinPowerMeterContainer);
Object.assign(steinPowerMeterContainer.style, {
  position: "absolute",
  top: '16px',
  right: '16px',
  fontSize: "18px",
  fontWeight: 'bold',
  width: "100%",
  textAlign: "left",
  letterSpacing: '2px',
  textShadow: '#0008 1px 1px 0px, #a850ffff 3px 1px 0px, #ab58ffff 1px 2px 0px',
  zIndex: 2,
  width: 'fit-content',
});

const crtEffectDef = document.createElement('div');
container.appendChild(crtEffectDef);
Object.assign(crtEffectDef.style, {
  position: "absolute",
  width: '100%',
  height: '100%',
  zIndex: 3,
  pointerEvents: 'none',
  opacity: 0.5,
  background: 'repeating-linear-gradient(180deg,#000 0px,#000 1px,transparent 1px,transparent 3px)'
});
crtEffectDef.innerHTML = `
<svg width="0" height="0" style="position:absolute;">
  <defs>
    <filter id="full-crt">
      <feColorMatrix in="SourceGraphic" type="matrix" 
        values="1 0 0 0 0  
                0 0 0 0 0  
                0 0 0 0 0  
                0 0 0 1 0" result="redChannel" />
      <feColorMatrix in="SourceGraphic" type="matrix" 
        values="0 0 0 0 0  
                0 1 0 0 0  
                0 0 1 0 0  
                0 0 0 1 0" result="greenBlueChannel" />
      <feOffset in="redChannel" dx="-1" dy="0" result="redShifted" />
      <feOffset in="greenBlueChannel" dx="1" dy="0" result="greenBlueShifted" />
      <feBlend in="redShifted" in2="greenBlueShifted" mode="screen" />
    </filter>
  </defs>
</svg>
`;

const lerp = (from, to, time) => from + (to - from) * time;

const Game = {
  isPaused: false,
  isCrtOn: true,
  isMuted: false,
  score: 0,
  lives: 3,
  level: 1,
  frame: 0,
  gutterGuardHealth: 5,
  omegaDevicePower: 0,
  camera: {
    scale: 1,
    offsetX: 0,
    offsetY: 0,
  },
  width: screenWidth,
  height: screenHeight,
  balls: [],
  bricks: [],
  paddles: [],
  powerUps: [],
  worms: [],
  tweens: new Set(),
  bkgSong: '',

  tween(target, prop, keyframes, duration, delay = 0, onComplete = undefined, iterations = 1) {
    this.tweens.add({
      target,
      prop,
      keyframes,
      t: 0,
      duration: duration / 1000,
      iterations,
      delay: delay / 1000,
      onComplete,
    });
  },

  handleMouseOrTouchInput(event) {
    if (event.target !== container) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();
    const x = event?.touches?.[0]?.clientX ?? event.offsetX;
    const y = event?.touches?.[0]?.clientY ?? event.offsetY;
    this.paddles.forEach(
      (p) =>
        (p.x = Math.min(this.width - p.width / 2, Math.max(p.width / 2, x)))
    );
  },

  addPaddle(x = this.width / 2, y = this.height - PADDLE_SIZE[1] * 2, type = PaddleTypes.Normal) {
    this.paddles.push({
      x,
      y,
      width: PADDLE_SIZE[0],
      height: PADDLE_SIZE[1],
      type,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      baseColor: [...PaddleColors[type]],
      color: [...PaddleColors[type]],
    });
  },
  addBall(x = this.width / 2, y = this.height / 2, type = BallTypes.Normal, speed = 210) {
    const thresholdDegrees = 20;
    const arc = thresholdDegrees * (Math.PI / 180);
    let angle = arc + Math.random() * (Math.PI - 2 * arc);
    this.balls.push({
      x,
      y,
      dx: Math.cos(angle) * speed,
      dy: Math.sin(angle) * speed,
      size: 10,
      isAlive: true,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      comboCount: 0,
      baseColor: [...BallColors[type]],
      color: [...BallColors[type]],
      type,
      lastHits: new Set(),
    });

    if (BallTypes.Laser === type || type === BallTypes.PowerLaser) {
      const ball = this.balls[this.balls.length-1];
      ball.dy = -speed - Math.random()*100;
      ball.dx *= 0.5;
      this.tween(ball, 'size', [12,18,6,16,6], 320, 0, undefined, Infinity);
    }
  },
  addBrick(x = this.width / 2, y = this.height / 2, type = 0, color = [255,255,255]) {
    this.bricks.push({
      x,
      y,
      type,
      health: type === BrickTypes.Unbreakable ? Infinity : 1,
      isAlive: true,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      baseColor: color,
      color
    });

    return this.bricks[this.bricks.length -1];
  },
  addPowerUp(x = this.width / 2, y = this.height / 2, type = PowerUpTypes.MultiBall, speed = 160) {
    this.powerUps.push({
      x,
      y,
      dx: 0,
      dy: speed,
      type,
      isAlive: true,
      size: 16,
      scale: 1,
      offsetX: 0,
      offsetY: 0,
      s1x: 2,  s1y: 0,
      color: [...PowerUpColors[type]],
    });
    const p = this.powerUps[this.powerUps.length - 1];
    Game.tween(p, 's1x', [1, 0.707, 0, -0.707, -1, -0.707, 0, 0.707, 1], 480, 0, undefined, Infinity);
    Game.tween(p, 's1y', [0, 0.707, 1, 0.707, 0, -0.707, -1, -0.707, 0],  480, 0, undefined, Infinity);
    Game.tween(p, 'scale', [1, .9, 1, 1.1, 1], 480, 0, undefined, Infinity);
  },
  addWorm(x = this.width/2, y = this.height/2, bricks = [], size = 30, speed = 300) {
    this.worms.push({
      x,
      y,
      dx: 0,
      dy: 0,
      size,
      tail: bricks,
      targetX: x,
      targetY: y,
      speed
    });
  },

  tick(dt) {
    this.frame++;

    if (!this.isPaused) {
      this.tickBalls(dt);
      this.balls = this.balls.filter((ball) => ball.isAlive);
      this.bricks = this.bricks.filter((brick) => brick.isAlive);
      this.tickPowerUps(dt);
      this.powerUps = this.powerUps.filter((pow) => pow.isAlive);
      this.tickWorms(dt);

      if (this.balls.length === 0) {
        if (this.lives > 0) {
          this.lives--;
          this.onLifeLost();
        } else {
          this.onResetGame();
        }
      }
      if (this.bricks.filter(b => b.type !== BrickTypes.Unbreakable).length === 0) {
        this.onWinLevel();
      }
    }

    this.tickTweens(dt);
  },

  tickPowerUps(dt) {
    for (const power of this.powerUps) {
      power.x += power.dx * dt;
      power.y += power.dy * dt;
      const halfSize = power.size/2;
      let left = power.x - halfSize;
      let right = power.x + halfSize;
      let top = power.y - halfSize;
      let bot = power.y + halfSize;

      power.scale = 1 + 0.1*Math.sin(this.frame*.2);

      // check paddles
      for (const p of this.paddles) {
        const pL = p.x - p.width / 2;
        const pR = p.x + p.width / 2;
        const pT = p.y - p.height / 2;
        const pB = p.y + p.height / 2;
        if (left > pR || right < pL) {
          continue;
        }
        if (top > pB || bot < pT) {
          continue;
        }
        power.isAlive = false;

        this.shakeIt(p, 3, [0, 1]);
        this.flashIt(p, power.color);
        break;
      }

      if (power.y > this.height) {
        power.isAlive = false;
        continue;
      }

      if (power.isAlive) {
        continue;
      }

      PowerUpEffects[power.type]?.(this, power);
    }
  },
  tickBalls(dt) {
    const { width: w, height: h } = this;

    this.balls.forEach((b) => {
      if (!b.isAlive) {
        return;
      }

      let didBounce = false;
      let edgeBounce = false;
      let paddleBounce = false;
      let brickBounce = false;
      let ballDx = b.dx * dt;
      let ballDy = b.dy * dt;
      const bHalfSize = b.size / 2;

      // check bricks.
      const [brickWidth, brickHeight] = BRICK_SIZE;
      // x axis
      b.x += ballDx;
      let ballL = b.x - bHalfSize;
      let ballR = b.x + bHalfSize;
      let ballT = b.y - bHalfSize;
      let ballB = b.y + bHalfSize;
      for (const brick of this.bricks) {
        if (!brick.isAlive || brick.health <= 0 || b.lastHits.has(brick)) {
          continue;
        }

        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if (
          ballT > brickB ||
          ballB < brickT ||
          ballL > brickR ||
          ballR < brickL
        ) {
          continue;
        }

        if ((b.type !== BallTypes.PowerBall && b.type !== BallTypes.PowerLaser) || brick.type === BrickTypes.Unbreakable) {
          b.x = ballDx > 0 ? brickL - bHalfSize - 1 : brickR + bHalfSize + 1;
          b.dx *= -1;
        }
        brickBounce = true;
        b.isAlive = BallTypes.Laser !== b.type;
        this.damageBrick(brick);
        if (BallTypes.Normal !== b.type) {
          b.lastHits.add(brick);
        }
        break;
      }

      // y axis
      b.y += ballDy;
      ballL = b.x - bHalfSize;
      ballR = b.x + bHalfSize;
      ballT = b.y - bHalfSize;
      ballB = b.y + bHalfSize;
      for (const brick of this.bricks) {
        if (!brick.isAlive || brick.health <= 0 || b.lastHits.has(brick)) {
          continue;
        }

        const brickL = brick.x - brickWidth / 2;
        const brickR = brick.x + brickWidth / 2;
        const brickT = brick.y - brickHeight / 2;
        const brickB = brick.y + brickHeight / 2;
        if (
          ballT > brickB ||
          ballB < brickT ||
          ballL > brickR ||
          ballR < brickL
        ) {
          continue;
        }

        if ((b.type !== BallTypes.PowerBall && b.type !== BallTypes.PowerLaser) || brick.type === BrickTypes.Unbreakable) {
          b.y = ballDy > 0 ? (b.y = brickT - bHalfSize - 1) : brickB + bHalfSize + 1;
          b.dy *= -1;
        }
        brickBounce = true;
        b.isAlive = BallTypes.Laser !== b.type;
        this.damageBrick(brick);
        if (BallTypes.Normal !== b.type) {
          b.lastHits.add(brick);
        }
        break;
      }

      // check bounds x
      if (b.x + b.size / 2 > w || b.x < b.size / 2) {
        b.x = ballDx > 0 ? w - b.size / 2 : b.size / 2;
        b.dx *= -1;
        this.shakeIt(this.camera, 3);
        edgeBounce = true;
      }
      // bounds y
      if (b.y < b.size / 2) {
        b.y = ballDy > 0 ? h - b.size / 2 : b.size / 2;
        b.dy *= -1;
        this.shakeIt(this.camera, 3);
        edgeBounce = true;
      }
      // y bottom
      const ggh = GUTTER_GUARD_SIZE[1];
      if (this.gutterGuardHealth > 0 && b.y + b.size/2 >= h - ggh) {
        b.y = this.height - ggh - b.size/2 - 1;
        b.dy *= -1;
        this.gutterGuardHealth--;
        this.tween(gutterDiv.style, 'opacity', [1, 0.5, 1], 280);
        this.tween(gutterDiv.style, 'marginTop', this.gutterGuardHealth > 1 ? [1, 1] : [1, 1.1, 0], 280);
        edgeBounce = true;
      } else if (b.y + b.size / 2 > h) {
        b.isAlive = false;
      }

      // check paddles
      for (const p of this.paddles) {
        if (b.type === BallTypes.Laser) continue;

        const pL = p.x - p.width / 2;
        const pR = p.x + p.width / 2;
        const pT = p.y - p.height / 2;
        const pB = p.y + p.height / 2;
        if (ballL > pR || ballR < pL) {
          continue;
        }
        if (ballT > pB || ballB < pT) {
          continue;
        }
        paddleBounce = true;
        const xDif = Math.min(ballR - pL, pR - ballL);
        const yDif = Math.min(ballB - pT, pB - ballT);
        if (xDif < yDif) {
          b.x += b.x > p.x ? xDif : -xDif;
        }
        b.y = p.y - p.height / 2 - b.size/2 - 1;

        const angle = Math.atan2(
          b.y - (p.y - p.height / 2 + p.width / 2),
          b.x - p.x
        );
        const speed = Math.sqrt(b.dx * b.dx + b.dy * b.dy);
        b.dx = Math.cos(angle) * speed;
        b.dy = Math.sin(angle) * speed;

        this.shakeIt(p, 3, [0, 1]);
        this.flashIt(p);

        if (p.type === PaddleTypes.Laser) {
          const type = b.type === BallTypes.PowerBall ? BallTypes.PowerLaser : BallTypes.Laser;
          this.addBall(b.x, b.y+b.size/2, type, 480);
          this.addBall(b.x, b.y+b.size/2, type, 480);
          this.addBall(b.x, b.y+b.size/2, type, 480);
          this.addBall(b.x, b.y+b.size/2, type, 480);
        }
        break;
      }

      didBounce = edgeBounce || brickBounce || paddleBounce;
      if (!didBounce) {
        return;
      }
      if (!brickBounce) {
        b.lastHits.clear();
      }
      if (edgeBounce) this.playSound('edge_hit', 0.5 + Math.random()*0.2, 1);
      if (paddleBounce) {
        this.playSound('paddle_hit', 1 + Math.random()*0.1, .4);
        b.comboCount = 0;
      }
      if (brickBounce) {
        const cycle = Math.floor(b.comboCount / this.hitSounds.length);
        const boost = 0.20;
        const rndPitch = Math.random()*.1;
        this.playSound(this.hitSounds[b.comboCount % this.hitSounds.length], 1 + boost*cycle + rndPitch, 2.2);
        b.comboCount++;
      }

      this.tween(b, "scale", [0.8, 1.2, 1], 60, 0, () => {
        if (b.type === BallTypes.Laser) {
          b.isAlive = false;
        }
        if (b.type === BallTypes.PowerLaser && edgeBounce) {
          b.isAlive = false;
        }
      });
      this.flashIt(b);
    });
  },
  tickWorms(dt) {
    const playAreaHeight = this.height - 120; 
    const bW = BRICK_SIZE[0]+1;
    const bH = BRICK_SIZE[1]+1;
        
    for (const worm of this.worms) {
      let { targetX, targetY, speed } = worm;
      worm.tail = worm.tail.filter(b => b.isAlive);

      const dirX = targetX - worm.x;
      const dirY = targetY - worm.y;
      const dist = Math.sqrt(dirX * dirX + dirY * dirY) || 1;
      worm.dx += dirX / dist * speed * dt;
      worm.dy += dirY / dist * speed * dt;
      worm.dx *= 0.99;
      worm.dy *= 0.98;
      worm.x += worm.dx*dt;
      worm.y += worm.dy*dt;

      if (dist < worm.size / 2) {
        worm.targetX = Math.random()*this.width;
        worm.targetY = Math.random()*playAreaHeight;
      }


      const occupiedGrid = new Map();
      for (const brick of worm.tail) {
        const gx = Math.floor(brick.x / bW);
        const gy = Math.floor(brick.y / bH);
        occupiedGrid.set(`${gx},${gy}`, true); 
      }

      const headRadius = worm.size / 2;
      const minGridX = Math.floor((worm.x - headRadius) / bW);
      const maxGridX = Math.floor((worm.x + headRadius) / bW);
      const minGridY = Math.floor((worm.y - headRadius) / bH);
      const maxGridY = Math.floor((worm.y + headRadius) / bH);
      let targetGridCells = new Map();
      
      for (let gx = minGridX; gx <= maxGridX; gx++) {
        for (let gy = minGridY; gy <= maxGridY; gy++) {
          const key = `${gx},${gy}`;
          if (!occupiedGrid.has(key)) {
            targetGridCells.set(key, {gx, gy})
          }
        }
      }
        
      if (targetGridCells.size > 0) {
        const randomIndex = Math.floor(Math.random() * targetGridCells.size);
        const { gx, gy } = [...targetGridCells.values()][randomIndex];
        const oldestBrick = worm.tail.pop(); 
        const brickKey = `${oldestBrick?.x},${oldestBrick?.y}`;
        if (oldestBrick && !targetGridCells.has(brickKey)) {
          oldestBrick.x = gx * bW + bW / 2;
          oldestBrick.y = gy * bH + bH / 2;
          if (worm.tail.length > 8) {
            this.tween(oldestBrick, 'scale', [0, 1.5, 0.8, 1.1, 1], 480);
          }
          worm.tail.unshift(oldestBrick);
        }
      }
    }
  },
  tickTweens(dt) {
    // do the tween
    for (const tween of this.tweens) {
      const { target, prop, keyframes, duration } = tween;
      tween.delay -= dt;
      if (tween.delay > 0) continue;
      tween.t += dt;
      const frames = keyframes.length - 1;
      const progress = Math.min(1, tween.t / duration);
      const kIdx = Math.min(frames - 1, Math.floor(frames * progress));
      const localProgress = (progress - kIdx / frames) / (1 / frames);
      target[prop] = lerp(keyframes[kIdx], keyframes[kIdx + 1], localProgress);
      if (tween.t < duration) {
        continue;
      }

      target[prop] = keyframes[keyframes.length - 1];
      if (tween.onComplete) {
        tween.onComplete();
      }
      tween.iterations -= 1;
      if (tween.iterations <= 0) {
        this.tweens.delete(tween);
      } else {
        tween.t = 0;
      }
    }
  },

  damageBrick(brick) {
    this.flashIt(brick);
    this.shakeIt(brick, 6);
    brick.health -= 1;
    if (brick.health > 0) {
      return;
    }

    this.updateScore(100);
    this.tween(
      brick,
      "scale",
      [1, 1],
      120,
      160,
      () => (brick.isAlive = false)
    );

    if (Math.random() > 0.85) {
      const types = Object.values(PowerUpTypes);
      this.addPowerUp(brick.x, brick.y, types[Math.floor(Math.random()*types.length)]);
    }
  },

  draw() {
    const [w, h] = GUTTER_GUARD_SIZE;
    Object.assign(gutterDiv.style, {
      position: "absolute",
      left: `-${w}`,
      top: `${this.height - h - 1 }px`,
      width: `${w}px`,
      height: `${h}px`,
      display: this.gutterGuardHealth > 0 ? 'initial' : 'none',
      boxShadow: `inset 0 0 0 1px #fff, inset 0 0 0 2px #000, inset 0 0 0 3px #fff`,
    });

    const [bW, bH] = BRICK_SIZE;
    brickDiv.style.boxShadow = this.bricks
      .map(
        (b) =>
          `${b.x + bW / 2 + b.offsetX}px ${b.y + bH / 2 + b.offsetY}px 0 ${
            (bH / 2) * (b.scale - 1)
          }px rgb(${b.color.join()})`
      )
      .join();

    ballDiv.style.boxShadow = this.balls
      .map(
        (b) =>
          `${b.x + b.offsetX}px ${b.y + b.offsetY}px 0 ${
            (b.size / 2) * b.scale
          }px rgb(${b.color.join()})`
      )
      .join();

    powerUpDiv.style.boxShadow = this.powerUps
      .map(
        (power) => {
          const centerX = power.x + power.size / 2 + power.offsetX;
          const centerY = power.y + power.size / 2 + power.offsetY;
          const spread = (power.size / 2) * (power.scale - 1);
          const col = `rgb(${power.color.join()})`;

          return [
            `${centerX}px ${centerY}px 0 ${spread-2}px #000`,
            `${centerX + power.s1x}px ${centerY + power.s1y}px 0 ${spread}px ${col}`,
          ].join(",");
        }
      )
      .join();

    if (this.paddles.length > 0) {
      const paddle = this.paddles[0];
      Object.assign(paddleDiv.style, {
        left: `-${paddle.width}px`,
        top: `-${paddle.height}px`,
        width: `${paddle.width}px`,
        height: `${paddle.height}px`,
      });
      paddleDiv.style.boxShadow = this.paddles
        .map(
          (p) =>
            `${p.x + p.width / 2 + p.offsetX}px ${
              p.y + p.height / 2 + p.offsetY
            }px 0 0 rgb(${p.color.join()})`
        )
        .join();
    }

    container.style.transform = `translate(${this.camera.offsetX}px,${this.camera.offsetY}px)`;
  },

  shakeIt(obj, dist = 4, dir = undefined) {
    let ox = -dist/2 + Math.random() * dist;
    let oy = -dist/2 + Math.random() * dist;
    if (dir) {
      ox = dir[0] * dist;
      oy = dir[1] * dist;
    }
    this.tween(obj, "offsetX", [0, ox, -ox, ox / 2, 0], 260);
    this.tween(obj, "offsetY", [0, oy, -oy, oy / 2, 0], 260);
  },
  megaShake(obj, dist = 8) {
    let offSets = new Array(10).fill(0).map(() => -dist/2 + Math.random() * dist);
    this.tween(obj, "offsetX", [0, ...offSets, 0], 260);
    this.tween(obj, "offsetY", [0, ...offSets.reverse(), 0], 620);
  },
  flashIt(obj, color,) {
    this.tween(obj.color, "0", [obj.color[0], color?.[0] ?? 100 + Math.random() * 155, obj.baseColor?.[0] ?? 255], 180);
    this.tween(obj.color, "1", [obj.color[1], color?.[1] ?? 100 + Math.random() * 155, obj.baseColor?.[1] ?? 255], 180);
    this.tween(obj.color, "2", [obj.color[2], color?.[2] ?? 100 + Math.random() * 155, obj.baseColor?.[2] ?? 255], 180);
  },
  updateScore(val) {
    this.score += val;
    scoreContainer.innerText = `${this.score.toLocaleString()}`;
    this.tween(scoreContainer.style, 'scale', [.85, 1.5, 1], 300, 0);
  },
  updateLives(val = 1) {
    this.lives += val;
    this.makeFancyText(livesContainer, `${this.lives}`, 2);
    this.tween(livesContainer.style, 'scale', [1, 0.7, 2, .8, 1.5, .9, 1.2, .95, 1], 680);
  },
  updateSteinPower(val = 1) {
    this.omegaDevicePower += val;
    this.makeFancyText(steinPowerMeterContainer, `${this.omegaDevicePower}/5`, 2);
    this.tween(steinPowerMeterContainer.style, 'scale', [1, 0.7, 2, .8, 1.5, .9, 1.2, .95, 1], 680);
  },
  spawnNextLevel() {
    this.level++;
    const levels = {
      0: () => this.spawnLevel({
        predicate: ({x, y, i, j}) => {
          const brick = this.addBrick(x, y);
          const delay = (i+j) * 30;
          brick.scale = 0;
          this.tween(brick, 'scale', [0, 1.2, 1], 480, delay);
        }
      }),
      1: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          if ((i === midX - 1 || i === midX) || (j === midY - 1 || j === midY)) {
            return;
          }

          let color = (i+j+1) % 3 !== 0 ? [255,255,255] : [100,255,255];
          if ((i+j+1) % 4 === 0) {
            color = [255,200,200];
          }

          const brick = this.addBrick(x, y, 0, color);
          if (j === 0 || j === yBrickCount-1) {
            brick.health = 2;
          }
          brick.scale = 0;
          this.tween(brick, 'scale', [0, 1], 480, distance(i, j, midX, midY) * 60);
        }
      }),
      2: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          if (i === midX - 1 || i === midX) {
            return;
          }

          let brick;
          if (i === 0 || j === 0 || i === xBrickCount-1 || j === yBrickCount-1) {
            brick = this.addBrick(x, y, 0, [75, 0, 130]);
            brick.health = 2;
          } else {
            brick = this.addBrick(x, y, 0, [100, 100, 255]);
          }
          const delay = (xBrickCount+yBrickCount - i - j) * 30;
          brick.scale = 0;
          this.tween(brick, 'scale', [0, 1.2, 1], 480, delay);
        }
      }),
      3: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          if ((j+1) % 3 === 0) {
            return;
          }

          const brick = this.addBrick(x, y, 0, j%2 === 0 ? [200,200,200] : [100,100,100]);
          if (j === 0) {
            brick.health = 2;
          } else if (j === yBrickCount-1) {
            brick.health = 5;
          }

          const delay = (i + (yBrickCount - j)) * 30;
          brick.scale = 0;
          this.tween(brick, 'scale', [0, 1.2, 1], 480, delay);
        }
      }),
      4: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          const colors = [
            [255, 0, 0],
            [255, 165, 0],
            [255, 255, 0],
            [0, 128, 0],
            [0, 0, 255],
            [75, 0, 130],
            [148, 0, 211] 
          ];

          const brick = this.addBrick(x, y, 0, [...colors[j % (colors.length-1)]]);
          brick.health += Math.floor(Math.random()*3);
          brick.scale = 0;
          this.tween(brick, 'scale', [0, 1], 680, distance(i, j, 0, midY) * 60);
        }
      }),
      5: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          if (i % 2 === 0) return;
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          const colors = [
            [100, 200, 100],
            [100, 220, 100],
            [100, 255, 100],
            [150, 255, 150],
          ];
          const brick = this.addBrick(x, y, 0, [...colors[j % (colors.length-1)]]);
          if (j % 2 === 0) brick.health = 3;
          
          brick.scale = 0;
          this.tween(brick, 'scale', [0, 1], 480, distance(i, j, midX, 0) * 60);
        }
      }),
      6: () => this.spawnLevel({
        predicate: ({x, y, i, j, xBrickCount, yBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          if (i === midX || j === midY) {
            return;
          }

          const b1 = this.addBrick(x, y, 0, [255,255,255]);
          const b2 = this.addBrick(x, y, 0, [55,55,255]);
          const b3 = this.addBrick(x, y, 0, [255,55,55]);
          b1.scale = 0;
          b2.scale = 0;
          b3.scale = 0;
          this.tween(b1, 'scale', [0, 1], 480, distance(i, j, midX, yBrickCount) * 60);
          this.tween(b2, 'scale', [0, 1], 480, distance(i, j, xBrickCount, midY) * 90);
          this.tween(b3, 'scale', [0, 1], 480, distance(i, j, xBrickCount, yBrickCount) * 120);
        }
      }),
      7: () => this.spawnLevel({
        predicate: ({x, y, i, j, yBrickCount, xBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          if (j === yBrickCount-1) {
            const b1 = this.addBrick(x, y, BrickTypes.Unbreakable, [80,80,80]);
            b1.scale = 0;
            this.tween(b1, 'scale', [0, 1], 480, distance(i, j, midX, yBrickCount) * 60);
            return;
          }
          if (i === 0 || i === xBrickCount) {
            const b2 = this.addBrick(x, y, 0, [255,j%2 === 0 ? 55:100, 255]);
            b2.scale = 0;
            this.tween(b2, 'scale', [0, 1], 480, distance(i, j, xBrickCount, midY) * 90);
            return;
          }
          const b3 = this.addBrick(x, y, 0, [255,55,55]);
          b3.scale = 0;
          this.tween(b3, 'scale', [0, 1], 480, distance(i, j, xBrickCount, yBrickCount) * 120);
        }
      }),
      8: () => this.spawnLevel({
        predicate: ({x, y, i, j, yBrickCount, xBrickCount}) => {
          const midX = Math.floor(xBrickCount / 2);
          const midY = Math.floor(yBrickCount / 2);
          if (j === yBrickCount-1 || j === 0 || i === 0 || i === xBrickCount -1) {
            const brick = this.addBrick(x, y, 0, [40,40,200]);
            brick.health = 5;
            brick.scale = 0;
            this.tween(brick, 'scale', [0, 1.2, 1], 480, (i+j) * 30);
            return;
          }
          if (j === yBrickCount-2 || j === 1 || i === 1 || i === xBrickCount -2) {
            const brick = this.addBrick(x, y, 0, [20,20,180]);
            brick.health = 3;
            brick.scale = 0;
            this.tween(brick, 'scale', [0, 1.2, 1], 480, (i+j) * 30);
            return;
          }
          if (i >= midX - 1 && i <= midX+1 && j >= midY - 1 && j <= midY+1) {
            const brick = this.addBrick(x, y, 0, [255, 127, 40]);
            brick.health = 2;
            brick.scale = 0;
            this.tween(brick, 'scale', [0, 1.2, 1], 480, (i+j) * 30);
            return;
          }
        }
      }),
      9: () => {
        this.spawnLevel({
          predicate: ({x, y, i, j, yBrickCount, xBrickCount}) => {
            const brick = this.addBrick(-x,-y,0, [20+5*i, 20+4*(yBrickCount-y), 20+10*(xBrickCount-i)]);
            brick.health = 5;
          }
        });

        this.addWorm(undefined, undefined, this.bricks, 50);
      },
    };
    const nLevels = Object.keys(levels).length;
    levels?.[((this.level - 1) % nLevels)]?.();

    this.stopSound(this.bkgSong);

    this.bkgSong = this.bkgSongs[(this.level - 1) % this.bkgSongs.length];
    this.playSound(this.bkgSong, 1, 0.28, true);
  },
  spawnLevel(config) {
    const defaults = {
      blockWidth: BRICK_SIZE[0],
      blockHeight: BRICK_SIZE[1],
      screenWidth: this.width,
      screenHeight: this.height,
      brickGutterPx: 1.5,
      xBrickPad: 1,
      yBrickPad: 0,
      playAreaPxBot: 180,
      playAreaPxTop: 60,
      playAreaPxLeft: 0,
      playAreaPxRight: 0,
      predicate: ({ x, y }) => this.addBrick(x, y),
    };
    const {
      blockWidth,
      blockHeight,
      screenWidth,
      screenHeight,
      brickGutterPx,
      xBrickPad,
      yBrickPad,
      playAreaPxBot,
      playAreaPxTop,
      playAreaPxLeft,
      playAreaPxRight,
      predicate,
    } = { ...defaults, ...config };
    const brickAreaW = screenWidth - playAreaPxRight - playAreaPxLeft;
    const brickAreaH = screenHeight - playAreaPxBot - playAreaPxTop;
    const bW = blockWidth + brickGutterPx;
    const bH = blockHeight + brickGutterPx;
    const xBrickCount = Math.floor(brickAreaW / bW);
    const yBrickCount = Math.floor(brickAreaH / bH);
    const rW = brickAreaW % bW;
    const rH = brickAreaH % bH;
    const sx = playAreaPxLeft + rW / 2 + bW / 2;
    const sy = playAreaPxTop + rH / 2 + bH / 2;
    for (let i = xBrickPad; i < xBrickCount - xBrickPad; i++) {
      const x = sx + i * bW;
      for (let j = yBrickPad; j < yBrickCount; j++) {
        const y = sy + j * bH;
        predicate({ x, y, i, j, xBrickCount, yBrickCount });
      }
    }
  },

  // events
  onLifeLost() {
    this.playSound('gameover', 1 + Math.random()*.2);
    this.showMenu();
    this.megaShake(this.camera, 30);
    this.tween(livesContainer.style, 'scale',
      [1, 0.7, 2, .8, 1.5, .9, 1.2, .95, 1], 680, 540,
      () => this.makeFancyText(livesContainer, `${this.lives}`, 2)
    );
    this.onResetLevel();
  },
  onDeath() {
    this.showMenu();

  },
  onWinLevel() {
    this.onResetLevel();
    this.bricks = [];
    this.spawnNextLevel();
    this.showMenu();
  },
  onResetLevel() {
    this.balls = [];
    this.powerUps = [];
    this.paddles = [];
    this.addBall(this.width/2, Game.height - 60);
    const b = this.balls[this.balls.length - 1];
    b.dy = -Math.abs(b.dy);
    this.addPaddle(this.width / 2);
  },
  onStartGame() {
    this.paddles.forEach(p => {
      p.x = this.width/2;
    });
    this.hideMenu();
  },
  onResetGame() {
    this.showMenu();
    this.onResetLevel();
    this.bricks = [];
    this.gutterGuardHealth = 0;
    this.score = 0;
    this.lives = 3;
    this.level = 0;
    this.spawnNextLevel();
    this.makeFancyText(livesContainer, `${this.lives}`);
    this.updateSteinPower(0);
  },
  hideMenu() {
    this.playSound('menu_open', 1 + Math.random()*2);
    this.tween(menuPaused.style, 'opacity', [1,0], 300, 80, () => {
      menuPaused.style.opacity = 1;
      menuPaused.style.scale = 1;
      menuPaused.style.display = 'none';
      this.isPaused = false;
    });
    this.tween(titleDiv.style, 'scale', [1,1.1,0.5], 380);
    this.tween(titleDiv.style, 'opacity', [0,1], 300);
    this.tween(startButton.style, 'scale', [1,1.1,0.5], 380);
    this.tween(startButton.style, 'opacity', [0,1], 300);
  },
  showMenu() {
    this.playSound('menu_open', 1 + Math.random()*2);
    this.isPaused = true;
    menuPaused.style.display = 'flex';
    this.makeFancyText(titleDiv, `Level ${this.level}`, 3, 3000, 100);
    this.tween(titleDiv.style, 'scale', [0.5, 0.4, 1.1, 1], 380);
    this.tween(titleDiv.style, 'opacity', [0,1], 300);
    this.tween(startButton.style, 'scale', [0.5, 0.4, 1.1, 1], 380);
    this.tween(startButton.style, 'opacity', [0,1], 300);
    this.tween(menuPaused.style, 'opacity', [0,1], 300);
  },

  audioCtx: new AudioContext(),
  masterGainNode: null,
  audioFiles: new Map(),
  audioSources: new Map(),
  isAudioMuted: false,
  hitSounds: [
    'hit_c5',
    'hit_d5',
    'hit_e5',
    'hit_f5',
    'hit_g5',
    'hit_a5',
    'hit_b5',
    'hit_c6',
    'hit_c5_chord',
    'hit_d5_chord',
    'hit_e5_chord',
    'hit_f5_chord',
  ],
  bkgSongs: [
    'song_1',
    'song_2',
    'song_3',
    'song_4',
    'song_5',
    'song_6',
    'song_7',
    'song_8',
    'song_9',
    'song_10',
  ],
  async preload() {
    const sounds = [
      'paddle_hit',
      'edge_hit',
      'menu_open',
      'power_ball',
      'power_speed_up',
      'power_speed_down',
      'power_split',
      'power_omega',
      'power_omega_boom',
      'power_laser',
      'power_life',
      'power_gg',
      'power_big_ball',
      'power_big_paddle',
      'turn_on',
      'turn_off',
      'gameover',
      ...this.hitSounds,
      ...this.bkgSongs
    ];
    this.masterGainNode = this.audioCtx.createGain();
    this.masterGainNode.connect(this.audioCtx.destination);

    return Promise.all(sounds.map(sound => 
      fetch(`/sounds/bronc/${sound}.ogg`)
        .then(res => {
          if (!res.ok) throw new Error(`failed to load ${sound}`);
          return res.arrayBuffer();
        })
        .then(ab => this.audioCtx.decodeAudioData(ab))
        .then(buff => {
          this.audioSources.set(sound, new Set());
          this.audioFiles.set(sound, buff);
        }).catch((e) => {
          console.error(e);
        })
    ));
  },
  playSound(name, pitch = 1, volume = 1, loop = false, onComplete = undefined) {
    if (!this.audioFiles.has(name)) {
      console.warn('woops no file to play', name);
      return;
    }

    const sources = this.audioSources.get(name);
    const buff = this.audioFiles.get(name);
    const source = this.audioCtx.createBufferSource();
    source.buffer = buff;
    source.playbackRate.value = pitch;
    source.loop = loop;

    const gainNode = this.audioCtx.createGain(); 
    gainNode.gain.value = volume;
    
    source.connect(gainNode);
    gainNode.connect(this.masterGainNode);

    sources.add(source);
    source.onended = () => {
      sources.delete(source);
      source.disconnect(gainNode);
      gainNode.disconnect(this.masterGainNode);

      if (onComplete) {
        onComplete();
      }
    };
    source.start(0);
  },
  stopSound(name) {
    if (!this.audioFiles.has(name)) {
      console.warn('woops cannot stop missing file', name);
      return;
    }

    const sources = this.audioSources.get(name);
    sources.forEach(s => {
      s.stop(0);
    });
  },
  stopAllSounds() {
    this.audioFiles.forEach((_, name) => {
      this.stopSound(name);
    });
  },
  muteAudio(isMuted = false) {
    this.isMuted = isMuted;
    this.masterGainNode.gain.setTargetAtTime(
      isMuted ? 0 : 1,
      this.audioCtx.currentTime,
      0.1
    );
    this.makeFancyText(soundToggle, `Sound ${isMuted ? 'Off' : 'On'}`, 1, 4000, 600);
    this.tween(soundToggle.style, 'opacity', isMuted ? [1, 0.5] : [0.5, 1], 300);
    soundToggle.style.boxShadow = isMuted ? '0px -1px 0 0px #fff6, 0px 1px 0 #fff' : '0px 1px 0 1px #fff4, 0px -1px 0 #fff';
  },
  toggleCrtEffect(isOn = true) {
    this.isCrtOn = isOn;

    this.makeFancyText(crtToggle, `CRT ${isOn ? 'On' : 'Off'}`, 1, 4000, 600);
    this.playSound(!isOn ? 'turn_off' : 'turn_on', 1 + Math.random()*0.2, 2);
    this.tween(crtToggle.style, 'opacity', !isOn ? [1, 0.5] : [0.5, 1], 300);

    crtToggle.style.boxShadow = !isOn ? '0px -1px 0 0px #fff6, 0px 1px 0 #fff' : '0px 1px 0 1px #fff4, 0px -1px 0 #fff';
    container.style.setProperty('filter', isOn ? 'url(#full-crt) brightness(1.5)' : 'none');
    crtEffectDef.style.setProperty('display', isOn ? 'block' : 'none');
  },
  makeFancyText(element, text, height = 3, durationMs = 2000, staggerMs = 120) {
    element.innerHTML = ''; 
    text.split("").forEach((char, index) => {
      const span = document.createElement('span');
      span.textContent = char;
      span.style.display = "inline-block";
      span.style.whiteSpace = "pre";
      span.style.translate = "0 calc(var(--y, 0) * 1px)";
      element.appendChild(span);

      const proxy = {
        y: 0,
        set y(val) {
          span.style.setProperty('--y', `${val}`);
        }
      }
      this.tween(proxy, 'y', [0, -height, 0, height, 0], durationMs, staggerMs * index, undefined, Infinity);
    });
  }
};

container.addEventListener("pointermove", (e) =>
  Game.handleMouseOrTouchInput(e)
);
container.addEventListener("touchmove", (e) => Game.handleMouseOrTouchInput(e));
startButton.addEventListener("click", (e) => {
  Game.onStartGame();
});
soundToggle.addEventListener('click', () => {
  Game.muteAudio(!Game.isMuted);
  Game.playSound(Game.isMuted ? 'turn_off' : 'turn_on', 1 + Math.random()*0.2, 2);
});
soundToggle.addEventListener('pointerdown', () => {
  Game.tween(soundToggle.style, 'scale', [1,1.1,0.8], 200);
});
soundToggle.addEventListener('pointerup', () => {
  Game.tween(soundToggle.style, 'scale', [0.8,0.75,1.1,1], 200);
});
crtToggle.addEventListener('click', () => {
  Game.toggleCrtEffect(!Game.isCrtOn);
})
crtToggle.addEventListener('pointerdown', () => {
  Game.tween(crtToggle.style, 'scale', [1,1.1,0.8], 200);
});
crtToggle.addEventListener('pointerup', () => {
  Game.tween(crtToggle.style, 'scale', [0.8,0.75,1.1,1], 200);
});

let lastTime = 0;
function universe(delta) {
  if (lastTime === 0) {
    lastTime = delta;
  }

  const dt = (delta - lastTime) / 1000;
  Game.tick(dt);
  Game.draw();

  lastTime = delta;
  frameId = requestAnimationFrame(universe);
}

// document.addEventListener('keydown', e => {
//   Game.bricks = [];
// });

// load and start it all
Game.preload().then(() => {
  Game.onResetGame();
  Game.muteAudio(false);
  Game.toggleCrtEffect(false);
  universe(lastTime);
});

container.addEventListener('unload', () => {
  cancelAnimationFrame(frameId);
  Game.stopAllSounds();
});

function distance(p1x, p1y, p2x, p2y) {
  return Math.sqrt((p1x - p2x)*(p1x - p2x) + (p1y - p2y)*(p1y - p2y));
}

Игровой движок

Думаю, можно с уверенностью сказать: мы сделали игру. И не какую-то надуманную «в теории это игра», а самую настоящую игру-игру. Ту, которая ощущается как игра. Ту, в которую вложено усилие. Если бы мы начали наращивать контент, сохраняя тот же уровень полировки, я легко могу представить, что кто-то стал бы за неё платить. В конце концов, капитализм — это универсальная мера ценности.

Постой-ка. Если это игра… разве это не значит, что у нас есть и игровой движок?

Именно так. Это полностью рабочий игровой движок. В нём есть графика, физика, звук, твины, ассеты, UI — весь стандартный набор. Более того, он кроссплатформенный! Он работает на мобильных устройствах, десктопе, консолях и даже на смарт-ТВ. Вот уж действительно мощный игровой движок. Кто бы мог подумать, что несколько HTML-div’ов способны на такое.

Хотя мне этот движок нравится, теперь, когда у нас за плечами есть готовая игра, можно улучшить некоторые моменты.

Большинство объектов в нашей вселенной имеют общие свойства: позицию, цвет, иногда скорость. У них может быть размер, ширина и высота. Иногда объекты нужно рисовать, а иногда у них есть только логика.

Есть несколько способов смоделировать это аккуратнее.

OOP

Один из вариантов — сосредоточиться на моделировании данных. Мы группируем объекты вместе с их кодом, наследуя их от базового класса, который задаёт каркас, ожидаемый движком.

class Base {
  position: {
    x:0,
    y:0,
  };
  size: {
    width: 10,
    height: 10,
  };
  color: {

  };

  tick(Game) {}
  draw(Game) {}
}

class Ball extends Base {
  tick(dt, Game) {
    // ball stuff
  }
  draw(Game) {
    // ball draw
  }
}

const Game = {
  objects: [],

  tick(dt) {
    this.objects.forEach(o => o.tick(dt, this));
  }
}

Эту структуру можно подогнать под себя, и она вполне ложится на текущий движок, но лично мне больше нравится другой подход.

Сначала данные

Давай сделаем шаг назад. Наша вселенная состоит из вещей. У этих вещей может быть связанный с ними набор данных — но не всегда. А ещё есть системы, которые изменяют эти вещи с течением времени. У мяча есть данные, а tick трансформирует их с учётом dt, дельты времени. Даже ввод пользователя — это всего лишь ещё один вид данных, который обрабатывается.

Кажется, направление понятно. Давай представим API, который позволяет максимально сосредоточиться на data-driven стиле.

Как и раньше, у нас есть вселенная.

class Universe {
  // methods and storage
}

const Universe = new Universe();

Теперь наполняем её данными.

Universe.addEntity({
  x: 0,
  y: 10,
  dx: -1,
  dy: 1,
  size: 20,
  color: [100,200,100],
});
Universe.addEntity({
  x: 0,
  y: 10,
  size: 20,
  color: [100,200,100],
});

Дальше добавляем правила — системы, которые описывают, как данные меняются со временем.

function MovementSystem(world) {
  for (const thing of world.query(['x','y','dx','dy'])) {
    thing.x += thing.dx * world.dt;
    thing.y += thing.dy * world.dt;
  }
}

function DrawSystem(world) {
  for (const drawableThing of world.query(['x','y','color', 'size'])) {
    // draw it
  }
}

Universe.addSystem(PhysicsSystem);
Universe.addSystem(DrawSystem);

И наконец, симулируем вселенную.

requestAnimationFrame(dt) {
  Universe.simulate(dt);
}

Этот стиль невероятно прост в реализации и при этом очень мощный. Всё, что имеет позицию, размер и цвет, можно отрисовать. Код движения можно переиспользовать и для Worm, и для мячей, и для всего остального, у чего есть позиция и скорость. Очистка «мёртвых» объектов становится тривиальной — никакого дублирования кода!

Universe.addEntity({
  x: 0,
  y: 10,
  size: 20,
  color: [100,200,100],
  isAlive: true,
});

function DeathSystem(world) {
  const deathables = world.query(['isAlive']);
  world.deleteEntities(deathables.filter(e => !e.isAlive));
}

Идея в том, что query возвращает только те сущности, у которых есть все запрошенные поля данных. Если у объекта нет isAlive, он просто не попадёт в результат запроса.

И вот главный момент — именно поэтому я обожаю такой дизайн. С помощью пары десятков строк кода ты получаешь движок, который способен тянуть практически всё, что ты в него закинешь. Объектно-ориентированный подход требует глубокого понимания того, что именно движок будет поддерживать, и со временем модель вселенной становится всё более узкой и жёсткой.

В data-driven подходе не все системы и сущности будут переиспользуемы между всеми играми, но большинство — да. И чтобы переиспользовать их, не нужно никакого рефакторинга или сложного распутывания зависимостей: просто берёшь свой любимый DeathSystem и добавляешь isAlive всему, что должно умирать.

«А как насчёт ассетов и ресурсов, вроде изображений и звуков?» — спросишь ты. Отличный вопрос. Ресурсы загружаются вне мира, но как только они загружены — это тоже данные. Их можно запрашивать точно так же. Проиграть звук — это просто добавить сущность во вселенную.

SoundSystem обрабатывает все звуковые данные, проигрывает звук по имени и сразу помечает сущность как isAlive = false. Никакой отдельной логики очистки — всё работает само.

Кстати, вполне возможно внедрить этот подход не сразу во весь движок, а, например, начать только с power ups. Понадобится немного синхронизировать состояние с текущей архитектурой, но всё спокойно встанет на место. Более того, такой гибридный подход — обычное дело в разработке движков. Ровно так же было сделано в ремастере Oblivion: Unreal отвечает за рендеринг, а джанковый движок Bethesda — за логику.

Думаю, мы оба уже проголодались, так что давай закончим это небольшим ретроспективным взглядом.

Картофельное пюре

Надеюсь, тебе было весело — мне точно да.

Да-да, я знаю. Зачем делать такой проклятый игровой движок? Почему вообще рисовать через box-shadow? SVG вместо шейдера? И с какой стати JavaScript? Это никогда не будет масштабироваться. Это никогда не заработает!

И всё же… как ни странно, работает. Он сделал ровно то, что мы хотели, и в него приятно играть. Целую игру можно построить на таких простых примитивах. Никаких шейдеров, GPU-вычислений, многопоточности или сложной физики не нужно.

Игровые движки пугают людей. Они кажутся огромными, монструозно сложными зверями. Я так не думаю. И самое классное — когда ты делаешь свой собственный движок (начиная с игры), игра ощущается не так, как всё остальное на рынке, потому что она действительно не похожа ни на что другое. Все особенности — и хорошие, и плохие — создают вкус, который дьявольски сложно воспроизвести.

Хотя мне и нравится игра Expedition 33, я не играл в неё больше пары часов. Она ощущалась как типичный RPG на Unreal Engine, потому что… ну, по сути, это он и есть. В итоге я просто посмотрел кат-сцены на YouTube. А представь, если бы они сделали свой собственный движок — со всем джанком и странностями?

Генеративный ИИ сделает игры ещё более одинаковыми и безвкусными. Кто-то этого боится, а я говорю: «Давайте, несите». Когда мир заливает картофельным пюре, достаточно щепотки соли, чтобы получилось что-то по-настоящему оригинальное и вкусное.

От одного повара другому — поддерживай этот огонь.

Русскоязычное JavaScript сообщество

Друзья! Эту статью перевела команда «JavaScript for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Frontend. Подписывайтесь, чтобы быть в курсе и ничего не упустить!