Сложность и кривая обучения

Хорошая игра ведёт тебя за руку: чуть-чуть тяжелее на каждом шаге — ровно настолько, чтобы было азартно, но не хотелось закрыть вкладку.

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

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

Зачем вообще думать о сложности

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

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

Поток: коридор между скукой и фрустрацией

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

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

Фокус в том, что навык игрока растёт. То, что было сложным на первом уровне, к десятому станет скучным. Значит, сложность тоже должна расти — иначе игрок упрётся в левую стену скуки.

Как это выглядит на графике

Если по горизонтали отложить время игры, а по вертикали — сложность, то идеальная кривая обучения похожа на лестницу, которая ползёт вверх. Каждая ступенька — новый вызов. После каждого подъёма — маленькая площадка, где игрок осваивается и чувствует себя крутым. А потом — снова чуть выше.

Что чувствует игрокГде он на кривойЧто делать дизайнеру
«Скучно, я уже всё умею»Слишком низко, левая стенаПоднять вызов: новый враг, меньше времени
«Азартно, но справляюсь»В тоннеле потока — идеальноНичего не трогать, держать ритм
«Бесит, я постоянно умираю»Слишком высоко, правая стенаСнизить вызов или подучить механике

Из чего складывается сложность: рычаги

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

  • Скорость — как быстро летят враги и снаряды. Чем быстрее, тем меньше времени на реакцию.
  • Количество — сколько врагов или препятствий одновременно на экране.
  • Точность — насколько узкие платформы и проёмы, в которые надо попасть прыжком.
  • Ресурсы — сколько у игрока жизней, времени, патронов. Меньше ресурсов = больнее ошибаться.
  • Новизна — незнакомые механики, которые надо понять прямо в бою.

Пример 1: уровень сложности как набор чисел

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

// Профили сложности для цыплёнка-платформера.
// Один объект — один уровень вызова.
const LEVELS = {
  easy: { enemySpeed: 1.5, enemyCount: 2, gapWidth: 120, lives: 5 },
  normal: { enemySpeed: 2.5, enemyCount: 4, gapWidth: 90, lives: 3 },
  hard: { enemySpeed: 4.0, enemyCount: 7, gapWidth: 60, lives: 1 },
};

let difficulty = LEVELS.easy; // стартуем с лёгкого

function spawnEnemies() {
  for (let i = 0; i < difficulty.enemyCount; i++) {
    enemies.push({
      x: canvas.width + i * 80,
      y: groundY,
      vx: -difficulty.enemySpeed, // враги ползут влево, на цыплёнка
      sprite: chickenEnemySprite,
    });
  }
}

Результат: на лёгком уровне навстречу цыплёнку медленно ползут два врага, проёмы между платформами широкие, и у игрока пять жизней — промахнуться не страшно. Стоит переключить difficulty на LEVELS.hard — и тех же врагов становится семь, летят они почти втрое быстрее, проёмы сужаются, а жизнь всего одна.

Разберём по шагам. Мы держим три профиля в LEVELS — каждый это просто мешочек чисел. Переменная difficulty указывает на текущий профиль. Функция spawnEnemies не знает никаких конкретных цифр: она спрашивает их у difficulty. Захотел сделать игру злее — поменял одну строчку, и весь спавн, скорость и количество подстроились сами.

Пример 2: плавный рост вместо ступенек

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

let score = 0;
let elapsed = 0; // секунд с начала забега

function update(dt) {
  elapsed += dt;

  // Скорость растёт плавно: каждые 10 секунд +0.5,
  // но не выше потолка, чтобы не стало нечестно.
  const speed = Math.min(2 + elapsed / 10 * 0.5, 8);

  for (const enemy of enemies) {
    enemy.x -= speed * dt; // двигаем с учётом дельта-времени
  }

  // Интервал спавна сжимается: враги появляются чаще.
  spawnTimer -= dt;
  if (spawnTimer <= 0) {
    spawnEnemies();
    spawnTimer = Math.max(2 - elapsed / 30, 0.6); // не чаще, чем раз в 0.6 с
  }
}

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

Ключевая деталь — потолки. Без Math.min скорость росла бы до бесконечности и через пять минут стала бы физически непроходимой. С потолком кривая выходит на плато: сложность высокая, но честная. Игрок проигрывает из-за своей ошибки, а не потому что игра жульничает.

Пример 3: невидимый помощник (резиновая лента)

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

let deathsInARow = 0; // подряд проигрышей

function onChickenDeath() {
  deathsInARow++;

  // Игрок застрял? Тихо снижаем вызов, ничего ему не говорим.
  if (deathsInARow >= 3) {
    difficulty.enemySpeed *= 0.9; // враги чуть медленнее
    difficulty.gapWidth += 10;    // проёмы чуть шире
  }
}

function onLevelClear() {
  deathsInARow = 0; // справился — сбрасываем счётчик
}

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

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

Кстати, резиновая лента работает и в другую сторону. Если игрок проходит уровни без единой смерти, можно тихо поддать жару: чуть ускорить врагов, добавить ещё одного. Так ты ловишь и тех, кому слишком тяжело, и тех, кому слишком легко, не заставляя их лезть в меню настроек. Гонки вроде Mario Kart этим знамениты: отстающим подкидывают мощные бонусы, а лидеру — слабенькие, чтобы гонка до финиша оставалась напряжённой для всех. Это и есть динамическая подстройка в чистом виде, просто завёрнутая в красивую обёртку.

Постепенный ввод механик

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

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

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

Как это выглядит в коде уровней

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

const STAGES = [
  { name: 'Учимся прыгать', features: ['jump'] },
  { name: 'Появились ямы', features: ['jump', 'gaps'] },
  { name: 'Первый враг', features: ['jump', 'gaps', 'enemy'] },
  { name: 'Монетки и риск', features: ['jump', 'gaps', 'enemy', 'coins'] },
];

function has(feature) {
  return currentStage.features.includes(feature);
}

function update(dt) {
  if (has('enemy')) updateEnemies(dt); // враги работают только там, где введены
  if (has('coins')) updateCoins(dt);
}

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

Заметь: функция update проверяет has('enemy') перед тем, как двигать врагов. Это значит, что один и тот же код уровня обслуживает все этапы — мы просто включаем механики флажком в данных, а не плодим четыре разных файла. Удобно для нас и предсказуемо для игрока.

Честность для разных игроков

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

  • Уровни сложности на старте (easy/normal/hard) — самый простой способ, мы его уже закодили в примере 1.
  • Необязательные испытания — основной путь проходим для всех, а спрятанные секретные комнаты с монетками — для тех, кто хочет хардкора.
  • Доступность — опции вроде «больше времени на реакцию» или «нельзя умереть на обучении». Это не «читы», это уважение к игроку.

Главный принцип честности: игрок должен проигрывать из-за своей ошибки, которую он понимает, а не из-за того, что игра подсунула невидимый шип или потребовала реакцию быстрее человеческой. Проигрыш в честной игре злит на себя («сейчас пройду!»), а в нечестной — на разработчика («да сколько можно!»). Разница огромная: первый игрок жмёт «Заново» и пробует снова, второй закрывает вкладку навсегда.

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

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

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

  • Стена сложности вместо лестницы. Первые уровни легко проходятся, а потом резкий скачок — и игрок упирается в непробиваемую стену. Кривая должна расти плавно, без обрывов. Если многие бросают на одном и том же месте — там у тебя стена.
  • «Мне же легко» — синдром разработчика. Ты прошёл свой уровень сто раз и знаешь, где будет враг. Для тебя игра лёгкая, для новичка — нет. Никогда не балансируй сложность по своим ощущениям: давай играть тем, кто видит игру впервые, и смотри, где они спотыкаются.
  • Сложность без обучения. Ты вводишь механику двойного прыжка и сразу требуешь её в опасном месте. Игрок не успел понять, как она работает, и злится. Сначала песочница, потом испытание.
  • Нечестная сложность. Враг появляется внезапно за краем экрана, увернуться нельзя. Игрок умирает не по своей вине. Такое бесит сильнее всего — давай игроку информацию заранее (предупреждающий значок, звук), чтобы у него был шанс среагировать.
  • Сложность ради сложности. «Сделаю-ка я мегахардкор!» — и в игру никто не играет дольше минуты. Сложность — это средство держать игрока в потоке, а не самоцель. Высокий вызов хорош только тогда, когда он по-прежнему проходим.

Мини-практика: спроектируй кривую

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

  1. Распиши массив STAGES из примера на четыре этапа. Для каждого укажи name, список features и числа сложности (скорость врага, ширину проёмов).
  2. Сделай так, чтобы скорость врага росла от этапа к этапу, но не больше чем на 30% за шаг — это и есть плавность.
  3. Добавь динамическую подстройку из примера 3: после трёх смертей подряд чуть снижай вызов на текущем этапе.
  4. Самое важное: дай поиграть кому-то, кто не видел твою игру. Запиши, на каком этапе он спотыкается. Если бросает на одном месте — там у тебя стена, сглаживай кривую.

Бонус: добавь на старте экран выбора easy / normal / hard и переключай объект difficulty. Так одной игрой ты накроешь и младшего брата, и хардкорщика.

Итоги

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

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

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

Проверьте себя
1. Что такое состояние потока (flow) в контексте сложности игры?
AКогда вода течёт по уровню и двигает платформы
BСостояние, когда задача по силам игроку — между скукой и фрустрацией
CСкорость, с которой враги появляются на экране
DРежим, в котором игрок может проходить уровни без смертей
2. Почему сложность игры должна расти по ходу прохождения?
AПотому что навык игрока растёт, и прежний вызов становится скучным
BПотому что так положено по правилам геймдизайна
CЧтобы игра быстрее заканчивалась
DЧтобы враги двигались плавнее
3. Зачем в примере 2 скорость врагов ограничена через Math.min(..., 8)?
AЧтобы враги вообще не двигались
BЧтобы скорость не росла бесконечно и игра не стала непроходимой и нечестной
CЧтобы сэкономить память браузера
DЧтобы цыплёнок бежал быстрее врагов
4. Как правильно вводить новую механику по принципу «одной новой вещи»?
AВключить все механики сразу на первом уровне
BСначала дать потрогать её в безопасной песочнице, потом требовать под давлением
CНикогда не объяснять — пусть игрок догадывается сам в опасном месте
DВводить по три механики за уровень, чтобы было насыщенно
5. Что такое нечестная сложность?
AКогда у игрока слишком много жизней
BКогда игрок проигрывает из-за того, что игра не дала шанса среагировать (внезапный враг за краем экрана)
CКогда уровни проходятся слишком быстро
DКогда в игре есть выбор easy/normal/hard
6. Зачем выносить настройки сложности в один объект вроде LEVELS или difficulty?
AЧтобы код выглядел длиннее и солиднее
BЧтобы менять вызов в одном месте, а не искать магические числа по всему коду
CЭто обязательное требование браузера для canvas
DЧтобы игра работала на большем числе FPS