Счёт и 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 — функция, которая вернёт мяч в центр (её напишем через минуту). На экране пока ничего не видно, потому что цифры мы ещё не рисуем, но в памяти счёт уже считается правильно.
Разберём по шагам, что здесь происходит:
- Сначала, как обычно, мяч сдвигается по своему вектору скорости:
ball.x += ball.vx. - Дальше две проверки границ.
ball.x < 0— центр мяча ушёл левее левого края. Значит, ты не успел отбить, гол твоему сопернику слева, прибавляемscore.right. ball.x > canvas.width— мяч улетел за правый край, гол левому игроку, прибавляемscore.left.- В обоих случаях зовём
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'.
Мини-практика: доводим табло до ума
Возьми готовый Понг со счётом за основу и доделай сам:
- Раздели цифры визуально: вместо одной строки
'3 : 1'нарисуй два отдельныхfillText— счёт левого игрока ближе к левому краю (например, наx = canvas.width / 2 - 60), счёт правого — наx = canvas.width / 2 + 60. Так табло будет похоже на настоящее. - Добавь объявление победы: когда чей-то счёт дойдёт до 5, рисуй по центру холста надпись
'Цыплёнок победил!'шрифтом поменьше ('32px sans-serif'). Подсказка: заведи проверкуif (score.left >= 5)в функции рисования. - Сделай разделительную пунктирную линию по центру поля, как в классическом Понге: в цикле нарисуй несколько коротких белых
fillRectсверху вниз по линииx = canvas.width / 2. Рисуй её до счёта, чтобы цифры остались сверху.
Если всё сошлось — у тебя получится Понг с раздельным табло, объявлением победителя и фирменной пунктирной серединой. Сохрани файл: score и drawScore из него пригодятся, когда будем делать паузу и экран проигрыша.
Итоги
Сегодня твой Понг превратился из «двигающихся квадратиков» в настоящую игру с целью:
- завёл состояние счёта снаружи цикла — объект
scoreс полямиleftиright; - научился ловить гол простой проверкой
ball.x < 0иball.x > canvas.widthи прибавлять очко нужному игроку; - вынес возврат мяча в центр в функцию
resetBallсо случайным направлением подачи; - нарисовал счёт на холсте через
ctx.fillText, разобравшись сfont,fillStyleи спасительнымtextAlign = 'center'.
Теперь игра помнит результат и реагирует на промахи — это и есть тот самый кор-луп Понга: подача, розыгрыш, гол, новая подача. Но пока матч идёт бесконечно: дошёл счёт до пяти — и ничего не происходит. В следующем уроке мы добавим игровые состояния — меню, игру и экран проигрыша, — чтобы матч можно было начать заново и объявить победителя по-настоящему. Готовь фантазию для красивого экрана победы цыплёнка.