Очки, комбо и обратная связь
Сегодня учим игру отвечать на каждое действие игрока так, чтобы по спине бежали мурашки: цифры, комбо, всплывашки и встряска экрана.
Сочная обратная связь — это набор мелких реакций игры (звук, вспышка, дрожь, всплывающее число) на действие игрока, благодаря которым даже простой клик ощущается приятно и весомо.
Зачем вообще тратить время на «красивости»
Представь две версии одной и той же игры про цыплёнка. В первой ты сбиваешь врага — и он просто исчезает. Тихо. Молча. Как будто кто-то стёр его ластиком. Во второй — враг лопается облачком частиц, над ним выскакивает жёлтая цифра +100, экран на долю секунды дёргается, а в углу счёт резко прыгает вверх. Угадай, в какую из них хочется играть ещё час?
Вот в этом и весь секрет. Механика у обеих игр одинаковая: ты нажал, враг умер, очки начислились. Но ощущается это совершенно по-разному. Геймдизайнеры называют сочную реакцию игры словом «juice» (сок) — и да, это официальный термин, его реально говорят на конференциях. Игра «сочная», когда она буквально брызжет реакциями тебе в лицо: каждое попадание чувствуется, каждое комбо хочется не растерять, каждое очко — маленький дофаминовый щелчок.
Ты уже умеешь делать частицы из урока «Частицы и эффекты» и знаешь, что такое кор-луп из урока «Кор-луп: чтобы было весело». Сегодня мы соберём поверх этого систему очков и комбо, научим игру выплёвывать всплывающие числа и трясти экраном — и наш цыплёнок наконец-то будет крушить врагов так, что захочется записать это на видео и кинуть в чат друзьям.
К концу урока у тебя будет вот такой результат: ты бьёшь врага — над ним вылетает +150 и улетает вверх, тая в воздухе; счёт в углу растёт; если бьёшь быстро, включается множитель x3 и очки капают втрое жирнее; а экран при ударе коротко вздрагивает. Погнали.
Очки: просто число, которое мы холим и лелеем
Начнём с самого простого — со счёта. Очки в игре это всего лишь одна переменная, которую мы увеличиваем, когда происходит что-то хорошее. Звучит скучно? Так и есть, пока мы не добавим вокруг неё магию. Но фундамент должен быть надёжным, так что давай заведём состояние счёта рядом с нашим цыплёнком.
// состояние игры — храним рядом с цыплёнком
const chicken = { x: 100, y: 300, vx: 0, vy: 0 };
const game = {
score: 0, // текущий счёт
combo: 1, // множитель комбо (начинаем с 1)
comboTimer: 0, // сколько секунд осталось у комбо
};
// функция, которую зовём при любом событии «начислить очки»
function addScore(base) {
const gained = base * game.combo; // базовые очки умножаем на множитель
game.score += gained;
return gained; // вернём, чтобы показать всплывашку
}Результат: на экране пока ничего не видно — мы только подготовили данные. Но теперь любой вызов addScore(100) прибавит к счёту 100 очков (или больше, если комбо разогнано), а в переменной game.score всегда лежит актуальное число.
Обрати внимание на пару деталей. Во-первых, я сразу храню combo как множитель, а не как «количество ударов подряд». Так удобнее: умножил базовые очки на game.combo — и готово. Во-вторых, addScore возвращает итоговое число gained. Это пригодится через минуту: мы покажем именно столько, сколько игрок реально заработал, с учётом комбо.
Рисуем счёт на canvas
Число в переменной — это здорово, но игрок его не видит. Давай выведем счёт в углу экрана. Тут вступает наш старый друг — контекст 2D.
function drawScore(ctx) {
ctx.fillStyle = '#fff';
ctx.font = 'bold 28px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('Очки: ' + game.score, 20, 40);
// комбо показываем, только когда оно больше 1
if (game.combo > 1) {
ctx.fillStyle = '#ffcc00';
ctx.fillText('x' + game.combo, 20, 75);
}
}Результат: в левом верхнем углу появляется белая надпись «Очки: 0», а когда комбо разгоняется — под ней жёлтым загорается «x2», «x3» и так далее. Чем выше множитель, тем заметнее, что ты сейчас в ударе.
Комбо: награда за то, что ты в потоке
А вот теперь интересное. Комбо — это механика, которая говорит игроку: «не останавливайся, ты на волне!» Если ты бьёшь врагов одного за другим без больших пауз, множитель растёт, и очки начисляются всё жирнее. Стоит замешкаться — и комбо сбрасывается обратно к x1.
Метафора простая: комбо — это как серия сообщений в Duolingo или streak в любимом приложении. Пока ты не пропускаешь дни, цифра растёт и тебе жалко её потерять. В игре роль «дней» играют секунды между ударами. Каждое попадание как бы заводит таймер обратного отсчёта: успел ударить снова до нуля — комбо живёт и растёт, не успел — обнулилось.
// зовём это КАЖДЫЙ раз, когда игрок попал по врагу
function registerHit() {
game.combo += 1; // множитель растёт
game.comboTimer = 1.5; // даём 1.5 секунды на следующий удар
}
// а это зовём каждый кадр в update, передавая дельта-время
function updateCombo(dt) {
if (game.comboTimer > 0) {
game.comboTimer -= dt; // таймер тает со временем
if (game.comboTimer <= 0) {
game.combo = 1; // время вышло — сброс множителя
}
}
}Результат: когда ты лупишь врагов быстро, в углу множитель ползёт вверх — x2, x3, x4. Если на секунду с лишним замешкался, жёлтая «x4» гаснет, и счёт снова начисляется по одному. Чувствуется давление: хочется не отпускать темп.
Заметь, что updateCombo работает на дельта-времени (dt), а не просто вычитает единичку каждый кадр. Помнишь метафору про автобус из ранних уроков? Если бы мы писали game.comboTimer -= 1, то на мощном ноутбуке с 120 FPS комбо сбрасывалось бы вдвое быстрее, чем на слабом телефоне с 30 FPS. А с дельта-временем 1.5 секунды — это честно 1.5 секунды на любом железе.
Связываем всё вместе в момент удара
Теперь соберём кусочки. Когда цыплёнок попадает по врагу, мы хотим: засчитать удар (разогнать комбо), начислить очки с учётом множителя и запомнить, сколько именно очков выпало — чтобы показать всплывашку. Вот функция-дирижёр.
function onEnemyHit(enemy) {
registerHit(); // 1. комбо +1, таймер заведён
const gained = addScore(50); // 2. очки * множитель
spawnPopup(enemy.x, enemy.y, gained); // 3. всплывашка над врагом
shakeScreen(6); // 4. лёгкая встряска
// ...тут же можно спавнить частицы из прошлого урока
}Результат: один вызов onEnemyHit при попадании запускает целый фейерверк реакций — счёт растёт, комбо тикает, над врагом вылетает число, экран дёргается. Игрок нажал один раз, а игра ответила пятью способами сразу. Вот это и есть «juice».
Всплывающие числа: цифры, которые умеют летать
Всплывающее число — это маленький текст, который рождается над событием, медленно поднимается вверх и тает. В шутерах так показывают урон, в три-в-ряд — очки за линию, в кликерах — каждый тап. Это, наверное, самый дешёвый по коду способ сделать игру в десять раз приятнее.
По сути всплывашка — это младший брат частицы. У неё есть координаты, скорость вверх, время жизни и прозрачность, которая падает к нулю. Раз ты уже делал частицы, тут всё покажется родным.
const popups = []; // массив живых всплывашек
function spawnPopup(x, y, value) {
popups.push({
x: x,
y: y,
text: '+' + value,
life: 1, // 1 = только родилась, 0 = пора умирать
vy: -40, // ползёт вверх, 40 пикселей в секунду
});
}
function updatePopups(dt) {
for (let i = popups.length - 1; i >= 0; i--) {
const p = popups[i];
p.y += p.vy * dt; // двигаем вверх с учётом дельта-времени
p.life -= dt; // жизнь тает примерно за 1 секунду
if (p.life <= 0) {
popups.splice(i, 1); // мёртвую всплывашку убираем из массива
}
}
}Результат: при попадании над врагом рождается текст вроде «+150», который плавно всплывает вверх и через секунду исчезает. Если бить часто, в воздухе одновременно висит россыпь летящих цифр — экран буквально оживает.
Один важный момент про цикл: я иду по массиву с конца (i--). Это не каприз. Когда мы удаляем элемент через splice прямо во время перебора, индексы всех элементов после него сдвигаются. Если идти с начала, легко перескочить через соседнюю всплывашку и не обработать её. А с конца удаление никак не путает ещё не пройденные элементы. Запомни этот приём — он работает для любых массивов, которые ты чистишь прямо в цикле.
Рисуем всплывашки красиво
Осталось показать их на canvas. Тут включим маленький трюк: чем меньше у всплывашки life, тем она прозрачнее. Так число не пропадает резко, а тает, будто растворяется в воздухе.
function drawPopups(ctx) {
ctx.textAlign = 'center';
ctx.font = 'bold 22px sans-serif';
for (const p of popups) {
ctx.globalAlpha = p.life; // прозрачность = остаток жизни
ctx.fillStyle = '#ffe14d';
ctx.fillText(p.text, p.x, p.y);
}
ctx.globalAlpha = 1; // ВАЖНО: вернуть назад!
}Результат: жёлтые числа всплывают над врагами и плавно угасают к прозрачности, словно дым. Свежая цифра яркая и чёткая, а та, что вот-вот исчезнет, — еле видна.
Видишь последнюю строку ctx.globalAlpha = 1? Не вздумай её выкинуть. Контекст 2D — это глобальная «кисть»: если ты выставил полупрозрачность и не вернул обратно, всё остальное, что ты рисуешь после (цыплёнок, фон, счёт), тоже станет блёклым. Это классическая ловушка, к ней мы ещё вернёмся в разделе про ошибки.
И ещё крошечная деталь, которая многое решает: я выставляю ctx.textAlign = 'center' перед рисованием. Без этого текст рисуется от левого края, и всплывашка вылетает не из центра врага, а откуда-то сбоку. Мелочь, а когда чисел много, бросается в глаза — цифры должны рождаться ровно там, где случилось событие, иначе мозг не свяжет одно с другим.
Встряска экрана: добавляем удару вес
Последний штрих — screen shake, встряска экрана. Когда происходит что-то мощное (удар, взрыв, приземление с высоты), вся картинка коротко дёргается на пару пикселей. Мозг считывает это как «бум, удар был тяжёлым». Это тот самый эффект, из-за которого в экшенах попадания ощущаются физически.
Реализуется до смешного просто. Мы заводим переменную «силы тряски». При ударе подбрасываем её вверх, каждый кадр она затухает к нулю, а перед отрисовкой сцены мы сдвигаем весь холст на случайную величину в пределах этой силы.
let shake = 0; // текущая сила тряски
function shakeScreen(power) {
shake = Math.max(shake, power); // берём максимум, чтобы удары не гасили друг друга
}
function updateShake(dt) {
shake -= 30 * dt; // тряска быстро затухает
if (shake < 0) shake = 0;
}
// в самом начале отрисовки кадра:
function render(ctx) {
ctx.save(); // запоминаем состояние холста
const dx = (Math.random() - 0.5) * shake * 2;
const dy = (Math.random() - 0.5) * shake * 2;
ctx.translate(dx, dy); // сдвигаем ВСЮ сцену
// ...тут рисуем фон, цыплёнка, врагов, частицы...
ctx.restore(); // возвращаем холст как было
// счёт и всплывашки можно рисовать ПОСЛЕ restore, чтобы они не тряслись
}Результат: в момент удара вся игровая сцена коротко вздрагивает на несколько пикселей и за долю секунды успокаивается. Удар сразу ощущается тяжёлым и весомым, хотя по факту мы просто чуть-чуть подвигали холст случайным образом.
Пара тонкостей. ctx.save() и ctx.restore() работают как скобки: первый запоминает текущее состояние холста, второй возвращает его обратно. Без restore сдвиг translate накапливался бы кадр за кадром, и сцена уползла бы за край экрана навсегда. А ещё я намеренно рисую счёт и всплывашки после restore — интерфейс не должен трястись вместе с миром, иначе цифры будет тяжело читать.
Частые ошибки и подводные камни
- Забыл вернуть
globalAlphaв 1. Самая популярная засада. Выставил прозрачность для всплывашек, не вернул — и весь экран стал бледным и призрачным. Всегда сбрасывай глобальные настройки контекста (globalAlpha,fillStyle) после того, как порисовал что-то особенное. - Тряска и таймеры без дельта-времени. Если писать
shake -= 1илиcomboTimer -= 1вместо умножения наdt, эффекты будут вести себя по-разному на разных устройствах: на мощном ПК комбо сбросится мгновенно, на слабом телефоне будет тянуться вечность. Всё, что меняется во времени, обязано опираться на дельта-время. - Удаление из массива в цикле «с начала». Если чистишь
popupsили частицы в обычном циклеfor (let i = 0; ...)и делаешьsplice, ты пропустишь следующий элемент — индексы сдвинутся. Иди по массиву с конца (i--) либо собирай живые в новый массив. - Тряска накапливается, сцена уезжает. Если вызвать
translateи забыть про паруsave/restore, сдвиг будет складываться каждый кадр, и через секунду игра уедет в угол экрана. Оборачивай отрисовку сцены вsave/restoreвсегда. - Слишком много сока. Звучит странно, но перебор тоже вредит. Если экран трясётся от каждого шага, всё мигает и заливает частицами, игрок устаёт и теряет из виду собственного цыплёнка. Сочность должна подчёркивать важные события, а не орать без остановки. Сильная тряска — для редких мощных ударов, лёгкая — для частых.
Мини-практика: разгони комбо до предела
Базовая система готова — теперь сделай её своей. Вот задание, которое стоит доделать самому:
- Цвет комбо по уровню. Сделай так, чтобы при
comboот 2 до 4 всплывашки и надпись были жёлтыми, от 5 до 9 — оранжевыми, а от 10 — красными. Подсказка: заведи функциюcomboColor(), которая по значениюgame.comboвозвращает строку цвета, и используй её вdrawScoreиdrawPopups. - Бонус за длинное комбо. Добавь в
onEnemyHitпроверку: еслиgame.comboдостигло, скажем, 10, начисли разовый бонусaddScore(500)и покажи всплывашку «КОМБО!» покрупнее. Игрок должен почувствовать, что добрался до чего-то особенного. - Сила тряски от комбо. Пусть встряска становится сильнее с ростом множителя: вместо
shakeScreen(6)зовиshakeScreen(4 + game.combo). Чем длиннее серия, тем мощнее ощущается каждый следующий удар. Только не забудь поставить разумный потолок, иначе при комбо x50 экран будет ходить ходуном.
Если хочется ещё — добавь короткий звуковой «дзынь» при каждом начислении и более громкий при бонусе за комбо. Звук — это половина сочности, и ты уже умеешь его подключать из прошлых модулей. Попробуй и такой трюк: пусть высота звука чуть растёт вместе с комбо. Тогда длинная серия превращается в восходящую мелодию, и ухо само начинает ждать следующую ноту — это затягивает не хуже самой механики.
Итоги и что дальше
Сегодня мы взяли голую механику «попал — получил очко» и обернули её в слой сочной обратной связи. Теперь ты умеешь:
- хранить
scoreиcomboв состоянии игры и начислять очки с множителем; - разгонять комбо за серии действий и честно сбрасывать его по таймеру на дельта-времени;
- спавнить всплывающие числа над событием и красиво гасить их через
globalAlpha; - добавлять встряску экрана через
save/restoreиtranslate; - и главное — понимать, зачем игре сок: чтобы каждое действие игрока ощущалось приятно и хотелось нажать ещё раз.
Эти кусочки кода — не одноразовые. Множитель комбо, всплывашки и тряска идеально лягут на любую механику нашего цыплёнка: и когда он собирает монетки в платформере, и когда сбивает мячиком блоки. В финальной аркаде про цыплёнка мы соберём всё это вместе, и поверь — без сегодняшнего слоя сочности игра выглядела бы вдвое скучнее.
В следующем уроке раздела мы поговорим о балансе сложности: как сделать так, чтобы игра не была ни слишком лёгкой (тогда скучно), ни слишком жестокой (тогда злишься и закрываешь). Очки и комбо, которые мы сделали сегодня, станут отличным инструментом, чтобы измерять, насколько хорошо у игрока идут дела — и подстраивать сложность под него. До встречи, и пусть твой цыплёнок крушит врагов с комбо x99.