Адаптив и мобильное управление

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

Зачем это вообще нужно

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

В этом уроке мы решим две задачи. Первая: canvas должен подстраиваться под размер экрана, а не быть жёстко прибитым к 800 на 600 пикселей. Вторая: на сенсорном экране нужны экранные кнопки и тач-джойстик — потому что пальцем по стрелочкам клавиатуры не потыкаешь.

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

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

Кстати, про сам тач мы уже говорили в уроке «Мышь и тач-управление» — там мы ловили одиночные касания: тап по кнопке, клик по объекту. Сейчас пойдём дальше и соберём из касаний полноценное удержание и перетаскивание — то есть управление, которое работает не «нажал-отпустил», а «держу и веду».

Метафора: игра как картина в рамке

Подумай о фотографии в Instagram. Один и тот же снимок ты видишь и на маленьком телефоне, и на большом планшете. Картинка не превращается в кашу: она просто масштабируется целиком, сохраняя пропорции. Квадрат остаётся квадратом, лица не вытягиваются.

Наш canvas должен вести себя так же. Внутри игры мы по-прежнему рисуем в удобных «логических» координатах — например, поле 800 на 600. А наружу показываем это поле увеличенным или уменьшенным под экран целиком, как одну большую картину в рамке. Рамка (окно браузера) бывает разной — а картина внутри всегда с одними и теми же пропорциями.

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

Запомни это разделение, оно главное в уроке: есть логический мир (поле 800 на 600, в котором живут цыплёнок, монетки, враги) и есть экран (то окно браузера, что досталось нам на телефоне). Внутри логического мира мы ничего не меняем — цыплёнок всё так же стоит в точке chicken.x = 400, что бы ни случилось с экраном. А мост между мирами — одно-единственное число scale. Поменялся экран — пересчитали scale — и весь рисунок автоматически подстроился. Никаких правок в логике игры.

Почему нельзя просто задать большой canvas в CSS

Тут новичка подстерегает ловушка. Кажется логичным: напишу в CSS canvas { width: 100% } — и всё растянется само. Растянется, да не так. CSS-ширина и атрибуты canvas.width/canvas.height — это два разных размера. Первый — сколько места canvas занимает на странице, второй — сколько внутри него настоящих пикселей для рисования. Если их не согласовать, браузер возьмёт маленькую картинку рисунка и тупо растянет её на большой блок, как растягивают мем низкого разрешения: получится мыло. Поэтому правильный путь — менять именно canvas.width и canvas.height в JavaScript, как мы и сделаем ниже.

Пример 1. Canvas во весь экран без искажений

Начнём с простого приёма: пусть canvas физически займёт всё окно браузера, но игра внутри останется в своих логических 800 на 600. Для этого разделим два понятия: логический размер (в нём мы думаем и считаем координаты) и экранный размер (сколько пикселей на самом деле занимает canvas).

const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');

// Логический размер игрового поля — он НЕ меняется
const GAME_W = 800;
const GAME_H = 600;

// Коэффициент масштаба считаем тут и переиспользуем дальше
let scale = 1;

function resize() {
  // Сколько раз поле помещается в окно по ширине и по высоте
  const scaleX = window.innerWidth / GAME_W;
  const scaleY = window.innerHeight / GAME_H;

  // Берём МЕНЬШИЙ — чтобы поле целиком влезло и сохранило пропорции
  scale = Math.min(scaleX, scaleY);

  // Физический размер canvas в пикселях экрана
  canvas.width = GAME_W * scale;
  canvas.height = GAME_H * scale;
}

window.addEventListener('resize', resize);
resize();

Результат: canvas занимает максимум места в окне, но цыплёнок и поле не искажаются — поле просто целиком масштабируется до размера экрана, оставляя по краям пустые поля, если пропорции окна не совпадают с 800 на 600.

Разберём по шагам, что здесь происходит. scaleX отвечает на вопрос «во сколько раз окно шире нашего поля», scaleY — то же по высоте. Если бы мы взяли больший из них, поле вылезло бы за край с одной стороны. Поэтому мы берём Math.min — меньший коэффициент гарантирует, что и ширина, и высота поместятся целиком. Это и есть тот самый «один коэффициент на оба измерения», который сохраняет пропорции.

Рисуем игру в логических координатах

Но есть нюанс: если мы увеличили физический canvas, а рисуем цыплёнка по старым координатам chicken.x и chicken.y, он окажется крошечным в углу. Нам нужно, чтобы весь рисунок тоже растянулся. Сделаем это одной командой ctx.scale в начале каждого кадра.

function draw() {
  // Сбрасываем все прошлые трансформации
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // Включаем масштаб: дальше рисуем в логических координатах 800x600
  ctx.scale(scale, scale);

  // А вот это уже знакомый код — координаты НЕ трогаем
  ctx.drawImage(chickenSprite, chicken.x, chicken.y, 48, 48);
}

Результат: цыплёнок рисуется по привычным координатам chicken.x, chicken.y, как и в прошлых уроках, но на экране выглядит крупным и чётким — весь рисунок растянут на коэффициент scale.

Красота приёма в том, что весь старый код игры не меняется. Цыплёнок всё так же живёт в поле 800 на 600, движется со своей скоростью, сталкивается со стенами по AABB — а масштаб мы добавили снаружи, одной строкой ctx.scale. Это тот самый принцип «писали раньше — переиспользуем дальше», который тянется у нас через весь курс: код перетекает из урока в урок без переписывания.

Подумай, что мы по сути сделали: спрятали всю возню с экраном в две функции — resize (пересчитать scale) и первые строки draw (применить scale). Остальная игра даже не подозревает, что её показывают на телефоне. Это очень удобный способ мыслить: каждый раз, когда новая фича грозит залезть во все уголки кода, спроси себя — нельзя ли запереть её в одном месте, а наружу отдать одно простое число? С масштабом получилось именно так.

Пример 2. Экранные кнопки для цыплёнка

Теперь управление. На телефоне нет стрелок, зато есть пальцы. Самый понятный для новичка вариант — нарисовать на экране кнопки «влево» и «вправо» и ловить касания по ним.

Хитрость в том, что кнопки не обязательно рисовать на canvas — проще сделать их обычными HTML-элементами поверх игры. Но чтобы остаться в рамках нашего игрового кода, давай заведём объект controls, который хранит, какие направления сейчас «нажаты». Игра каждый кадр просто смотрит в этот объект — точь-в-точь как раньше смотрела на нажатые клавиши.

// Текущее состояние ввода: что зажато прямо сейчас
const controls = { left: false, right: false };

// Допустим, в HTML есть две кнопки с id="btn-left" и id="btn-right"
const btnLeft = document.getElementById('btn-left');
const btnRight = document.getElementById('btn-right');

// Палец коснулся кнопки — ставим флаг
btnLeft.addEventListener('touchstart', () => { controls.left = true; });
btnRight.addEventListener('touchstart', () => { controls.right = true; });

// Палец убрали — снимаем флаг
btnLeft.addEventListener('touchend', () => { controls.left = false; });
btnRight.addEventListener('touchend', () => { controls.right = false; });

Результат: когда ты держишь палец на левой кнопке, controls.left равно true; отпустил — снова false. Сами кнопки пока ничего не двигают, но игра уже знает о намерениях игрока.

Теперь свяжем флаги с движением цыплёнка прямо в функции обновления состояния. Скорость, как обычно, умножаем на дельта-время, чтобы цыплёнок бежал одинаково на любом FPS.

const SPEED = 220; // пикселей в секунду

function update(dt) {
  if (controls.left)  chicken.x -= SPEED * dt;
  if (controls.right) chicken.x += SPEED * dt;

  // Не даём цыплёнку убежать за край поля
  if (chicken.x < 0)            chicken.x = 0;
  if (chicken.x > GAME_W - 48)  chicken.x = GAME_W - 48;
}

Результат: пока палец на левой кнопке — цыплёнок плавно едет влево, на правой — вправо; у краёв поля он упирается и не вылезает наружу.

Заметь: функция update не знает и знать не хочет, откуда взялись controls.left и controls.right — с клавиатуры, с кнопок или с джойстика. Это удобно: можно подключить любое управление, не переписывая логику движения. Тот же приём с разделением «ввод отдельно, логика отдельно» мы использовали, когда строили игровые состояния — там игра тоже читала состояние, а не реагировала на каждое событие напрямую.

На практике это значит, что ты можешь оставить и старую клавиатуру: пусть стрелка влево тоже ставит controls.left = true. Тогда одна и та же игра управляется и с клавиатуры на ноутбуке, и с экранных кнопок на телефоне, а движок об этом даже не догадывается. Игрок берёт то, что под рукой, — и всё работает.

Пример 3. Простой тач-джойстик

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

Идея простая. Запоминаем точку, где палец коснулся экрана (центр джойстика). Пока палец двигается, считаем разницу между текущим положением пальца и центром — это и есть наш вектор скорости. Отпустили палец — вектор обнуляется.

// Центр джойстика и текущий вектор тяги
let stickStart = null;       // точка, где палец коснулся
const stick = { dx: 0, dy: 0 }; // куда и насколько тянем

canvas.addEventListener('touchstart', (e) => {
  const t = e.touches[0];
  stickStart = { x: t.clientX, y: t.clientY };
});

canvas.addEventListener('touchmove', (e) => {
  if (!stickStart) return;
  const t = e.touches[0];
  // Разница = направление и сила тяги
  stick.dx = t.clientX - stickStart.x;
  stick.dy = t.clientY - stickStart.y;
});

canvas.addEventListener('touchend', () => {
  stickStart = null;
  stick.dx = 0;
  stick.dy = 0;
});

Результат: касание задаёт центр джойстика; пока тянешь палец, в stick.dx и stick.dy копится вектор «куда тянем»; отпустил — вектор сбрасывается в ноль.

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

const STICK_SPEED = 4; // множитель скорости джойстика
const DEAD_ZONE = 10;  // мёртвая зона в пикселях

function update(dt) {
  // Длина вектора тяги по теореме Пифагора
  const len = Math.sqrt(stick.dx * stick.dx + stick.dy * stick.dy);

  if (len > DEAD_ZONE) {
    // Нормируем вектор (делаем длиной 1) и задаём скорость
    chicken.vx = (stick.dx / len) * STICK_SPEED * dt * 60;
    chicken.vy = (stick.dy / len) * STICK_SPEED * dt * 60;
  } else {
    chicken.vx = 0;
    chicken.vy = 0;
  }

  chicken.x += chicken.vx;
  chicken.y += chicken.vy;
}

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

Здесь мы сделали важную вещь — нормировали вектор. Делим dx и dy на длину len, чтобы получить чистое направление длиной 1, а скорость задаём отдельным множителем. Без этого цыплёнок ехал бы тем быстрее, чем дальше ты оттянул палец — иногда это и нужно, но для ровного движения лучше нормировать. Мы снова используем chicken.vx и chicken.vy — те же имена вектора скорости, что и в уроках про движение и гравитацию.

Почему длину считаем именно так, через Math.sqrt? Это старая добрая теорема Пифагора: палец оттянут на dx по горизонтали и на dy по вертикали, а нам нужна прямая длина этого сдвига — гипотенуза. Деление каждой координаты на эту длину — и есть нормирование: вектор сжимается или растягивается ровно до единицы, сохраняя направление. Дальше умножаем на STICK_SPEED и на dt * 60 — последний множитель привязывает скорость к дельта-времени, чтобы цыплёнок успевал «на тот же автобус» и при 30, и при 60 FPS. Если тебе захочется аналогового управления, как на настоящем геймпаде (легонько тянешь — крадёшься, сильно — бежишь), просто не нормируй вектор, а ограничь его максимальную длину. Это уже вопрос вкуса твоей игры.

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

1. Растягиваешь width и height по-разному

Самая популярная беда: взять canvas.width = window.innerWidth и canvas.height = window.innerHeight напрямую. Тогда поле растянется под форму окна, и цыплёнок превратится в приплюснутый блин на широком экране или в тощую соплю на узком. Лекарство — один общий коэффициент scale = Math.min(scaleX, scaleY) на оба измерения.

2. Забываешь сбросить трансформацию перед кадром

Если вызывать ctx.scale(scale, scale) каждый кадр без ctx.setTransform(1, 0, 0, 1, 0, 0) в начале, масштаб будет накапливаться: кадр за кадром картинка раздувается, пока цыплёнок не улетит в бесконечность. Всегда обнуляй трансформацию в начале draw.

3. Браузер прокручивает страницу вместо игры

На телефоне касание по canvas часто заодно скроллит страницу или вызывает зум двойным тапом. Игра при этом дёргается. Лечится вызовом e.preventDefault() внутри обработчиков touchstart и touchmove — он говорит браузеру «это касание для игры, не трогай страницу».

4. Координаты касания не совпадают с игровыми

Палец возвращает координаты в пикселях экрана (clientX, clientY), а игра живёт в логических 800 на 600. Если ты ловишь касание прямо по объектам игры, не забудь поделить координаты касания на scale и вычесть отступ canvas через canvas.getBoundingClientRect() — иначе попадания будут мимо. Для джойстика из примера 3 это не важно (там мы работаем с относительным сдвигом), но для тапа по объекту — критично.

5. Палец «прилипает» к зажатой кнопке

Бывает так: убрал палец с кнопки, а цыплёнок всё едет. Это значит, что touchend сработал не там, где ты ждал. На телефоне палец легко соскальзывает с кнопки, и событие отпускания прилетает уже на соседний элемент, а флаг controls.left так и остаётся true. Подстрахуйся: вешай сброс флага ещё и на событие touchcancel, которое браузер шлёт, когда касание «потерялось». Тогда застрявших движений не будет.

6. Тестируешь только на компьютере

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

Мини-проект: мобильная панель для цыплёнка

Собери всё в одну небольшую сборку и доведи её сам:

  1. Возьми любую свою игру про цыплёнка из прошлых разделов (Понг, Змейку или платформер).
  2. Добавь функцию resize из примера 1 и масштабирование ctx.scale в draw, чтобы поле целиком влезало в экран.
  3. Подключи экранные кнопки из примера 2 — минимум «влево» и «вправо». Если игра требует прыжка, добавь третью кнопку, которая ставит controls.jump = true на одно касание.
  4. Задание со звёздочкой: вместо кнопок прикрути тач-джойстик из примера 3 и сделай так, чтобы джойстик появлялся только в той половине экрана, где игрок поставил палец (левая половина — движение, правая — действие).

Проверь сборку обязательно на телефоне: открой игру, поверни экран горизонтально и убедись, что цыплёнок не вылез за край и слушается пальцев.

Итоги

Сегодня ты сделал игру дружелюбной к телефону. Главное, что стоит унести с собой:

  • Разделяй логический размер поля (в нём считаешь координаты) и экранный (сколько занимает canvas) — игровой код при этом не меняется.
  • Масштабируй оба измерения одним коэффициентом Math.min(scaleX, scaleY), чтобы сохранить пропорции и не приплюснуть цыплёнка.
  • Держи ввод отдельно от логики: кнопки и джойстик просто заполняют объект controls или вектор stick, а update читает их, не зная источника.
  • Для джойстика нормируй вектор и добавляй мёртвую зону, чтобы движение было ровным.

В следующем уроке раздела «Релиз» мы займёмся тем, чтобы твою игру можно было не просто открыть, а по-настоящему выпустить: упакуем её и сделаем так, чтобы ссылкой было не стыдно поделиться. Цыплёнок почти готов к большому миру!

Проверьте себя
1. Почему для масштабирования canvas берут Math.min(scaleX, scaleY), а не Math.max?
AЧтобы поле целиком поместилось в окно и сохранило пропорции
BЧтобы canvas всегда был как можно больше, даже вылезая за край
CПотому что Math.max не работает с дробными числами
DЧтобы игра шла на 60 FPS
2. Что произойдёт, если каждый кадр вызывать ctx.scale(scale, scale) без сброса трансформации?
AНичего, scale просто перезапишется
BМасштаб будет накапливаться, и картинка раздуется до бесконечности
CCanvas станет чёрным
DЦыплёнок начнёт двигаться быстрее
3. Зачем функция update читает объект controls, а не работает с событиями кнопок напрямую?
AТак требует синтаксис JavaScript
BЧтобы логика движения не зависела от источника ввода — клавиатуры, кнопок или джойстика
CЧтобы код занимал меньше места
DИначе игра не запустится на телефоне
4. Для чего в тач-джойстике нужна мёртвая зона (DEAD_ZONE)?
AЧтобы джойстик работал только в центре экрана
BЧтобы лёгкое дрожание пальца не сдвигало героя
CЧтобы ускорить игру
DЧтобы джойстик исчезал при отпускании пальца
5. Что даёт нормирование вектора (деление dx и dy на его длину) в джойстике?
AСкорость становится постоянной независимо от того, как далеко оттянут палец
BЦыплёнок начинает двигаться только по диагонали
CВектор всегда указывает вверх
DЭто отключает гравитацию
6. Почему игру с тач-управлением обязательно проверять на настоящем телефоне?
AНа компьютере JavaScript работает иначе
BНа десктопе нет событий touchstart, и удобство на маленьком экране заранее не угадаешь
CТелефон считает FPS точнее
DИначе canvas не масштабируется