Очки, комбо и обратная связь

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

Зачем вообще тратить время на «красивости»

Представь две версии одной и той же игры про цыплёнка. В первой ты сбиваешь врага — и он просто исчезает. Тихо. Молча. Как будто кто-то стёр его ластиком. Во второй — враг лопается облачком частиц, над ним выскакивает жёлтая цифра +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 всегда.
  • Слишком много сока. Звучит странно, но перебор тоже вредит. Если экран трясётся от каждого шага, всё мигает и заливает частицами, игрок устаёт и теряет из виду собственного цыплёнка. Сочность должна подчёркивать важные события, а не орать без остановки. Сильная тряска — для редких мощных ударов, лёгкая — для частых.

Мини-практика: разгони комбо до предела

Базовая система готова — теперь сделай её своей. Вот задание, которое стоит доделать самому:

  1. Цвет комбо по уровню. Сделай так, чтобы при combo от 2 до 4 всплывашки и надпись были жёлтыми, от 5 до 9 — оранжевыми, а от 10 — красными. Подсказка: заведи функцию comboColor(), которая по значению game.combo возвращает строку цвета, и используй её в drawScore и drawPopups.
  2. Бонус за длинное комбо. Добавь в onEnemyHit проверку: если game.combo достигло, скажем, 10, начисли разовый бонус addScore(500) и покажи всплывашку «КОМБО!» покрупнее. Игрок должен почувствовать, что добрался до чего-то особенного.
  3. Сила тряски от комбо. Пусть встряска становится сильнее с ростом множителя: вместо shakeScreen(6) зови shakeScreen(4 + game.combo). Чем длиннее серия, тем мощнее ощущается каждый следующий удар. Только не забудь поставить разумный потолок, иначе при комбо x50 экран будет ходить ходуном.

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

Итоги и что дальше

Сегодня мы взяли голую механику «попал — получил очко» и обернули её в слой сочной обратной связи. Теперь ты умеешь:

  • хранить score и combo в состоянии игры и начислять очки с множителем;
  • разгонять комбо за серии действий и честно сбрасывать его по таймеру на дельта-времени;
  • спавнить всплывающие числа над событием и красиво гасить их через globalAlpha;
  • добавлять встряску экрана через save/restore и translate;
  • и главное — понимать, зачем игре сок: чтобы каждое действие игрока ощущалось приятно и хотелось нажать ещё раз.

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

В следующем уроке раздела мы поговорим о балансе сложности: как сделать так, чтобы игра не была ни слишком лёгкой (тогда скучно), ни слишком жестокой (тогда злишься и закрываешь). Очки и комбо, которые мы сделали сегодня, станут отличным инструментом, чтобы измерять, насколько хорошо у игрока идут дела — и подстраивать сложность под него. До встречи, и пусть твой цыплёнок крушит врагов с комбо x99.

Проверьте себя
1. Зачем в игру добавляют «сочную» обратную связь (juice) — всплывашки, тряску, вспышки?
AЧтобы усложнить код и впечатлить других программистов
BЧтобы каждое действие игрока ощущалось приятно и весомо, и в игру хотелось играть ещё
CЧтобы игра занимала больше места на диске
DЧтобы скрыть баги в основной механике
2. Почему таймер комбо уменьшают через `game.comboTimer -= dt`, а не через `-= 1`?
AТак короче писать
BЧтобы комбо сбрасывалось ровно за одинаковое реальное время на любом FPS
CПотому что dt всегда равно единице
DЧтобы комбо никогда не сбрасывалось
3. Почему массив всплывашек чистят циклом с конца (`for (let i = popups.length - 1; i >= 0; i--)`)?
AС конца перебирать быстрее по скорости
BЧтобы splice не сдвигал индексы ещё не пройденных элементов и ни один не был пропущен
CИначе всплывашки рисуются в обратном порядке
DЭто требование самого метода splice
4. Что обязательно нужно сделать после того, как ты порисовал полупрозрачные всплывашки через `ctx.globalAlpha = p.life`?
AВызвать ctx.clearRect, чтобы стереть всё
BВернуть ctx.globalAlpha = 1, иначе всё нарисованное дальше тоже станет блёклым
CПерезагрузить страницу
DНичего, globalAlpha сбрасывается сам каждый кадр
5. Зачем отрисовку сцены при встряске оборачивают в `ctx.save()` и `ctx.restore()`?
AЧтобы сохранить игру на диск
BЧтобы сдвиг translate не накапливался кадр за кадром и сцена не уехала за край экрана
CЧтобы ускорить рисование
DЭто нужно только для всплывашек
6. Почему счёт и всплывашки лучше рисовать ПОСЛЕ ctx.restore(), а не внутри встряски?
AТак они нарисуются быстрее
BЧтобы интерфейс и цифры не тряслись вместе с миром и оставались читаемыми
CИначе они не появятся вообще
DЧтобы они тоже дрожали для красоты