Счёт и UI

Мяч уже красиво отскакивает от ракеток и стен — но игра пока не помнит, кто выигрывает. Сегодня научим её считать голы и показывать счёт прямо на холсте.
Чтобы засчитать гол, мы ловим момент, когда мяч улетел за левый или правый край, прибавляем единицу в переменную счёта, рисуем цифры через ctx.fillText и возвращаем мяч в центр.

В прошлом уроке про отскоки мы заставили мяч честно отбиваться от ракеток и от верхней и нижней стен. Получился настоящий пинг-понг: мяч носится по холсту, цыплёнок на ракетке его отбивает, азарт есть. Но представь, что ты позвал друга сыграть, вы рубитесь пять минут — а игра никак не реагирует. Промахнулся? Ну и ладно, мяч просто улетел в пустоту и пропал. Никакого «1:0», никакого «ты проиграл, давай реванш». А ведь именно ради счёта в игры и играют по второму разу.

Зачем игре счёт

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

Вот к чему мы придём в конце урока. Сверху по центру холста крупными белыми цифрами горит счёт, например 3 : 1. Как только мяч улетает за чей-то край, нужная цифра увеличивается на единицу, а мяч сам собой возвращается в центр и снова уходит в игру. Игра наконец-то начинает помнить, что происходит, и реагировать на твои промахи.

Звучит как что-то сложное, но на самом деле тут всего три новых кусочка: переменные, которые хранят числа, проверка «мяч вылетел за край?» и одна-единственная команда рисования текста — ctx.fillText. Давай соберём это по частям.

Храним счёт в переменных

Игра должна где-то держать в памяти, сколько голов забил каждый игрок. Помнишь состояние — набор данных об объекте, который меняется по ходу игры? Счёт — это тоже состояние, только не про координаты, а про результат матча. Заведём для него объект, как мы это делали для цыплёнка:

// счёт матча — состояние, которое живёт между кадрами
const score = { left: 0, right: 0 };

Результат: в памяти появляются два счётчика, оба пока на нуле. На экране ничего не меняется — это чистые данные, мы их только готовим.

Почему объект, а не две отдельные переменные scoreLeft и scoreRight? Можно и так, разницы для игры почти нет. Но когда связанные данные лежат в одном объекте, их удобнее передавать и не запутаться: score.left — очки левого игрока (это ты, цыплёнок на левой ракетке), score.right — очки правого (соперник или второй цыплёнок). Та же привычка, что и с chicken: складываем родственные факты в один аккуратный объект.

Главное про эти переменные — где их объявлять. Они должны жить снаружи игрового цикла, рядом с chicken и мячом. Если объявить score внутри функции, которая зовётся каждый кадр, счёт будет обнуляться шестьдесят раз в секунду и навсегда останется нулём. Состояние, которое должно помниться между кадрами, всегда живёт снаружи цикла — это правило мы ещё не раз повторим.

Ловим гол: мяч улетел за край

Теперь самое интересное — как понять, что случился гол. Вспомни, что у мяча есть позиция ball.x, ball.y и вектор скорости ball.vx, ball.vy — пара чисел, на которые он сдвигается каждый кадр. В прошлом уроке мы проверяли столкновения мяча со стенами и ракетками. Гол — это просто ещё одна проверка позиции, только теперь нас интересуют левый и правый края холста.

Логика на словах: если центр мяча ушёл левее нуля — мяч пролетел мимо левой ракетки, очко уходит правому игроку. Если центр мяча ушёл правее canvas.width — мяч пролетел мимо правой ракетки, очко левому. В коде, внутри функции обновления состояния update, это выглядит так:

function update() {
  // ... здесь уже есть движение мяча и отскоки из прошлого урока ...
  ball.x += ball.vx;
  ball.y += ball.vy;

  // мяч улетел за ЛЕВЫЙ край — гол правого игрока
  if (ball.x < 0) {
    score.right += 1;
    resetBall();
  }

  // мяч улетел за ПРАВЫЙ край — гол левого игрока
  if (ball.x > canvas.width) {
    score.left += 1;
    resetBall();
  }
}

Результат: как только мяч пересекает левую или правую границу холста, соответствующий счётчик увеличивается на единицу, и тут же вызывается resetBall — функция, которая вернёт мяч в центр (её напишем через минуту). На экране пока ничего не видно, потому что цифры мы ещё не рисуем, но в памяти счёт уже считается правильно.

Разберём по шагам, что здесь происходит:

  1. Сначала, как обычно, мяч сдвигается по своему вектору скорости: ball.x += ball.vx.
  2. Дальше две проверки границ. ball.x < 0 — центр мяча ушёл левее левого края. Значит, ты не успел отбить, гол твоему сопернику слева, прибавляем score.right.
  3. ball.x > canvas.width — мяч улетел за правый край, гол левому игроку, прибавляем score.left.
  4. В обоих случаях зовём resetBall — иначе мяч продолжил бы лететь в бесконечную даль, и проверка срабатывала бы каждый кадр, накручивая сотни голов за секунду.

Обрати внимание: проверки на левый и правый край — это отдельные столкновения с краями, не путай их с отскоками от ракеток. От ракетки мяч отбивается (меняет знак vx), а за край — улетает в гол. Разница в том, что ракетка стоит чуть в глубине холста, а гол засчитывается, только когда мяч проскочил мимо неё до самой стенки.

Возвращаем мяч в центр после гола

После гола мяч надо поставить обратно в середину холста и снова отправить в игру. Вынесем это в отдельную функцию resetBall — так код чище, и звать её из двух мест удобнее:

function resetBall() {
  // ставим мяч ровно в центр холста
  ball.x = canvas.width / 2;
  ball.y = canvas.height / 2;

  // запускаем в случайную сторону по горизонтали
  ball.vx = (Math.random() < 0.5 ? -1 : 1) * 4;
  ball.vy = (Math.random() < 0.5 ? -1 : 1) * 3;
}

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

Что тут важно:

  • canvas.width / 2 и canvas.height / 2 — это и есть центр холста. Мяч у нас рисуется от своего центра, поэтому ставим прямо в середину без поправок.
  • Math.random() возвращает дробь от 0 до 1. Сравнение < 0.5 даёт примерно честную «монетку»: половину раз получаем true, половину — false. Тернарный оператор ? -1 : 1 превращает её в знак направления: минус — влево/вверх, плюс — вправо/вниз.
  • Умножаем на 4 и 3 — это скорость подачи по горизонтали и вертикали. Можешь покрутить эти числа: больше — мяч злее и быстрее, меньше — для разминки.

Почему мы дали мячу разную скорость по осям (4 и 3)? Чтобы он летел по наклонной, а не строго влево-вправо. Если vy сделать нулём, мяч будет елозить ровно по горизонтали, отбивать его станет скучно и слишком легко. Лёгкий наклон делает траекторию живой.

Рисуем счёт через fillText

Данные считаются, мяч возвращается — осталось показать счёт игроку. Для текста на холсте есть специальная команда контекста — ctx.fillText. Работает она почти как fillRect, только вместо прямоугольника рисует буквы и цифры:

ctx.fillText('текст', x, y);

Перед тем как писать, кисти-контексту надо объяснить, каким шрифтом и каким цветом рисовать, и от какой точки отсчитывать текст. За это отвечают три свойства: ctx.font, ctx.fillStyle и ctx.textAlign. Соберём функцию рисования счёта целиком:

function drawScore() {
  ctx.fillStyle = '#ffffff';        // белые цифры
  ctx.font = '48px sans-serif';     // размер и шрифт
  ctx.textAlign = 'center';         // текст центрируется по точке x

  // счёт по центру холста, чуть ниже верха
  ctx.fillText(score.left + ' : ' + score.right, canvas.width / 2, 60);
}

Результат: сверху по центру холста крупными белыми цифрами появляется счёт вида 0 : 0. Когда мяч улетает за край, нужная цифра тут же меняется — например, на 1 : 0. Текст ровно по центру и не съезжает, даже когда счёт становится двузначным.

Разберём свойства по очереди:

  • ctx.fillStyle = '#ffffff' — тот же «фломастер», что и для прямоугольников. fillText использует тот же цвет заливки. Берём белый, чтобы цифры читались на любом фоне.
  • ctx.font = '48px sans-serif' — задаём шрифт строкой, как в CSS: сначала размер в пикселях, потом название шрифта. 48px — крупно и видно издалека.
  • ctx.textAlign = 'center' — а вот это спасает от мучений. По умолчанию fillText рисует текст вправо от точки x (выравнивание 'left'). Тогда счёт 10 : 8 съехал бы вправо и стал бы шире, чем 0 : 0. Со значением 'center' точка x становится серединой текста, и счёт всегда висит ровно по центру, сколько бы цифр в нём ни было.

Строка score.left + ' : ' + score.right просто склеивает два числа и красивый разделитель в одну строку: если score.left равен 3, а score.right равен 1, получится '3 : 1'. Числа при склейке с текстом сами превращаются в строки — JavaScript делает это за нас.

Куда вставить drawScore в кадр

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

function draw() {
  // 1. стираем прошлый кадр
  ctx.fillStyle = '#1b2838';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // 2. рисуем ракетки и мяч (код из прошлых уроков)
  drawPaddles();
  drawBall();

  // 3. поверх всего — счёт
  drawScore();
}

Результат: каждый кадр сначала закрашивается тёмно-синий фон (стирая прошлую картинку), затем рисуются ракетки с цыплёнком и мяч, а самым последним сверху ложится белый счёт. Игра выглядит как настоящий Понг с табло.

Частые ошибки и подводные камни

1. Счёт всегда остаётся нулём

Самая обидная ошибка: объявил const score = { left: 0, right: 0 } внутри функции update или draw. Тогда он создаётся заново каждый кадр, и любое score.right += 1 тут же стирается следующим обнулением. Лекарство: объявляй score снаружи игрового цикла, на верхнем уровне файла, рядом с ball и chicken.

2. После гола счёт улетает на сотни

Если забыть вызвать resetBall, мяч продолжит лететь за краем холста, а условие ball.x < 0 будет истинным каждый следующий кадр. За секунду накрутится 60 «голов». Признак беды — счёт мгновенно скачет в сотни. Лекарство: всегда после прибавления очка возвращай мяч в центр, чтобы ball.x вышел из зоны срабатывания.

3. Текст не виден на холсте

Нарисовал fillText, а цифр нет. Чаще всего одно из трёх: цвет fillStyle совпал с фоном (белый текст на белом фоне не виден), y-координата равна 0 (тогда текст рисуется выше точки и уезжает за верхний край холста — у fillText точка y это нижняя линия букв, базовая линия), либо drawScore вызвали до заливки фона, и фон его закрасил. Ставь счёт последним и держи y хотя бы 40-60 пикселей от верха.

4. Двузначный счёт съезжает вбок

Забыл ctx.textAlign = 'center' — и при счёте 0 : 0 всё ровно, а на 10 : 8 табло уползает вправо и выглядит криво. Причина: по умолчанию текст растёт вправо от точки x. Лекарство — выставить textAlign = 'center', тогда x станет серединой строки и счёт останется на месте при любом числе цифр.

5. Шрифт задают без размера

Строка ctx.font = 'sans-serif' без размера не сработает как надо — браузер возьмёт крошечный шрифт по умолчанию (около 10px), и счёт будет еле виден. Свойству font всегда нужен размер: сначала 48px, потом название шрифта, как в CSS — '48px sans-serif'.

Мини-практика: доводим табло до ума

Возьми готовый Понг со счётом за основу и доделай сам:

  1. Раздели цифры визуально: вместо одной строки '3 : 1' нарисуй два отдельных fillText — счёт левого игрока ближе к левому краю (например, на x = canvas.width / 2 - 60), счёт правого — на x = canvas.width / 2 + 60. Так табло будет похоже на настоящее.
  2. Добавь объявление победы: когда чей-то счёт дойдёт до 5, рисуй по центру холста надпись 'Цыплёнок победил!' шрифтом поменьше ('32px sans-serif'). Подсказка: заведи проверку if (score.left >= 5) в функции рисования.
  3. Сделай разделительную пунктирную линию по центру поля, как в классическом Понге: в цикле нарисуй несколько коротких белых fillRect сверху вниз по линии x = canvas.width / 2. Рисуй её до счёта, чтобы цифры остались сверху.

Если всё сошлось — у тебя получится Понг с раздельным табло, объявлением победителя и фирменной пунктирной серединой. Сохрани файл: score и drawScore из него пригодятся, когда будем делать паузу и экран проигрыша.

Итоги

Сегодня твой Понг превратился из «двигающихся квадратиков» в настоящую игру с целью:

  • завёл состояние счёта снаружи цикла — объект score с полями left и right;
  • научился ловить гол простой проверкой ball.x < 0 и ball.x > canvas.width и прибавлять очко нужному игроку;
  • вынес возврат мяча в центр в функцию resetBall со случайным направлением подачи;
  • нарисовал счёт на холсте через ctx.fillText, разобравшись с font, fillStyle и спасительным textAlign = 'center'.

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

Проверьте себя
1. Где нужно объявлять объект score = { left: 0, right: 0 }, чтобы счёт правильно накапливался?
AВнутри функции update, чтобы он обновлялся каждый кадр
BСнаружи игрового цикла, на верхнем уровне файла
CВнутри функции draw, рядом с рисованием
DВнутри resetBall, чтобы сбрасывался вместе с мячом
2. Мяч улетел за левый край холста (ball.x < 0). Кому засчитывается гол?
AЛевому игроку — score.left += 1
BПравому игроку — score.right += 1
CНикому, мяч просто исчезает
DОбоим сразу по очку
3. Зачем сразу после прибавления очка вызывать resetBall()?
AЧтобы стереть весь холст перед новым кадром
BЧтобы поменять цвет мяча на случайный
CЧтобы вернуть мяч в центр и не дать условию гола срабатывать каждый кадр
DЧтобы увеличить скорость игры с каждым голом
4. Что делает свойство ctx.textAlign = 'center' перед fillText?
AДелает точку x серединой текста, чтобы счёт не съезжал вбок при разном числе цифр
BЦентрирует текст по вертикали холста
CАвтоматически выбирает размер шрифта
DЗакрашивает фон под текстом
5. Почему drawScore() вызывают в самом конце функции draw, после фона, ракеток и мяча?
AТак быстрее работает игровой цикл
BИначе fillText вообще не сработает
CНа холсте нарисованное позже ложится поверх — счёт должен быть над игровым полем
DЧтобы сэкономить память браузера
6. Почему в resetBall мячу задают разную скорость по осям, например vx = 4 и vy = 3, а не vy = 0?
AИначе мяч улетит за пределы холста
BЧтобы мяч летел по наклонной траектории, а не скучно ровно по горизонтали
CПотому что fillText требует ненулевой vy
DЧтобы счёт обновлялся быстрее